diff --git a/src/Symfony/Component/Workflow/Definition.php b/src/Symfony/Component/Workflow/Definition.php new file mode 100644 index 0000000000..912a08dbb4 --- /dev/null +++ b/src/Symfony/Component/Workflow/Definition.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * @author Fabien Potencier + */ +class Definition +{ + private $class; + private $states = array(); + private $transitions = array(); + private $initialState; + + public function __construct($class) + { + $this->class = $class; + } + + public function getClass() + { + return $this->class; + } + + public function getStates() + { + return $this->states; + } + + public function getTransitions() + { + return $this->transitions; + } + + public function getInitialState() + { + return $this->initialState; + } + + public function setInitialState($name) + { + if (!isset($this->states[$name])) { + throw new \LogicException(sprintf('State "%s" cannot be the initial state as it does not exist.', $name)); + } + + $this->initialState = $name; + } + + public function addState($name) + { + if (!count($this->states)) { + $this->initialState = $name; + } + + $this->states[$name] = $name; + } + + public function addTransition(Transition $transition) + { + if (isset($this->transitions[$transition->getName()])) { + throw new \LogicException(sprintf('Transition "%s" is already defined.', $transition->getName())); + } + + foreach ($transition->getFroms() as $from) { + if (!isset($this->states[$from])) { + throw new \LogicException(sprintf('State "%s" referenced in transition "%s" does not exist.', $from, $name)); + } + } + + foreach ($transition->getTos() as $to) { + if (!isset($this->states[$to])) { + throw new \LogicException(sprintf('State "%s" referenced in transition "%s" does not exist.', $to, $name)); + } + } + + $this->transitions[$transition->getName()] = $transition; + } +} diff --git a/src/Symfony/Component/Workflow/Dumper/DumperInterface.php b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php new file mode 100644 index 0000000000..2cea51e885 --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/DumperInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Dumper; + +use Symfony\Component\Workflow\Definition; + +/** + * DumperInterface is the interface implemented by workflow dumper classes. + * + * @author Fabien Potencier + */ +interface DumperInterface +{ + /** + * Dumps a workflow definition. + * + * @param Definition $definition A Definition instance + * @param array $options An array of options + * + * @return string The representation of the workflow + */ + public function dump(Definition $definition, array $options = array()); +} diff --git a/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php new file mode 100644 index 0000000000..ef28521e70 --- /dev/null +++ b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Dumper; + +use Symfony\Component\Workflow\Definition; + +/** + * GraphvizDumper dumps a workflow as a graphviz file. + * + * You can convert the generated dot file with the dot utility (http://www.graphviz.org/): + * + * dot -Tpng workflow.dot > workflow.png + * + * @author Fabien Potencier + */ +class GraphvizDumper implements DumperInterface +{ + private $nodes; + private $edges; + private $options = array( + 'graph' => array('ratio' => 'compress', 'rankdir' => 'LR'), + 'node' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333', 'shape' => 'circle', 'fillcolor' => 'lightblue', 'fixedsize' => true, 'width' => 1), + 'edge' => array('fontsize' => 9, 'fontname' => 'Arial', 'color' => '#333', 'arrowhead' => 'normal', 'arrowsize' => 0.5), + ); + + /** + * Dumps the workflow as a graphviz graph. + * + * Available options: + * + * * graph: The default options for the whole graph + * * node: The default options for nodes + * * edge: The default options for edges + * + * @param Definition $definition A Definition instance + * @param array $options An array of options + * + * @return string The dot representation of the workflow + */ + public function dump(Definition $definition, array $options = array()) + { + foreach (array('graph', 'node', 'edge') as $key) { + if (isset($options[$key])) { + $this->options[$key] = array_merge($this->options[$key], $options[$key]); + } + } + + $this->nodes = $this->findNodes($definition); + $this->edges = $this->findEdges($definition); + + return $this->startDot().$this->addNodes().$this->addEdges().$this->endDot(); + } + + /** + * Finds all nodes. + * + * @return array An array of all nodes + */ + private function findNodes(Definition $definition) + { + $nodes = array(); + foreach ($definition->getStates() as $state) { + $nodes[$state] = array( + 'attributes' => array_merge($this->options['node'], array('style' => $state == $definition->getInitialState() ? 'filled' : 'solid')) + ); + } + + return $nodes; + } + + /** + * Returns all nodes. + * + * @return string A string representation of all nodes + */ + private function addNodes() + { + $code = ''; + foreach ($this->nodes as $id => $node) { + $code .= sprintf(" node_%s [label=\"%s\", shape=%s%s];\n", $this->dotize($id), $id, $this->options['node']['shape'], $this->addAttributes($node['attributes'])); + } + + return $code; + } + + private function findEdges(Definition $definition) + { + $edges = array(); + foreach ($definition->getTransitions() as $transition) { + foreach ($transition->getFroms() as $from) { + foreach ($transition->getTos() as $to) { + $edges[$from][] = array( + 'name' => $transition->getName(), + 'to' => $to, + ); + } + } + } + + return $edges; + } + + /** + * Returns all edges. + * + * @return string A string representation of all edges + */ + private function addEdges() + { + $code = ''; + foreach ($this->edges as $id => $edges) { + foreach ($edges as $edge) { + $code .= sprintf(" node_%s -> node_%s [label=\"%s\" style=\"%s\"];\n", $this->dotize($id), $this->dotize($edge['to']), $edge['name'], 'solid'); + } + } + + return $code; + } + + /** + * Returns the start dot. + * + * @return string The string representation of a start dot + */ + private function startDot() + { + return sprintf("digraph workflow {\n %s\n node [%s];\n edge [%s];\n\n", + $this->addOptions($this->options['graph']), + $this->addOptions($this->options['node']), + $this->addOptions($this->options['edge']) + ); + } + + /** + * Returns the end dot. + * + * @return string + */ + private function endDot() + { + return "}\n"; + } + + /** + * Adds attributes + * + * @param array $attributes An array of attributes + * + * @return string A comma separated list of attributes + */ + private function addAttributes($attributes) + { + $code = array(); + foreach ($attributes as $k => $v) { + $code[] = sprintf('%s="%s"', $k, $v); + } + + return $code ? ', '.implode(', ', $code) : ''; + } + + /** + * Adds options + * + * @param array $options An array of options + * + * @return string A space separated list of options + */ + private function addOptions($options) + { + $code = array(); + foreach ($options as $k => $v) { + $code[] = sprintf('%s="%s"', $k, $v); + } + + return implode(' ', $code); + } + + /** + * Dotizes an identifier. + * + * @param string $id The identifier to dotize + * + * @return string A dotized string + */ + private function dotize($id) + { + return strtolower(preg_replace('/[^\w]/i', '_', $id)); + } +} diff --git a/src/Symfony/Component/Workflow/Event/Event.php b/src/Symfony/Component/Workflow/Event/Event.php new file mode 100644 index 0000000000..c406223e5a --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/Event.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +use Symfony\Component\EventDispatcher\Event as BaseEvent; + +/** + * @author Fabien Potencier + */ +class Event extends BaseEvent +{ + private $object; + private $state; + private $attributes; + + public function __construct($object, $state, array $attributes = array()) + { + $this->object = $object; + $this->state = $state; + $this->attributes = $attributes; + } + + public function getState() + { + return $this->state; + } + + public function getObject() + { + return $this->object; + } + + public function getAttribute($key) + { + return isset($this->attributes[$key]) ? $this->attributes[$key] : null; + } + + public function hastAttribute($key) + { + return isset($this->attributes[$key]); + } +} diff --git a/src/Symfony/Component/Workflow/Event/GuardEvent.php b/src/Symfony/Component/Workflow/Event/GuardEvent.php new file mode 100644 index 0000000000..64df6f8184 --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/GuardEvent.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +/** + * @author Fabien Potencier + */ +class GuardEvent extends Event +{ + private $allowed = null; + + public function isAllowed() + { + return $this->allowed; + } + + public function setAllowed($allowed) + { + $this->allowed = (Boolean) $allowed; + } +} diff --git a/src/Symfony/Component/Workflow/Event/TransitionEvent.php b/src/Symfony/Component/Workflow/Event/TransitionEvent.php new file mode 100644 index 0000000000..1fe0e2ff96 --- /dev/null +++ b/src/Symfony/Component/Workflow/Event/TransitionEvent.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Event; + +/** + * @author Fabien Potencier + */ +class TransitionEvent extends Event +{ + private $nextState; + + public function setNextState($state) + { + $this->nextState = $state; + } + + public function getNextState() + { + return $this->nextState; + } +} diff --git a/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php new file mode 100644 index 0000000000..045c3ee584 --- /dev/null +++ b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Workflow\Event\Event; + +class AuditTrailListener implements EventSubscriberInterface +{ + public function onEnter(Event $event) + { +// FIXME: object "identity", timestamp, who, ... +error_log('entering "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + } + + public function onLeave(Event $event) + { +error_log('leaving "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + } + + public function onTransition(Event $event) + { +error_log('transition "'.$event->getState().'" generic for object of class '.get_class($event->getObject())); + } + + public static function getSubscribedEvents() + { + return array( +// FIXME: add a way to listen to workflow.XXX.* + 'workflow.transition' => array('onTransition'), + 'workflow.leave' => array('onLeave'), + 'workflow.enter' => array('onEnter'), + ); + } +} diff --git a/src/Symfony/Component/Workflow/Registry.php b/src/Symfony/Component/Workflow/Registry.php new file mode 100644 index 0000000000..cdc98f9a25 --- /dev/null +++ b/src/Symfony/Component/Workflow/Registry.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * @author Fabien Potencier + */ +class Registry +{ + private $workflows = array(); + + public function __construct(array $workflows = array()) + { + foreach ($workflows as $workflow) { + $this->add($workflow); + } + } + + public function add(Workflow $workflow) + { + $this->workflows[] = $workflow; + } + + public function get($object) + { + foreach ($this->workflows as $workflow) { + if ($workflow->supports($object)) { + return $workflow; + } + } + + throw new \InvalidArgumentException(sprintf('Unable to find a workflow for class "%s".', get_class($object))); + } +} diff --git a/src/Symfony/Component/Workflow/Transition.php b/src/Symfony/Component/Workflow/Transition.php new file mode 100644 index 0000000000..f5797ed5b6 --- /dev/null +++ b/src/Symfony/Component/Workflow/Transition.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +/** + * @author Fabien Potencier + */ +class Transition +{ + private $name; + private $froms = array(); + private $tos = array(); + + public function __construct($name, $froms, $tos) + { + $this->name = $name; + $this->froms = (array) $froms; + $this->tos = (array) $tos; + } + + public function getName() + { + return $this->name; + } + + public function getFroms() + { + return $this->froms; + } + + public function getTos() + { + return $this->tos; + } +} diff --git a/src/Symfony/Component/Workflow/Workflow.php b/src/Symfony/Component/Workflow/Workflow.php new file mode 100644 index 0000000000..41fac3a52e --- /dev/null +++ b/src/Symfony/Component/Workflow/Workflow.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\Workflow\Event\Event; +use Symfony\Component\Workflow\Event\GuardEvent; +use Symfony\Component\Workflow\Event\TransitionEvent; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; + +/** + * @author Fabien Potencier + */ +class Workflow +{ + private $name; + private $dispatcher; + private $propertyAccessor; + private $property = 'state'; + private $stateTransitions = array(); + private $states; + private $initialState; + private $class; + + public function __construct($name, Definition $definition, EventDispatcherInterface $dispatcher = null) + { + $this->name = $name; + $this->dispatcher = $dispatcher; + $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + + $this->states = $definition->getStates(); + $this->class = $definition->getClass(); + $this->initialState = $definition->getInitialState(); + foreach ($definition->getTransitions() as $name => $transition) { + $this->transitions[$name] = $transition; + foreach ($transition->getFroms() as $from) { + $this->stateTransitions[$from][$name] = $name; + } + } + } + + public function supports($class) + { + return $class instanceof $this->class; + } + + public function can($object, $transition) + { + if (!isset($this->transitions[$transition])) { + throw new \LogicException(sprintf('Transition "%s" does not exist for workflow "%s".', $transition, $this->name)); + } + + if (null !== $this->dispatcher) { + $event = new GuardEvent($object, $this->getState($object)); + + $this->dispatcher->dispatch(sprintf('workflow.%s.guard.%s', $this->name, $transition), $event); + + if (null !== $ret = $event->isAllowed()) { + return $ret; + } + } + + return isset($this->stateTransitions[$this->getState($object)][$transition]); + } + + public function getState($object) + { + $state = $this->propertyAccessor->getValue($object, $this->property); + + // check if the object is already in the workflow + if (null === $state) { + $this->enter($object, $this->initialState, array()); + + $state = $this->propertyAccessor->getValue($object, $this->property); + } + + // check that the object has a known state + if (!isset($this->states[$state])) { + throw new \LogicException(sprintf('State "%s" is not valid for workflow "%s".', $transition, $this->name)); + } + + return $state; + } + + public function apply($object, $transition, array $attributes = array()) + { + $current = $this->getState($object); + + if (!$this->can($object, $transition)) { + throw new \LogicException(sprintf('Unable to apply transition "%s" from state "%s" for workflow "%s".', $transition, $current, $this->name)); + } + + $transition = $this->determineTransition($current, $transition); + + $this->leave($object, $current, $attributes); + + $state = $this->transition($object, $current, $transition, $attributes); + + $this->enter($object, $state, $attributes); + } + + public function getAvailableTransitions($object) + { + return array_keys($this->stateTransitions[$this->getState($object)]); + } + + public function getNextStates($object) + { + if (!$stateTransitions = $this->stateTransitions[$this->getState($object)]) { + return array(); + } + + $states = array(); + foreach ($stateTransitions as $transition) { + foreach ($this->transitions[$transition]->getTos() as $to) { + $states[] = $to; + } + } + + return $states; + } + + public function setStateProperty($property) + { + $this->property = $property; + } + + public function setPropertyAccessor(PropertyAccessor $propertyAccessor) + { + $this->propertyAccessor = $propertyAccessor; + } + + public function __call($method, $arguments) + { + if (!count($arguments)) { + throw new BadMethodCallException(); + } + + return $this->apply($arguments[0], $method, array_slice($arguments, 1)); + } + + private function leave($object, $state, $attributes) + { + if (null === $this->dispatcher) { + return; + } + + $this->dispatcher->dispatch(sprintf('workflow.leave', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.leave.%s', $this->name, $state), new Event($object, $state, $attributes)); + } + + private function transition($object, $current, Transition $transition, $attributes) + { + $state = null; + $tos = $transition->getTos(); + + if (null !== $this->dispatcher) { + // the generic event cannot change the next state + $this->dispatcher->dispatch(sprintf('workflow.transition', $this->name), new Event($object, $current, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.transition', $this->name), new Event($object, $current, $attributes)); + + $event = new TransitionEvent($object, $current, $attributes); + $this->dispatcher->dispatch(sprintf('workflow.%s.transition.%s', $this->name, $transition->getName()), $event); + $state = $event->getNextState(); + + if (null !== $state && !in_array($state, $tos)) { + throw new \LogicException(sprintf('Transition "%s" cannot go to state "%s" for workflow "%s"', $transition->getName(), $state, $this->name)); + } + } + + if (null === $state) { + if (count($tos) > 1) { + throw new \LogicException(sprintf('Unable to apply transition "%s" as the new state is not unique for workflow "%s".', $transition->getName(), $this->name)); + } + + $state = $tos[0]; + } + + return $state; + } + + private function enter($object, $state, $attributes) + { + $this->propertyAccessor->setValue($object, $this->property, $state); + + if (null !== $this->dispatcher) { + $this->dispatcher->dispatch(sprintf('workflow.enter', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.enter', $this->name), new Event($object, $state, $attributes)); + $this->dispatcher->dispatch(sprintf('workflow.%s.enter.%s', $this->name, $state), new Event($object, $state, $attributes)); + } + } + + private function determineTransition($current, $transition) + { + return $this->transitions[$transition]; + } +} diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json new file mode 100644 index 0000000000..4b6ee3a7cf --- /dev/null +++ b/src/Symfony/Component/Workflow/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/workflow", + "type": "library", + "description": "Symfony Workflow Component", + "keywords": [], + "homepage": "http://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "require": { + "php": ">=5.3.3", + "symfony/event-dispatcher": "~2.1", + "symfony/property-access": "~2.3" + }, + "require-dev": { + "twig/twig": "~1.14" + }, + "autoload": { + "psr-0": { "Symfony\\Component\\Workflow\\": "" } + }, + "target-dir": "Symfony/Component/Workflow", + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "2.5-dev" + } + } +}