added the first more-or-less working version of the Workflow component

This commit is contained in:
Fabien Potencier 2013-12-01 10:16:07 +01:00
parent 9af416d096
commit 17d59a7c66
11 changed files with 803 additions and 0 deletions

View File

@ -0,0 +1,87 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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;
}
}

View File

@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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());
}

View File

@ -0,0 +1,198 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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));
}
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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]);
}
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
class GuardEvent extends Event
{
private $allowed = null;
public function isAllowed()
{
return $this->allowed;
}
public function setAllowed($allowed)
{
$this->allowed = (Boolean) $allowed;
}
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
class TransitionEvent extends Event
{
private $nextState;
public function setNextState($state)
{
$this->nextState = $state;
}
public function getNextState()
{
return $this->nextState;
}
}

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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'),
);
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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)));
}
}

View File

@ -0,0 +1,44 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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;
}
}

View File

@ -0,0 +1,208 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <fabien@symfony.com>
*/
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];
}
}

View File

@ -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"
}
}
}