From 17d59a7c661088aaa56e0209a691fb441fdfd50e Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sun, 1 Dec 2013 10:16:07 +0100 Subject: [PATCH 1/2] added the first more-or-less working version of the Workflow component --- src/Symfony/Component/Workflow/Definition.php | 87 ++++++++ .../Workflow/Dumper/DumperInterface.php | 32 +++ .../Workflow/Dumper/GraphvizDumper.php | 198 +++++++++++++++++ .../Component/Workflow/Event/Event.php | 51 +++++ .../Component/Workflow/Event/GuardEvent.php | 30 +++ .../Workflow/Event/TransitionEvent.php | 30 +++ .../EventListener/AuditTrailListener.php | 44 ++++ src/Symfony/Component/Workflow/Registry.php | 43 ++++ src/Symfony/Component/Workflow/Transition.php | 44 ++++ src/Symfony/Component/Workflow/Workflow.php | 208 ++++++++++++++++++ src/Symfony/Component/Workflow/composer.json | 36 +++ 11 files changed, 803 insertions(+) create mode 100644 src/Symfony/Component/Workflow/Definition.php create mode 100644 src/Symfony/Component/Workflow/Dumper/DumperInterface.php create mode 100644 src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php create mode 100644 src/Symfony/Component/Workflow/Event/Event.php create mode 100644 src/Symfony/Component/Workflow/Event/GuardEvent.php create mode 100644 src/Symfony/Component/Workflow/Event/TransitionEvent.php create mode 100644 src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php create mode 100644 src/Symfony/Component/Workflow/Registry.php create mode 100644 src/Symfony/Component/Workflow/Transition.php create mode 100644 src/Symfony/Component/Workflow/Workflow.php create mode 100644 src/Symfony/Component/Workflow/composer.json diff --git a/src/Symfony/Component/Workflow/Definition.php b/src/Symfony/Component/Workflow/Definition.php new file mode 100644 index 0000000000..912a08dbb4 --- /dev/null +++ b/src/Symfony/Component/Workflow/Definition.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * @author Fabien Potencier + */ +class Definition +{ + private $class; + private $states = array(); + private $transitions = array(); + private $initialState; + + public function __construct($class) + { + $this->class = $class; + } + + public function getClass() + { + return $this->class; + } + + public function getStates() + { + return $this->states; + } + + public function getTransitions() + { + return $this->transitions; + } + + public function getInitialState() + { + return $this->initialState; + } + + public function setInitialState($name) + { + if (!isset($this->states[$name])) { + throw new \LogicException(sprintf('State "%s" cannot be the initial state as it does not exist.', $name)); + } + + $this->initialState = $name; + } + + public function addState($name) + { + if (!count($this->states)) { + $this->initialState = $name; + } + + $this->states[$name] = $name; + } + + public function addTransition(Transition $transition) + { + if (isset($this->transitions[$transition->getName()])) { + throw new \LogicException(sprintf('Transition "%s" is already defined.', $transition->getName())); + } + + foreach ($transition->getFroms() as $from) { + if (!isset($this->states[$from])) { + throw new \LogicException(sprintf('State "%s" referenced in transition "%s" does not exist.', $from, $name)); + } + } + + foreach ($transition->getTos() as $to) { + if (!isset($this->states[$to])) { + throw new \LogicException(sprintf('State "%s" referenced in transition "%s" does not exist.', $to, $name)); + } + } + + $this->transitions[$transition->getName()] = $transition; + } +} diff --git a/src/Symfony/Component/Workflow/Dumper/DumperInterface.php b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php new file mode 100644 index 0000000000..2cea51e885 --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php @@ -0,0 +1,32 @@ + + * + * 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; + +/** + * DumperInterface is the interface implemented by workflow dumper classes. + * + * @author Fabien Potencier + */ +interface DumperInterface +{ + /** + * Dumps a workflow definition. + * + * @param Definition $definition A Definition instance + * @param array $options An array of options + * + * @return string The representation of the workflow + */ + public function dump(Definition $definition, 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 0000000000..ef28521e70 --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php @@ -0,0 +1,198 @@ + + * + * 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; + +/** + * 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 + */ +class GraphvizDumper implements DumperInterface +{ + private $nodes; + private $edges; + private $options = array( + 'graph' => array('ratio' => 'compress', 'rankdir' => 'LR'), + 'node' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333', 'shape' => 'circle', 'fillcolor' => 'lightblue', 'fixedsize' => true, 'width' => 1), + 'edge' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333', 'arrowhead' => 'normal', 'arrowsize' => 0.5), + ); + + /** + * Dumps the workflow as a graphviz graph. + * + * Available options: + * + * * graph: The default options for the whole graph + * * node: The default options for nodes + * * edge: The default options for edges + * + * @param Definition $definition A Definition instance + * @param array $options An array of options + * + * @return string The dot representation of the workflow + */ + public function dump(Definition $definition, array $options = array()) + { + foreach (array('graph', 'node', 'edge') as $key) { + if (isset($options[$key])) { + $this->options[$key] = array_merge($this->options[$key], $options[$key]); + } + } + + $this->nodes = $this->findNodes($definition); + $this->edges = $this->findEdges($definition); + + return $this->startDot().$this->addNodes().$this->addEdges().$this->endDot(); + } + + /** + * Finds all nodes. + * + * @return array An array of all nodes + */ + private function findNodes(Definition $definition) + { + $nodes = array(); + foreach ($definition->getStates() as $state) { + $nodes[$state] = array( + 'attributes' => array_merge($this->options['node'], array('style' => $state == $definition->getInitialState() ? 'filled' : 'solid')) + ); + } + + return $nodes; + } + + /** + * Returns all nodes. + * + * @return string A string representation of all nodes + */ + private function addNodes() + { + $code = ''; + foreach ($this->nodes as $id => $node) { + $code .= sprintf(" node_%s [label=\"%s\", shape=%s%s];\n", $this->dotize($id), $id, $this->options['node']['shape'], $this->addAttributes($node['attributes'])); + } + + return $code; + } + + private function findEdges(Definition $definition) + { + $edges = array(); + foreach ($definition->getTransitions() as $transition) { + foreach ($transition->getFroms() as $from) { + foreach ($transition->getTos() as $to) { + $edges[$from][] = array( + 'name' => $transition->getName(), + 'to' => $to, + ); + } + } + } + + return $edges; + } + + /** + * Returns all edges. + * + * @return string A string representation of all edges + */ + private function addEdges() + { + $code = ''; + foreach ($this->edges as $id => $edges) { + foreach ($edges as $edge) { + $code .= sprintf(" node_%s -> node_%s [label=\"%s\" style=\"%s\"];\n", $this->dotize($id), $this->dotize($edge['to']), $edge['name'], 'solid'); + } + } + + return $code; + } + + /** + * Returns the start dot. + * + * @return string The string representation of a start dot + */ + private function startDot() + { + return sprintf("digraph workflow {\n %s\n node [%s];\n edge [%s];\n\n", + $this->addOptions($this->options['graph']), + $this->addOptions($this->options['node']), + $this->addOptions($this->options['edge']) + ); + } + + /** + * Returns the end dot. + * + * @return string + */ + private function endDot() + { + return "}\n"; + } + + /** + * Adds attributes + * + * @param array $attributes An array of attributes + * + * @return string A comma separated list of attributes + */ + private function addAttributes($attributes) + { + $code = array(); + foreach ($attributes as $k => $v) { + $code[] = sprintf('%s="%s"', $k, $v); + } + + return $code ? ', '.implode(', ', $code) : ''; + } + + /** + * Adds options + * + * @param array $options An array of options + * + * @return string A space separated list of options + */ + private function addOptions($options) + { + $code = array(); + foreach ($options as $k => $v) { + $code[] = sprintf('%s="%s"', $k, $v); + } + + return implode(' ', $code); + } + + /** + * Dotizes an identifier. + * + * @param string $id The identifier to dotize + * + * @return string A dotized string + */ + 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 0000000000..c406223e5a --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/Event.php @@ -0,0 +1,51 @@ + + * + * 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; + +/** + * @author Fabien Potencier + */ +class Event extends BaseEvent +{ + private $object; + private $state; + private $attributes; + + public function __construct($object, $state, array $attributes = array()) + { + $this->object = $object; + $this->state = $state; + $this->attributes = $attributes; + } + + public function getState() + { + return $this->state; + } + + public function getObject() + { + return $this->object; + } + + public function getAttribute($key) + { + return isset($this->attributes[$key]) ? $this->attributes[$key] : null; + } + + public function hastAttribute($key) + { + return isset($this->attributes[$key]); + } +} diff --git a/src/Symfony/Component/Workflow/Event/GuardEvent.php b/src/Symfony/Component/Workflow/Event/GuardEvent.php new file mode 100644 index 0000000000..64df6f8184 --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/GuardEvent.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 GuardEvent extends Event +{ + private $allowed = null; + + public function isAllowed() + { + return $this->allowed; + } + + public function setAllowed($allowed) + { + $this->allowed = (Boolean) $allowed; + } +} diff --git a/src/Symfony/Component/Workflow/Event/TransitionEvent.php b/src/Symfony/Component/Workflow/Event/TransitionEvent.php new file mode 100644 index 0000000000..1fe0e2ff96 --- /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 0000000000..045c3ee584 --- /dev/null +++ b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php @@ -0,0 +1,44 @@ + + * + * 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 Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Workflow\Event\Event; + +class AuditTrailListener implements EventSubscriberInterface +{ + public function onEnter(Event $event) + { +// FIXME: object "identity", timestamp, who, ... +error_log('entering "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + } + + public function onLeave(Event $event) + { +error_log('leaving "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + } + + public function onTransition(Event $event) + { +error_log('transition "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + } + + public static function getSubscribedEvents() + { + return array( +// FIXME: add a way to listen to workflow.XXX.* + 'workflow.transition' => array('onTransition'), + 'workflow.leave' => array('onLeave'), + 'workflow.enter' => array('onEnter'), + ); + } +} diff --git a/src/Symfony/Component/Workflow/Registry.php b/src/Symfony/Component/Workflow/Registry.php new file mode 100644 index 0000000000..cdc98f9a25 --- /dev/null +++ b/src/Symfony/Component/Workflow/Registry.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * @author Fabien Potencier + */ +class Registry +{ + private $workflows = array(); + + public function __construct(array $workflows = array()) + { + foreach ($workflows as $workflow) { + $this->add($workflow); + } + } + + public function add(Workflow $workflow) + { + $this->workflows[] = $workflow; + } + + public function get($object) + { + foreach ($this->workflows as $workflow) { + if ($workflow->supports($object)) { + return $workflow; + } + } + + throw new \InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_class($object))); + } +} diff --git a/src/Symfony/Component/Workflow/Transition.php b/src/Symfony/Component/Workflow/Transition.php new file mode 100644 index 0000000000..f5797ed5b6 --- /dev/null +++ b/src/Symfony/Component/Workflow/Transition.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * @author Fabien Potencier + */ +class Transition +{ + private $name; + private $froms = array(); + private $tos = array(); + + public function __construct($name, $froms, $tos) + { + $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 0000000000..41fac3a52e --- /dev/null +++ b/src/Symfony/Component/Workflow/Workflow.php @@ -0,0 +1,208 @@ + + * + * 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\Event\TransitionEvent; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; + +/** + * @author Fabien Potencier + */ +class Workflow +{ + private $name; + private $dispatcher; + private $propertyAccessor; + private $property = 'state'; + private $stateTransitions = array(); + private $states; + private $initialState; + private $class; + + public function __construct($name, Definition $definition, EventDispatcherInterface $dispatcher = null) + { + $this->name = $name; + $this->dispatcher = $dispatcher; + $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + + $this->states = $definition->getStates(); + $this->class = $definition->getClass(); + $this->initialState = $definition->getInitialState(); + foreach ($definition->getTransitions() as $name => $transition) { + $this->transitions[$name] = $transition; + foreach ($transition->getFroms() as $from) { + $this->stateTransitions[$from][$name] = $name; + } + } + } + + public function supports($class) + { + return $class instanceof $this->class; + } + + public function can($object, $transition) + { + if (!isset($this->transitions[$transition])) { + throw new \LogicException(sprintf('Transition "%s" does not exist for workflow "%s".', $transition, $this->name)); + } + + if (null !== $this->dispatcher) { + $event = new GuardEvent($object, $this->getState($object)); + + $this->dispatcher->dispatch(sprintf('workflow.%s.guard.%s', $this->name, $transition), $event); + + if (null !== $ret = $event->isAllowed()) { + return $ret; + } + } + + return isset($this->stateTransitions[$this->getState($object)][$transition]); + } + + public function getState($object) + { + $state = $this->propertyAccessor->getValue($object, $this->property); + + // check if the object is already in the workflow + if (null === $state) { + $this->enter($object, $this->initialState, array()); + + $state = $this->propertyAccessor->getValue($object, $this->property); + } + + // check that the object has a known state + if (!isset($this->states[$state])) { + throw new \LogicException(sprintf('State "%s" is not valid for workflow "%s".', $transition, $this->name)); + } + + return $state; + } + + public function apply($object, $transition, array $attributes = array()) + { + $current = $this->getState($object); + + if (!$this->can($object, $transition)) { + throw new \LogicException(sprintf('Unable to apply transition "%s" from state "%s" for workflow "%s".', $transition, $current, $this->name)); + } + + $transition = $this->determineTransition($current, $transition); + + $this->leave($object, $current, $attributes); + + $state = $this->transition($object, $current, $transition, $attributes); + + $this->enter($object, $state, $attributes); + } + + public function getAvailableTransitions($object) + { + return array_keys($this->stateTransitions[$this->getState($object)]); + } + + public function getNextStates($object) + { + if (!$stateTransitions = $this->stateTransitions[$this->getState($object)]) { + return array(); + } + + $states = array(); + foreach ($stateTransitions as $transition) { + foreach ($this->transitions[$transition]->getTos() as $to) { + $states[] = $to; + } + } + + return $states; + } + + public function setStateProperty($property) + { + $this->property = $property; + } + + public function setPropertyAccessor(PropertyAccessor $propertyAccessor) + { + $this->propertyAccessor = $propertyAccessor; + } + + public function __call($method, $arguments) + { + if (!count($arguments)) { + throw new BadMethodCallException(); + } + + return $this->apply($arguments[0], $method, array_slice($arguments, 1)); + } + + private function leave($object, $state, $attributes) + { + if (null === $this->dispatcher) { + return; + } + + $this->dispatcher->dispatch(sprintf('workflow.leave', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave.%s', $this->name, $state), new Event($object, $state, $attributes)); + } + + private function transition($object, $current, Transition $transition, $attributes) + { + $state = null; + $tos = $transition->getTos(); + + if (null !== $this->dispatcher) { + // the generic event cannot change the next state + $this->dispatcher->dispatch(sprintf('workflow.transition', $this->name), new Event($object, $current, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.transition', $this->name), new Event($object, $current, $attributes)); + + $event = new TransitionEvent($object, $current, $attributes); + $this->dispatcher->dispatch(sprintf('workflow.%s.transition.%s', $this->name, $transition->getName()), $event); + $state = $event->getNextState(); + + if (null !== $state && !in_array($state, $tos)) { + throw new \LogicException(sprintf('Transition "%s" cannot go to state "%s" for workflow "%s"', $transition->getName(), $state, $this->name)); + } + } + + if (null === $state) { + if (count($tos) > 1) { + throw new \LogicException(sprintf('Unable to apply transition "%s" as the new state is not unique for workflow "%s".', $transition->getName(), $this->name)); + } + + $state = $tos[0]; + } + + return $state; + } + + private function enter($object, $state, $attributes) + { + $this->propertyAccessor->setValue($object, $this->property, $state); + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(sprintf('workflow.enter', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.enter', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.enter.%s', $this->name, $state), new Event($object, $state, $attributes)); + } + } + + private function determineTransition($current, $transition) + { + return $this->transitions[$transition]; + } +} diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json new file mode 100644 index 0000000000..4b6ee3a7cf --- /dev/null +++ b/src/Symfony/Component/Workflow/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/workflow", + "type": "library", + "description": "Symfony Workflow Component", + "keywords": [], + "homepage": "http://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.3.3", + "symfony/event-dispatcher": "~2.1", + "symfony/property-access": "~2.3" + }, + "require-dev": { + "twig/twig": "~1.14" + }, + "autoload": { + "psr-0": { "Symfony\\Component\\Workflow\\": "" } + }, + "target-dir": "Symfony/Component/Workflow", + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.5-dev" + } + } +} From 078e27f1395fd4f1f42b1638afd40d165687157b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Fri, 25 Mar 2016 16:43:30 +0100 Subject: [PATCH 2/2] [Workflow] Added initial set of files --- composer.json | 1 + .../Twig/Extension/WorkflowExtension.php | 52 ++++ .../Command/WorkflowDumpCommand.php | 78 +++++ .../DependencyInjection/Configuration.php | 94 ++++++ .../FrameworkExtension.php | 51 ++++ .../Resources/config/schema/symfony-1.0.xsd | 39 +++ .../Resources/config/workflow.xml | 25 ++ .../DependencyInjection/ConfigurationTest.php | 1 + .../Fixtures/php/workflow.php | 30 ++ .../Fixtures/xml/workflow.xml | 29 ++ .../Fixtures/yml/workflow.yml | 16 + .../FrameworkExtensionTest.php | 7 + src/Symfony/Component/Workflow/CHANGELOG.md | 2 + src/Symfony/Component/Workflow/Definition.php | 83 +++-- .../Workflow/Dumper/DumperInterface.php | 9 +- .../Workflow/Dumper/GraphvizDumper.php | 191 ++++++------ .../Component/Workflow/Event/Event.php | 43 +-- .../Component/Workflow/Event/GuardEvent.php | 11 +- .../EventListener/AuditTrailListener.php | 27 +- .../Workflow/Exception/ExceptionInterface.php | 20 ++ .../Exception/InvalidArgumentException.php | 20 ++ .../Workflow/Exception/LogicException.php | 20 ++ src/Symfony/Component/Workflow/LICENSE | 19 ++ src/Symfony/Component/Workflow/Marking.php | 52 ++++ .../MarkingStore/MarkingStoreInterface.php | 39 +++ .../PropertyAccessorMarkingStore.php | 56 ++++ .../MarkingStore/ScalarMarkingStore.php | 62 ++++ .../UniqueTransitionOutputInterface.php | 21 ++ src/Symfony/Component/Workflow/README.md | 11 + src/Symfony/Component/Workflow/Registry.php | 48 ++- .../Workflow/Tests/DefinitionTest.php | 73 +++++ .../Tests/Dumper/GraphvizDumperTest.php | 203 ++++++++++++ .../EventListener/AuditTrailListenerTest.php | 54 ++++ .../PropertyAccessorMarkingStoreTest.php | 32 ++ .../MarkingStore/ScalarMarkingStoreTest.php | 32 ++ .../Component/Workflow/Tests/MarkingTest.php | 35 +++ .../Component/Workflow/Tests/RegistryTest.php | 74 +++++ .../Workflow/Tests/TransitionTest.php | 26 ++ .../Component/Workflow/Tests/WorkflowTest.php | 288 ++++++++++++++++++ src/Symfony/Component/Workflow/Transition.php | 20 +- src/Symfony/Component/Workflow/Workflow.php | 288 +++++++++++------- src/Symfony/Component/Workflow/composer.json | 18 +- .../Component/Workflow/phpunit.xml.dist | 28 ++ 43 files changed, 2040 insertions(+), 288 deletions(-) create mode 100644 src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/workflow.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow.yml create mode 100644 src/Symfony/Component/Workflow/CHANGELOG.md create mode 100644 src/Symfony/Component/Workflow/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/Workflow/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/Workflow/Exception/LogicException.php create mode 100644 src/Symfony/Component/Workflow/LICENSE create mode 100644 src/Symfony/Component/Workflow/Marking.php create mode 100644 src/Symfony/Component/Workflow/MarkingStore/MarkingStoreInterface.php create mode 100644 src/Symfony/Component/Workflow/MarkingStore/PropertyAccessorMarkingStore.php create mode 100644 src/Symfony/Component/Workflow/MarkingStore/ScalarMarkingStore.php create mode 100644 src/Symfony/Component/Workflow/MarkingStore/UniqueTransitionOutputInterface.php create mode 100644 src/Symfony/Component/Workflow/README.md create mode 100644 src/Symfony/Component/Workflow/Tests/DefinitionTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/Dumper/GraphvizDumperTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/EventListener/AuditTrailListenerTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/MarkingStore/PropertyAccessorMarkingStoreTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/MarkingStore/ScalarMarkingStoreTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/MarkingTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/RegistryTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/TransitionTest.php create mode 100644 src/Symfony/Component/Workflow/Tests/WorkflowTest.php create mode 100644 src/Symfony/Component/Workflow/phpunit.xml.dist diff --git a/composer.json b/composer.json index 05c98d8318..b4fb3a6e10 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 0000000000..c2c5a55af9 --- /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 0000000000..d1b4e2a766 --- /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 f38a6dd638..57f9e29c0c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -103,6 +103,7 @@ class Configuration implements ConfigurationInterface $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 @@ class Configuration implements ConfigurationInterface ; } + 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 c92c083119..16b5431b1a 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\DataUriNormalizer; 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 @@ use Symfony\Component\Validator\Validation; * @author Fabien Potencier * @author Jeremy Mikola * @author Kévin Dunglas + * @author Grégoire Pineau */ class FrameworkExtension extends Extension { @@ -129,6 +131,7 @@ class FrameworkExtension extends Extension $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 @@ class FrameworkExtension extends Extension } } + /** + * 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 830702213f..5cd851efbe 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 0000000000..d37b5d3e06 --- /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 e1a677daa7..94b6e315b8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -273,6 +273,7 @@ class ConfigurationTest extends \PHPUnit_Framework_TestCase '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 0000000000..7f29cc385b --- /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 0000000000..add799b82f --- /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 0000000000..e9eb8e1977 --- /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 ef0c5522fd..0406057aad 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -117,6 +117,13 @@ abstract class FrameworkExtensionTest extends TestCase $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 0000000000..c4df4750f7 --- /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 index 912a08dbb4..8bc1bd38ac 100644 --- a/src/Symfony/Component/Workflow/Definition.php +++ b/src/Symfony/Component/Workflow/Definition.php @@ -11,29 +11,41 @@ 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 $class; - private $states = array(); + private $places = array(); + private $transitions = array(); - private $initialState; - public function __construct($class) + private $initialPlace; + + /** + * Definition constructor. + * + * @param string[] $places + * @param Transition[] $transitions + */ + public function __construct(array $places = array(), array $transitions = array()) { - $this->class = $class; + $this->addPlaces($places); + $this->addTransitions($transitions); } - public function getClass() + public function getInitialPlace() { - return $this->class; + return $this->initialPlace; } - public function getStates() + public function getPlaces() { - return $this->states; + return $this->places; } public function getTransitions() @@ -41,47 +53,58 @@ class Definition return $this->transitions; } - public function getInitialState() + public function setInitialPlace($place) { - return $this->initialState; - } - - public function setInitialState($name) - { - if (!isset($this->states[$name])) { - throw new \LogicException(sprintf('State "%s" cannot be the initial state as it does not exist.', $name)); + if (!isset($this->places[$place])) { + throw new LogicException(sprintf('Place "%s" cannot be the initial place as it does not exist.', $place)); } - $this->initialState = $name; + $this->initialPlace = $place; } - public function addState($name) + public function addPlace($place) { - if (!count($this->states)) { - $this->initialState = $name; + if (!preg_match('{^[\w\d_-]+$}', $place)) { + throw new InvalidArgumentException(sprintf('The place "%s" contains invalid characters.', $name)); } - $this->states[$name] = $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) { - if (isset($this->transitions[$transition->getName()])) { - throw new \LogicException(sprintf('Transition "%s" is already defined.', $transition->getName())); - } + $name = $transition->getName(); foreach ($transition->getFroms() as $from) { - if (!isset($this->states[$from])) { - throw new \LogicException(sprintf('State "%s" referenced in transition "%s" does not exist.', $from, $name)); + 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->states[$to])) { - throw new \LogicException(sprintf('State "%s" referenced in transition "%s" does not exist.', $to, $name)); + if (!isset($this->places[$to])) { + throw new LogicException(sprintf('Place "%s" referenced in transition "%s" does not exist.', $to, $name)); } } - $this->transitions[$transition->getName()] = $transition; + $this->transitions[$name] = $transition; } } diff --git a/src/Symfony/Component/Workflow/Dumper/DumperInterface.php b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php index 2cea51e885..b0eebd34f1 100644 --- a/src/Symfony/Component/Workflow/Dumper/DumperInterface.php +++ b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php @@ -12,21 +12,24 @@ 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 array $options An array of options + * @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, array $options = array()); + 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 index ef28521e70..56bbef64ad 100644 --- a/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php +++ b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Workflow\Dumper; use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Marking; /** * GraphvizDumper dumps a workflow as a graphviz file. @@ -21,72 +22,101 @@ use Symfony\Component\Workflow\Definition; * dot -Tpng workflow.dot > workflow.png * * @author Fabien Potencier + * @author Grégoire Pineau */ class GraphvizDumper implements DumperInterface { - private $nodes; - private $edges; - private $options = array( + private static $defaultOptions = array( 'graph' => array('ratio' => 'compress', 'rankdir' => 'LR'), - 'node' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333', 'shape' => 'circle', 'fillcolor' => 'lightblue', 'fixedsize' => true, 'width' => 1), - 'edge' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333', 'arrowhead' => 'normal', 'arrowsize' => 0.5), + '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 + * * node: The default options for nodes (places + transitions) * * edge: The default options for edges - * - * @param Definition $definition A Definition instance - * @param array $options An array of options - * - * @return string The dot representation of the workflow */ - public function dump(Definition $definition, array $options = array()) + public function dump(Definition $definition, Marking $marking = null, array $options = array()) { - foreach (array('graph', 'node', 'edge') as $key) { - if (isset($options[$key])) { - $this->options[$key] = array_merge($this->options[$key], $options[$key]); - } - } + $places = $this->findPlaces($definition, $marking); + $transitions = $this->findTransitions($definition); + $edges = $this->findEdges($definition); - $this->nodes = $this->findNodes($definition); - $this->edges = $this->findEdges($definition); + $options = array_replace_recursive(self::$defaultOptions, $options); - return $this->startDot().$this->addNodes().$this->addEdges().$this->endDot(); + return $this->startDot($options) + .$this->addPlaces($places) + .$this->addTransitions($transitions) + .$this->addEdges($edges) + .$this->endDot(); } - /** - * Finds all nodes. - * - * @return array An array of all nodes - */ - private function findNodes(Definition $definition) + private function findPlaces(Definition $definition, Marking $marking = null) { - $nodes = array(); - foreach ($definition->getStates() as $state) { - $nodes[$state] = array( - 'attributes' => array_merge($this->options['node'], array('style' => $state == $definition->getInitialState() ? 'filled' : 'solid')) + $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 $nodes; + return $places; } - /** - * Returns all nodes. - * - * @return string A string representation of all nodes - */ - private function addNodes() + 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 ($this->nodes as $id => $node) { - $code .= sprintf(" node_%s [label=\"%s\", shape=%s%s];\n", $this->dotize($id), $id, $this->options['node']['shape'], $this->addAttributes($node['attributes'])); + + 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; @@ -94,72 +124,62 @@ class GraphvizDumper implements DumperInterface private function findEdges(Definition $definition) { - $edges = array(); + $dotEdges = array(); + foreach ($definition->getTransitions() as $transition) { foreach ($transition->getFroms() as $from) { - foreach ($transition->getTos() as $to) { - $edges[$from][] = array( - 'name' => $transition->getName(), - 'to' => $to, - ); - } + $dotEdges[] = array( + 'from' => $from, + 'to' => $transition->getName(), + 'direction' => 'from', + ); + } + foreach ($transition->getTos() as $to) { + $dotEdges[] = array( + 'from' => $transition->getName(), + 'to' => $to, + 'direction' => 'to', + ); } } - return $edges; + return $dotEdges; } - /** - * Returns all edges. - * - * @return string A string representation of all edges - */ - private function addEdges() + private function addEdges($edges) { $code = ''; - foreach ($this->edges as $id => $edges) { - foreach ($edges as $edge) { - $code .= sprintf(" node_%s -> node_%s [label=\"%s\" style=\"%s\"];\n", $this->dotize($id), $this->dotize($edge['to']), $edge['name'], 'solid'); - } + + 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; } - /** - * Returns the start dot. - * - * @return string The string representation of a start dot - */ - private function startDot() + private function startDot(array $options) { return sprintf("digraph workflow {\n %s\n node [%s];\n edge [%s];\n\n", - $this->addOptions($this->options['graph']), - $this->addOptions($this->options['node']), - $this->addOptions($this->options['edge']) + $this->addOptions($options['graph']), + $this->addOptions($options['node']), + $this->addOptions($options['edge']) ); } - /** - * Returns the end dot. - * - * @return string - */ private function endDot() { return "}\n"; } - /** - * Adds attributes - * - * @param array $attributes An array of attributes - * - * @return string A comma separated list of attributes - */ private function addAttributes($attributes) { $code = array(); + foreach ($attributes as $k => $v) { $code[] = sprintf('%s="%s"', $k, $v); } @@ -167,16 +187,10 @@ class GraphvizDumper implements DumperInterface return $code ? ', '.implode(', ', $code) : ''; } - /** - * Adds options - * - * @param array $options An array of options - * - * @return string A space separated list of options - */ private function addOptions($options) { $code = array(); + foreach ($options as $k => $v) { $code[] = sprintf('%s="%s"', $k, $v); } @@ -184,13 +198,6 @@ class GraphvizDumper implements DumperInterface return implode(' ', $code); } - /** - * Dotizes an identifier. - * - * @param string $id The identifier to dotize - * - * @return string A dotized string - */ 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 index c406223e5a..a690b2b330 100644 --- a/src/Symfony/Component/Workflow/Event/Event.php +++ b/src/Symfony/Component/Workflow/Event/Event.php @@ -12,40 +12,47 @@ 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 $object; - private $state; - private $attributes; + private $subject; - public function __construct($object, $state, array $attributes = array()) + private $marking; + + private $transition; + + /** + * Event constructor. + * + * @param mixed $subject + * @param Marking $marking + * @param Transition $transition + */ + public function __construct($subject, Marking $marking, Transition $transition) { - $this->object = $object; - $this->state = $state; - $this->attributes = $attributes; + $this->subject = $subject; + $this->marking = $marking; + $this->transition = $transition; } - public function getState() + public function getMarking() { - return $this->state; + return $this->marking; } - public function getObject() + public function getSubject() { - return $this->object; + return $this->subject; } - public function getAttribute($key) + public function getTransition() { - return isset($this->attributes[$key]) ? $this->attributes[$key] : null; - } - - public function hastAttribute($key) - { - return isset($this->attributes[$key]); + return $this->transition; } } diff --git a/src/Symfony/Component/Workflow/Event/GuardEvent.php b/src/Symfony/Component/Workflow/Event/GuardEvent.php index 64df6f8184..bf4b6f3971 100644 --- a/src/Symfony/Component/Workflow/Event/GuardEvent.php +++ b/src/Symfony/Component/Workflow/Event/GuardEvent.php @@ -13,18 +13,19 @@ namespace Symfony\Component\Workflow\Event; /** * @author Fabien Potencier + * @author Grégoire Pineau */ class GuardEvent extends Event { - private $allowed = null; + private $blocked = false; - public function isAllowed() + public function isBlocked() { - return $this->allowed; + return $this->blocked; } - public function setAllowed($allowed) + public function setBlocked($blocked) { - $this->allowed = (Boolean) $allowed; + $this->blocked = (bool) $blocked; } } diff --git a/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php index 045c3ee584..a39c89d18c 100644 --- a/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php +++ b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php @@ -11,33 +11,46 @@ 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 { - public function onEnter(Event $event) + private $logger; + + public function __construct(LoggerInterface $logger) { -// FIXME: object "identity", timestamp, who, ... -error_log('entering "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + $this->logger = $logger; } public function onLeave(Event $event) { -error_log('leaving "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + 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) { -error_log('transition "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + $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( -// FIXME: add a way to listen to workflow.XXX.* - 'workflow.transition' => array('onTransition'), '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 0000000000..b0dfa9b79b --- /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 0000000000..c44fa05cdd --- /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 0000000000..d0cf09f9df --- /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 0000000000..39fa189d2b --- /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 0000000000..0d8bab25b7 --- /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 0000000000..e73c9eb596 --- /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 0000000000..faf1e8a6c4 --- /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 0000000000..949661ee10 --- /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 0000000000..35c00eb58d --- /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 0000000000..a2bf37aa7f --- /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 index cdc98f9a25..2f94288ebb 100644 --- a/src/Symfony/Component/Workflow/Registry.php +++ b/src/Symfony/Component/Workflow/Registry.php @@ -11,33 +11,55 @@ namespace Symfony\Component\Workflow; +use Symfony\Component\Workflow\Exception\InvalidArgumentException; + /** * @author Fabien Potencier + * @author Grégoire Pineau */ class Registry { private $workflows = array(); - public function __construct(array $workflows = array()) + /** + * @param Workflow $workflow + * @param string $classname + */ + public function add(Workflow $workflow, $classname) { - foreach ($workflows as $workflow) { - $this->add($workflow); - } + $this->workflows[] = array($workflow, $classname); } - public function add(Workflow $workflow) + public function get($subject, $workflowName = null) { - $this->workflows[] = $workflow; - } + $matched = null; - public function get($object) - { - foreach ($this->workflows as $workflow) { - if ($workflow->supports($object)) { - return $workflow; + 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; } } - throw new \InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_class($object))); + 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 0000000000..4a4465fd64 --- /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 0000000000..d6e9cd30f3 --- /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 0000000000..0422009f0e --- /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 0000000000..557a241689 --- /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 0000000000..df8d748a0d --- /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 0000000000..026ca607be --- /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 0000000000..719886dd1c --- /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 0000000000..fbf9b38b23 --- /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 0000000000..58e9ecc9ea --- /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 index f5797ed5b6..30cc5eca47 100644 --- a/src/Symfony/Component/Workflow/Transition.php +++ b/src/Symfony/Component/Workflow/Transition.php @@ -11,17 +11,33 @@ namespace Symfony\Component\Workflow; +use Symfony\Component\Workflow\Exception\InvalidArgumentException; + /** * @author Fabien Potencier + * @author Grégoire Pineau */ class Transition { private $name; - private $froms = array(); - private $tos = array(); + 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; diff --git a/src/Symfony/Component/Workflow/Workflow.php b/src/Symfony/Component/Workflow/Workflow.php index 41fac3a52e..0bf637e3d7 100644 --- a/src/Symfony/Component/Workflow/Workflow.php +++ b/src/Symfony/Component/Workflow/Workflow.php @@ -14,195 +14,261 @@ 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\Event\TransitionEvent; -use Symfony\Component\PropertyAccess\PropertyAccess; -use Symfony\Component\PropertyAccess\PropertyAccessor; +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 $name; + private $definition; + private $markingStore; private $dispatcher; - private $propertyAccessor; - private $property = 'state'; - private $stateTransitions = array(); - private $states; - private $initialState; - private $class; + private $name; - public function __construct($name, Definition $definition, EventDispatcherInterface $dispatcher = null) + public function __construct(Definition $definition, MarkingStoreInterface $markingStore, EventDispatcherInterface $dispatcher = null, $name = 'unnamed') { - $this->name = $name; + $this->definition = $definition; + $this->markingStore = $markingStore; $this->dispatcher = $dispatcher; - $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + $this->name = $name; - $this->states = $definition->getStates(); - $this->class = $definition->getClass(); - $this->initialState = $definition->getInitialState(); - foreach ($definition->getTransitions() as $name => $transition) { - $this->transitions[$name] = $transition; - foreach ($transition->getFroms() as $from) { - $this->stateTransitions[$from][$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()))); + } } } } - public function supports($class) + /** + * Returns the object's Marking. + * + * @param object $subject A subject + * + * @return Marking The Marking + * + * @throws LogicException + */ + public function getMarking($subject) { - return $class instanceof $this->class; - } + $marking = $this->markingStore->getMarking($subject); - public function can($object, $transition) - { - if (!isset($this->transitions[$transition])) { - throw new \LogicException(sprintf('Transition "%s" does not exist for workflow "%s".', $transition, $this->name)); + 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)); } - if (null !== $this->dispatcher) { - $event = new GuardEvent($object, $this->getState($object)); + // 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()); + } - $this->dispatcher->dispatch(sprintf('workflow.%s.guard.%s', $this->name, $transition), $event); + // 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.'; + } - if (null !== $ret = $event->isAllowed()) { - return $ret; + throw new LogicException($message); } } - return isset($this->stateTransitions[$this->getState($object)][$transition]); + // Because the marking could have been initialized, we update the subject + $this->markingStore->setMarking($subject, $marking); + + return $marking; } - public function getState($object) + /** + * 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) { - $state = $this->propertyAccessor->getValue($object, $this->property); + $transitions = $this->definition->getTransitions(); - // check if the object is already in the workflow - if (null === $state) { - $this->enter($object, $this->initialState, array()); - - $state = $this->propertyAccessor->getValue($object, $this->property); + if (!isset($transitions[$transitionName])) { + throw new LogicException(sprintf('Transition "%s" does not exist for workflow "%s".', $transitionName, $this->name)); } - // check that the object has a known state - if (!isset($this->states[$state])) { - throw new \LogicException(sprintf('State "%s" is not valid for workflow "%s".', $transition, $this->name)); - } + $transition = $transitions[$transitionName]; - return $state; + $marking = $this->getMarking($subject); + + return $this->doCan($subject, $marking, $transition); } - public function apply($object, $transition, array $attributes = array()) + /** + * 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) { - $current = $this->getState($object); - - if (!$this->can($object, $transition)) { - throw new \LogicException(sprintf('Unable to apply transition "%s" from state "%s" for workflow "%s".', $transition, $current, $this->name)); + if (!$this->can($subject, $transitionName)) { + throw new LogicException(sprintf('Unable to apply transition "%s" for workflow "%s".', $transitionName, $this->name)); } - $transition = $this->determineTransition($current, $transition); + // 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); - $this->leave($object, $current, $attributes); + $transition = $this->definition->getTransitions()[$transitionName]; - $state = $this->transition($object, $current, $transition, $attributes); + $this->leave($subject, $transition, $marking); - $this->enter($object, $state, $attributes); + $this->transition($subject, $transition, $marking); + + $this->enter($subject, $transition, $marking); + + $this->announce($subject, $transition, $marking); + + $this->markingStore->setMarking($subject, $marking); + + return $marking; } - public function getAvailableTransitions($object) + /** + * Returns all enabled transitions. + * + * @param object $subject A subject + * + * @return Transition[] All enabled transitions + */ + public function getEnabledTransitions($subject) { - return array_keys($this->stateTransitions[$this->getState($object)]); - } + $enabled = array(); - public function getNextStates($object) - { - if (!$stateTransitions = $this->stateTransitions[$this->getState($object)]) { - return array(); - } + $marking = $this->getMarking($subject); - $states = array(); - foreach ($stateTransitions as $transition) { - foreach ($this->transitions[$transition]->getTos() as $to) { - $states[] = $to; + foreach ($this->definition->getTransitions() as $transition) { + if ($this->doCan($subject, $marking, $transition)) { + $enabled[$transition->getName()] = $transition; } } - return $states; + return $enabled; } - public function setStateProperty($property) + public function getName() { - $this->property = $property; + return $this->name; } - public function setPropertyAccessor(PropertyAccessor $propertyAccessor) + private function doCan($subject, Marking $marking, Transition $transition) { - $this->propertyAccessor = $propertyAccessor; - } - - public function __call($method, $arguments) - { - if (!count($arguments)) { - throw new BadMethodCallException(); + foreach ($transition->getFroms() as $place) { + if (!$marking->has($place)) { + return false; + } } - return $this->apply($arguments[0], $method, array_slice($arguments, 1)); + if (true === $this->guardTransition($subject, $marking, $transition)) { + return false; + } + + return true; } - private function leave($object, $state, $attributes) + private function guardTransition($subject, Marking $marking, Transition $transition) { if (null === $this->dispatcher) { return; } - $this->dispatcher->dispatch(sprintf('workflow.leave', $this->name), new Event($object, $state, $attributes)); - $this->dispatcher->dispatch(sprintf('workflow.%s.leave', $this->name), new Event($object, $state, $attributes)); - $this->dispatcher->dispatch(sprintf('workflow.%s.leave.%s', $this->name, $state), new Event($object, $state, $attributes)); + $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 transition($object, $current, Transition $transition, $attributes) + private function leave($subject, Transition $transition, Marking $marking) { - $state = null; - $tos = $transition->getTos(); - if (null !== $this->dispatcher) { - // the generic event cannot change the next state - $this->dispatcher->dispatch(sprintf('workflow.transition', $this->name), new Event($object, $current, $attributes)); - $this->dispatcher->dispatch(sprintf('workflow.%s.transition', $this->name), new Event($object, $current, $attributes)); + $event = new Event($subject, $marking, $transition); - $event = new TransitionEvent($object, $current, $attributes); - $this->dispatcher->dispatch(sprintf('workflow.%s.transition.%s', $this->name, $transition->getName()), $event); - $state = $event->getNextState(); + $this->dispatcher->dispatch('workflow.leave', $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave', $this->name), $event); + } - if (null !== $state && !in_array($state, $tos)) { - throw new \LogicException(sprintf('Transition "%s" cannot go to state "%s" for workflow "%s"', $transition->getName(), $state, $this->name)); + foreach ($transition->getFroms() as $place) { + $marking->unmark($place); + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(sprintf('workflow.%s.leave.%s', $this->name, $place), $event); } } - - if (null === $state) { - if (count($tos) > 1) { - throw new \LogicException(sprintf('Unable to apply transition "%s" as the new state is not unique for workflow "%s".', $transition->getName(), $this->name)); - } - - $state = $tos[0]; - } - - return $state; } - private function enter($object, $state, $attributes) + private function transition($subject, Transition $transition, Marking $marking) { - $this->propertyAccessor->setValue($object, $this->property, $state); + 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) { - $this->dispatcher->dispatch(sprintf('workflow.enter', $this->name), new Event($object, $state, $attributes)); - $this->dispatcher->dispatch(sprintf('workflow.%s.enter', $this->name), new Event($object, $state, $attributes)); - $this->dispatcher->dispatch(sprintf('workflow.%s.enter.%s', $this->name, $state), new Event($object, $state, $attributes)); + $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 determineTransition($current, $transition) + private function announce($subject, Transition $initialTransition, Marking $marking) { - return $this->transitions[$transition]; + 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 index 4b6ee3a7cf..3f12343dc7 100644 --- a/src/Symfony/Component/Workflow/composer.json +++ b/src/Symfony/Component/Workflow/composer.json @@ -2,7 +2,7 @@ "name": "symfony/workflow", "type": "library", "description": "Symfony Workflow Component", - "keywords": [], + "keywords": ["workflow", "petrinet", "place", "transition"], "homepage": "http://symfony.com", "license": "MIT", "authors": [ @@ -10,27 +10,31 @@ "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.3.3", - "symfony/event-dispatcher": "~2.1", - "symfony/property-access": "~2.3" + "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-0": { "Symfony\\Component\\Workflow\\": "" } + "psr-4": { "Symfony\\Component\\Workflow\\": "" } }, - "target-dir": "Symfony/Component/Workflow", "minimum-stability": "dev", "extra": { "branch-alias": { - "dev-master": "2.5-dev" + "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 0000000000..5817db3b8f --- /dev/null +++ b/src/Symfony/Component/Workflow/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +