diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2610d4311f74f..b8f69fc225caa 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1109,6 +1109,29 @@ private function registerWorkflowConfiguration(array $config, ContainerBuilder $ $container->setParameter('workflow.has_guard_listeners', true); } } + + $listenerAttributes = [ + Workflow\Attribute\AsAnnounceListener::class, + Workflow\Attribute\AsCompletedListener::class, + Workflow\Attribute\AsEnterListener::class, + Workflow\Attribute\AsEnteredListener::class, + Workflow\Attribute\AsGuardListener::class, + Workflow\Attribute\AsLeaveListener::class, + Workflow\Attribute\AsTransitionListener::class, + ]; + + foreach ($listenerAttributes as $attribute) { + $container->registerAttributeForAutoconfiguration($attribute, static function (ChildDefinition $definition, AsEventListener $attribute, \ReflectionClass|\ReflectionMethod $reflector) { + $tagAttributes = get_object_vars($attribute); + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new LogicException(sprintf('"%s" attribute cannot declare a method on "%s::%s()".', $attribute::class, $reflector->class, $reflector->name)); + } + $tagAttributes['method'] = $reflector->getName(); + } + $definition->addTag('kernel.event_listener', $tagAttributes); + }); + } } private function registerDebugConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void diff --git a/src/Symfony/Component/Workflow/Attribute/AsAnnounceListener.php b/src/Symfony/Component/Workflow/Attribute/AsAnnounceListener.php new file mode 100644 index 0000000000000..01669dc3696f5 --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsAnnounceListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsAnnounceListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $transition = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('announce', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/AsCompletedListener.php b/src/Symfony/Component/Workflow/Attribute/AsCompletedListener.php new file mode 100644 index 0000000000000..012b3040a883b --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsCompletedListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsCompletedListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $transition = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('completed', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/AsEnterListener.php b/src/Symfony/Component/Workflow/Attribute/AsEnterListener.php new file mode 100644 index 0000000000000..fe55f6e40e8c9 --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsEnterListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsEnterListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $place = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('enter', 'place', $workflow, $place), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/AsEnteredListener.php b/src/Symfony/Component/Workflow/Attribute/AsEnteredListener.php new file mode 100644 index 0000000000000..474cf09b5ec20 --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsEnteredListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsEnteredListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $place = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('entered', 'place', $workflow, $place), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/AsGuardListener.php b/src/Symfony/Component/Workflow/Attribute/AsGuardListener.php new file mode 100644 index 0000000000000..994fe326a6b90 --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsGuardListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsGuardListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $transition = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('guard', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/AsLeaveListener.php b/src/Symfony/Component/Workflow/Attribute/AsLeaveListener.php new file mode 100644 index 0000000000000..e4ea4dc23a1ce --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsLeaveListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsLeaveListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $place = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('leave', 'place', $workflow, $place), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/AsTransitionListener.php b/src/Symfony/Component/Workflow/Attribute/AsTransitionListener.php new file mode 100644 index 0000000000000..589ef7a5d592e --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/AsTransitionListener.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; + +/** + * @author Grégoire Pineau + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class AsTransitionListener extends AsEventListener +{ + use BuildEventNameTrait; + + public function __construct( + string $workflow = null, + string $transition = null, + string $method = null, + int $priority = 0, + string $dispatcher = null, + ) { + parent::__construct($this->buildEventName('transition', 'transition', $workflow, $transition), $method, $priority, $dispatcher); + } +} diff --git a/src/Symfony/Component/Workflow/Attribute/BuildEventNameTrait.php b/src/Symfony/Component/Workflow/Attribute/BuildEventNameTrait.php new file mode 100644 index 0000000000000..0ca7a09fed1a7 --- /dev/null +++ b/src/Symfony/Component/Workflow/Attribute/BuildEventNameTrait.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Attribute; + +use Symfony\Component\Workflow\Exception\LogicException; + +/** + * @author Grégoire Pineau + * + * @internal + */ +trait BuildEventNameTrait +{ + private static function buildEventName(string $keyword, string $argument, string $workflow = null, string $node = null): string + { + if (null === $workflow) { + if (null !== $node) { + throw new LogicException(sprintf('The "%s" argument of "%s" cannot be used without a "workflow" argument.', $argument, self::class)); + } + + return sprintf('workflow.%s', $keyword); + } + + if (null === $node) { + return sprintf('workflow.%s.%s', $workflow, $keyword); + } + + return sprintf('workflow.%s.%s.%s', $workflow, $keyword, $node); + } +} diff --git a/src/Symfony/Component/Workflow/CHANGELOG.md b/src/Symfony/Component/Workflow/CHANGELOG.md index 117b12410b9a8..ecc900ebc4e85 100644 --- a/src/Symfony/Component/Workflow/CHANGELOG.md +++ b/src/Symfony/Component/Workflow/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Add support for storing marking in a property * Add a profiler * Add support for multiline descriptions in PlantUML diagrams + * Add PHP attributes to register listeners and guards 6.2 --- diff --git a/src/Symfony/Component/Workflow/Tests/Attribute/AsListenerTest.php b/src/Symfony/Component/Workflow/Tests/Attribute/AsListenerTest.php new file mode 100644 index 0000000000000..78de4e0d6d638 --- /dev/null +++ b/src/Symfony/Component/Workflow/Tests/Attribute/AsListenerTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Workflow\Attribute; +use Symfony\Component\Workflow\Exception\LogicException; + +class AsListenerTest extends TestCase +{ + /** + * @dataProvider provideOkTests + */ + public function testOk(string $class, string $expectedEvent, string $workflow = null, string $node = null) + { + $attribute = new $class($workflow, $node); + + $this->assertSame($expectedEvent, $attribute->event); + } + + public static function provideOkTests(): iterable + { + yield [Attribute\AsAnnounceListener::class, 'workflow.announce']; + yield [Attribute\AsAnnounceListener::class, 'workflow.w.announce', 'w']; + yield [Attribute\AsAnnounceListener::class, 'workflow.w.announce.n', 'w', 'n']; + + yield [Attribute\AsCompletedListener::class, 'workflow.completed']; + yield [Attribute\AsCompletedListener::class, 'workflow.w.completed', 'w']; + yield [Attribute\AsCompletedListener::class, 'workflow.w.completed.n', 'w', 'n']; + + yield [Attribute\AsEnterListener::class, 'workflow.enter']; + yield [Attribute\AsEnterListener::class, 'workflow.w.enter', 'w']; + yield [Attribute\AsEnterListener::class, 'workflow.w.enter.n', 'w', 'n']; + + yield [Attribute\AsEnteredListener::class, 'workflow.entered']; + yield [Attribute\AsEnteredListener::class, 'workflow.w.entered', 'w']; + yield [Attribute\AsEnteredListener::class, 'workflow.w.entered.n', 'w', 'n']; + + yield [Attribute\AsGuardListener::class, 'workflow.guard']; + yield [Attribute\AsGuardListener::class, 'workflow.w.guard', 'w']; + yield [Attribute\AsGuardListener::class, 'workflow.w.guard.n', 'w', 'n']; + + yield [Attribute\AsLeaveListener::class, 'workflow.leave']; + yield [Attribute\AsLeaveListener::class, 'workflow.w.leave', 'w']; + yield [Attribute\AsLeaveListener::class, 'workflow.w.leave.n', 'w', 'n']; + + yield [Attribute\AsTransitionListener::class, 'workflow.transition']; + yield [Attribute\AsTransitionListener::class, 'workflow.w.transition', 'w']; + yield [Attribute\AsTransitionListener::class, 'workflow.w.transition.n', 'w', 'n']; + } + + /** + * @dataProvider provideTransitionThrowException + */ + public function testTransitionThrowException(string $class) + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The "transition" argument of "%s" cannot be used without a "workflow" argument.', $class)); + + new $class(transition: 'some'); + } + + public static function provideTransitionThrowException(): iterable + { + yield [Attribute\AsAnnounceListener::class, 'workflow.announce']; + yield [Attribute\AsCompletedListener::class, 'workflow.completed']; + yield [Attribute\AsGuardListener::class, 'workflow.guard']; + yield [Attribute\AsTransitionListener::class, 'workflow.transition']; + } + + /** + * @dataProvider providePlaceThrowException + */ + public function testPlaceThrowException(string $class) + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage(sprintf('The "place" argument of "%s" cannot be used without a "workflow" argument.', $class)); + + new $class(place: 'some'); + } + + public static function providePlaceThrowException(): iterable + { + yield [Attribute\AsEnteredListener::class, 'workflow.entered']; + yield [Attribute\AsEnterListener::class, 'workflow.enter']; + yield [Attribute\AsLeaveListener::class, 'workflow.leave']; + } +}