From 207fc49a8957387622e20a738a8e75bf7d76e5c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Wed, 1 Oct 2025 10:07:08 +0200 Subject: [PATCH] [Workflow] Add support for weighted transitions --- .../Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../DependencyInjection/Configuration.php | 72 ++++++++-- .../FrameworkExtension.php | 8 +- .../Resources/config/schema/symfony-1.0.xsd | 12 +- ...th_multiple_transitions_with_same_name.php | 7 +- ...th_multiple_transitions_with_same_name.xml | 7 +- ...th_multiple_transitions_with_same_name.yml | 17 +-- .../FrameworkExtensionTestCase.php | 124 ++++++++++++----- .../Bundle/FrameworkBundle/composer.json | 4 +- src/Symfony/Component/Workflow/Arc.php | 30 ++++ src/Symfony/Component/Workflow/CHANGELOG.md | 1 + src/Symfony/Component/Workflow/Definition.php | 12 +- .../Workflow/Dumper/GraphvizDumper.php | 20 +-- .../Workflow/Dumper/MermaidDumper.php | 37 +++-- .../Workflow/Dumper/PlantUmlDumper.php | 8 +- .../Dumper/StateMachineGraphvizDumper.php | 8 +- .../EventListener/AuditTrailListener.php | 8 +- src/Symfony/Component/Workflow/Marking.php | 9 ++ .../Component/Workflow/Tests/ArcTest.php | 34 +++++ .../Workflow/Tests/StateMachineTest.php | 4 +- .../Workflow/Tests/TransitionTest.php | 34 ++++- .../Validator/StateMachineValidatorTest.php | 29 ++++ .../Tests/Validator/WorkflowValidatorTest.php | 29 ++++ .../Component/Workflow/Tests/WorkflowTest.php | 128 ++++++++++++++++++ src/Symfony/Component/Workflow/Transition.php | 53 ++++++-- .../Validator/StateMachineValidator.php | 23 +++- .../Workflow/Validator/WorkflowValidator.php | 18 ++- src/Symfony/Component/Workflow/Workflow.php | 26 ++-- 28 files changed, 628 insertions(+), 135 deletions(-) create mode 100644 src/Symfony/Component/Workflow/Arc.php create mode 100644 src/Symfony/Component/Workflow/Tests/ArcTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index dbbfe1bccbc7b..fb63d9158ce9f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -13,6 +13,7 @@ CHANGELOG * Add `KernelBrowser::getSession()` * Add support for configuring workflow places with glob patterns matching consts/backed enums * Add support for configuring the `CachingHttpClient` + * Add support for weighted transitions in workflows 7.3 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 4902be08bc4c0..9f73bed940052 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -562,11 +562,11 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->requiresAtLeastOneElement() ->prototype('array') ->children() - ->scalarNode('name') + ->stringNode('name') ->isRequired() ->cannotBeEmpty() ->end() - ->scalarNode('guard') + ->stringNode('guard') ->cannotBeEmpty() ->info('An expression to block the transition.') ->example('is_fully_authenticated() and is_granted(\'ROLE_JOURNALIST\') and subject.getTitle() == \'My first article\'') @@ -576,11 +576,52 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->acceptAndWrap(['backed-enum', 'string']) ->beforeNormalization() ->ifArray() - ->then(static fn ($from) => array_map(static fn ($v) => $v instanceof \BackedEnum ? $v->value : $v, $from)) + ->then($workflowNormalizeArcs = static function ($arcs) { + // Fix XML parsing, when only one arc is defined + if (\array_key_exists('value', $arcs) && \array_key_exists('weight', $arcs)) { + return [[ + 'place' => $arcs['value'], + 'weight' => $arcs['weight'], + ]]; + } + + $normalizedArcs = []; + foreach ($arcs as $arc) { + if ($arc instanceof \BackedEnum) { + $arc = $arc->value; + } + if (\is_string($arc)) { + $arc = [ + 'place' => $arc, + 'weight' => 1, + ]; + } elseif (!\is_array($arc)) { + throw new InvalidConfigurationException('The "from" arcs must be a list of strings or arrays in workflow configuration.'); + } elseif (\array_key_exists('value', $arc) && \array_key_exists('weight', $arc)) { + // Fix XML parsing + $arc = [ + 'place' => $arc['value'], + 'weight' => $arc['weight'], + ]; + } + + $normalizedArcs[] = $arc; + } + + return $normalizedArcs; + }) ->end() ->requiresAtLeastOneElement() - ->prototype('scalar') - ->cannotBeEmpty() + ->prototype('array') + ->children() + ->stringNode('place') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->integerNode('weight') + ->isRequired() + ->end() + ->end() ->end() ->end() ->arrayNode('to') @@ -588,11 +629,26 @@ private function addWorkflowSection(ArrayNodeDefinition $rootNode): void ->acceptAndWrap(['backed-enum', 'string']) ->beforeNormalization() ->ifArray() - ->then(static fn ($to) => array_map(static fn ($v) => $v instanceof \BackedEnum ? $v->value : $v, $to)) + ->then($workflowNormalizeArcs) ->end() ->requiresAtLeastOneElement() - ->prototype('scalar') - ->cannotBeEmpty() + ->prototype('array') + ->children() + ->stringNode('place') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->integerNode('weight') + ->isRequired() + ->end() + ->end() + ->end() + ->end() + ->integerNode('weight') + ->defaultValue(1) + ->validate() + ->ifTrue(static fn ($v) => $v < 1) + ->thenInvalid('The weight must be greater than 0.') ->end() ->end() ->arrayNode('metadata') diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 616f6da177197..f2799858bc7f8 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -236,6 +236,7 @@ use Symfony\Component\WebLink\HttpHeaderParser; use Symfony\Component\WebLink\HttpHeaderSerializer; use Symfony\Component\Workflow; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Component\Yaml\Command\LintCommand as BaseYamlLintCommand; use Symfony\Component\Yaml\Yaml; @@ -1114,6 +1115,11 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ // Global transition counter per workflow $transitionCounter = 0; foreach ($workflow['transitions'] as $transition) { + foreach (['from', 'to'] as $direction) { + foreach ($transition[$direction] as $k => $arc) { + $transition[$direction][$k] = new Definition(Arc::class, [$arc['place'], $arc['weight'] ?? 1]); + } + } if ('workflow' === $type) { $transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); $container->register($transitionId, Workflow\Transition::class) @@ -1137,7 +1143,7 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ foreach ($transition['to'] as $to) { $transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); $container->register($transitionId, Workflow\Transition::class) - ->setArguments([$transition['name'], $from, $to]); + ->setArguments([$transition['name'], [$from], [$to]]); $transitions[] = new Reference($transitionId); if (isset($transition['guard'])) { $eventName = \sprintf('workflow.%s.guard.%s', $name, $transition['name']); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 49269562af5db..d55d45767c39c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -518,8 +518,8 @@ - - + + @@ -527,6 +527,14 @@ + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow_with_multiple_transitions_with_same_name.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow_with_multiple_transitions_with_same_name.php index f4956eccb453c..93a415fd83ee5 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow_with_multiple_transitions_with_same_name.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/php/workflow_with_multiple_transitions_with_same_name.php @@ -22,13 +22,14 @@ 'approved_by_spellchecker', 'published', ], + // We also test different configuration formats here 'transitions' => [ 'request_review' => [ 'from' => 'draft', 'to' => ['wait_for_journalist', 'wait_for_spellchecker'], ], 'journalist_approval' => [ - 'from' => 'wait_for_journalist', + 'from' => ['wait_for_journalist'], 'to' => 'approved_by_journalist', ], 'spellchecker_approval' => [ @@ -36,13 +37,13 @@ 'to' => 'approved_by_spellchecker', ], 'publish' => [ - 'from' => ['approved_by_journalist', 'approved_by_spellchecker'], + 'from' => [['place' => 'approved_by_journalist', 'weight' => 1], 'approved_by_spellchecker'], 'to' => 'published', ], 'publish_editor_in_chief' => [ 'name' => 'publish', 'from' => 'draft', - 'to' => 'published', + 'to' => [['place' => 'published', 'weight' => 2]], ], ], ], diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow_with_multiple_transitions_with_same_name.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow_with_multiple_transitions_with_same_name.xml index 0435447b6c6ce..b7f2724a50856 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow_with_multiple_transitions_with_same_name.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/workflow_with_multiple_transitions_with_same_name.xml @@ -18,6 +18,7 @@ + draft wait_for_journalist @@ -32,13 +33,13 @@ approved_by_spellchecker - approved_by_journalist - approved_by_spellchecker + approved_by_journalist + approved_by_spellchecker published draft - published + published diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow_with_multiple_transitions_with_same_name.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow_with_multiple_transitions_with_same_name.yml index 67eccb425a84e..a3f52e05de11b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow_with_multiple_transitions_with_same_name.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/workflow_with_multiple_transitions_with_same_name.yml @@ -17,20 +17,21 @@ framework: - wait_for_spellchecker - approved_by_spellchecker - published + # We also test different configuration formats here transitions: request_review: - from: [draft] + from: draft to: [wait_for_journalist, wait_for_spellchecker] journalist_approval: from: [wait_for_journalist] - to: [approved_by_journalist] + to: approved_by_journalist spellchecker_approval: - from: [wait_for_spellchecker] - to: [approved_by_spellchecker] + from: wait_for_spellchecker + to: approved_by_spellchecker publish: - from: [approved_by_journalist, approved_by_spellchecker] - to: [published] + from: [{place: approved_by_journalist, weight: 1}, approved_by_spellchecker] + to: published publish_editor_in_chief: name: publish - from: [draft] - to: [published] + from: draft + to: [{place: published, weight: 2}] diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 4d2f564e9d838..96a750a2b34ac 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -100,6 +100,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Webhook\Client\RequestParser; use Symfony\Component\Webhook\Controller\WebhookController; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\DependencyInjection\WorkflowValidatorPass; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; @@ -464,61 +465,104 @@ public function testWorkflowMultipleTransitionsWithSameName() $this->assertCount(5, $transitions); - $this->assertSame('.workflow.article.transition.0', (string) $transitions[0]); - $this->assertSame([ + $this->assertTransitionReference( + $container, + '.workflow.article.transition.0', 'request_review', [ - 'draft', + ['place' => 'draft', 'weight' => 1], ], [ - 'wait_for_journalist', 'wait_for_spellchecker', + ['place' => 'wait_for_journalist', 'weight' => 1], + ['place' => 'wait_for_spellchecker', 'weight' => 1], ], - ], $container->getDefinition($transitions[0])->getArguments()); + $transitions[0] + ); - $this->assertSame('.workflow.article.transition.1', (string) $transitions[1]); - $this->assertSame([ + $this->assertTransitionReference( + $container, + '.workflow.article.transition.1', 'journalist_approval', [ - 'wait_for_journalist', + ['place' => 'wait_for_journalist', 'weight' => 1], ], [ - 'approved_by_journalist', + ['place' => 'approved_by_journalist', 'weight' => 1], ], - ], $container->getDefinition($transitions[1])->getArguments()); + $transitions[1] + ); - $this->assertSame('.workflow.article.transition.2', (string) $transitions[2]); - $this->assertSame([ + $this->assertTransitionReference( + $container, + '.workflow.article.transition.2', 'spellchecker_approval', [ - 'wait_for_spellchecker', + ['place' => 'wait_for_spellchecker', 'weight' => 1], ], [ - 'approved_by_spellchecker', + ['place' => 'approved_by_spellchecker', 'weight' => 1], ], - ], $container->getDefinition($transitions[2])->getArguments()); + $transitions[2] + ); - $this->assertSame('.workflow.article.transition.3', (string) $transitions[3]); - $this->assertSame([ + $this->assertTransitionReference( + $container, + '.workflow.article.transition.3', 'publish', [ - 'approved_by_journalist', - 'approved_by_spellchecker', + ['place' => 'approved_by_journalist', 'weight' => 1], + ['place' => 'approved_by_spellchecker', 'weight' => 1], ], [ - 'published', + ['place' => 'published', 'weight' => 1], ], - ], $container->getDefinition($transitions[3])->getArguments()); + $transitions[3] + ); - $this->assertSame('.workflow.article.transition.4', (string) $transitions[4]); - $this->assertSame([ + $this->assertTransitionReference( + $container, + '.workflow.article.transition.4', 'publish', [ - 'draft', + ['place' => 'draft', 'weight' => 1], ], [ - 'published', + ['place' => 'published', 'weight' => 2], ], - ], $container->getDefinition($transitions[4])->getArguments()); + $transitions[4] + ); + } + + private function assertTransitionReference(ContainerBuilder $container, string $expectedServiceId, string $expectedName, array $expectedFroms, array $expectedTos, Reference $transition): void + { + $this->assertSame($expectedServiceId, (string) $transition); + + $args = $container->getDefinition($transition)->getArguments(); + $this->assertTransition($expectedName, $expectedFroms, $expectedTos, $args); + } + + private function assertTransition(string $expectedName, array $expectedFroms, array $expectedTos, array $args): void + { + $this->assertCount(3, $args); + $this->assertSame($expectedName, $args[0]); + + $this->assertCount(\count($expectedFroms), $args[1]); + foreach ($expectedFroms as $i => ['place' => $place, 'weight' => $weight]) { + $this->assertInstanceOf(Definition::class, $args[1][$i]); + $this->assertSame(Arc::class, $args[1][$i]->getClass()); + $arcArgs = array_values($args[1][$i]->getArguments()); + $this->assertSame($place, $arcArgs[0]); + $this->assertSame($weight, $arcArgs[1]); + } + + $this->assertCount(\count($expectedTos), $args[2]); + foreach ($expectedTos as $i => ['place' => $place, 'weight' => $weight]) { + $this->assertInstanceOf(Definition::class, $args[2][$i]); + $this->assertSame(Arc::class, $args[2][$i]->getClass()); + $arcArgs = array_values($args[2][$i]->getArguments()); + $this->assertSame($place, $arcArgs[0]); + $this->assertSame($weight, $arcArgs[1]); + } } public function testWorkflowEnumPlaces() @@ -527,10 +571,23 @@ public function testWorkflowEnumPlaces() $workflowDefinition = $container->getDefinition('state_machine.enum.definition'); $this->assertSame(['a', 'b', 'c'], $workflowDefinition->getArgument(0)); - $transitionOne = $container->getDefinition('.state_machine.enum.transition.0'); - $this->assertSame(['one', 'a', 'b'], $transitionOne->getArguments()); - $transitionTwo = $container->getDefinition('.state_machine.enum.transition.1'); - $this->assertSame(['two', 'b', 'c'], $transitionTwo->getArguments()); + $this->assertTransitionReference( + $container, + '.state_machine.enum.transition.0', + 'one', + [['place' => 'a', 'weight' => 1]], + [['place' => 'b', 'weight' => 1]], + $workflowDefinition->getArgument(1)[0], + ); + + $this->assertTransitionReference( + $container, + '.state_machine.enum.transition.1', + 'two', + [['place' => 'b', 'weight' => 1]], + [['place' => 'c', 'weight' => 1]], + $workflowDefinition->getArgument(1)[1], + ); } public function testWorkflowGlobPlaces() @@ -622,7 +679,12 @@ public function testWorkflowTransitionsPerformNoDeepMerging() } $this->assertCount(1, $transitions); - $this->assertSame(['base_transition', ['middle'], ['alternative']], $transitions[0]); + $this->assertTransition( + 'base_transition', + [['place' => 'middle', 'weight' => 1]], + [['place' => 'alternative', 'weight' => 1]], + $transitions[0], + ); } public function testEnabledPhpErrorsConfig() diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 1fab93dd01405..6ad6e6e595438 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -69,7 +69,7 @@ "symfony/twig-bundle": "^6.4|^7.0|^8.0", "symfony/type-info": "^7.1.8|^8.0", "symfony/validator": "^7.4|^8.0", - "symfony/workflow": "^7.3|^8.0", + "symfony/workflow": "^7.4|^8.0", "symfony/yaml": "^7.3|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/json-streamer": "^7.3|^8.0", @@ -109,7 +109,7 @@ "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", "symfony/webhook": "<7.2", - "symfony/workflow": "<7.3" + "symfony/workflow": "<7.4" }, "autoload": { "psr-4": { "Symfony\\Bundle\\FrameworkBundle\\": "" }, diff --git a/src/Symfony/Component/Workflow/Arc.php b/src/Symfony/Component/Workflow/Arc.php new file mode 100644 index 0000000000000..61d19b3b3a590 --- /dev/null +++ b/src/Symfony/Component/Workflow/Arc.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; + +/** + * @author Grégoire Pineau + */ +final readonly class Arc +{ + public function __construct( + public string $place, + public int $weight, + ) { + if ($weight < 1) { + throw new \InvalidArgumentException(\sprintf('The weight must be greater than 0, %d given.', $weight)); + } + if (!$place) { + throw new \InvalidArgumentException('The place name cannot be empty.'); + } + } +} diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md index e793b032bea15..1b40c9e60eafd 100644 --- a/src/Symfony/Component/Workflow/CHANGELOG.md +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add support for `BackedEnum` in `MethodMarkingStore` + * Add support for weighted transitions 7.3 --- diff --git a/src/Symfony/Component/Workflow/Definition.php b/src/Symfony/Component/Workflow/Definition.php index 0b5697b758945..446c4eb13952d 100644 --- a/src/Symfony/Component/Workflow/Definition.php +++ b/src/Symfony/Component/Workflow/Definition.php @@ -104,15 +104,15 @@ private function addPlace(string $place): void private function addTransition(Transition $transition): void { - foreach ($transition->getFroms() as $from) { - if (!\array_key_exists($from, $this->places)) { - $this->addPlace($from); + foreach ($transition->getFroms(true) as $arc) { + if (!\array_key_exists($arc->place, $this->places)) { + $this->addPlace($arc->place); } } - foreach ($transition->getTos() as $to) { - if (!\array_key_exists($to, $this->places)) { - $this->addPlace($to); + foreach ($transition->getTos(true) as $arc) { + if (!\array_key_exists($arc->place, $this->places)) { + $this->addPlace($arc->place); } } diff --git a/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php index ad7b0c23d12fc..4a998d557491f 100644 --- a/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php +++ b/src/Symfony/Component/Workflow/Dumper/GraphvizDumper.php @@ -199,20 +199,22 @@ protected function findEdges(Definition $definition): array foreach ($definition->getTransitions() as $i => $transition) { $transitionName = $workflowMetadata->getMetadata('label', $transition) ?? $transition->getName(); - foreach ($transition->getFroms() as $from) { + foreach ($transition->getFroms(true) as $arc) { $dotEdges[] = [ - 'from' => $from, + 'from' => $arc->place, 'to' => $transitionName, 'direction' => 'from', 'transition_number' => $i, + 'weight' => $arc->weight, ]; } - foreach ($transition->getTos() as $to) { + foreach ($transition->getTos(true) as $arc) { $dotEdges[] = [ 'from' => $transitionName, - 'to' => $to, + 'to' => $arc->place, 'direction' => 'to', 'transition_number' => $i, + 'weight' => $arc->weight, ]; } } @@ -229,14 +231,16 @@ protected function addEdges(array $edges): string foreach ($edges as $edge) { if ('from' === $edge['direction']) { - $code .= \sprintf(" place_%s -> transition_%s [style=\"solid\"];\n", + $code .= \sprintf(" place_%s -> transition_%s [style=\"solid\"%s];\n", $this->dotize($edge['from']), - $this->dotize($edge['transition_number']) + $this->dotize($edge['transition_number']), + $edge['weight'] > 1 ? \sprintf(',label="%s"', $this->escape($edge['weight'])) : '', ); } else { - $code .= \sprintf(" transition_%s -> place_%s [style=\"solid\"];\n", + $code .= \sprintf(" transition_%s -> place_%s [style=\"solid\"%s];\n", $this->dotize($edge['transition_number']), - $this->dotize($edge['to']) + $this->dotize($edge['to']), + $edge['weight'] > 1 ? \sprintf(',label="%s"', $this->escape($edge['weight'])) : '', ); } } diff --git a/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php b/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php index bd7a6fada2168..38ca207af76fe 100644 --- a/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php +++ b/src/Symfony/Component/Workflow/Dumper/MermaidDumper.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Workflow\Dumper; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\Definition; use Symfony\Component\Workflow\Exception\InvalidArgumentException; use Symfony\Component\Workflow\Marking; @@ -69,7 +70,8 @@ public function dump(Definition $definition, ?Marking $marking = null, array $op $place, $meta->getPlaceMetadata($place), \in_array($place, $definition->getInitialPlaces(), true), - $marking?->has($place) ?? false + $marking?->has($place) ?? false, + $marking?->getTokenCount($place) ?? 0 ); $output[] = $placeNode; @@ -91,16 +93,15 @@ public function dump(Definition $definition, ?Marking $marking = null, array $op $transitionLabel = $transitionMeta['label']; } - foreach ($transition->getFroms() as $from) { - $from = $placeNameMap[$from]; - - foreach ($transition->getTos() as $to) { - $to = $placeNameMap[$to]; - + foreach ($transition->getFroms(true) as $fromArc) { + foreach ($transition->getTos(true) as $toArc) { if (self::TRANSITION_TYPE_STATEMACHINE === $this->transitionType) { + $from = $placeNameMap[$fromArc->place]; + $to = $placeNameMap[$toArc->place]; + $transitionOutput = $this->styleStateMachineTransition($from, $to, $transitionLabel, $transitionMeta); } else { - $transitionOutput = $this->styleWorkflowTransition($from, $to, $transitionId, $transitionLabel, $transitionMeta); + $transitionOutput = $this->styleWorkflowTransition($placeNameMap, $fromArc, $toArc, $transitionId, $transitionLabel, $transitionMeta); } foreach ($transitionOutput as $line) { @@ -122,12 +123,15 @@ public function dump(Definition $definition, ?Marking $marking = null, array $op return implode("\n", $output); } - private function preparePlace(int $placeId, string $placeName, array $meta, bool $isInitial, bool $hasMarking): array + private function preparePlace(int $placeId, string $placeName, array $meta, bool $isInitial, bool $hasMarking, int $tokenCount): array { $placeLabel = $placeName; if (\array_key_exists('label', $meta)) { $placeLabel = $meta['label']; } + if (1 < $tokenCount) { + $placeLabel .= ' ('.$tokenCount.')'; + } $placeLabel = $this->escape($placeLabel); @@ -206,7 +210,7 @@ private function styleStateMachineTransition(string $from, string $to, string $t return $transitionOutput; } - private function styleWorkflowTransition(string $from, string $to, int $transitionId, string $transitionLabel, array $transitionMeta): array + private function styleWorkflowTransition(array $placeNameMap, Arc $from, Arc $to, int $transitionId, string $transitionLabel, array $transitionMeta): array { $transitionOutput = []; @@ -220,8 +224,11 @@ private function styleWorkflowTransition(string $from, string $to, int $transiti $transitionOutput[] = $transitionNodeStyle; } - $connectionStyle = '%s-->%s'; - $transitionOutput[] = \sprintf($connectionStyle, $from, $transitionNodeName); + if ($from->weight > 1) { + $transitionOutput[] = \sprintf('%s-->|%d|%s', $placeNameMap[$from->place], $from->weight, $transitionNodeName); + } else { + $transitionOutput[] = \sprintf('%s-->%s', $placeNameMap[$from->place], $transitionNodeName); + } $linkStyle = $this->styleLink($transitionMeta); if ('' !== $linkStyle) { @@ -230,7 +237,11 @@ private function styleWorkflowTransition(string $from, string $to, int $transiti ++$this->linkCount; - $transitionOutput[] = \sprintf($connectionStyle, $transitionNodeName, $to); + if ($to->weight > 1) { + $transitionOutput[] = \sprintf('%s-->|%d|%s', $transitionNodeName, $to->weight, $placeNameMap[$to->place]); + } else { + $transitionOutput[] = \sprintf('%s-->%s', $transitionNodeName, $placeNameMap[$to->place]); + } $linkStyle = $this->styleLink($transitionMeta); if ('' !== $linkStyle) { diff --git a/src/Symfony/Component/Workflow/Dumper/PlantUmlDumper.php b/src/Symfony/Component/Workflow/Dumper/PlantUmlDumper.php index 9bd621ad59733..ca9e77b139345 100644 --- a/src/Symfony/Component/Workflow/Dumper/PlantUmlDumper.php +++ b/src/Symfony/Component/Workflow/Dumper/PlantUmlDumper.php @@ -78,10 +78,10 @@ public function dump(Definition $definition, ?Marking $marking = null, array $op } foreach ($definition->getTransitions() as $transition) { $transitionEscaped = $this->escape($transition->getName()); - foreach ($transition->getFroms() as $from) { - $fromEscaped = $this->escape($from); - foreach ($transition->getTos() as $to) { - $toEscaped = $this->escape($to); + foreach ($transition->getFroms(true) as $fromArc) { + $fromEscaped = $this->escape($fromArc->place); + foreach ($transition->getTos(true) as $toArc) { + $toEscaped = $this->escape($toArc->place); $transitionEscapedWithStyle = $this->getTransitionEscapedWithStyle($workflowMetadata, $transition, $transitionEscaped); diff --git a/src/Symfony/Component/Workflow/Dumper/StateMachineGraphvizDumper.php b/src/Symfony/Component/Workflow/Dumper/StateMachineGraphvizDumper.php index 7bd9d730fd026..e8db9e69eda24 100644 --- a/src/Symfony/Component/Workflow/Dumper/StateMachineGraphvizDumper.php +++ b/src/Symfony/Component/Workflow/Dumper/StateMachineGraphvizDumper.php @@ -65,14 +65,14 @@ protected function findEdges(Definition $definition): array $attributes['color'] = $arrowColor; } - foreach ($transition->getFroms() as $from) { - foreach ($transition->getTos() as $to) { + foreach ($transition->getFroms(true) as $fromArc) { + foreach ($transition->getTos(true) as $toArc) { $edge = [ 'name' => $transitionName, - 'to' => $to, + 'to' => $toArc->place, 'attributes' => $attributes, ]; - $edges[$from][] = $edge; + $edges[$fromArc->place][] = $edge; } } } diff --git a/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php index fe7ccdf48f327..cf7a2d35c8e7c 100644 --- a/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php +++ b/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php @@ -27,8 +27,8 @@ public function __construct( public function onLeave(Event $event): void { - foreach ($event->getTransition()->getFroms() as $place) { - $this->logger->info(\sprintf('Leaving "%s" for subject of class "%s" in workflow "%s".', $place, $event->getSubject()::class, $event->getWorkflowName())); + foreach ($event->getTransition()->getFroms(true) as $arc) { + $this->logger->info(\sprintf('Leaving "%s" for subject of class "%s" in workflow "%s".', $arc->place, $event->getSubject()::class, $event->getWorkflowName())); } } @@ -39,8 +39,8 @@ public function onTransition(Event $event): void public function onEnter(Event $event): void { - foreach ($event->getTransition()->getTos() as $place) { - $this->logger->info(\sprintf('Entering "%s" for subject of class "%s" in workflow "%s".', $place, $event->getSubject()::class, $event->getWorkflowName())); + foreach ($event->getTransition()->getTos(true) as $arc) { + $this->logger->info(\sprintf('Entering "%s" for subject of class "%s" in workflow "%s".', $arc->place, $event->getSubject()::class, $event->getWorkflowName())); } } diff --git a/src/Symfony/Component/Workflow/Marking.php b/src/Symfony/Component/Workflow/Marking.php index c3629a2432798..9602efa3ad786 100644 --- a/src/Symfony/Component/Workflow/Marking.php +++ b/src/Symfony/Component/Workflow/Marking.php @@ -18,7 +18,11 @@ */ class Marking { + /** + * @var array> Keys are the place names and values are the number of tokens in that place + */ private array $places = []; + private ?array $context = null; /** @@ -83,6 +87,11 @@ public function has(string $place): bool return isset($this->places[$place]); } + public function getTokenCount(string $place): int + { + return $this->places[$place] ?? 0; + } + public function getPlaces(): array { return $this->places; diff --git a/src/Symfony/Component/Workflow/Tests/ArcTest.php b/src/Symfony/Component/Workflow/Tests/ArcTest.php new file mode 100644 index 0000000000000..11e6b1b40187b --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/ArcTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Arc; + +class ArcTest extends TestCase +{ + public function testConstructorWithInvalidPlaceName() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The place name cannot be empty.'); + + new Arc('', 1); + } + + public function testConstructorWithInvalidWeight() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The weight must be greater than 0, 0 given.'); + + new Arc('not empty', 0); + } +} diff --git a/src/Symfony/Component/Workflow/Tests/StateMachineTest.php b/src/Symfony/Component/Workflow/Tests/StateMachineTest.php index 5d10fdef89613..da08140c7663e 100644 --- a/src/Symfony/Component/Workflow/Tests/StateMachineTest.php +++ b/src/Symfony/Component/Workflow/Tests/StateMachineTest.php @@ -88,7 +88,7 @@ public function testBuildTransitionBlockerListReturnsExpectedReasonOnBranchMerge $net = new StateMachine($definition, null, $dispatcher); $dispatcher->addListener('workflow.guard', function (GuardEvent $event) { - $event->addTransitionBlocker(new TransitionBlocker(\sprintf('Transition blocker of place %s', $event->getTransition()->getFroms()[0]), 'blocker')); + $event->addTransitionBlocker(new TransitionBlocker(\sprintf('Transition blocker of place %s', $event->getTransition()->getFroms(true)[0]->place), 'blocker')); }); $subject = new Subject(); @@ -124,7 +124,7 @@ public function testApplyReturnsExpectedReasonOnBranchMerge() $net = new StateMachine($definition, null, $dispatcher); $dispatcher->addListener('workflow.guard', function (GuardEvent $event) { - $event->addTransitionBlocker(new TransitionBlocker(\sprintf('Transition blocker of place %s', $event->getTransition()->getFroms()[0]), 'blocker')); + $event->addTransitionBlocker(new TransitionBlocker(\sprintf('Transition blocker of place %s', $event->getTransition()->getFroms(true)[0]->place), 'blocker')); }); $subject = new Subject(); diff --git a/src/Symfony/Component/Workflow/Tests/TransitionTest.php b/src/Symfony/Component/Workflow/Tests/TransitionTest.php index aee514717a3f2..d9b4b103c3b45 100644 --- a/src/Symfony/Component/Workflow/Tests/TransitionTest.php +++ b/src/Symfony/Component/Workflow/Tests/TransitionTest.php @@ -11,16 +11,46 @@ namespace Symfony\Component\Workflow\Tests; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\Transition; class TransitionTest extends TestCase { - public function testConstructor() + public static function provideConstructorTests(): iterable { - $transition = new Transition('name', 'a', 'b'); + yield 'plain strings' => ['a', 'b']; + yield 'array of strings' => [['a'], ['b']]; + yield 'array of arcs' => [[new Arc('a', 1)], [new Arc('b', 1)]]; + } + + #[DataProvider('provideConstructorTests')] + public function testConstructor(mixed $froms, mixed $tos) + { + $transition = new Transition('name', $froms, $tos); $this->assertSame('name', $transition->getName()); + $this->assertCount(1, $transition->getFroms(true)); + $this->assertSame('a', $transition->getFroms(true)[0]->place); + $this->assertSame(1, $transition->getFroms(true)[0]->weight); + $this->assertCount(1, $transition->getTos(true)); + $this->assertSame('b', $transition->getTos(true)[0]->place); + $this->assertSame(1, $transition->getTos(true)[0]->weight); + } + + public function testConstructorWithInvalidData() + { + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('The type of arc is invalid. Expected string or Arc, got "bool".'); + + new Transition('name', [true], ['a']); + } + + public function testLegacyGetter() + { + $transition = new Transition('name', 'a', 'b'); + $this->assertSame(['a'], $transition->getFroms()); $this->assertSame(['b'], $transition->getTos()); } diff --git a/src/Symfony/Component/Workflow/Tests/Validator/StateMachineValidatorTest.php b/src/Symfony/Component/Workflow/Tests/Validator/StateMachineValidatorTest.php index e88408bf693dd..e34c2b46e6f51 100644 --- a/src/Symfony/Component/Workflow/Tests/Validator/StateMachineValidatorTest.php +++ b/src/Symfony/Component/Workflow/Tests/Validator/StateMachineValidatorTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Workflow\Tests\Validator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\Definition; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Transition; @@ -125,4 +126,32 @@ public function testWithTooManyInitialPlaces() (new StateMachineValidator())->validate($definition, 'foo'); } + + public function testWithArcInFromTooHeavy() + { + $places = ['a', 'b']; + $transitions = [ + new Transition('t1', [new Arc('a', 2)], [new Arc('b', 1)]), + ]; + $definition = new Definition($places, $transitions); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('A transition in StateMachine can only have arc with weight equals to one. But the transition "t1" in StateMachine "foo" has an arc from "a" to the transition with a weight equals to 2.'); + + (new StateMachineValidator())->validate($definition, 'foo'); + } + + public function testWithArcInToTooHeavy() + { + $places = ['a', 'b']; + $transitions = [ + new Transition('t1', [new Arc('a', 1)], [new Arc('b', 2)]), + ]; + $definition = new Definition($places, $transitions); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('A transition in StateMachine can only have arc with weight equals to one. But the transition "t1" in StateMachine "foo" has an arc from the transition to "b" with a weight equals to 2.'); + + (new StateMachineValidator())->validate($definition, 'foo'); + } } diff --git a/src/Symfony/Component/Workflow/Tests/Validator/WorkflowValidatorTest.php b/src/Symfony/Component/Workflow/Tests/Validator/WorkflowValidatorTest.php index 50c3abd98b541..ed2acc4dd1975 100644 --- a/src/Symfony/Component/Workflow/Tests/Validator/WorkflowValidatorTest.php +++ b/src/Symfony/Component/Workflow/Tests/Validator/WorkflowValidatorTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Workflow\Tests\Validator; use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\Definition; use Symfony\Component\Workflow\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Tests\WorkflowBuilderTrait; @@ -83,4 +84,32 @@ public function testWithTooManyInitialPlaces() (new WorkflowValidator(true))->validate($definition, 'foo'); } + + public function testWithArcInFromTooHeavy() + { + $places = ['a', 'b']; + $transitions = [ + new Transition('t1', [new Arc('a', 2)], [new Arc('b', 1)]), + ]; + $definition = new Definition($places, $transitions); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('The marking store of workflow "t1" cannot store many places. But the transition "foo" has an arc from the transition to "a" with a weight equals to 2.'); + + (new WorkflowValidator(true))->validate($definition, 'foo'); + } + + public function testWithArcInToTooHeavy() + { + $places = ['a', 'b']; + $transitions = [ + new Transition('t1', [new Arc('a', 1)], [new Arc('b', 2)]), + ]; + $definition = new Definition($places, $transitions); + + $this->expectException(InvalidDefinitionException::class); + $this->expectExceptionMessage('The marking store of workflow "t1" cannot store many places. But the transition "foo" has an arc from "b" to the transition with a weight equals to 2.'); + + (new WorkflowValidator(true))->validate($definition, 'foo'); + } } diff --git a/src/Symfony/Component/Workflow/Tests/WorkflowTest.php b/src/Symfony/Component/Workflow/Tests/WorkflowTest.php index d44d2c6ff1877..60bb06fa44f5c 100644 --- a/src/Symfony/Component/Workflow/Tests/WorkflowTest.php +++ b/src/Symfony/Component/Workflow/Tests/WorkflowTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\Definition; use Symfony\Component\Workflow\Event\EnteredEvent; use Symfony\Component\Workflow\Event\Event; @@ -869,6 +870,133 @@ public function testApplyWithSameNameBackTransition(string $transition) ], $marking); } + public function testWithArcAndWeight() + { + // ┌───────────────────┐ ┌─────────────┐ ┌─────────────┐ 4 + // │ prepare_leg │ ──▶ │ build_leg │ ──▶ │ leg_created │ ───────────────────────────┐ + // └───────────────────┘ └─────────────┘ └─────────────┘ │ + // ▲ │ + // │ 4 │ + // │ ▼ + // ┌──────┐ ┌───────────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────┐ ┌──────────┐ + // │ init │ ──▶ │ start │ ──▶ │ prepare_top │ ──▶ │ build_top │ ───▶ │ top_created │ ──▶ │ join │ ──▶ │ finished │ + // └──────┘ └───────────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └──────┘ └──────────┘ + // │ ▲ + // │ │ + // ▼ │ + // ┌───────────────────┐ │ + // │ stopwatch_running │ ───────────────────────────────────────────────────────────────────┘ + // └───────────────────┘ + // + // make_table: + // transitions: + // start: + // from: init + // to: + // - place: prepare_leg + // weight: 4 + // - place: prepare_top + // weight: 1 + // - place: stopwatch_running + // weight: 1 + // build_leg: + // from: prepare_leg + // to: leg_created + // build_top: + // from: prepare_top + // to: top_created + // join: + // from: + // - place: leg_created + // weight: 4 + // - top_created + // - stopwatch_running + // to: finished + + $definition = new Definition( + [], + [ + new Transition('start', 'init', [new Arc('prepare_leg', 4), 'prepare_top', 'stopwatch_running']), + new Transition('build_leg', 'prepare_leg', 'leg_created'), + new Transition('build_top', 'prepare_top', 'top_created'), + new Transition('join', [new Arc('leg_created', 4), 'top_created', 'stopwatch_running'], 'finished'), + ] + ); + + $subject = new Subject(); + $workflow = new Workflow($definition); + + $this->assertTrue($workflow->can($subject, 'start')); + $this->assertFalse($workflow->can($subject, 'build_leg')); + $this->assertFalse($workflow->can($subject, 'build_top')); + $this->assertFalse($workflow->can($subject, 'join')); + + $workflow->apply($subject, 'start'); + + $this->assertSame([ + 'prepare_leg' => 4, + 'prepare_top' => 1, + 'stopwatch_running' => 1, + ], $subject->getMarking()); + $this->assertTrue($workflow->can($subject, 'build_leg')); + $this->assertTrue($workflow->can($subject, 'build_top')); + $this->assertFalse($workflow->can($subject, 'join')); + + $workflow->apply($subject, 'build_leg'); + + $this->assertSame([ + 'prepare_leg' => 3, + 'prepare_top' => 1, + 'stopwatch_running' => 1, + 'leg_created' => 1, + ], $subject->getMarking()); + $this->assertTrue($workflow->can($subject, 'build_leg')); + $this->assertTrue($workflow->can($subject, 'build_top')); + $this->assertFalse($workflow->can($subject, 'join')); + + $workflow->apply($subject, 'build_top'); + + $this->assertSame([ + 'prepare_leg' => 3, + 'stopwatch_running' => 1, + 'leg_created' => 1, + 'top_created' => 1, + ], $subject->getMarking()); + $this->assertTrue($workflow->can($subject, 'build_leg')); + $this->assertFalse($workflow->can($subject, 'build_top')); + $this->assertFalse($workflow->can($subject, 'join')); + + $workflow->apply($subject, 'build_leg'); + + $this->assertSame([ + 'prepare_leg' => 2, + 'stopwatch_running' => 1, + 'leg_created' => 2, + 'top_created' => 1, + ], $subject->getMarking()); + $this->assertTrue($workflow->can($subject, 'build_leg')); + $this->assertFalse($workflow->can($subject, 'build_top')); + $this->assertFalse($workflow->can($subject, 'join')); + + $workflow->apply($subject, 'build_leg'); + $workflow->apply($subject, 'build_leg'); + + $this->assertSame([ + 'stopwatch_running' => 1, + 'leg_created' => 4, + 'top_created' => 1, + ], $subject->getMarking()); + $this->assertFalse($workflow->can($subject, 'build_leg')); + $this->assertFalse($workflow->can($subject, 'build_top')); + $this->assertTrue($workflow->can($subject, 'join')); + + $workflow->apply($subject, 'join'); + + $this->assertSame([ + 'finished' => 1, + ], $subject->getMarking()); + } + private function assertPlaces(array $expected, Marking $marking) { $places = $marking->getPlaces(); diff --git a/src/Symfony/Component/Workflow/Transition.php b/src/Symfony/Component/Workflow/Transition.php index 05fe26771fb5c..f40924aba1f44 100644 --- a/src/Symfony/Component/Workflow/Transition.php +++ b/src/Symfony/Component/Workflow/Transition.php @@ -17,20 +17,27 @@ */ class Transition { - private array $froms; - private array $tos; + /** + * @var Arc[] + */ + private array $fromArcs; + + /** + * @var Arc[] + */ + private array $toArcs; /** - * @param string|string[] $froms - * @param string|string[] $tos + * @param string|string[]|Arc[] $froms + * @param string|string[]|Arc[] $tos */ public function __construct( private string $name, string|array $froms, string|array $tos, ) { - $this->froms = (array) $froms; - $this->tos = (array) $tos; + $this->fromArcs = array_map($this->normalize(...), (array) $froms); + $this->toArcs = array_map($this->normalize(...), (array) $tos); } public function getName(): string @@ -39,18 +46,40 @@ public function getName(): string } /** - * @return string[] + * @return $asArc is true ? array : array */ - public function getFroms(): array + public function getFroms(/* bool $asArc = false */): array { - return $this->froms; + if (1 <= \func_num_args() && func_get_arg(0)) { + return $this->fromArcs; + } + + return array_column($this->fromArcs, 'place'); } /** - * @return string[] + * @return $asArc is true ? array : array */ - public function getTos(): array + public function getTos(/* bool $asArc = false */): array { - return $this->tos; + if (1 <= \func_num_args() && func_get_arg(0)) { + return $this->toArcs; + } + + return array_column($this->toArcs, 'place'); + } + + // No type hint for $arc to avoid implicit cast + private function normalize(mixed $arc): Arc + { + if ($arc instanceof Arc) { + return $arc; + } + + if (\is_string($arc)) { + return new Arc($arc, 1); + } + + throw new \TypeError(\sprintf('The type of arc is invalid. Expected string or Arc, got "%s".', get_debug_type($arc))); } } diff --git a/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php b/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php index 626a20eea8af8..ef5b4bafaf638 100644 --- a/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php +++ b/src/Symfony/Component/Workflow/Validator/StateMachineValidator.php @@ -24,18 +24,29 @@ public function validate(Definition $definition, string $name): void $transitionFromNames = []; foreach ($definition->getTransitions() as $transition) { // Make sure that each transition has exactly one TO - if (1 !== \count($transition->getTos())) { - throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one output. But the transition "%s" in StateMachine "%s" has %d outputs.', $transition->getName(), $name, \count($transition->getTos()))); + if (1 !== \count($transition->getTos(true))) { + throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one output. But the transition "%s" in StateMachine "%s" has %d outputs.', $transition->getName(), $name, \count($transition->getTos(true)))); + } + foreach ($transition->getFroms(true) as $arc) { + if (1 < $arc->weight) { + throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have arc with weight equals to one. But the transition "%s" in StateMachine "%s" has an arc from "%s" to the transition with a weight equals to %d.', $transition->getName(), $name, $arc->place, $arc->weight)); + } } // Make sure that each transition has exactly one FROM - $froms = $transition->getFroms(); - if (1 !== \count($froms)) { - throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one input. But the transition "%s" in StateMachine "%s" has %d inputs.', $transition->getName(), $name, \count($froms))); + $fromArcs = $transition->getFroms(true); + if (1 !== \count($fromArcs)) { + throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have one input. But the transition "%s" in StateMachine "%s" has %d inputs.', $transition->getName(), $name, \count($fromArcs))); + } + foreach ($transition->getTos(true) as $arc) { + if (1 !== $arc->weight) { + throw new InvalidDefinitionException(\sprintf('A transition in StateMachine can only have arc with weight equals to one. But the transition "%s" in StateMachine "%s" has an arc from the transition to "%s" with a weight equals to %d.', $transition->getName(), $name, $arc->place, $arc->weight)); + } } // Enforcing uniqueness of the names of transitions starting at each node - $from = reset($froms); + $fromArc = reset($fromArcs); + $from = $fromArc->place; if (isset($transitionFromNames[$from][$transition->getName()])) { throw new InvalidDefinitionException(\sprintf('A transition from a place/state must have an unique name. Multiple transitions named "%s" from place/state "%s" were found on StateMachine "%s".', $transition->getName(), $from, $name)); } diff --git a/src/Symfony/Component/Workflow/Validator/WorkflowValidator.php b/src/Symfony/Component/Workflow/Validator/WorkflowValidator.php index f4eb2926692f0..d7e2b7d1e52c5 100644 --- a/src/Symfony/Component/Workflow/Validator/WorkflowValidator.php +++ b/src/Symfony/Component/Workflow/Validator/WorkflowValidator.php @@ -30,7 +30,8 @@ public function validate(Definition $definition, string $name): void // Make sure all transitions for one place has unique name. $places = array_fill_keys($definition->getPlaces(), []); foreach ($definition->getTransitions() as $transition) { - foreach ($transition->getFroms() as $from) { + foreach ($transition->getFroms(true) as $arc) { + $from = $arc->place; if (\in_array($transition->getName(), $places[$from], true)) { throw new InvalidDefinitionException(\sprintf('All transitions for a place must have an unique name. Multiple transitions named "%s" where found for place "%s" in workflow "%s".', $transition->getName(), $from, $name)); } @@ -43,8 +44,19 @@ public function validate(Definition $definition, string $name): void } foreach ($definition->getTransitions() as $transition) { - if (1 < \count($transition->getTos())) { - throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the transition "%s" has too many output (%d). Only one is accepted.', $name, $transition->getName(), \count($transition->getTos()))); + if (1 < \count($transition->getTos(true))) { + throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the transition "%s" has too many output (%d). Only one is accepted.', $name, $transition->getName(), \count($transition->getTos(true)))); + } + + foreach ($transition->getFroms(true) as $arc) { + if (1 < $arc->weight) { + throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the transition "%s" has an arc from the transition to "%s" with a weight equals to %d.', $transition->getName(), $name, $arc->place, $arc->weight)); + } + } + foreach ($transition->getTos(true) as $arc) { + if (1 < $arc->weight) { + throw new InvalidDefinitionException(\sprintf('The marking store of workflow "%s" cannot store many places. But the transition "%s" has an arc from "%s" to the transition with a weight equals to %d.', $transition->getName(), $name, $arc->place, $arc->weight)); + } } } diff --git a/src/Symfony/Component/Workflow/Workflow.php b/src/Symfony/Component/Workflow/Workflow.php index 9165ebb2b24a5..f0d025626c599 100644 --- a/src/Symfony/Component/Workflow/Workflow.php +++ b/src/Symfony/Component/Workflow/Workflow.php @@ -283,8 +283,8 @@ public function getMetadataStore(): MetadataStoreInterface private function buildTransitionBlockerListForTransition(object $subject, Marking $marking, Transition $transition): TransitionBlockerList { - foreach ($transition->getFroms() as $place) { - if (!$marking->has($place)) { + foreach ($transition->getFroms(true) as $arc) { + if ($marking->getTokenCount($arc->place) < $arc->weight) { return new TransitionBlockerList([ TransitionBlocker::createBlockedByMarking($marking), ]); @@ -321,7 +321,7 @@ private function guardTransition(object $subject, Marking $marking, Transition $ private function leave(object $subject, Transition $transition, Marking $marking, array $context = []): void { - $places = $transition->getFroms(); + $arcs = $transition->getFroms(true); if ($this->shouldDispatchEvent(WorkflowEvents::LEAVE, $context)) { $event = new LeaveEvent($subject, $marking, $transition, $this, $context); @@ -329,13 +329,13 @@ private function leave(object $subject, Transition $transition, Marking $marking $this->dispatcher->dispatch($event, WorkflowEvents::LEAVE); $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave', $this->name)); - foreach ($places as $place) { - $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave.%s', $this->name, $place)); + foreach ($arcs as $arc) { + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.leave.%s', $this->name, $arc->place)); } } - foreach ($places as $place) { - $marking->unmark($place); + foreach ($arcs as $arc) { + $marking->unmark($arc->place, $arc->weight); } } @@ -356,7 +356,7 @@ private function transition(object $subject, Transition $transition, Marking $ma private function enter(object $subject, Transition $transition, Marking $marking, array $context): void { - $places = $transition->getTos(); + $arcs = $transition->getTos(true); if ($this->shouldDispatchEvent(WorkflowEvents::ENTER, $context)) { $event = new EnterEvent($subject, $marking, $transition, $this, $context); @@ -364,13 +364,13 @@ private function enter(object $subject, Transition $transition, Marking $marking $this->dispatcher->dispatch($event, WorkflowEvents::ENTER); $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter', $this->name)); - foreach ($places as $place) { - $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter.%s', $this->name, $place)); + foreach ($arcs as $arc) { + $this->dispatcher->dispatch($event, \sprintf('workflow.%s.enter.%s', $this->name, $arc->place)); } } - foreach ($places as $place) { - $marking->mark($place); + foreach ($arcs as $arc) { + $marking->mark($arc->place, $arc->weight); } } @@ -387,7 +387,7 @@ private function entered(object $subject, ?Transition $transition, Marking $mark $placeNames = []; if ($transition) { - $placeNames = $transition->getTos(); + $placeNames = array_column($transition->getTos(true), 'place'); } elseif ($this->definition->getInitialPlaces()) { $placeNames = $this->definition->getInitialPlaces(); }