Skip to content

Commit

Permalink
feature #11882 [Workflow] Introducing the workflow component (fabpot,…
Browse files Browse the repository at this point in the history
… lyrixx)

This PR was merged into the 3.2-dev branch.

Discussion
----------

[Workflow] Introducing the workflow component

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | not yet
| Fixed tickets | n/a
| License       | MIT
| Doc PR        | n/a

TODO:

* [x] Add tests
* [x] Add PHP doc
* [x] Add Symfony fullstack integration (Config, DIC, command to dump the state-machine into graphiz format)

So why another component?

This component take another approach that what you can find on [Packagist](https://packagist.org/search/?q=state%20machine).

Here, the workflow component is not tied to a specific object like with [Finite](https://github.com/yohang/Finite). It means that the component workflow is stateless and can be a symfony service.

Some code:

```php
#!/usr/bin/env php
<?php

require __DIR__.'/vendor/autoload.php';

use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\Workflow\Definition;
use Symfony\Component\Workflow\Dumper\GraphvizDumper;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\MarkingStore\PropertyAccessorMarkingStore;
use Symfony\Component\Workflow\MarkingStore\ScalarMarkingStore;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;

class Foo
{
    public $marking;

    public function __construct($init = 'a')
    {
        $this->marking = $init;
        $this->marking = [$init => true];
    }
}

$fooDefinition = new Definition(Foo::class);
$fooDefinition->addPlaces([
    'a', 'b', 'c', 'd', 'e', 'f', 'g',
]);

//                                           name  from        to
$fooDefinition->addTransition(new Transition('t1', 'a',        ['b', 'c']));
$fooDefinition->addTransition(new Transition('t2', ['b', 'c'],  'd'));
$fooDefinition->addTransition(new Transition('t3', 'd',         'e'));
$fooDefinition->addTransition(new Transition('t4', 'd',         'f'));
$fooDefinition->addTransition(new Transition('t5', 'e',         'g'));
$fooDefinition->addTransition(new Transition('t6', 'f',         'g'));

$graph = (new GraphvizDumper())->dump($fooDefinition);

$ed = new TraceableEventDispatcher(new EventDispatcher(), new Stopwatch(), new \Monolog\Logger('app'));

// $workflow = new Workflow($fooDefinition, new ScalarMarkingStore(), $ed);
$workflow = new Workflow($fooDefinition, new PropertyAccessorMarkingStore(), $ed);

$foo = new Foo(isset($argv[1]) ? $argv[1] : 'a');

$graph = (new GraphvizDumper())->dump($fooDefinition, $workflow->getMarking($foo));

dump([
    'AvailableTransitions' => $workflow->getAvailableTransitions($foo),
    'CurrentMarking' => clone $workflow->getMarking($foo),
    'can validate t1' => $workflow->can($foo, 't1'),
    'can validate t3' => $workflow->can($foo, 't3'),
    'can validate t6' => $workflow->can($foo, 't6'),
    'apply t1' => clone $workflow->apply($foo, 't1'),
    'can validate t2' => $workflow->can($foo, 't2'),
    'apply t2' => clone $workflow->apply($foo, 't2'),
    'can validate t1 bis' => $workflow->can($foo, 't1'),
    'can validate t3 bis' => $workflow->can($foo, 't3'),
    'can validate t6 bis' => $workflow->can($foo, 't6'),
]);

```

The workflown:

![workflow](https://cloud.githubusercontent.com/assets/408368/14183999/4a43483c-f773-11e5-9c8b-7f157e0cb75f.png)

The output:

```
array:10 [
  "AvailableTransitions" => array:1 [
    0 => Symfony\Component\Workflow\Transition {#4
      -name: "t1"
      -froms: array:1 [
        0 => "a"
      ]
      -tos: array:2 [
        0 => "b"
        1 => "c"
      ]
    }
  ]
  "CurrentMarking" => Symfony\Component\Workflow\Marking {#19
    -places: array:1 [
      "a" => true
    ]
  }
  "can validate t1" => true
  "can validate t3" => false
  "can validate t6" => false
  "apply t1" => Symfony\Component\Workflow\Marking {#22
    -places: array:2 [
      "b" => true
      "c" => true
    ]
  }
  "apply t2" => Symfony\Component\Workflow\Marking {#47
    -places: array:1 [
      "d" => true
    ]
  }
  "can validate t1 bis" => false
  "can validate t3 bis" => true
  "can validate t6 bis" => false
]
```

Commits
-------

078e27f [Workflow] Added initial set of files
17d59a7 added the first more-or-less working version of the Workflow component
  • Loading branch information
fabpot committed Jun 23, 2016
2 parents 9af416d + 078e27f commit d36097b
Show file tree
Hide file tree
Showing 44 changed files with 2,555 additions and 0 deletions.
1 change: 1 addition & 0 deletions composer.json
Expand Up @@ -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": {
Expand Down
52 changes: 52 additions & 0 deletions src/Symfony/Bridge/Twig/Extension/WorkflowExtension.php
@@ -0,0 +1,52 @@
<?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\Bridge\Twig\Extension;

use Symfony\Component\Workflow\Registry;

/**
* WorkflowExtension.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
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';
}
}
78 changes: 78 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Command/WorkflowDumpCommand.php
@@ -0,0 +1,78 @@
<?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\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 <lyrixx@lyrixx.info>
*/
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 <info>%command.name%</info> command dumps the graphical representation of a
workflow in DOT format
%command.full_name% <workflow 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);
}
}
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Expand Up @@ -31,13 +31,15 @@
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Workflow;

/**
* FrameworkExtension.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jeremy Mikola <jmikola@gmail.com>
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class FrameworkExtension extends Extension
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
*
Expand Down
Expand Up @@ -26,6 +26,7 @@
<xsd:element name="serializer" type="serializer" minOccurs="0" maxOccurs="1" />
<xsd:element name="property-info" type="property_info" minOccurs="0" maxOccurs="1" />
<xsd:element name="cache" type="cache" minOccurs="0" maxOccurs="1" />
<xsd:element name="workflows" type="workflows" minOccurs="0" maxOccurs="1" />
</xsd:all>

<xsd:attribute name="http-method-override" type="xsd:boolean" />
Expand Down Expand Up @@ -224,4 +225,42 @@
<xsd:attribute name="provider" type="xsd:string" />
<xsd:attribute name="clearer" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="workflows">
<xsd:choice minOccurs="0" maxOccurs="unbounded">
<xsd:element name="workflow" type="workflow" />
</xsd:choice>
</xsd:complexType>

<xsd:complexType name="workflow">
<xsd:sequence>
<xsd:element name="marking-store" type="marking_store" />
<xsd:element name="supports" type="xsd:string" minOccurs="1" maxOccurs="unbounded" />
<xsd:element name="places" type="xsd:string" minOccurs="1" maxOccurs="unbounded" />
<xsd:element name="transitions" type="transitions" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>

<xsd:complexType name="marking_store">
<xsd:sequence>
<xsd:element name="type" type="xsd:string" minOccurs="0" maxOccurs="1" />
<xsd:element name="arguments" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="service" type="xsd:string" minOccurs="0" maxOccurs="1" />
</xsd:sequence>
</xsd:complexType>

<xsd:complexType name="transitions">
<xsd:sequence>
<xsd:element name="transition" type="transition" />
</xsd:sequence>
</xsd:complexType>

<xsd:complexType name="transition">
<xsd:sequence>
<xsd:element name="from" type="xsd:string" minOccurs="1" maxOccurs="unbounded" />
<xsd:element name="to" type="xsd:string" minOccurs="1" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:schema>

0 comments on commit d36097b

Please sign in to comment.