Skip to content

Commit

Permalink
feature #26076 [Workflow] Add transition blockers (d-ph, lyrixx)
Browse files Browse the repository at this point in the history
This PR was merged into the 4.1-dev branch.

Discussion
----------

[Workflow] Add transition blockers

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #24745 #24501
| License       | MIT

Commits
-------

2b8faff [Workflow] Cleaned the transition blocker implementations
4d10e10 [Workflow] Add transition blockers
  • Loading branch information
fabpot committed Mar 20, 2018
2 parents a5dbc68 + 2b8faff commit 5605d2f
Show file tree
Hide file tree
Showing 10 changed files with 438 additions and 65 deletions.
4 changes: 3 additions & 1 deletion src/Symfony/Component/Workflow/CHANGELOG.md
Expand Up @@ -4,10 +4,12 @@ CHANGELOG
4.1.0
-----

* Deprecate the usage of `add(Workflow $workflow, $supportStrategy)` in `Workflow/Registry`, use `addWorkflow(WorkflowInterface, $supportStrategy)` instead.
* Deprecate the usage of `add(Workflow $workflow, $supportStrategy)` in `Workflow/Registry`, use `addWorkflow(WorkflowInterface, $supportStrategy)` instead.
* Deprecate the usage of `SupportStrategyInterface`, use `WorkflowSupportStrategyInterface` instead.
* The `Workflow` class now implements `WorkflowInterface`.
* Deprecated the class `ClassInstanceSupportStrategy` in favor of the class `InstanceOfSupportStrategy`.
* Added TransitionBlockers as a way to pass around reasons why exactly
transitions can't be made.

4.0.0
-----
Expand Down
41 changes: 36 additions & 5 deletions src/Symfony/Component/Workflow/Event/GuardEvent.php
Expand Up @@ -11,21 +11,52 @@

namespace Symfony\Component\Workflow\Event;

use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\TransitionBlocker;
use Symfony\Component\Workflow\TransitionBlockerList;

/**
* @author Fabien Potencier <fabien@symfony.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class GuardEvent extends Event
{
private $blocked = false;
private $transitionBlockerList;

/**
* {@inheritdoc}
*/
public function __construct($subject, Marking $marking, Transition $transition, $workflowName = 'unnamed')
{
parent::__construct($subject, $marking, $transition, $workflowName);

$this->transitionBlockerList = new TransitionBlockerList();
}

public function isBlocked(): bool
{
return !$this->transitionBlockerList->isEmpty();
}

public function setBlocked(bool $blocked): void
{
if (!$blocked) {
$this->transitionBlockerList->reset();

return;
}

$this->transitionBlockerList->add(TransitionBlocker::createUnknown());
}

public function isBlocked()
public function getTransitionBlockerList(): TransitionBlockerList
{
return $this->blocked;
return $this->transitionBlockerList;
}

public function setBlocked($blocked)
public function addTransitionBlocker(TransitionBlocker $transitionBlocker): void
{
$this->blocked = (bool) $blocked;
$this->transitionBlockerList->add($transitionBlocker);
}
}
Expand Up @@ -18,6 +18,7 @@
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Exception\InvalidTokenConfigurationException;
use Symfony\Component\Workflow\TransitionBlocker;

/**
* @author Grégoire Pineau <lyrixx@lyrixx.info>
Expand Down Expand Up @@ -49,8 +50,11 @@ public function onTransition(GuardEvent $event, $eventName)
return;
}

if (!$this->expressionLanguage->evaluate($this->configuration[$eventName], $this->getVariables($event))) {
$event->setBlocked(true);
$expression = $this->configuration[$eventName];

if (!$this->expressionLanguage->evaluate($expression, $this->getVariables($event))) {
$blocker = TransitionBlocker::createBlockedByExpressionGuardListener($expression);
$event->addTransitionBlocker($blocker);
}
}

Expand Down
@@ -0,0 +1,36 @@
<?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\Exception;

use Symfony\Component\Workflow\TransitionBlockerList;

/**
* Thrown by Workflow when a not enabled transition is applied on a subject.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class NotEnabledTransitionException extends LogicException
{
private $transitionBlockerList;

public function __construct(string $transitionName, string $workflowName, TransitionBlockerList $transitionBlockerList)
{
parent::__construct(sprintf('Transition "%s" is not enabled for workflow "%s".', $transitionName, $workflowName));

$this->transitionBlockerList = $transitionBlockerList;
}

public function getTransitionBlockerList(): TransitionBlockerList
{
return $this->transitionBlockerList;
}
}
@@ -0,0 +1,25 @@
<?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\Exception;

/**
* Thrown by Workflow when an undefined transition is applied on a subject.
*
* @author Grégoire Pineau <lyrixx@lyrixx.info>
*/
class UndefinedTransitionException extends LogicException
{
public function __construct(string $transitionName, string $workflowName)
{
parent::__construct(sprintf('Transition "%s" is not defined for workflow "%s".', $transitionName, $workflowName));
}
}
107 changes: 94 additions & 13 deletions src/Symfony/Component/Workflow/Tests/WorkflowTest.php
Expand Up @@ -7,10 +7,12 @@
use Symfony\Component\Workflow\Definition;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface;
use Symfony\Component\Workflow\MarkingStore\MultipleStateMarkingStore;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\TransitionBlocker;
use Symfony\Component\Workflow\Workflow;

class WorkflowTest extends TestCase
Expand Down Expand Up @@ -162,35 +164,114 @@ public function testCanDoesNotTriggerGuardEventsForNotEnabledTransitions()
$this->assertSame(array('workflow_name.guard.t3'), $dispatchedEvents);
}

public function testCanWithSameNameTransition()
{
$definition = $this->createWorkflowWithSameNameTransition();
$workflow = new Workflow($definition, new MultipleStateMarkingStore());

$subject = new \stdClass();
$subject->marking = null;
$this->assertTrue($workflow->can($subject, 'a_to_bc'));
$this->assertFalse($workflow->can($subject, 'b_to_c'));
$this->assertFalse($workflow->can($subject, 'to_a'));

$subject->marking = array('b' => 1);
$this->assertFalse($workflow->can($subject, 'a_to_bc'));
$this->assertTrue($workflow->can($subject, 'b_to_c'));
$this->assertTrue($workflow->can($subject, 'to_a'));
}

/**
* @expectedException \Symfony\Component\Workflow\Exception\LogicException
* @expectedExceptionMessage Unable to apply transition "t2" for workflow "unnamed".
* @expectedException \Symfony\Component\Workflow\Exception\UndefinedTransitionException
* @expectedExceptionMessage Transition "404 Not Found" is not defined for workflow "unnamed".
*/
public function testApplyWithImpossibleTransition()
public function testBuildTransitionBlockerListReturnsUndefinedTransition()
{
$definition = $this->createSimpleWorkflowDefinition();
$subject = new \stdClass();
$subject->marking = null;
$workflow = new Workflow($definition);

$workflow->buildTransitionBlockerList($subject, '404 Not Found');
}

public function testBuildTransitionBlockerListReturnsReasonsProvidedByMarking()
{
$definition = $this->createComplexWorkflowDefinition();
$subject = new \stdClass();
$subject->marking = null;
$workflow = new Workflow($definition, new MultipleStateMarkingStore());

$workflow->apply($subject, 't2');
$transitionBlockerList = $workflow->buildTransitionBlockerList($subject, 't2');
$this->assertCount(1, $transitionBlockerList);
$blockers = iterator_to_array($transitionBlockerList);
$this->assertSame('The marking does not enable the transition.', $blockers[0]->getMessage());
$this->assertSame('19beefc8-6b1e-4716-9d07-a39bd6d16e34', $blockers[0]->getCode());
}

public function testCanWithSameNameTransition()
public function testBuildTransitionBlockerListReturnsReasonsProvidedInGuards()
{
$definition = $this->createWorkflowWithSameNameTransition();
$definition = $this->createSimpleWorkflowDefinition();
$subject = new \stdClass();
$subject->marking = null;
$dispatcher = new EventDispatcher();
$workflow = new Workflow($definition, new MultipleStateMarkingStore(), $dispatcher);

$dispatcher->addListener('workflow.guard', function (GuardEvent $event) {
$event->addTransitionBlocker(new TransitionBlocker('Transition blocker 1', 'blocker_1'));
$event->addTransitionBlocker(new TransitionBlocker('Transition blocker 2', 'blocker_2'));
});
$dispatcher->addListener('workflow.guard', function (GuardEvent $event) {
$event->addTransitionBlocker(new TransitionBlocker('Transition blocker 3', 'blocker_3'));
});
$dispatcher->addListener('workflow.guard', function (GuardEvent $event) {
$event->setBlocked(true);
});

$transitionBlockerList = $workflow->buildTransitionBlockerList($subject, 't1');
$this->assertCount(4, $transitionBlockerList);
$blockers = iterator_to_array($transitionBlockerList);
$this->assertSame('Transition blocker 1', $blockers[0]->getMessage());
$this->assertSame('blocker_1', $blockers[0]->getCode());
$this->assertSame('Transition blocker 2', $blockers[1]->getMessage());
$this->assertSame('blocker_2', $blockers[1]->getCode());
$this->assertSame('Transition blocker 3', $blockers[2]->getMessage());
$this->assertSame('blocker_3', $blockers[2]->getCode());
$this->assertSame('Unknown reason.', $blockers[3]->getMessage());
$this->assertSame('e8b5bbb9-5913-4b98-bfa6-65dbd228a82a', $blockers[3]->getCode());
}

/**
* @expectedException \Symfony\Component\Workflow\Exception\UndefinedTransitionException
* @expectedExceptionMessage Transition "404 Not Found" is not defined for workflow "unnamed".
*/
public function testApplyWithNotExisingTransition()
{
$definition = $this->createComplexWorkflowDefinition();
$subject = new \stdClass();
$subject->marking = null;
$workflow = new Workflow($definition, new MultipleStateMarkingStore());

$workflow->apply($subject, '404 Not Found');
}

public function testApplyWithNotEnabledTransition()
{
$definition = $this->createComplexWorkflowDefinition();
$subject = new \stdClass();
$subject->marking = null;
$this->assertTrue($workflow->can($subject, 'a_to_bc'));
$this->assertFalse($workflow->can($subject, 'b_to_c'));
$this->assertFalse($workflow->can($subject, 'to_a'));
$workflow = new Workflow($definition, new MultipleStateMarkingStore());

$subject->marking = array('b' => 1);
$this->assertFalse($workflow->can($subject, 'a_to_bc'));
$this->assertTrue($workflow->can($subject, 'b_to_c'));
$this->assertTrue($workflow->can($subject, 'to_a'));
try {
$workflow->apply($subject, 't2');

$this->fail('Should throw an exception');
} catch (NotEnabledTransitionException $e) {
$this->assertSame('Transition "t2" is not enabled for workflow "unnamed".', $e->getMessage());
$this->assertCount(1, $e->getTransitionBlockerList());
$list = iterator_to_array($e->getTransitionBlockerList());
$this->assertSame('The marking does not enable the transition.', $list[0]->getMessage());
}
}

public function testApply()
Expand Down
91 changes: 91 additions & 0 deletions src/Symfony/Component/Workflow/TransitionBlocker.php
@@ -0,0 +1,91 @@
<?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;

/**
* A reason why a transition cannot be performed for a subject.
*/
final class TransitionBlocker
{
const BLOCKED_BY_MARKING = '19beefc8-6b1e-4716-9d07-a39bd6d16e34';
const BLOCKED_BY_EXPRESSION_GUARD_LISTENER = '326a1e9c-0c12-11e8-ba89-0ed5f89f718b';
const UNKNOWN = 'e8b5bbb9-5913-4b98-bfa6-65dbd228a82a';

private $message;
private $code;
private $parameters;

/**
* @param string $code Code is a machine-readable string, usually an UUID
* @param array $parameters This is useful if you would like to pass around the condition values, that
* blocked the transition. E.g. for a condition "distance must be larger than
* 5 miles", you might want to pass around the value of 5.
*/
public function __construct(string $message, string $code, array $parameters = array())
{
$this->message = $message;
$this->code = $code;
$this->parameters = $parameters;
}

/**
* Create a blocker that says the transition cannot be made because it is
* not enabled.
*
* It means the subject is in wrong place (i.e. status):
* * If the workflow is a state machine: the subject is not in the previous place of the transition.
* * If the workflow is a workflow: the subject is not in all previous places of the transition.
*/
public static function createBlockedByMarking(Marking $marking): self
{
return new static('The marking does not enable the transition.', self::BLOCKED_BY_MARKING, array(
'marking' => $marking,
));
}

/**
* Creates a blocker that says the transition cannot be made because it has
* been blocked by the expression guard listener.
*/
public static function createBlockedByExpressionGuardListener(string $expression): self
{
return new static('The expression blocks the transition.', self::BLOCKED_BY_EXPRESSION_GUARD_LISTENER, array(
'expression' => $expression,
));
}

/**
* Creates a blocker that says the transition cannot be made because of an
* unknown reason.
*
* This blocker code is chiefly for preserving backwards compatibility.
*/
public static function createUnknown(): self
{
return new static('Unknown reason.', self::UNKNOWN);
}

public function getMessage(): string
{
return $this->message;
}

public function getCode(): string
{
return $this->code;
}

public function getParameters(): array
{
return $this->parameters;
}
}

0 comments on commit 5605d2f

Please sign in to comment.