diff --git a/composer.json b/composer.json index 05c98d831864..b4fb3a6e105f 100644 --- a/composer.json +++ b/composer.json @@ -72,6 +72,7 @@ "symfony/validator": "self.version", "symfony/var-dumper": "self.version", "symfony/web-profiler-bundle": "self.version", + "symfony/workflow": "self.version", "symfony/yaml": "self.version" }, "require-dev": { diff --git a/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php b/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php new file mode 100644 index 000000000000..c2c5a55af954 --- /dev/null +++ b/src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Twig\Extension; + +use Symfony\Component\Workflow\Registry; + +/** + * WorkflowExtension. + * + * @author Grégoire Pineau + */ +class WorkflowExtension extends \Twig_Extension +{ + private $workflowRegistry; + + public function __construct(Registry $workflowRegistry) + { + $this->workflowRegistry = $workflowRegistry; + } + + public function getFunctions() + { + return array( + new \Twig_SimpleFunction('workflow_can', array($this, 'canTransition')), + new \Twig_SimpleFunction('workflow_transitions', array($this, 'getEnabledTransitions')), + ); + } + + public function canTransition($object, $transition, $name = null) + { + return $this->workflowRegistry->get($object, $name)->can($object, $transition); + } + + public function getEnabledTransitions($object, $name = null) + { + return $this->workflowRegistry->get($object, $name)->getEnabledTransitions($object); + } + + public function getName() + { + return 'workflow'; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php new file mode 100644 index 000000000000..d1b4e2a766d6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Command; + +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Workflow\Dumper\GraphvizDumper; +use Symfony\Component\Workflow\Marking; + +/** + * @author Grégoire Pineau + */ +class WorkflowDumpCommand extends ContainerAwareCommand +{ + public function isEnabled() + { + return $this->getContainer()->has('workflow.registry'); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('workflow:dump') + ->setDefinition(array( + new InputArgument('name', InputArgument::REQUIRED, 'A workflow name'), + new InputArgument('marking', InputArgument::IS_ARRAY, 'A marking (a list of places)'), + )) + ->setDescription('Dump a workflow') + ->setHelp(<<<'EOF' +The %command.name% command dumps the graphical representation of a +workflow in DOT format + + %command.full_name% | dot -Tpng > workflow.png + +EOF + ) + ; + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $workflow = $this->getContainer()->get('workflow.'.$input->getArgument('name')); + $definition = $this->getProperty($workflow, 'definition'); + + $dumper = new GraphvizDumper(); + + $marking = new Marking(); + foreach ($input->getArgument('marking') as $place) { + $marking->mark($place); + } + + $output->writeln($dumper->dump($definition, $marking)); + } + + private function getProperty($object, $property) + { + $reflectionProperty = new \ReflectionProperty(get_class($object), $property); + $reflectionProperty->setAccessible(true); + + return $reflectionProperty->getValue($object); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index f38a6dd63850..57f9e29c0cf2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -103,6 +103,7 @@ public function getConfigTreeBuilder() $this->addSsiSection($rootNode); $this->addFragmentsSection($rootNode); $this->addProfilerSection($rootNode); + $this->addWorkflowSection($rootNode); $this->addRouterSection($rootNode); $this->addSessionSection($rootNode); $this->addRequestSection($rootNode); @@ -226,6 +227,99 @@ private function addProfilerSection(ArrayNodeDefinition $rootNode) ; } + private function addWorkflowSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('workflows') + ->useAttributeAsKey('name') + ->prototype('array') + ->children() + ->arrayNode('marking_store') + ->isRequired() + ->children() + ->enumNode('type') + ->values(array('property_accessor', 'scalar')) + ->end() + ->arrayNode('arguments') + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->prototype('scalar') + ->end() + ->end() + ->scalarNode('service') + ->cannotBeEmpty() + ->end() + ->end() + ->validate() + ->always(function ($v) { + if (isset($v['type']) && isset($v['service'])) { + throw new \InvalidArgumentException('"type" and "service" could not be used together.'); + } + + return $v; + }) + ->end() + ->end() + ->arrayNode('supports') + ->isRequired() + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->prototype('scalar') + ->cannotBeEmpty() + ->validate() + ->ifTrue(function ($v) { return !class_exists($v); }) + ->thenInvalid('The supported class %s does not exist.') + ->end() + ->end() + ->end() + ->arrayNode('places') + ->isRequired() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->end() + ->arrayNode('transitions') + ->useAttributeAsKey('name') + ->isRequired() + ->requiresAtLeastOneElement() + ->prototype('array') + ->children() + ->arrayNode('from') + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->end() + ->arrayNode('to') + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + private function addRouterSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c92c083119ba..16b5431b1acc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -31,6 +31,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; use Symfony\Component\Validator\Validation; +use Symfony\Component\Workflow; /** * FrameworkExtension. @@ -38,6 +39,7 @@ * @author Fabien Potencier * @author Jeremy Mikola * @author Kévin Dunglas + * @author Grégoire Pineau */ class FrameworkExtension extends Extension { @@ -129,6 +131,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerTranslatorConfiguration($config['translator'], $container); $this->registerProfilerConfiguration($config['profiler'], $container, $loader); $this->registerCacheConfiguration($config['cache'], $container); + $this->registerWorkflowConfiguration($config['workflows'], $container, $loader); if ($this->isConfigEnabled($container, $config['router'])) { $this->registerRouterConfiguration($config['router'], $container, $loader); @@ -346,6 +349,54 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ } } + /** + * Loads the workflow configuration. + * + * @param array $workflows A workflow configuration array + * @param ContainerBuilder $container A ContainerBuilder instance + * @param XmlFileLoader $loader An XmlFileLoader instance + */ + private function registerWorkflowConfiguration(array $workflows, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!$workflows) { + return; + } + + $loader->load('workflow.xml'); + + $registryDefinition = $container->getDefinition('workflow.registry'); + + foreach ($workflows as $name => $workflow) { + $definitionDefinition = new Definition(Workflow\Definition::class); + $definitionDefinition->addMethodCall('addPlaces', array($workflow['places'])); + foreach ($workflow['transitions'] as $transitionName => $transition) { + $definitionDefinition->addMethodCall('addTransition', array(new Definition(Workflow\Transition::class, array($transitionName, $transition['from'], $transition['to'])))); + } + + if (isset($workflow['marking_store']['type'])) { + $markingStoreDefinition = new DefinitionDecorator('workflow.marking_store.'.$workflow['marking_store']['type']); + foreach ($workflow['marking_store']['arguments'] as $argument) { + $markingStoreDefinition->addArgument($argument); + } + } else { + $markingStoreDefinition = new Reference($workflow['marking_store']['service']); + } + + $workflowDefinition = new DefinitionDecorator('workflow.abstract'); + $workflowDefinition->replaceArgument(0, $definitionDefinition); + $workflowDefinition->replaceArgument(1, $markingStoreDefinition); + $workflowDefinition->replaceArgument(3, $name); + + $workflowId = 'workflow.'.$name; + + $container->setDefinition($workflowId, $workflowDefinition); + + foreach ($workflow['supports'] as $supportedClass) { + $registryDefinition->addMethodCall('add', array(new Reference($workflowId), $supportedClass)); + } + } + } + /** * Loads the router configuration. * diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 830702213f9e..5cd851efbefa 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -26,6 +26,7 @@ + @@ -224,4 +225,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml new file mode 100644 index 000000000000..d37b5d3e0670 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index e1a677daa73a..94b6e315b8c2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -273,6 +273,7 @@ protected static function getBundleDefaultConfig() 'directory' => '%kernel.cache_dir%/pools', 'default_redis_provider' => 'redis://localhost', ), + 'workflows' => array(), ); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow.php new file mode 100644 index 000000000000..7f29cc385ba5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow.php @@ -0,0 +1,30 @@ +loadFromExtension('framework', array( + 'workflows' => array( + 'my_workflow' => array( + 'marking_store' => array( + 'type' => 'property_accessor', + ), + 'supports' => array( + FrameworkExtensionTest::class, + ), + 'places' => array( + 'first', + 'last', + ), + 'transitions' => array( + 'go' => array( + 'from' => array( + 'first', + ), + 'to' => array( + 'last', + ), + ), + ), + ), + ), +)); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow.xml new file mode 100644 index 000000000000..add799b82fd4 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow.xml @@ -0,0 +1,29 @@ + + + + + + + + + property_accessor + a + a + + Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\FrameworkExtensionTest + first + last + + + a + a + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow.yml new file mode 100644 index 000000000000..e9eb8e1977a9 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow.yml @@ -0,0 +1,16 @@ +framework: + workflows: + my_workflow: + marking_store: + type: property_accessor + supports: + - Symfony\Bundle\FrameworkBundle\Tests\DependencyInjection\FrameworkExtensionTest + places: + - first + - last + transitions: + go: + from: + - first + to: + - last diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php index ef0c5522fd11..0406057aad97 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -117,6 +117,13 @@ public function testDisabledProfiler() $this->assertFalse($container->hasDefinition('data_collector.config'), '->registerProfilerConfiguration() does not load collectors.xml'); } + public function testWorkflow() + { + $container = $this->createContainerFromFile('workflow'); + + $this->assertTrue($container->hasDefinition('workflow.my_workflow')); + } + public function testRouter() { $container = $this->createContainerFromFile('full'); diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md new file mode 100644 index 000000000000..c4df4750f73b --- /dev/null +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -0,0 +1,2 @@ +CHANGELOG +========= diff --git a/src/Symfony/Component/Workflow/Definition.php b/src/Symfony/Component/Workflow/Definition.php new file mode 100644 index 000000000000..8bc1bd38ace9 --- /dev/null +++ b/src/Symfony/Component/Workflow/Definition.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +use Symfony\Component\Workflow\Exception\InvalidArgumentException; +use Symfony\Component\Workflow\Exception\LogicException; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class Definition +{ + private $places = array(); + + private $transitions = array(); + + private $initialPlace; + + /** + * Definition constructor. + * + * @param string[] $places + * @param Transition[] $transitions + */ + public function __construct(array $places = array(), array $transitions = array()) + { + $this->addPlaces($places); + $this->addTransitions($transitions); + } + + public function getInitialPlace() + { + return $this->initialPlace; + } + + public function getPlaces() + { + return $this->places; + } + + public function getTransitions() + { + return $this->transitions; + } + + public function setInitialPlace($place) + { + if (!isset($this->places[$place])) { + throw new LogicException(sprintf('Place "%s" cannot be the initial place as it does not exist.', $place)); + } + + $this->initialPlace = $place; + } + + public function addPlace($place) + { + if (!preg_match('{^[\w\d_-]+$}', $place)) { + throw new InvalidArgumentException(sprintf('The place "%s" contains invalid characters.', $name)); + } + + if (!count($this->places)) { + $this->initialPlace = $place; + } + + $this->places[$place] = $place; + } + + public function addPlaces(array $places) + { + foreach ($places as $place) { + $this->addPlace($place); + } + } + + public function addTransitions(array $transitions) + { + foreach ($transitions as $transition) { + $this->addTransition($transition); + } + } + + public function addTransition(Transition $transition) + { + $name = $transition->getName(); + + foreach ($transition->getFroms() as $from) { + if (!isset($this->places[$from])) { + throw new LogicException(sprintf('Place "%s" referenced in transition "%s" does not exist.', $from, $name)); + } + } + + foreach ($transition->getTos() as $to) { + if (!isset($this->places[$to])) { + throw new LogicException(sprintf('Place "%s" referenced in transition "%s" does not exist.', $to, $name)); + } + } + + $this->transitions[$name] = $transition; + } +} diff --git a/src/Symfony/Component/Workflow/Dumper/DumperInterface.php b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php new file mode 100644 index 000000000000..b0eebd34f195 --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Dumper; + +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Marking; + +/** + * DumperInterface is the interface implemented by workflow dumper classes. + * + * @author Fabien Potencier + * @author Grégoire Pineau + */ +interface DumperInterface +{ + /** + * Dumps a workflow definition. + * + * @param Definition $definition A Definition instance + * @param Marking|null $marking A Marking instance + * @param array $options An array of options + * + * @return string The representation of the workflow + */ + public function dump(Definition $definition, Marking $marking = null, array $options = array()); +} diff --git a/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php new file mode 100644 index 000000000000..56bbef64aded --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Dumper; + +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Marking; + +/** + * GraphvizDumper dumps a workflow as a graphviz file. + * + * You can convert the generated dot file with the dot utility (http://www.graphviz.org/): + * + * dot -Tpng workflow.dot > workflow.png + * + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class GraphvizDumper implements DumperInterface +{ + private static $defaultOptions = array( + 'graph' => array('ratio' => 'compress', 'rankdir' => 'LR'), + 'node' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333333', 'fillcolor' => 'lightblue', 'fixedsize' => true, 'width' => 1), + 'edge' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333333', 'arrowhead' => 'normal', 'arrowsize' => 0.5), + ); + + /** + * {@inheritdoc} + * + * Dumps the workflow as a graphviz graph. + * + * Available options: + * + * * graph: The default options for the whole graph + * * node: The default options for nodes (places + transitions) + * * edge: The default options for edges + */ + public function dump(Definition $definition, Marking $marking = null, array $options = array()) + { + $places = $this->findPlaces($definition, $marking); + $transitions = $this->findTransitions($definition); + $edges = $this->findEdges($definition); + + $options = array_replace_recursive(self::$defaultOptions, $options); + + return $this->startDot($options) + .$this->addPlaces($places) + .$this->addTransitions($transitions) + .$this->addEdges($edges) + .$this->endDot(); + } + + private function findPlaces(Definition $definition, Marking $marking = null) + { + $places = array(); + + foreach ($definition->getPlaces() as $place) { + $attributes = array(); + if ($place === $definition->getInitialPlace()) { + $attributes['style'] = 'filled'; + } + if ($marking && $marking->has($place)) { + $attributes['color'] = '#FF0000'; + $attributes['shape'] = 'doublecircle'; + } + $places[$place] = array( + 'attributes' => $attributes, + ); + } + + return $places; + } + + private function findTransitions(Definition $definition) + { + $transitions = array(); + + foreach ($definition->getTransitions() as $name => $transition) { + $transitions[$name] = array( + 'attributes' => array('shape' => 'box', 'regular' => true), + ); + } + + return $transitions; + } + + private function addPlaces(array $places) + { + $code = ''; + + foreach ($places as $id => $place) { + $code .= sprintf(" place_%s [label=\"%s\", shape=circle%s];\n", + $this->dotize($id), + $id, + $this->addAttributes($place['attributes']) + ); + } + + return $code; + } + + private function addTransitions(array $transitions) + { + $code = ''; + + foreach ($transitions as $id => $place) { + $code .= sprintf(" transition_%s [label=\"%s\", shape=box%s];\n", + $this->dotize($id), + $id, + $this->addAttributes($place['attributes']) + ); + } + + return $code; + } + + private function findEdges(Definition $definition) + { + $dotEdges = array(); + + foreach ($definition->getTransitions() as $transition) { + foreach ($transition->getFroms() as $from) { + $dotEdges[] = array( + 'from' => $from, + 'to' => $transition->getName(), + 'direction' => 'from', + ); + } + foreach ($transition->getTos() as $to) { + $dotEdges[] = array( + 'from' => $transition->getName(), + 'to' => $to, + 'direction' => 'to', + ); + } + } + + return $dotEdges; + } + + private function addEdges($edges) + { + $code = ''; + + foreach ($edges as $edge) { + $code .= sprintf(" %s_%s -> %s_%s [style=\"solid\"];\n", + 'from' === $edge['direction'] ? 'place' : 'transition', + $this->dotize($edge['from']), + 'from' === $edge['direction'] ? 'transition' : 'place', + $this->dotize($edge['to']) + ); + } + + return $code; + } + + private function startDot(array $options) + { + return sprintf("digraph workflow {\n %s\n node [%s];\n edge [%s];\n\n", + $this->addOptions($options['graph']), + $this->addOptions($options['node']), + $this->addOptions($options['edge']) + ); + } + + private function endDot() + { + return "}\n"; + } + + private function addAttributes($attributes) + { + $code = array(); + + foreach ($attributes as $k => $v) { + $code[] = sprintf('%s="%s"', $k, $v); + } + + return $code ? ', '.implode(', ', $code) : ''; + } + + private function addOptions($options) + { + $code = array(); + + foreach ($options as $k => $v) { + $code[] = sprintf('%s="%s"', $k, $v); + } + + return implode(' ', $code); + } + + private function dotize($id) + { + return strtolower(preg_replace('/[^\w]/i', '_', $id)); + } +} diff --git a/src/Symfony/Component/Workflow/Event/Event.php b/src/Symfony/Component/Workflow/Event/Event.php new file mode 100644 index 000000000000..a690b2b330e7 --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/Event.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +use Symfony\Component\EventDispatcher\Event as BaseEvent; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\Transition; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class Event extends BaseEvent +{ + private $subject; + + private $marking; + + private $transition; + + /** + * Event constructor. + * + * @param mixed $subject + * @param Marking $marking + * @param Transition $transition + */ + public function __construct($subject, Marking $marking, Transition $transition) + { + $this->subject = $subject; + $this->marking = $marking; + $this->transition = $transition; + } + + public function getMarking() + { + return $this->marking; + } + + public function getSubject() + { + return $this->subject; + } + + public function getTransition() + { + return $this->transition; + } +} diff --git a/src/Symfony/Component/Workflow/Event/GuardEvent.php b/src/Symfony/Component/Workflow/Event/GuardEvent.php new file mode 100644 index 000000000000..bf4b6f3971e7 --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/GuardEvent.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class GuardEvent extends Event +{ + private $blocked = false; + + public function isBlocked() + { + return $this->blocked; + } + + public function setBlocked($blocked) + { + $this->blocked = (bool) $blocked; + } +} diff --git a/src/Symfony/Component/Workflow/Event/TransitionEvent.php b/src/Symfony/Component/Workflow/Event/TransitionEvent.php new file mode 100644 index 000000000000..1fe0e2ff962a --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/TransitionEvent.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +/** + * @author Fabien Potencier + */ +class TransitionEvent extends Event +{ + private $nextState; + + public function setNextState($state) + { + $this->nextState = $state; + } + + public function getNextState() + { + return $this->nextState; + } +} diff --git a/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php new file mode 100644 index 000000000000..a39c89d18c3c --- /dev/null +++ b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\EventListener; + +use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Workflow\Event\Event; + +/** + * @author Grégoire Pineau + */ +class AuditTrailListener implements EventSubscriberInterface +{ + private $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function onLeave(Event $event) + { + foreach ($event->getTransition()->getFroms() as $place) { + $this->logger->info(sprintf('leaving "%s" for subject of class "%s"', $place, get_class($event->getSubject()))); + } + } + + public function onTransition(Event $event) + { + $this->logger->info(sprintf('transition "%s" for subject of class "%s"', $event->getTransition()->getName(), get_class($event->getSubject()))); + } + + public function onEnter(Event $event) + { + foreach ($event->getTransition()->getTos() as $place) { + $this->logger->info(sprintf('entering "%s" for subject of class "%s"', $place, get_class($event->getSubject()))); + } + } + + public static function getSubscribedEvents() + { + return array( + 'workflow.leave' => array('onLeave'), + 'workflow.transition' => array('onTransition'), + 'workflow.enter' => array('onEnter'), + ); + } +} diff --git a/src/Symfony/Component/Workflow/Exception/ExceptionInterface.php b/src/Symfony/Component/Workflow/Exception/ExceptionInterface.php new file mode 100644 index 000000000000..b0dfa9b79bbc --- /dev/null +++ b/src/Symfony/Component/Workflow/Exception/ExceptionInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Exception; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +interface ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Workflow/Exception/InvalidArgumentException.php b/src/Symfony/Component/Workflow/Exception/InvalidArgumentException.php new file mode 100644 index 000000000000..c44fa05cdd05 --- /dev/null +++ b/src/Symfony/Component/Workflow/Exception/InvalidArgumentException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Exception; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Workflow/Exception/LogicException.php b/src/Symfony/Component/Workflow/Exception/LogicException.php new file mode 100644 index 000000000000..d0cf09f9dfe6 --- /dev/null +++ b/src/Symfony/Component/Workflow/Exception/LogicException.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Exception; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Workflow/LICENSE b/src/Symfony/Component/Workflow/LICENSE new file mode 100644 index 000000000000..39fa189d2b5f --- /dev/null +++ b/src/Symfony/Component/Workflow/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014-2016 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Workflow/Marking.php b/src/Symfony/Component/Workflow/Marking.php new file mode 100644 index 000000000000..0d8bab25b7fe --- /dev/null +++ b/src/Symfony/Component/Workflow/Marking.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * Marking contains the place of every tokens. + * + * @author Grégoire Pineau + */ +class Marking +{ + private $places = array(); + + /** + * @param string[] $representation Keys are the place name and values should be 1 + */ + public function __construct(array $representation = array()) + { + foreach ($representation as $place => $nbToken) { + $this->mark($place); + } + } + + public function mark($place) + { + $this->places[$place] = 1; + } + + public function unmark($place) + { + unset($this->places[$place]); + } + + public function has($place) + { + return isset($this->places[$place]); + } + + public function getPlaces() + { + return $this->places; + } +} diff --git a/src/Symfony/Component/Workflow/MarkingStore/MarkingStoreInterface.php b/src/Symfony/Component/Workflow/MarkingStore/MarkingStoreInterface.php new file mode 100644 index 000000000000..e73c9eb596c6 --- /dev/null +++ b/src/Symfony/Component/Workflow/MarkingStore/MarkingStoreInterface.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\MarkingStore; + +use Symfony\Component\Workflow\Marking; + +/** + * MarkingStoreInterface. + * + * @author Grégoire Pineau + */ +interface MarkingStoreInterface +{ + /** + * Gets a Marking from a subject. + * + * @param object $subject A subject + * + * @return Marking The marking + */ + public function getMarking($subject); + + /** + * Sets a Marking to a subject. + * + * @param object $subject A subject + * @param Marking $marking A marking + */ + public function setMarking($subject, Marking $marking); +} diff --git a/src/Symfony/Component/Workflow/MarkingStore/PropertyAccessorMarkingStore.php b/src/Symfony/Component/Workflow/MarkingStore/PropertyAccessorMarkingStore.php new file mode 100644 index 000000000000..faf1e8a6c402 --- /dev/null +++ b/src/Symfony/Component/Workflow/MarkingStore/PropertyAccessorMarkingStore.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\MarkingStore; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Workflow\Marking; + +/** + * PropertyAccessorMarkingStore. + * + * @author Grégoire Pineau + */ +class PropertyAccessorMarkingStore implements MarkingStoreInterface +{ + private $property; + + private $propertyAccessor; + + /** + * PropertyAccessorMarkingStore constructor. + * + * @param string $property + * @param PropertyAccessorInterface|null $propertyAccessor + */ + public function __construct($property = 'marking', PropertyAccessorInterface $propertyAccessor = null) + { + $this->property = $property; + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + */ + public function getMarking($subject) + { + return new Marking($this->propertyAccessor->getValue($subject, $this->property) ?: array()); + } + + /** + * {@inheritdoc} + */ + public function setMarking($subject, Marking $marking) + { + $this->propertyAccessor->setValue($subject, $this->property, $marking->getPlaces()); + } +} diff --git a/src/Symfony/Component/Workflow/MarkingStore/ScalarMarkingStore.php b/src/Symfony/Component/Workflow/MarkingStore/ScalarMarkingStore.php new file mode 100644 index 000000000000..949661ee1062 --- /dev/null +++ b/src/Symfony/Component/Workflow/MarkingStore/ScalarMarkingStore.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\MarkingStore; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Workflow\Marking; + +/** + * ScalarMarkingStore. + * + * @author Grégoire Pineau + */ +class ScalarMarkingStore implements MarkingStoreInterface, UniqueTransitionOutputInterface +{ + private $property; + + private $propertyAccessor; + + /** + * ScalarMarkingStore constructor. + * + * @param string $property + * @param PropertyAccessorInterface|null $propertyAccessor + */ + public function __construct($property = 'marking', PropertyAccessorInterface $propertyAccessor = null) + { + $this->property = $property; + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + */ + public function getMarking($subject) + { + $placeName = $this->propertyAccessor->getValue($subject, $this->property); + + if (!$placeName) { + return new Marking(); + } + + return new Marking(array($placeName => 1)); + } + + /** + * {@inheritdoc} + */ + public function setMarking($subject, Marking $marking) + { + $this->propertyAccessor->setValue($subject, $this->property, key($marking->getPlaces())); + } +} diff --git a/src/Symfony/Component/Workflow/MarkingStore/UniqueTransitionOutputInterface.php b/src/Symfony/Component/Workflow/MarkingStore/UniqueTransitionOutputInterface.php new file mode 100644 index 000000000000..35c00eb58d10 --- /dev/null +++ b/src/Symfony/Component/Workflow/MarkingStore/UniqueTransitionOutputInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\MarkingStore; + +/** + * UniqueTransitionOutputInterface. + * + * @author Grégoire Pineau + */ +interface UniqueTransitionOutputInterface +{ +} diff --git a/src/Symfony/Component/Workflow/README.md b/src/Symfony/Component/Workflow/README.md new file mode 100644 index 000000000000..a2bf37aa7f3c --- /dev/null +++ b/src/Symfony/Component/Workflow/README.md @@ -0,0 +1,11 @@ +Workflow Component +=================== + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/workflow/introduction.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Workflow/Registry.php b/src/Symfony/Component/Workflow/Registry.php new file mode 100644 index 000000000000..2f94288ebb9c --- /dev/null +++ b/src/Symfony/Component/Workflow/Registry.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +use Symfony\Component\Workflow\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class Registry +{ + private $workflows = array(); + + /** + * @param Workflow $workflow + * @param string $classname + */ + public function add(Workflow $workflow, $classname) + { + $this->workflows[] = array($workflow, $classname); + } + + public function get($subject, $workflowName = null) + { + $matched = null; + + foreach ($this->workflows as list($workflow, $classname)) { + if ($this->supports($workflow, $classname, $subject, $workflowName)) { + if ($matched) { + throw new InvalidArgumentException('At least two workflows match this subject. Set a different name on each and use the second (name) argument of this method.'); + } + $matched = $workflow; + } + } + + if (!$matched) { + throw new InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_class($subject))); + } + + return $matched; + } + + private function supports(Workflow $workflow, $classname, $subject, $name) + { + if (!$subject instanceof $classname) { + return false; + } + + if (null === $name) { + return true; + } + + return $name === $workflow->getName(); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/DefinitionTest.php b/src/Symfony/Component/Workflow/Tests/DefinitionTest.php new file mode 100644 index 000000000000..4a4465fd64a1 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/DefinitionTest.php @@ -0,0 +1,73 @@ +assertCount(5, $definition->getPlaces()); + + $this->assertEquals('a', $definition->getInitialPlace()); + } + + public function testSetInitialPlace() + { + $places = range('a', 'e'); + $definition = new Definition($places); + + $definition->setInitialPlace($places[3]); + + $this->assertEquals($places[3], $definition->getInitialPlace()); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage Place "d" cannot be the initial place as it does not exist. + */ + public function testSetInitialPlaceAndPlaceIsNotDefined() + { + $definition = new Definition(); + + $definition->setInitialPlace('d'); + } + + public function testAddTransition() + { + $places = range('a', 'b'); + + $transition = new Transition('name', $places[0], $places[1]); + $definition = new Definition($places, array($transition)); + + $this->assertCount(1, $definition->getTransitions()); + $this->assertSame($transition, $definition->getTransitions()['name']); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage Place "c" referenced in transition "name" does not exist. + */ + public function testAddTransitionAndFromPlaceIsNotDefined() + { + $places = range('a', 'b'); + + new Definition($places, array(new Transition('name', 'c', $places[1]))); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage Place "c" referenced in transition "name" does not exist. + */ + public function testAddTransitionAndToPlaceIsNotDefined() + { + $places = range('a', 'b'); + + new Definition($places, array(new Transition('name', $places[0], 'c'))); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/Dumper/GraphvizDumperTest.php b/src/Symfony/Component/Workflow/Tests/Dumper/GraphvizDumperTest.php new file mode 100644 index 000000000000..d6e9cd30f31d --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/Dumper/GraphvizDumperTest.php @@ -0,0 +1,203 @@ +dumper = new GraphvizDumper(); + } + + /** + * @dataProvider provideWorkflowDefinitionWithoutMarking + */ + public function testGraphvizDumperWithoutMarking($definition, $expected) + { + $dump = $this->dumper->dump($definition); + + $this->assertEquals($expected, $dump); + } + + /** + * @dataProvider provideWorkflowDefinitionWithMarking + */ + public function testWorkflowWithMarking($definition, $marking, $expected) + { + $dump = $this->dumper->dump($definition, $marking); + + $this->assertEquals($expected, $dump); + } + + public function provideWorkflowDefinitionWithMarking() + { + yield array( + $this->createprovideComplexWorkflowDefinition(), + new Marking(array('b' => 1)), + $this->createComplexWorkflowDumpWithMarking(), + ); + + yield array( + $this->provideSimpleWorkflowDefinition(), + new Marking(array('c' => 1, 'd' => 1)), + $this->createSimpleWorkflowDumpWithMarking(), + ); + } + + public function provideWorkflowDefinitionWithoutMarking() + { + yield array($this->createprovideComplexWorkflowDefinition(), $this->provideComplexWorkflowDumpWithoutMarking()); + yield array($this->provideSimpleWorkflowDefinition(), $this->provideSimpleWorkflowDumpWithoutMarking()); + } + + public function createprovideComplexWorkflowDefinition() + { + $definition = new Definition(); + + $definition->addPlaces(range('a', 'g')); + + $definition->addTransition(new Transition('t1', 'a', array('b', 'c'))); + $definition->addTransition(new Transition('t2', array('b', 'c'), 'd')); + $definition->addTransition(new Transition('t3', 'd', 'e')); + $definition->addTransition(new Transition('t4', 'd', 'f')); + $definition->addTransition(new Transition('t5', 'e', 'g')); + $definition->addTransition(new Transition('t6', 'f', 'g')); + + return $definition; + } + + public function provideSimpleWorkflowDefinition() + { + $definition = new Definition(); + + $definition->addPlaces(range('a', 'c')); + + $definition->addTransition(new Transition('t1', 'a', 'b')); + $definition->addTransition(new Transition('t2', 'b', 'c')); + + return $definition; + } + + public function createComplexWorkflowDumpWithMarking() + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="1" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_a [label="a", shape=circle, style="filled"]; + place_b [label="b", shape=circle, color="#FF0000", shape="doublecircle"]; + place_c [label="c", shape=circle]; + place_d [label="d", shape=circle]; + place_e [label="e", shape=circle]; + place_f [label="f", shape=circle]; + place_g [label="g", shape=circle]; + transition_t1 [label="t1", shape=box, shape="box", regular="1"]; + transition_t2 [label="t2", shape=box, shape="box", regular="1"]; + transition_t3 [label="t3", shape=box, shape="box", regular="1"]; + transition_t4 [label="t4", shape=box, shape="box", regular="1"]; + transition_t5 [label="t5", shape=box, shape="box", regular="1"]; + transition_t6 [label="t6", shape=box, shape="box", regular="1"]; + place_a -> transition_t1 [style="solid"]; + transition_t1 -> place_b [style="solid"]; + transition_t1 -> place_c [style="solid"]; + place_b -> transition_t2 [style="solid"]; + place_c -> transition_t2 [style="solid"]; + transition_t2 -> place_d [style="solid"]; + place_d -> transition_t3 [style="solid"]; + transition_t3 -> place_e [style="solid"]; + place_d -> transition_t4 [style="solid"]; + transition_t4 -> place_f [style="solid"]; + place_e -> transition_t5 [style="solid"]; + transition_t5 -> place_g [style="solid"]; + place_f -> transition_t6 [style="solid"]; + transition_t6 -> place_g [style="solid"]; +} +'; + } + + public function createSimpleWorkflowDumpWithMarking() + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="1" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_a [label="a", shape=circle, style="filled"]; + place_b [label="b", shape=circle]; + place_c [label="c", shape=circle, color="#FF0000", shape="doublecircle"]; + transition_t1 [label="t1", shape=box, shape="box", regular="1"]; + transition_t2 [label="t2", shape=box, shape="box", regular="1"]; + place_a -> transition_t1 [style="solid"]; + transition_t1 -> place_b [style="solid"]; + place_b -> transition_t2 [style="solid"]; + transition_t2 -> place_c [style="solid"]; +} +'; + } + + public function provideComplexWorkflowDumpWithoutMarking() + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="1" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_a [label="a", shape=circle, style="filled"]; + place_b [label="b", shape=circle]; + place_c [label="c", shape=circle]; + place_d [label="d", shape=circle]; + place_e [label="e", shape=circle]; + place_f [label="f", shape=circle]; + place_g [label="g", shape=circle]; + transition_t1 [label="t1", shape=box, shape="box", regular="1"]; + transition_t2 [label="t2", shape=box, shape="box", regular="1"]; + transition_t3 [label="t3", shape=box, shape="box", regular="1"]; + transition_t4 [label="t4", shape=box, shape="box", regular="1"]; + transition_t5 [label="t5", shape=box, shape="box", regular="1"]; + transition_t6 [label="t6", shape=box, shape="box", regular="1"]; + place_a -> transition_t1 [style="solid"]; + transition_t1 -> place_b [style="solid"]; + transition_t1 -> place_c [style="solid"]; + place_b -> transition_t2 [style="solid"]; + place_c -> transition_t2 [style="solid"]; + transition_t2 -> place_d [style="solid"]; + place_d -> transition_t3 [style="solid"]; + transition_t3 -> place_e [style="solid"]; + place_d -> transition_t4 [style="solid"]; + transition_t4 -> place_f [style="solid"]; + place_e -> transition_t5 [style="solid"]; + transition_t5 -> place_g [style="solid"]; + place_f -> transition_t6 [style="solid"]; + transition_t6 -> place_g [style="solid"]; +} +'; + } + + public function provideSimpleWorkflowDumpWithoutMarking() + { + return 'digraph workflow { + ratio="compress" rankdir="LR" + node [fontsize="9" fontname="Arial" color="#333333" fillcolor="lightblue" fixedsize="1" width="1"]; + edge [fontsize="9" fontname="Arial" color="#333333" arrowhead="normal" arrowsize="0.5"]; + + place_a [label="a", shape=circle, style="filled"]; + place_b [label="b", shape=circle]; + place_c [label="c", shape=circle]; + transition_t1 [label="t1", shape=box, shape="box", regular="1"]; + transition_t2 [label="t2", shape=box, shape="box", regular="1"]; + place_a -> transition_t1 [style="solid"]; + transition_t1 -> place_b [style="solid"]; + place_b -> transition_t2 [style="solid"]; + transition_t2 -> place_c [style="solid"]; +} +'; + } +} diff --git a/src/Symfony/Component/Workflow/Tests/EventListener/AuditTrailListenerTest.php b/src/Symfony/Component/Workflow/Tests/EventListener/AuditTrailListenerTest.php new file mode 100644 index 000000000000..0422009f0ee2 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/EventListener/AuditTrailListenerTest.php @@ -0,0 +1,54 @@ +marking = null; + + $logger = new Logger(); + + $ed = new EventDispatcher(); + $ed->addSubscriber(new AuditTrailListener($logger)); + + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore(), $ed); + + $workflow->apply($object, 't1'); + + $expected = array( + 'leaving "a" for subject of class "stdClass"', + 'transition "t1" for subject of class "stdClass"', + 'entering "b" for subject of class "stdClass"', + ); + + $this->assertSame($expected, $logger->logs); + } +} + +class Logger extends AbstractLogger +{ + public $logs = array(); + + public function log($level, $message, array $context = array()) + { + $this->logs[] = $message; + } +} diff --git a/src/Symfony/Component/Workflow/Tests/MarkingStore/PropertyAccessorMarkingStoreTest.php b/src/Symfony/Component/Workflow/Tests/MarkingStore/PropertyAccessorMarkingStoreTest.php new file mode 100644 index 000000000000..557a241689ec --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/MarkingStore/PropertyAccessorMarkingStoreTest.php @@ -0,0 +1,32 @@ +myMarks = null; + + $markingStore = new PropertyAccessorMarkingStore('myMarks'); + + $marking = $markingStore->getMarking($subject); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertCount(0, $marking->getPlaces()); + + $marking->mark('first_place'); + + $markingStore->setMarking($subject, $marking); + + $this->assertSame(array('first_place' => 1), $subject->myMarks); + + $marking2 = $markingStore->getMarking($subject); + + $this->assertEquals($marking, $marking2); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/MarkingStore/ScalarMarkingStoreTest.php b/src/Symfony/Component/Workflow/Tests/MarkingStore/ScalarMarkingStoreTest.php new file mode 100644 index 000000000000..df8d748a0d92 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/MarkingStore/ScalarMarkingStoreTest.php @@ -0,0 +1,32 @@ +myMarks = null; + + $markingStore = new ScalarMarkingStore('myMarks'); + + $marking = $markingStore->getMarking($subject); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertCount(0, $marking->getPlaces()); + + $marking->mark('first_place'); + + $markingStore->setMarking($subject, $marking); + + $this->assertSame('first_place', $subject->myMarks); + + $marking2 = $markingStore->getMarking($subject); + + $this->assertEquals($marking, $marking2); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/MarkingTest.php b/src/Symfony/Component/Workflow/Tests/MarkingTest.php new file mode 100644 index 000000000000..026ca607bee1 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/MarkingTest.php @@ -0,0 +1,35 @@ + 1)); + + $this->assertTrue($marking->has('a')); + $this->assertFalse($marking->has('b')); + $this->assertSame(array('a' => 1), $marking->getPlaces()); + + $marking->mark('b'); + + $this->assertTrue($marking->has('a')); + $this->assertTrue($marking->has('b')); + $this->assertSame(array('a' => 1, 'b' => 1), $marking->getPlaces()); + + $marking->unmark('a'); + + $this->assertFalse($marking->has('a')); + $this->assertTrue($marking->has('b')); + $this->assertSame(array('b' => 1), $marking->getPlaces()); + + $marking->unmark('b'); + + $this->assertFalse($marking->has('a')); + $this->assertFalse($marking->has('b')); + $this->assertSame(array(), $marking->getPlaces()); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/RegistryTest.php b/src/Symfony/Component/Workflow/Tests/RegistryTest.php new file mode 100644 index 000000000000..719886dd1c03 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/RegistryTest.php @@ -0,0 +1,74 @@ +registry = new Registry(); + + $this->registry->add(new Workflow(new Definition(), $this->getMock(MarkingStoreInterface::class), $this->getMock(EventDispatcherInterface::class), 'workflow1'), Subject1::class); + $this->registry->add(new Workflow(new Definition(), $this->getMock(MarkingStoreInterface::class), $this->getMock(EventDispatcherInterface::class), 'workflow2'), Subject2::class); + $this->registry->add(new Workflow(new Definition(), $this->getMock(MarkingStoreInterface::class), $this->getMock(EventDispatcherInterface::class), 'workflow3'), Subject2::class); + } + + protected function tearDown() + { + $this->registry = null; + } + + public function testGetWithSuccess() + { + $workflow = $this->registry->get(new Subject1()); + $this->assertInstanceOf(Workflow::class, $workflow); + $this->assertSame('workflow1', $workflow->getName()); + + $workflow = $this->registry->get(new Subject1(), 'workflow1'); + $this->assertInstanceOf(Workflow::class, $workflow); + $this->assertSame('workflow1', $workflow->getName()); + + $workflow = $this->registry->get(new Subject2(), 'workflow2'); + $this->assertInstanceOf(Workflow::class, $workflow); + $this->assertSame('workflow2', $workflow->getName()); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\InvalidArgumentException + * @expectedExceptionMessage At least two workflows match this subject. Set a different name on each and use the second (name) argument of this method. + */ + public function testGetWithMultipleMatch() + { + $w1 = $this->registry->get(new Subject2()); + $this->assertInstanceOf(Workflow::class, $w1); + $this->assertSame('workflow1', $w1->getName()); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\InvalidArgumentException + * @expectedExceptionMessage Unable to find a workflow for class "stdClass". + */ + public function testGetWithNoMatch() + { + $w1 = $this->registry->get(new \stdClass()); + $this->assertInstanceOf(Workflow::class, $w1); + $this->assertSame('workflow1', $w1->getName()); + } +} + +class Subject1 +{ +} +class Subject2 +{ +} diff --git a/src/Symfony/Component/Workflow/Tests/TransitionTest.php b/src/Symfony/Component/Workflow/Tests/TransitionTest.php new file mode 100644 index 000000000000..fbf9b38b23bb --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/TransitionTest.php @@ -0,0 +1,26 @@ +assertSame('name', $transition->getName()); + $this->assertSame(array('a'), $transition->getFroms()); + $this->assertSame(array('b'), $transition->getTos()); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/WorkflowTest.php b/src/Symfony/Component/Workflow/Tests/WorkflowTest.php new file mode 100644 index 000000000000..58e9ecc9ead1 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/WorkflowTest.php @@ -0,0 +1,288 @@ +createComplexWorkflow(); + + new Workflow($definition, new ScalarMarkingStore()); + } + + public function testConstructorWithUniqueTransitionOutputInterfaceAndSimpleWorkflow() + { + $places = array('a', 'b'); + $transition = new Transition('t1', 'a', 'b'); + $definition = new Definition($places, array($transition)); + + new Workflow($definition, new ScalarMarkingStore()); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage The value returned by the MarkingStore is not an instance of "Symfony\Component\Workflow\Marking" for workflow "unnamed". + */ + public function testGetMarkingWithInvalidStoreReturn() + { + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow(new Definition(), $this->getMock(MarkingStoreInterface::class)); + + $workflow->getMarking($subject); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage The Marking is empty and there is no initial place for workflow "unnamed". + */ + public function testGetMarkingWithEmptyDefinition() + { + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow(new Definition(), new PropertyAccessorMarkingStore()); + + $workflow->getMarking($subject); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage Place "nope" is not valid for workflow "unnamed". + */ + public function testGetMarkingWithImpossiblePlace() + { + $subject = new \stdClass(); + $subject->marking = null; + $subject->marking = array('nope' => true); + $workflow = new Workflow(new Definition(), new PropertyAccessorMarkingStore()); + + $workflow->getMarking($subject); + } + + public function testGetMarkingWithEmptyInitialMarking() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore()); + + $marking = $workflow->getMarking($subject); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertTrue($marking->has('a')); + $this->assertSame(array('a' => 1), $subject->marking); + } + + public function testGetMarkingWithExistingMarking() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $subject->marking = array('b' => 1, 'c' => 1); + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore()); + + $marking = $workflow->getMarking($subject); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertTrue($marking->has('b')); + $this->assertTrue($marking->has('c')); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage Transition "foobar" does not exist for workflow "unnamed". + */ + public function testCanWithUnexistingTransition() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore()); + + $workflow->can($subject, 'foobar'); + } + + public function testCan() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore()); + + $this->assertTrue($workflow->can($subject, 't1')); + $this->assertFalse($workflow->can($subject, 't2')); + } + + public function testCanWithGuard() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener('workflow.workflow_name.guard.t1', function (GuardEvent $event) { $event->setBlocked(true); }); + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore(), $eventDispatcher, 'workflow_name'); + + $this->assertFalse($workflow->can($subject, 't1')); + } + + /** + * @expectedException Symfony\Component\Workflow\Exception\LogicException + * @expectedExceptionMessage Unable to apply transition "t2" for workflow "unnamed". + */ + public function testApplyWithImpossibleTransition() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore()); + + $workflow->apply($subject, 't2'); + } + + public function testApply() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore()); + + $marking = $workflow->apply($subject, 't1'); + + $this->assertInstanceOf(Marking::class, $marking); + $this->assertFalse($marking->has('a')); + $this->assertTrue($marking->has('b')); + $this->assertTrue($marking->has('c')); + } + + public function testApplyWithEventDispatcher() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $eventDispatcher = new EventDispatcherMock(); + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore(), $eventDispatcher, 'workflow_name'); + + $eventNameExpected = array( + 'workflow.guard', + 'workflow.workflow_name.guard', + 'workflow.workflow_name.guard.t1', + 'workflow.leave', + 'workflow.workflow_name.leave', + 'workflow.workflow_name.leave.a', + 'workflow.transition', + 'workflow.workflow_name.transition', + 'workflow.workflow_name.transition.t1', + 'workflow.enter', + 'workflow.workflow_name.enter', + 'workflow.workflow_name.enter.b', + 'workflow.workflow_name.enter.c', + // Following events are fired because of announce() method + 'workflow.guard', + 'workflow.workflow_name.guard', + 'workflow.workflow_name.guard.t2', + 'workflow.workflow_name.announce.t2', + ); + + $marking = $workflow->apply($subject, 't1'); + + $this->assertSame($eventNameExpected, $eventDispatcher->dispatchedEvents); + } + + public function testGetEnabledTransitions() + { + $definition = $this->createComplexWorkflow(); + $subject = new \stdClass(); + $subject->marking = null; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener('workflow.workflow_name.guard.t1', function (GuardEvent $event) { $event->setBlocked(true); }); + $workflow = new Workflow($definition, new PropertyAccessorMarkingStore(), $eventDispatcher, 'workflow_name'); + + $this->assertEmpty($workflow->getEnabledTransitions($subject)); + + $subject->marking = array('d' => true); + $transitions = $workflow->getEnabledTransitions($subject); + $this->assertCount(2, $transitions); + $this->assertSame('t3', $transitions['t3']->getName()); + $this->assertSame('t4', $transitions['t4']->getName()); + + $subject->marking = array('c' => true, 'e' => true); + $transitions = $workflow->getEnabledTransitions($subject); + $this->assertCount(1, $transitions); + $this->assertSame('t5', $transitions['t5']->getName()); + } + + private function createComplexWorkflow() + { + $definition = new Definition(); + + $definition->addPlaces(range('a', 'g')); + + $definition->addTransition(new Transition('t1', 'a', array('b', 'c'))); + $definition->addTransition(new Transition('t2', array('b', 'c'), 'd')); + $definition->addTransition(new Transition('t3', 'd', 'e')); + $definition->addTransition(new Transition('t4', 'd', 'f')); + $definition->addTransition(new Transition('t5', 'e', 'g')); + $definition->addTransition(new Transition('t6', 'f', 'g')); + + return $definition; + + // The graph looks like: + // + // +---+ +----+ +---+ +----+ +----+ +----+ +----+ +----+ +---+ + // | a | --> | t1 | --> | c | --> | t2 | --> | d | --> | t4 | --> | f | --> | t6 | --> | g | + // +---+ +----+ +---+ +----+ +----+ +----+ +----+ +----+ +---+ + // | ^ | ^ + // | | | | + // v | v | + // +----+ | +----+ +----+ +----+ | + // | b | ----------------+ | t3 | --> | e | --> | t5 | -----------------+ + // +----+ +----+ +----+ +----+ + } +} + +class EventDispatcherMock implements \Symfony\Component\EventDispatcher\EventDispatcherInterface +{ + public $dispatchedEvents = array(); + + public function dispatch($eventName, \Symfony\Component\EventDispatcher\Event $event = null) + { + $this->dispatchedEvents[] = $eventName; + } + + public function addListener($eventName, $listener, $priority = 0) + { + } + public function addSubscriber(\Symfony\Component\EventDispatcher\EventSubscriberInterface $subscriber) + { + } + public function removeListener($eventName, $listener) + { + } + public function removeSubscriber(\Symfony\Component\EventDispatcher\EventSubscriberInterface $subscriber) + { + } + public function getListeners($eventName = null) + { + } + public function getListenerPriority($eventName, $listener) + { + } + public function hasListeners($eventName = null) + { + } +} diff --git a/src/Symfony/Component/Workflow/Transition.php b/src/Symfony/Component/Workflow/Transition.php new file mode 100644 index 000000000000..30cc5eca47d8 --- /dev/null +++ b/src/Symfony/Component/Workflow/Transition.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +use Symfony\Component\Workflow\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class Transition +{ + private $name; + + private $froms; + + private $tos; + + /** + * Transition constructor. + * + * @param string $name + * @param string|string[] $froms + * @param string|string[] $tos + */ + public function __construct($name, $froms, $tos) + { + if (!preg_match('{^[\w\d_-]+$}', $name)) { + throw new InvalidArgumentException(sprintf('The transition "%s" contains invalid characters.', $name)); + } + + $this->name = $name; + $this->froms = (array) $froms; + $this->tos = (array) $tos; + } + + public function getName() + { + return $this->name; + } + + public function getFroms() + { + return $this->froms; + } + + public function getTos() + { + return $this->tos; + } +} diff --git a/src/Symfony/Component/Workflow/Workflow.php b/src/Symfony/Component/Workflow/Workflow.php new file mode 100644 index 000000000000..0bf637e3d7eb --- /dev/null +++ b/src/Symfony/Component/Workflow/Workflow.php @@ -0,0 +1,274 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Workflow\Event\Event; +use Symfony\Component\Workflow\Event\GuardEvent; +use Symfony\Component\Workflow\Exception\LogicException; +use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; +use Symfony\Component\Workflow\MarkingStore\UniqueTransitionOutputInterface; + +/** + * @author Fabien Potencier + * @author Grégoire Pineau + */ +class Workflow +{ + private $definition; + private $markingStore; + private $dispatcher; + private $name; + + public function __construct(Definition $definition, MarkingStoreInterface $markingStore, EventDispatcherInterface $dispatcher = null, $name = 'unnamed') + { + $this->definition = $definition; + $this->markingStore = $markingStore; + $this->dispatcher = $dispatcher; + $this->name = $name; + + // If the marking can contain only one place, we should control the definition + if ($markingStore instanceof UniqueTransitionOutputInterface) { + foreach ($definition->getTransitions() as $transition) { + if (1 < count($transition->getTos())) { + throw new LogicException(sprintf('The marking store (%s) of workflow "%s" can not store many places. But the transition "%s" has too many output (%d). Only one is accepted.', get_class($markingStore), $this->name, $transition->getName(), count($transition->getTos()))); + } + } + } + } + + /** + * Returns the object's Marking. + * + * @param object $subject A subject + * + * @return Marking The Marking + * + * @throws LogicException + */ + public function getMarking($subject) + { + $marking = $this->markingStore->getMarking($subject); + + if (!$marking instanceof Marking) { + throw new LogicException(sprintf('The value returned by the MarkingStore is not an instance of "%s" for workflow "%s".', Marking::class, $this->name)); + } + + // check if the subject is already in the workflow + if (!$marking->getPlaces()) { + if (!$this->definition->getInitialPlace()) { + throw new LogicException(sprintf('The Marking is empty and there is no initial place for workflow "%s".', $this->name)); + } + $marking->mark($this->definition->getInitialPlace()); + } + + // check that the subject has a known place + $places = $this->definition->getPlaces(); + foreach ($marking->getPlaces() as $placeName => $nbToken) { + if (!isset($places[$placeName])) { + $message = sprintf('Place "%s" is not valid for workflow "%s".', $placeName, $this->name); + if (!$places) { + $message .= ' It seems you forgot to add places to the current workflow.'; + } + + throw new LogicException($message); + } + } + + // Because the marking could have been initialized, we update the subject + $this->markingStore->setMarking($subject, $marking); + + return $marking; + } + + /** + * Returns true if the transition is enabled. + * + * @param object $subject A subject + * @param string $transitionName A transition + * + * @return bool true if the transition is enabled + * + * @throws LogicException If the transition does not exist + */ + public function can($subject, $transitionName) + { + $transitions = $this->definition->getTransitions(); + + if (!isset($transitions[$transitionName])) { + throw new LogicException(sprintf('Transition "%s" does not exist for workflow "%s".', $transitionName, $this->name)); + } + + $transition = $transitions[$transitionName]; + + $marking = $this->getMarking($subject); + + return $this->doCan($subject, $marking, $transition); + } + + /** + * Fire a transition. + * + * @param object $subject A subject + * @param string $transitionName A transition + * + * @return Marking The new Marking + * + * @throws LogicException If the transition is not applicable + * @throws LogicException If the transition does not exist + */ + public function apply($subject, $transitionName) + { + if (!$this->can($subject, $transitionName)) { + throw new LogicException(sprintf('Unable to apply transition "%s" for workflow "%s".', $transitionName, $this->name)); + } + + // We can shortcut the getMarking method in order to boost performance, + // since the "can" method already checks the Marking state + $marking = $this->markingStore->getMarking($subject); + + $transition = $this->definition->getTransitions()[$transitionName]; + + $this->leave($subject, $transition, $marking); + + $this->transition($subject, $transition, $marking); + + $this->enter($subject, $transition, $marking); + + $this->announce($subject, $transition, $marking); + + $this->markingStore->setMarking($subject, $marking); + + return $marking; + } + + /** + * Returns all enabled transitions. + * + * @param object $subject A subject + * + * @return Transition[] All enabled transitions + */ + public function getEnabledTransitions($subject) + { + $enabled = array(); + + $marking = $this->getMarking($subject); + + foreach ($this->definition->getTransitions() as $transition) { + if ($this->doCan($subject, $marking, $transition)) { + $enabled[$transition->getName()] = $transition; + } + } + + return $enabled; + } + + public function getName() + { + return $this->name; + } + + private function doCan($subject, Marking $marking, Transition $transition) + { + foreach ($transition->getFroms() as $place) { + if (!$marking->has($place)) { + return false; + } + } + + if (true === $this->guardTransition($subject, $marking, $transition)) { + return false; + } + + return true; + } + + private function guardTransition($subject, Marking $marking, Transition $transition) + { + if (null === $this->dispatcher) { + return; + } + + $event = new GuardEvent($subject, $marking, $transition); + + $this->dispatcher->dispatch('workflow.guard', $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.guard', $this->name), $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.guard.%s', $this->name, $transition->getName()), $event); + + return $event->isBlocked(); + } + + private function leave($subject, Transition $transition, Marking $marking) + { + if (null !== $this->dispatcher) { + $event = new Event($subject, $marking, $transition); + + $this->dispatcher->dispatch('workflow.leave', $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave', $this->name), $event); + } + + foreach ($transition->getFroms() as $place) { + $marking->unmark($place); + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(sprintf('workflow.%s.leave.%s', $this->name, $place), $event); + } + } + } + + private function transition($subject, Transition $transition, Marking $marking) + { + if (null === $this->dispatcher) { + return; + } + + $event = new Event($subject, $marking, $transition); + + $this->dispatcher->dispatch('workflow.transition', $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.transition', $this->name), $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.transition.%s', $this->name, $transition->getName()), $event); + } + + private function enter($subject, Transition $transition, Marking $marking) + { + if (null !== $this->dispatcher) { + $event = new Event($subject, $marking, $transition); + + $this->dispatcher->dispatch('workflow.enter', $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.enter', $this->name), $event); + } + + foreach ($transition->getTos() as $place) { + $marking->mark($place); + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(sprintf('workflow.%s.enter.%s', $this->name, $place), $event); + } + } + } + + private function announce($subject, Transition $initialTransition, Marking $marking) + { + if (null === $this->dispatcher) { + return; + } + + $event = new Event($subject, $marking, $initialTransition); + + foreach ($this->definition->getTransitions() as $transition) { + if ($this->doCan($subject, $marking, $transition)) { + $this->dispatcher->dispatch(sprintf('workflow.%s.announce.%s', $this->name, $transition->getName()), $event); + } + } + } +} diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json new file mode 100644 index 000000000000..3f12343dc78a --- /dev/null +++ b/src/Symfony/Component/Workflow/composer.json @@ -0,0 +1,40 @@ +{ + "name": "symfony/workflow", + "type": "library", + "description": "Symfony Workflow Component", + "keywords": ["workflow", "petrinet", "place", "transition"], + "homepage": "http://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.5.9" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/event-dispatcher": "~2.1|~3.0", + "symfony/property-access": "~2.3|~3.0", + "twig/twig": "~1.14" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Workflow\\": "" } + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + } +} diff --git a/src/Symfony/Component/Workflow/phpunit.xml.dist b/src/Symfony/Component/Workflow/phpunit.xml.dist new file mode 100644 index 000000000000..5817db3b8fe2 --- /dev/null +++ b/src/Symfony/Component/Workflow/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +