diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 8e70fb98e42fe..da8de057510f7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -25,6 +25,7 @@ CHANGELOG * Set `framework.rate_limiter.limiters.*.lock_factory` to `auto` by default * Deprecate `RateLimiterFactory` autowiring aliases, use `RateLimiterFactoryInterface` instead * Allow configuring compound rate limiters + * Add support for weighted transitions in workflows 7.2 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 6b168a2d4a0fd..c1f601f64960a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -546,27 +546,80 @@ 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\'') ->end() ->arrayNode('from') - ->beforeNormalization()->castToArray()->end() + ->beforeNormalization() + ->always() + ->then($workflowNormalizeArcs = static function ($arcs) { + $arcs = (array) $arcs; + + // Fix XML parsing, when only one arc is defined + if (array_key_exists('weight', $arcs) && array_key_exists('value', $arcs)) { + $arcs = [$arcs]; + } + + foreach ($arcs as $k => $arc) { + if (is_int($k) && is_string($arc)) { + $arcs[$k] = [ + 'place' => $arc, + 'weight' => 1, + ]; + } else if (array_key_exists('weight', $arc) && array_key_exists('value', $arc)) { + // Fix XML parsing + $arcs[$k] = [ + 'place' => $arc['value'], + 'weight' => $arc['weight'], + ]; + } + } + + return $arcs; + }) + ->end() ->requiresAtLeastOneElement() - ->prototype('scalar') - ->cannotBeEmpty() + ->prototype('array') + ->children() + ->stringNode('place') + ->isRequired() + ->cannotBeEmpty() + ->end() + ->integerNode('weight') + ->isRequired() + ->end() + ->end() ->end() ->end() ->arrayNode('to') - ->beforeNormalization()->castToArray()->end() + ->beforeNormalization() + ->always() + ->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 (int $v): bool => $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 5595e14b36329..d05ce15201f09 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -17,11 +17,9 @@ use Doctrine\ORM\Mapping\MappedSuperclass; use Http\Client\HttpAsyncClient; use Http\Client\HttpClient; -use phpDocumentor\Reflection\DocBlockFactoryInterface; -use phpDocumentor\Reflection\Types\ContextFactory; -use PhpParser\Parser; use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPUnit\Framework\TestCase; +use PhpParser\Parser; use Psr\Cache\CacheItemPoolInterface; use Psr\Clock\ClockInterface as PsrClockInterface; use Psr\Container\ContainerInterface as PsrContainerInterface; @@ -33,10 +31,10 @@ use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; use Symfony\Bundle\FullStack; use Symfony\Bundle\MercureBundle\MercureBundle; -use Symfony\Component\Asset\PackageInterface; use Symfony\Component\AssetMapper\AssetMapper; use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; use Symfony\Component\AssetMapper\Compressor\CompressorInterface; +use Symfony\Component\Asset\PackageInterface; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -49,9 +47,9 @@ use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\Config\ResourceCheckerInterface; use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\Config\Resource\FileResource; -use Symfony\Component\Config\ResourceCheckerInterface; use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -208,15 +206,16 @@ use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Uid\UuidV4; use Symfony\Component\Validator\Constraint; -use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\Constraints\ExpressionLanguageProvider; use Symfony\Component\Validator\GroupProviderInterface; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\ObjectInitializerInterface; use Symfony\Component\Validator\Validation; -use Symfony\Component\Webhook\Controller\WebhookController; use Symfony\Component\WebLink\HttpHeaderSerializer; +use Symfony\Component\Webhook\Controller\WebhookController; 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; @@ -229,6 +228,8 @@ use Symfony\Contracts\Service\ResetInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\Contracts\Translation\LocaleAwareInterface; +use phpDocumentor\Reflection\DocBlockFactoryInterface; +use phpDocumentor\Reflection\Types\ContextFactory; /** * Process the configuration and prepare the dependency injection container with @@ -1074,6 +1075,14 @@ 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, [ + '$place' => $arc['place'], + '$weight' => $arc['weight'] ?? 1, + ]); + } + } if ('workflow' === $type) { $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], $transition['from'], $transition['to']]); $transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); @@ -1095,7 +1104,7 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ } elseif ('state_machine' === $type) { foreach ($transition['from'] as $from) { foreach ($transition['to'] as $to) { - $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], $from, $to]); + $transitionDefinition = new Definition(Workflow\Transition::class, [$transition['name'], [$from], [$to]]); $transitionId = \sprintf('.%s.transition.%s', $workflowId, $transitionCounter++); $container->setDefinition($transitionId, $transitionDefinition); $transitions[] = new Reference($transitionId); 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 c4ee3486dae87..9c1ed50ef5acf 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 @@ -502,14 +502,22 @@ - - + + + + + + + + + + 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 d942c122c826a..cb847c216feb6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -91,6 +91,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\Exception\InvalidDefinitionException; use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore; use Symfony\Component\Workflow\WorkflowEvents; @@ -433,61 +434,96 @@ public function testWorkflowMultipleTransitionsWithSameName() $this->assertCount(5, $transitions); - $this->assertSame('.workflow.article.transition.0', (string) $transitions[0]); - $this->assertSame([ + $assertTransitions = function( + string $expectedServiceId, + string $expectedName, + array $expectedFroms, + array $expectedTos, + Reference $transition + ) use ($container) { + $this->assertSame($expectedServiceId, (string) $transition); + $args = $container->getDefinition($transition)->getArguments(); + $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()); + $this->assertSame($place, $args[1][$i]->getArgument('$place')); + $this->assertSame($weight, $args[1][$i]->getArgument('$weight')); + } + + $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()); + $this->assertSame($place, $args[2][$i]->getArgument('$place')); + $this->assertSame($weight, $args[2][$i]->getArgument('$weight')); + } + }; + + $assertTransitions( + '.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([ + $assertTransitions( + '.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([ + $assertTransitions( + '.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([ + $assertTransitions( + '.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([ + $assertTransitions( + '.workflow.article.transition.4', 'publish', [ - 'draft', + ['place' => 'draft', 'weight' => 1], ], [ - 'published', + ['place' => 'published', 'weight' => 2], ], - ], $container->getDefinition($transitions[4])->getArguments()); + $transitions[4] + ); } public function testWorkflowGuardExpressions() diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 2ecedbc45660e..68c5ff5d94809 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -67,7 +67,7 @@ "symfony/twig-bundle": "^6.4|^7.0", "symfony/type-info": "^7.1", "symfony/validator": "^6.4|^7.0", - "symfony/workflow": "^6.4|^7.0", + "symfony/workflow": "^7.3", "symfony/yaml": "^6.4|^7.0", "symfony/property-info": "^6.4|^7.0", "symfony/json-streamer": "7.3.*", @@ -108,7 +108,7 @@ "symfony/validator": "<6.4", "symfony/web-profiler-bundle": "<6.4", "symfony/webhook": "<7.2", - "symfony/workflow": "<6.4" + "symfony/workflow": "<7.3" }, "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..9b91c60d63892 --- /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, "%s" 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 2926da4e6428d..3b61a5013dd54 100644 --- a/src/Symfony/Component/Workflow/CHANGELOG.md +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -1,13 +1,18 @@ CHANGELOG ========= +7.3 +--- + + * Add support for weighted transitions + 7.1 --- * Add method `getEnabledTransition()` to `WorkflowInterface` * Automatically register places from transitions * Add support for workflows that need to store many tokens in the marking - * Add method `getName()` in event classes to build event names in subscribers + * Add method `getName()` in event classes to build event names in subscribers 7.0 --- diff --git a/src/Symfony/Component/Workflow/Definition.php b/src/Symfony/Component/Workflow/Definition.php index 0b5697b758945..1bbbd25bf5402 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->getFromArcs() 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->getToArcs() 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 2aaf54932aae5..9a875a02fc1ce 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->getFromArcs() 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->getToArcs() 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..9caef4263076a 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->getFromArcs() as $fromArc) { + foreach ($transition->getToArcs() 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 ($tokenCount > 1) { + $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..3e7b8c76421da 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->getFromArcs() as $fromArc) { + $fromEscaped = $this->escape($fromArc->place); + foreach ($transition->getToArcs() 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..10f2fcc50838a 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->getFromArcs() as $fromArc) { + foreach ($transition->getToArcs() 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..1916fd63e491c 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()->getFromArcs() 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()->getToArcs() 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..b59b60c203d80 100644 --- a/src/Symfony/Component/Workflow/Marking.php +++ b/src/Symfony/Component/Workflow/Marking.php @@ -83,6 +83,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..a3661705696e2 --- /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..a630392cd2ca0 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()->getFromArcs()[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()->getFromArcs()[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..01c78aa8e2e1e 100644 --- a/src/Symfony/Component/Workflow/Tests/TransitionTest.php +++ b/src/Symfony/Component/Workflow/Tests/TransitionTest.php @@ -12,15 +12,49 @@ namespace Symfony\Component\Workflow\Tests; 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->getFromArcs()); + $this->assertSame('a', $transition->getFromArcs()[0]->place); + $this->assertSame(1, $transition->getFromArcs()[0]->weight); + $this->assertCount(1, $transition->getToArcs()); + $this->assertSame('b', $transition->getToArcs()[0]->place); + $this->assertSame(1, $transition->getToArcs()[0]->weight); + } + + public function testConstructorWithInvalidData() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The type of arc is invalid. Expected string or Arc, got "bool".'); + + new Transition('name', [true], ['a']); + } + + /** + * @group legacy + */ + 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 49f04000fe4f3..ed2acc4dd1975 100644 --- a/src/Symfony/Component/Workflow/Tests/Validator/WorkflowValidatorTest.php +++ b/src/Symfony/Component/Workflow/Tests/Validator/WorkflowValidatorTest.php @@ -84,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 18cbaf0dd979e..9915fc1a0255c 100644 --- a/src/Symfony/Component/Workflow/Tests/WorkflowTest.php +++ b/src/Symfony/Component/Workflow/Tests/WorkflowTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\Workflow\Arc; use Symfony\Component\Workflow\Definition; use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Event\GuardEvent; @@ -831,6 +832,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..046019d98c911 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,56 @@ public function getName(): string } /** + * @deprecated since Symfony 7.3, use getFromArcs() instead + * * @return string[] */ public function getFroms(): array { - return $this->froms; + trigger_deprecation('symfony/workflow', '7.3', 'Method "%s()" is deprecated, use "getFromArcs()" instead.', __METHOD__); + + return array_map(static fn (Arc $arc): string => $arc->place, $this->fromArcs); + } + + /** + * @return Arc[] + */ + public function getFromArcs(): array + { + return $this->fromArcs; } /** + * @deprecated since Symfony 7.3, use getFromArcs() instead + * * @return string[] */ public function getTos(): array { - return $this->tos; + trigger_deprecation('symfony/workflow', '7.3', 'Method "%s()" is deprecated, use "getToArcs()" instead.', __METHOD__); + + return array_map(static fn (Arc $arc): string => $arc->place, $this->toArcs); + } + + /** + * @return Arc[] + */ + public function getToArcs(): array + { + return $this->toArcs; + } + + // 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 \InvalidArgumentException(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..2e1d1998c1dd1 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->getToArcs())) { + 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->getToArcs()))); + } + foreach ($transition->getFromArcs() 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->getFromArcs(); + 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->getToArcs() 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..0760ecb22e586 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->getFromArcs() 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->getToArcs())) { + 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->getToArcs()))); + } + + foreach ($transition->getFromArcs() 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->getToArcs() 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 04b0084dd5fbd..141805e825933 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->getFromArcs() 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->getFromArcs(); 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->getToArcs(); 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); } } diff --git a/src/Symfony/Component/Workflow/composer.json b/src/Symfony/Component/Workflow/composer.json index 44a300057c04b..d259e1589d405 100644 --- a/src/Symfony/Component/Workflow/composer.json +++ b/src/Symfony/Component/Workflow/composer.json @@ -20,15 +20,16 @@ } ], "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0" }, "require-dev": { "psr/log": "^1|^2|^3", "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", "symfony/error-handler": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", "symfony/security-core": "^6.4|^7.0", "symfony/stopwatch": "^6.4|^7.0", "symfony/validator": "^6.4|^7.0"