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 0000000000000..912a08dbb46c4 --- /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 0000000000000..2cea51e885fac --- /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 0000000000000..ef28521e708df --- /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 0000000000000..c406223e5a658 --- /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 0000000000000..64df6f81848de --- /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 0000000000000..1fe0e2ff962a9 --- /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 0000000000000..045c3ee584915 --- /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 0000000000000..cdc98f9a2553b --- /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 0000000000000..f5797ed5b6351 --- /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 0000000000000..41fac3a52e29a --- /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 0000000000000..4b6ee3a7cfe96 --- /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 | 77 +++-- .../Workflow/Dumper/DumperInterface.php | 9 +- .../Workflow/Dumper/GraphvizDumper.php | 187 ++++++------ .../Component/Workflow/Event/Event.php | 41 +-- .../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 | 46 ++- .../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 | 286 ++++++++++------- src/Symfony/Component/Workflow/composer.json | 18 +- .../Component/Workflow/phpunit.xml.dist | 28 ++ 43 files changed, 2032 insertions(+), 280 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 05c98d8318642..b4fb3a6e105f0 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 0000000000000..c2c5a55af954f --- /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 0000000000000..d1b4e2a766d6e --- /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 f38a6dd638507..57f9e29c0cf2e 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -103,6 +103,7 @@ public function getConfigTreeBuilder() $this->addSsiSection($rootNode); $this->addFragmentsSection($rootNode); $this->addProfilerSection($rootNode); + $this->addWorkflowSection($rootNode); $this->addRouterSection($rootNode); $this->addSessionSection($rootNode); $this->addRequestSection($rootNode); @@ -226,6 +227,99 @@ private function addProfilerSection(ArrayNodeDefinition $rootNode) ; } + private function addWorkflowSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('workflows') + ->useAttributeAsKey('name') + ->prototype('array') + ->children() + ->arrayNode('marking_store') + ->isRequired() + ->children() + ->enumNode('type') + ->values(array('property_accessor', 'scalar')) + ->end() + ->arrayNode('arguments') + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->prototype('scalar') + ->end() + ->end() + ->scalarNode('service') + ->cannotBeEmpty() + ->end() + ->end() + ->validate() + ->always(function ($v) { + if (isset($v['type']) && isset($v['service'])) { + throw new \InvalidArgumentException('"type" and "service" could not be used together.'); + } + + return $v; + }) + ->end() + ->end() + ->arrayNode('supports') + ->isRequired() + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->prototype('scalar') + ->cannotBeEmpty() + ->validate() + ->ifTrue(function ($v) { return !class_exists($v); }) + ->thenInvalid('The supported class %s does not exist.') + ->end() + ->end() + ->end() + ->arrayNode('places') + ->isRequired() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->end() + ->arrayNode('transitions') + ->useAttributeAsKey('name') + ->isRequired() + ->requiresAtLeastOneElement() + ->prototype('array') + ->children() + ->arrayNode('from') + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->end() + ->arrayNode('to') + ->beforeNormalization() + ->ifString() + ->then(function ($v) { return array($v); }) + ->end() + ->requiresAtLeastOneElement() + ->prototype('scalar') + ->cannotBeEmpty() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + private function addRouterSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index c92c083119ba9..16b5431b1acc5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -31,6 +31,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer; use Symfony\Component\Validator\Validation; +use Symfony\Component\Workflow; /** * FrameworkExtension. @@ -38,6 +39,7 @@ * @author Fabien Potencier * @author Jeremy Mikola * @author Kévin Dunglas + * @author Grégoire Pineau */ class FrameworkExtension extends Extension { @@ -129,6 +131,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerTranslatorConfiguration($config['translator'], $container); $this->registerProfilerConfiguration($config['profiler'], $container, $loader); $this->registerCacheConfiguration($config['cache'], $container); + $this->registerWorkflowConfiguration($config['workflows'], $container, $loader); if ($this->isConfigEnabled($container, $config['router'])) { $this->registerRouterConfiguration($config['router'], $container, $loader); @@ -346,6 +349,54 @@ private function registerProfilerConfiguration(array $config, ContainerBuilder $ } } + /** + * Loads the workflow configuration. + * + * @param array $workflows A workflow configuration array + * @param ContainerBuilder $container A ContainerBuilder instance + * @param XmlFileLoader $loader An XmlFileLoader instance + */ + private function registerWorkflowConfiguration(array $workflows, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!$workflows) { + return; + } + + $loader->load('workflow.xml'); + + $registryDefinition = $container->getDefinition('workflow.registry'); + + foreach ($workflows as $name => $workflow) { + $definitionDefinition = new Definition(Workflow\Definition::class); + $definitionDefinition->addMethodCall('addPlaces', array($workflow['places'])); + foreach ($workflow['transitions'] as $transitionName => $transition) { + $definitionDefinition->addMethodCall('addTransition', array(new Definition(Workflow\Transition::class, array($transitionName, $transition['from'], $transition['to'])))); + } + + if (isset($workflow['marking_store']['type'])) { + $markingStoreDefinition = new DefinitionDecorator('workflow.marking_store.'.$workflow['marking_store']['type']); + foreach ($workflow['marking_store']['arguments'] as $argument) { + $markingStoreDefinition->addArgument($argument); + } + } else { + $markingStoreDefinition = new Reference($workflow['marking_store']['service']); + } + + $workflowDefinition = new DefinitionDecorator('workflow.abstract'); + $workflowDefinition->replaceArgument(0, $definitionDefinition); + $workflowDefinition->replaceArgument(1, $markingStoreDefinition); + $workflowDefinition->replaceArgument(3, $name); + + $workflowId = 'workflow.'.$name; + + $container->setDefinition($workflowId, $workflowDefinition); + + foreach ($workflow['supports'] as $supportedClass) { + $registryDefinition->addMethodCall('add', array(new Reference($workflowId), $supportedClass)); + } + } + } + /** * Loads the router configuration. * diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 830702213f9e7..5cd851efbefa3 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 0000000000000..d37b5d3e0670d --- /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 e1a677daa73a5..94b6e315b8c20 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -273,6 +273,7 @@ protected static function getBundleDefaultConfig() 'directory' => '%kernel.cache_dir%/pools', 'default_redis_provider' => 'redis://localhost', ), + 'workflows' => array(), ); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow.php new file mode 100644 index 0000000000000..7f29cc385ba5b --- /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 0000000000000..add799b82fd44 --- /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 0000000000000..e9eb8e1977a9d --- /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 ef0c5522fd110..0406057aad979 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTest.php @@ -117,6 +117,13 @@ public function testDisabledProfiler() $this->assertFalse($container->hasDefinition('data_collector.config'), '->registerProfilerConfiguration() does not load collectors.xml'); } + public function testWorkflow() + { + $container = $this->createContainerFromFile('workflow'); + + $this->assertTrue($container->hasDefinition('workflow.my_workflow')); + } + public function testRouter() { $container = $this->createContainerFromFile('full'); diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md new file mode 100644 index 0000000000000..c4df4750f73b2 --- /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 912a08dbb46c4..8bc1bd38ace99 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 @@ public function getTransitions() return $this->transitions; } - public function getInitialState() + public function setInitialPlace($place) { - return $this->initialState; + if (!isset($this->places[$place])) { + throw new LogicException(sprintf('Place "%s" cannot be the initial place as it does not exist.', $place)); + } + + $this->initialPlace = $place; } - public function setInitialState($name) + public function addPlace($place) { - if (!isset($this->states[$name])) { - throw new \LogicException(sprintf('State "%s" cannot be the initial state as it does not exist.', $name)); + if (!preg_match('{^[\w\d_-]+$}', $place)) { + throw new InvalidArgumentException(sprintf('The place "%s" contains invalid characters.', $name)); } - $this->initialState = $name; + if (!count($this->places)) { + $this->initialPlace = $place; + } + + $this->places[$place] = $place; } - public function addState($name) + public function addPlaces(array $places) { - if (!count($this->states)) { - $this->initialState = $name; + foreach ($places as $place) { + $this->addPlace($place); } + } - $this->states[$name] = $name; + 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 2cea51e885fac..b0eebd34f1952 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 ef28521e708df..56bbef64aded4 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 @@ * 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()) + { + $places = $this->findPlaces($definition, $marking); + $transitions = $this->findTransitions($definition); + $edges = $this->findEdges($definition); + + $options = array_replace_recursive(self::$defaultOptions, $options); + + return $this->startDot($options) + .$this->addPlaces($places) + .$this->addTransitions($transitions) + .$this->addEdges($edges) + .$this->endDot(); + } + + private function findPlaces(Definition $definition, Marking $marking = null) { - foreach (array('graph', 'node', 'edge') as $key) { - if (isset($options[$key])) { - $this->options[$key] = array_merge($this->options[$key], $options[$key]); + $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, + ); } - $this->nodes = $this->findNodes($definition); - $this->edges = $this->findEdges($definition); + return $places; + } + + private function findTransitions(Definition $definition) + { + $transitions = array(); + + foreach ($definition->getTransitions() as $name => $transition) { + $transitions[$name] = array( + 'attributes' => array('shape' => 'box', 'regular' => true), + ); + } - return $this->startDot().$this->addNodes().$this->addEdges().$this->endDot(); + return $transitions; } - /** - * Finds all nodes. - * - * @return array An array of all nodes - */ - private function findNodes(Definition $definition) + private function addPlaces(array $places) { - $nodes = array(); - foreach ($definition->getStates() as $state) { - $nodes[$state] = array( - 'attributes' => array_merge($this->options['node'], array('style' => $state == $definition->getInitialState() ? 'filled' : 'solid')) + $code = ''; + + foreach ($places as $id => $place) { + $code .= sprintf(" place_%s [label=\"%s\", shape=circle%s];\n", + $this->dotize($id), + $id, + $this->addAttributes($place['attributes']) ); } - return $nodes; + return $code; } - /** - * Returns all nodes. - * - * @return string A string representation of all nodes - */ - private function addNodes() + private function addTransitions(array $transitions) { $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 ($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 @@ private function addNodes() 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 @@ private function addAttributes($attributes) 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 @@ private function addOptions($options) 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 c406223e5a658..a690b2b330e7a 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()) - { - $this->object = $object; - $this->state = $state; - $this->attributes = $attributes; - } + private $marking; + + private $transition; - public function getState() + /** + * Event constructor. + * + * @param mixed $subject + * @param Marking $marking + * @param Transition $transition + */ + public function __construct($subject, Marking $marking, Transition $transition) { - return $this->state; + $this->subject = $subject; + $this->marking = $marking; + $this->transition = $transition; } - public function getObject() + public function getMarking() { - return $this->object; + return $this->marking; } - public function getAttribute($key) + public function getSubject() { - return isset($this->attributes[$key]) ? $this->attributes[$key] : null; + return $this->subject; } - public function hastAttribute($key) + public function getTransition() { - 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 64df6f81848de..bf4b6f3971e7a 100644 --- a/src/Symfony/Component/Workflow/Event/GuardEvent.php +++ b/src/Symfony/Component/Workflow/Event/GuardEvent.php @@ -13,18 +13,19 @@ /** * @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 045c3ee584915..a39c89d18c3cb 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 0000000000000..b0dfa9b79bbc1 --- /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 0000000000000..c44fa05cdd05c --- /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 0000000000000..d0cf09f9dfe63 --- /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 0000000000000..39fa189d2b5fc --- /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 0000000000000..0d8bab25b7fe3 --- /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 0000000000000..e73c9eb596c62 --- /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 0000000000000..faf1e8a6c4024 --- /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 0000000000000..949661ee10627 --- /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 0000000000000..35c00eb58d101 --- /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 0000000000000..a2bf37aa7f3c4 --- /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 cdc98f9a2553b..2f94288ebb9c5 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; + + foreach ($this->workflows as list($workflow, $classname)) { + if ($this->supports($workflow, $classname, $subject, $workflowName)) { + if ($matched) { + throw new InvalidArgumentException('At least two workflows match this subject. Set a different name on each and use the second (name) argument of this method.'); + } + $matched = $workflow; + } + } + + if (!$matched) { + throw new InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_class($subject))); + } + + return $matched; } - public function get($object) + private function supports(Workflow $workflow, $classname, $subject, $name) { - foreach ($this->workflows as $workflow) { - if ($workflow->supports($object)) { - return $workflow; - } + if (!$subject instanceof $classname) { + return false; + } + + if (null === $name) { + return true; } - throw new \InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_class($object))); + 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 0000000000000..4a4465fd64a10 --- /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 0000000000000..d6e9cd30f31d9 --- /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 0000000000000..0422009f0ee24 --- /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 0000000000000..557a241689ece --- /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 0000000000000..df8d748a0d927 --- /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 0000000000000..026ca607bee13 --- /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 0000000000000..719886dd1c03f --- /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 0000000000000..fbf9b38b23bb6 --- /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 0000000000000..58e9ecc9ead15 --- /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 f5797ed5b6351..30cc5eca47d82 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 41fac3a52e29a..0bf637e3d7ebf 100644 --- a/src/Symfony/Component/Workflow/Workflow.php +++ b/src/Symfony/Component/Workflow/Workflow.php @@ -14,195 +14,261 @@ 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; - - public function __construct($name, Definition $definition, EventDispatcherInterface $dispatcher = null) + private $name; + + 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->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; + $this->name = $name; + + // If the marking can contain only one place, we should control the definition + if ($markingStore instanceof UniqueTransitionOutputInterface) { + foreach ($definition->getTransitions() as $transition) { + if (1 < count($transition->getTos())) { + throw new LogicException(sprintf('The marking store (%s) of workflow "%s" can not store many places. But the transition "%s" has too many output (%d). Only one is accepted.', get_class($markingStore), $this->name, $transition->getName(), count($transition->getTos()))); + } } } } - 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); - - // check if the object is already in the workflow - if (null === $state) { - $this->enter($object, $this->initialState, array()); + $transitions = $this->definition->getTransitions(); - $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]; + + $marking = $this->getMarking($subject); - return $state; + 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); - public function getAvailableTransitions($object) - { - return array_keys($this->stateTransitions[$this->getState($object)]); + $this->enter($subject, $transition, $marking); + + $this->announce($subject, $transition, $marking); + + $this->markingStore->setMarking($subject, $marking); + + return $marking; } - public function getNextStates($object) + /** + * Returns all enabled transitions. + * + * @param object $subject A subject + * + * @return Transition[] All enabled transitions + */ + public function getEnabledTransitions($subject) { - if (!$stateTransitions = $this->stateTransitions[$this->getState($object)]) { - return array(); - } + $enabled = 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; - } + foreach ($transition->getFroms() as $place) { + if (!$marking->has($place)) { + return false; + } + } - public function __call($method, $arguments) - { - if (!count($arguments)) { - throw new BadMethodCallException(); + if (true === $this->guardTransition($subject, $marking, $transition)) { + return false; } - return $this->apply($arguments[0], $method, array_slice($arguments, 1)); + 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(); - - 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)); - } + $this->dispatcher->dispatch('workflow.leave', $event); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave', $this->name), $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)); + foreach ($transition->getFroms() as $place) { + $marking->unmark($place); + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(sprintf('workflow.%s.leave.%s', $this->name, $place), $event); } + } + } - $state = $tos[0]; + private function transition($subject, Transition $transition, Marking $marking) + { + if (null === $this->dispatcher) { + return; } - return $state; + $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($object, $state, $attributes) + private function enter($subject, Transition $transition, Marking $marking) { - $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)); + $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 4b6ee3a7cfe96..3f12343dc78a1 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 0000000000000..5817db3b8fe26 --- /dev/null +++ b/src/Symfony/Component/Workflow/phpunit.xml.dist @@ -0,0 +1,28 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy