Skip to content

Commit 5605d2f

Browse files
committed
feature symfony#26076 [Workflow] Add transition blockers (d-ph, lyrixx)
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 | symfony#24745 symfony#24501 | License | MIT Commits ------- 2b8faff [Workflow] Cleaned the transition blocker implementations 4d10e10 [Workflow] Add transition blockers
2 parents a5dbc68 + 2b8faff commit 5605d2f

File tree

10 files changed

+438
-65
lines changed

10 files changed

+438
-65
lines changed

src/Symfony/Component/Workflow/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ CHANGELOG
44
4.1.0
55
-----
66

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

1214
4.0.0
1315
-----

src/Symfony/Component/Workflow/Event/GuardEvent.php

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,52 @@
1111

1212
namespace Symfony\Component\Workflow\Event;
1313

14+
use Symfony\Component\Workflow\Marking;
15+
use Symfony\Component\Workflow\Transition;
16+
use Symfony\Component\Workflow\TransitionBlocker;
17+
use Symfony\Component\Workflow\TransitionBlockerList;
18+
1419
/**
1520
* @author Fabien Potencier <fabien@symfony.com>
1621
* @author Grégoire Pineau <lyrixx@lyrixx.info>
1722
*/
1823
class GuardEvent extends Event
1924
{
20-
private $blocked = false;
25+
private $transitionBlockerList;
26+
27+
/**
28+
* {@inheritdoc}
29+
*/
30+
public function __construct($subject, Marking $marking, Transition $transition, $workflowName = 'unnamed')
31+
{
32+
parent::__construct($subject, $marking, $transition, $workflowName);
33+
34+
$this->transitionBlockerList = new TransitionBlockerList();
35+
}
36+
37+
public function isBlocked(): bool
38+
{
39+
return !$this->transitionBlockerList->isEmpty();
40+
}
41+
42+
public function setBlocked(bool $blocked): void
43+
{
44+
if (!$blocked) {
45+
$this->transitionBlockerList->reset();
46+
47+
return;
48+
}
49+
50+
$this->transitionBlockerList->add(TransitionBlocker::createUnknown());
51+
}
2152

22-
public function isBlocked()
53+
public function getTransitionBlockerList(): TransitionBlockerList
2354
{
24-
return $this->blocked;
55+
return $this->transitionBlockerList;
2556
}
2657

27-
public function setBlocked($blocked)
58+
public function addTransitionBlocker(TransitionBlocker $transitionBlocker): void
2859
{
29-
$this->blocked = (bool) $blocked;
60+
$this->transitionBlockerList->add($transitionBlocker);
3061
}
3162
}

src/Symfony/Component/Workflow/EventListener/GuardListener.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Symfony\Component\Validator\Validator\ValidatorInterface;
1919
use Symfony\Component\Workflow\Event\GuardEvent;
2020
use Symfony\Component\Workflow\Exception\InvalidTokenConfigurationException;
21+
use Symfony\Component\Workflow\TransitionBlocker;
2122

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

52-
if (!$this->expressionLanguage->evaluate($this->configuration[$eventName], $this->getVariables($event))) {
53-
$event->setBlocked(true);
53+
$expression = $this->configuration[$eventName];
54+
55+
if (!$this->expressionLanguage->evaluate($expression, $this->getVariables($event))) {
56+
$blocker = TransitionBlocker::createBlockedByExpressionGuardListener($expression);
57+
$event->addTransitionBlocker($blocker);
5458
}
5559
}
5660

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Workflow\Exception;
13+
14+
use Symfony\Component\Workflow\TransitionBlockerList;
15+
16+
/**
17+
* Thrown by Workflow when a not enabled transition is applied on a subject.
18+
*
19+
* @author Grégoire Pineau <lyrixx@lyrixx.info>
20+
*/
21+
class NotEnabledTransitionException extends LogicException
22+
{
23+
private $transitionBlockerList;
24+
25+
public function __construct(string $transitionName, string $workflowName, TransitionBlockerList $transitionBlockerList)
26+
{
27+
parent::__construct(sprintf('Transition "%s" is not enabled for workflow "%s".', $transitionName, $workflowName));
28+
29+
$this->transitionBlockerList = $transitionBlockerList;
30+
}
31+
32+
public function getTransitionBlockerList(): TransitionBlockerList
33+
{
34+
return $this->transitionBlockerList;
35+
}
36+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Workflow\Exception;
13+
14+
/**
15+
* Thrown by Workflow when an undefined transition is applied on a subject.
16+
*
17+
* @author Grégoire Pineau <lyrixx@lyrixx.info>
18+
*/
19+
class UndefinedTransitionException extends LogicException
20+
{
21+
public function __construct(string $transitionName, string $workflowName)
22+
{
23+
parent::__construct(sprintf('Transition "%s" is not defined for workflow "%s".', $transitionName, $workflowName));
24+
}
25+
}

src/Symfony/Component/Workflow/Tests/WorkflowTest.php

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
use Symfony\Component\Workflow\Definition;
88
use Symfony\Component\Workflow\Event\Event;
99
use Symfony\Component\Workflow\Event\GuardEvent;
10+
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
1011
use Symfony\Component\Workflow\Marking;
1112
use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface;
1213
use Symfony\Component\Workflow\MarkingStore\MultipleStateMarkingStore;
1314
use Symfony\Component\Workflow\Transition;
15+
use Symfony\Component\Workflow\TransitionBlocker;
1416
use Symfony\Component\Workflow\Workflow;
1517

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

167+
public function testCanWithSameNameTransition()
168+
{
169+
$definition = $this->createWorkflowWithSameNameTransition();
170+
$workflow = new Workflow($definition, new MultipleStateMarkingStore());
171+
172+
$subject = new \stdClass();
173+
$subject->marking = null;
174+
$this->assertTrue($workflow->can($subject, 'a_to_bc'));
175+
$this->assertFalse($workflow->can($subject, 'b_to_c'));
176+
$this->assertFalse($workflow->can($subject, 'to_a'));
177+
178+
$subject->marking = array('b' => 1);
179+
$this->assertFalse($workflow->can($subject, 'a_to_bc'));
180+
$this->assertTrue($workflow->can($subject, 'b_to_c'));
181+
$this->assertTrue($workflow->can($subject, 'to_a'));
182+
}
183+
165184
/**
166-
* @expectedException \Symfony\Component\Workflow\Exception\LogicException
167-
* @expectedExceptionMessage Unable to apply transition "t2" for workflow "unnamed".
185+
* @expectedException \Symfony\Component\Workflow\Exception\UndefinedTransitionException
186+
* @expectedExceptionMessage Transition "404 Not Found" is not defined for workflow "unnamed".
168187
*/
169-
public function testApplyWithImpossibleTransition()
188+
public function testBuildTransitionBlockerListReturnsUndefinedTransition()
189+
{
190+
$definition = $this->createSimpleWorkflowDefinition();
191+
$subject = new \stdClass();
192+
$subject->marking = null;
193+
$workflow = new Workflow($definition);
194+
195+
$workflow->buildTransitionBlockerList($subject, '404 Not Found');
196+
}
197+
198+
public function testBuildTransitionBlockerListReturnsReasonsProvidedByMarking()
170199
{
171200
$definition = $this->createComplexWorkflowDefinition();
172201
$subject = new \stdClass();
173202
$subject->marking = null;
174203
$workflow = new Workflow($definition, new MultipleStateMarkingStore());
175204

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

179-
public function testCanWithSameNameTransition()
212+
public function testBuildTransitionBlockerListReturnsReasonsProvidedInGuards()
180213
{
181-
$definition = $this->createWorkflowWithSameNameTransition();
214+
$definition = $this->createSimpleWorkflowDefinition();
215+
$subject = new \stdClass();
216+
$subject->marking = null;
217+
$dispatcher = new EventDispatcher();
218+
$workflow = new Workflow($definition, new MultipleStateMarkingStore(), $dispatcher);
219+
220+
$dispatcher->addListener('workflow.guard', function (GuardEvent $event) {
221+
$event->addTransitionBlocker(new TransitionBlocker('Transition blocker 1', 'blocker_1'));
222+
$event->addTransitionBlocker(new TransitionBlocker('Transition blocker 2', 'blocker_2'));
223+
});
224+
$dispatcher->addListener('workflow.guard', function (GuardEvent $event) {
225+
$event->addTransitionBlocker(new TransitionBlocker('Transition blocker 3', 'blocker_3'));
226+
});
227+
$dispatcher->addListener('workflow.guard', function (GuardEvent $event) {
228+
$event->setBlocked(true);
229+
});
230+
231+
$transitionBlockerList = $workflow->buildTransitionBlockerList($subject, 't1');
232+
$this->assertCount(4, $transitionBlockerList);
233+
$blockers = iterator_to_array($transitionBlockerList);
234+
$this->assertSame('Transition blocker 1', $blockers[0]->getMessage());
235+
$this->assertSame('blocker_1', $blockers[0]->getCode());
236+
$this->assertSame('Transition blocker 2', $blockers[1]->getMessage());
237+
$this->assertSame('blocker_2', $blockers[1]->getCode());
238+
$this->assertSame('Transition blocker 3', $blockers[2]->getMessage());
239+
$this->assertSame('blocker_3', $blockers[2]->getCode());
240+
$this->assertSame('Unknown reason.', $blockers[3]->getMessage());
241+
$this->assertSame('e8b5bbb9-5913-4b98-bfa6-65dbd228a82a', $blockers[3]->getCode());
242+
}
243+
244+
/**
245+
* @expectedException \Symfony\Component\Workflow\Exception\UndefinedTransitionException
246+
* @expectedExceptionMessage Transition "404 Not Found" is not defined for workflow "unnamed".
247+
*/
248+
public function testApplyWithNotExisingTransition()
249+
{
250+
$definition = $this->createComplexWorkflowDefinition();
251+
$subject = new \stdClass();
252+
$subject->marking = null;
182253
$workflow = new Workflow($definition, new MultipleStateMarkingStore());
183254

255+
$workflow->apply($subject, '404 Not Found');
256+
}
257+
258+
public function testApplyWithNotEnabledTransition()
259+
{
260+
$definition = $this->createComplexWorkflowDefinition();
184261
$subject = new \stdClass();
185262
$subject->marking = null;
186-
$this->assertTrue($workflow->can($subject, 'a_to_bc'));
187-
$this->assertFalse($workflow->can($subject, 'b_to_c'));
188-
$this->assertFalse($workflow->can($subject, 'to_a'));
263+
$workflow = new Workflow($definition, new MultipleStateMarkingStore());
189264

190-
$subject->marking = array('b' => 1);
191-
$this->assertFalse($workflow->can($subject, 'a_to_bc'));
192-
$this->assertTrue($workflow->can($subject, 'b_to_c'));
193-
$this->assertTrue($workflow->can($subject, 'to_a'));
265+
try {
266+
$workflow->apply($subject, 't2');
267+
268+
$this->fail('Should throw an exception');
269+
} catch (NotEnabledTransitionException $e) {
270+
$this->assertSame('Transition "t2" is not enabled for workflow "unnamed".', $e->getMessage());
271+
$this->assertCount(1, $e->getTransitionBlockerList());
272+
$list = iterator_to_array($e->getTransitionBlockerList());
273+
$this->assertSame('The marking does not enable the transition.', $list[0]->getMessage());
274+
}
194275
}
195276

196277
public function testApply()
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Workflow;
13+
14+
/**
15+
* A reason why a transition cannot be performed for a subject.
16+
*/
17+
final class TransitionBlocker
18+
{
19+
const BLOCKED_BY_MARKING = '19beefc8-6b1e-4716-9d07-a39bd6d16e34';
20+
const BLOCKED_BY_EXPRESSION_GUARD_LISTENER = '326a1e9c-0c12-11e8-ba89-0ed5f89f718b';
21+
const UNKNOWN = 'e8b5bbb9-5913-4b98-bfa6-65dbd228a82a';
22+
23+
private $message;
24+
private $code;
25+
private $parameters;
26+
27+
/**
28+
* @param string $code Code is a machine-readable string, usually an UUID
29+
* @param array $parameters This is useful if you would like to pass around the condition values, that
30+
* blocked the transition. E.g. for a condition "distance must be larger than
31+
* 5 miles", you might want to pass around the value of 5.
32+
*/
33+
public function __construct(string $message, string $code, array $parameters = array())
34+
{
35+
$this->message = $message;
36+
$this->code = $code;
37+
$this->parameters = $parameters;
38+
}
39+
40+
/**
41+
* Create a blocker that says the transition cannot be made because it is
42+
* not enabled.
43+
*
44+
* It means the subject is in wrong place (i.e. status):
45+
* * If the workflow is a state machine: the subject is not in the previous place of the transition.
46+
* * If the workflow is a workflow: the subject is not in all previous places of the transition.
47+
*/
48+
public static function createBlockedByMarking(Marking $marking): self
49+
{
50+
return new static('The marking does not enable the transition.', self::BLOCKED_BY_MARKING, array(
51+
'marking' => $marking,
52+
));
53+
}
54+
55+
/**
56+
* Creates a blocker that says the transition cannot be made because it has
57+
* been blocked by the expression guard listener.
58+
*/
59+
public static function createBlockedByExpressionGuardListener(string $expression): self
60+
{
61+
return new static('The expression blocks the transition.', self::BLOCKED_BY_EXPRESSION_GUARD_LISTENER, array(
62+
'expression' => $expression,
63+
));
64+
}
65+
66+
/**
67+
* Creates a blocker that says the transition cannot be made because of an
68+
* unknown reason.
69+
*
70+
* This blocker code is chiefly for preserving backwards compatibility.
71+
*/
72+
public static function createUnknown(): self
73+
{
74+
return new static('Unknown reason.', self::UNKNOWN);
75+
}
76+
77+
public function getMessage(): string
78+
{
79+
return $this->message;
80+
}
81+
82+
public function getCode(): string
83+
{
84+
return $this->code;
85+
}
86+
87+
public function getParameters(): array
88+
{
89+
return $this->parameters;
90+
}
91+
}

0 commit comments

Comments
 (0)