diff --git a/src/Symfony/Component/EventDispatcher/Attribute/AsEventListener.php b/src/Symfony/Component/EventDispatcher/Attribute/AsEventListener.php index bb931b82dc2b1..9bda2f3c24c88 100644 --- a/src/Symfony/Component/EventDispatcher/Attribute/AsEventListener.php +++ b/src/Symfony/Component/EventDispatcher/Attribute/AsEventListener.php @@ -24,6 +24,10 @@ public function __construct( public ?string $method = null, public int $priority = 0, public ?string $dispatcher = null, + /** @param string|array{0: string, 1: string}|null $after */ + public string|array|null $before = null, + /** @param string|array{0: string, 1: string}|null $after */ + public string|array|null $after = null, ) { } } diff --git a/src/Symfony/Component/EventDispatcher/DependencyInjection/ListenerDefinition.php b/src/Symfony/Component/EventDispatcher/DependencyInjection/ListenerDefinition.php new file mode 100644 index 0000000000000..c1e88a52618c4 --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/DependencyInjection/ListenerDefinition.php @@ -0,0 +1,80 @@ +priorityModifier = 0; + $this->beforeAfterMethod = null; + $this->beforeAfterService = null; + + return; + } + + $this->priorityModifier = null !== $before ? 1 : -1; + + $beforeAfterDefinition = $before ?? $after; + + if (\is_array($beforeAfterDefinition)) { + if (!array_is_list($beforeAfterDefinition) || 2 !== \count($beforeAfterDefinition)) { + throw new InvalidArgumentException(sprintf('Invalid before/after definition for service "%s": when declaring as an array, first item must be a service id and second item must be the method.', $this->serviceId)); + } + + $this->beforeAfterMethod = $beforeAfterDefinition[1]; + $this->beforeAfterService = $beforeAfterDefinition[0]; + } else { + $this->beforeAfterMethod = null; + $this->beforeAfterService = $beforeAfterDefinition; + } + } + + public function withPriority(int $priority): self + { + return new self( + $this->serviceId, + $this->event, + $this->method, + $priority, + $this->dispatchers, + $this->before, + $this->after, + ); + } + + public function name(): string + { + return "{$this->serviceId}::{$this->method}"; + } + + public function printableBeforeAfterDefinition(): string|null + { + return match (true){ + null !== $this->beforeAfterMethod => sprintf('%s::%s()', $this->beforeAfterService, $this->beforeAfterMethod), + null !== $this->beforeAfterService => $this->beforeAfterService, + default => null, + }; + } +} diff --git a/src/Symfony/Component/EventDispatcher/DependencyInjection/ListenerDefinitionsIterator.php b/src/Symfony/Component/EventDispatcher/DependencyInjection/ListenerDefinitionsIterator.php new file mode 100644 index 0000000000000..7d57498ff258d --- /dev/null +++ b/src/Symfony/Component/EventDispatcher/DependencyInjection/ListenerDefinitionsIterator.php @@ -0,0 +1,97 @@ + $listenerDefinitions + */ + public function __construct(array $listenerDefinitions, private readonly ContainerBuilder $container) + { + $this->listenerDefinitions = $listenerDefinitions; + } + + /** + * @return array> + */ + public function iterate(): array + { + $listeners = []; + + foreach ($this->listenerDefinitions as $listener) { + $listeners[$listener->serviceId] ??= []; + $listeners[$listener->serviceId][] = $listener->withPriority($this->getPriorityFor($listener)); + } + + return $listeners; + } + + private function getPriorityFor(ListenerDefinition $listener, array $alreadyVisited = []): int + { + if ($alreadyVisited[$listener->name()] ?? false) { + throw new InvalidArgumentException(sprintf('Invalid before/after definition for service "%s": circular reference detected.', array_key_first($alreadyVisited))); + } + + $alreadyVisited[$listener->name()] = true; + + if (!$listener->beforeAfterService) { + return $listener->priority; + } + + $beforeAfterListeners = $this->matchingBeforeAfterListeners($listener); + + $beforeAfterListener = match (true) { + !$beforeAfterListeners => throw new InvalidArgumentException( + sprintf('Invalid before/after definition for service "%s": "%s" does not listen to the same event.', $listener->serviceId, $listener->printableBeforeAfterDefinition()) + ), + !$listener->beforeAfterMethod && count($beforeAfterListeners) === 1 => current($beforeAfterListeners), + !$listener->beforeAfterMethod && count($beforeAfterListeners) > 1 => throw new InvalidArgumentException( + sprintf('Invalid before/after definition for service "%s": "%s" has multiple methods. Please specify the "method" attribute.', $listener->serviceId, $listener->printableBeforeAfterDefinition()) + ), + $listener->beforeAfterMethod && !isset($beforeAfterListeners[$listener->beforeAfterMethod]) => throw new InvalidArgumentException( + sprintf('Invalid before/after definition for service "%s": method "%s" does not exist or is not a listener.', $listener->serviceId, $listener->printableBeforeAfterDefinition()) + ), + $listener->beforeAfterMethod && isset($beforeAfterListeners[$listener->beforeAfterMethod]) => $beforeAfterListeners[$listener->beforeAfterMethod], + default => new \LogicException('This should never happen') + }; + + if ($beforeAfterListener->dispatchers !== $listener->dispatchers) { + throw new InvalidArgumentException(sprintf('Invalid before/after definition for service "%s": "%s" is not handled by the same dispatchers.', $listener->serviceId, $listener->printableBeforeAfterDefinition())); + } + + return $this->getPriorityFor($beforeAfterListener, $alreadyVisited) + $listener->priorityModifier; + } + + /** + * @return array + */ + private function matchingBeforeAfterListeners(ListenerDefinition $listener): array + { + $beforeAfterService = $listener->beforeAfterService; + + if ( + $this->container->has($beforeAfterService) + && (($def = $this->container->findDefinition($beforeAfterService))->hasTag('kernel.event_listener') || $def->hasTag('kernel.event_subscriber')) + ) { + $listenersWithServiceId = array_filter( + $this->listenerDefinitions, + static fn(ListenerDefinition $listenerDefinition) => $listenerDefinition->serviceId === $beforeAfterService && $listenerDefinition->event === $listener->event + ); + + return array_combine( + array_map(static fn(ListenerDefinition $listenerDefinition) => $listenerDefinition->method, $listenersWithServiceId), + $listenersWithServiceId, + ); + } + + throw new InvalidArgumentException(sprintf('Invalid before/after definition for service "%s": "%s" is not a listener.', $listener->serviceId, $listener->printableBeforeAfterDefinition())); + } +} diff --git a/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php b/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php index 866f4e64ffc42..9dca515574b05 100644 --- a/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php +++ b/src/Symfony/Component/EventDispatcher/DependencyInjection/RegisterListenersPass.php @@ -57,20 +57,56 @@ public function process(ContainerBuilder $container) return; } - $aliases = []; - - if ($container->hasParameter('event_dispatcher.event_aliases')) { - $aliases = $container->getParameter('event_dispatcher.event_aliases'); - } + $listerDefinitions = new ListenerDefinitionsIterator([ + ...iterator_to_array($this->collectListeners($container)), + ...iterator_to_array($this->collectSubscribers($container)), + ], $container + ); $globalDispatcherDefinition = $container->findDefinition('event_dispatcher'); - foreach ($container->findTaggedServiceIds('kernel.event_listener', true) as $id => $events) { + foreach ($listerDefinitions->iterate() as $id => $listenerDefinitions) { $noPreload = 0; - foreach ($events as $event) { - $priority = $event['priority'] ?? 0; + foreach ($listenerDefinitions as $listenerDefinition) { + $dispatcherDefinitions = []; + foreach ($listenerDefinition->dispatchers as $dispatcher) { + $dispatcherDefinitions[] = 'event_dispatcher' === $dispatcher ? $globalDispatcherDefinition : $container->findDefinition($dispatcher); + } + + foreach ($dispatcherDefinitions as $dispatcherDefinition) { + $dispatcherDefinition->addMethodCall( + 'addListener', + [ + $listenerDefinition->event, + [new ServiceClosureArgument(new Reference($id)), $listenerDefinition->method], + $listenerDefinition->priority ?? 0, + ] + ); + } + + if (isset($this->hotPathEvents[$listenerDefinition->event])) { + $container->getDefinition($id)->addTag('container.hot_path'); + } elseif (isset($this->noPreloadEvents[$listenerDefinition->event])) { + ++$noPreload; + } + } + if ($noPreload && \count($listenerDefinitions) === $noPreload) { + $container->getDefinition($id)->addTag('container.no_preload'); + } + } + } + + /** + * @return \Generator> + */ + private function collectListeners(ContainerBuilder $container): \Generator + { + $aliases = $this->getEventsAliases($container); + + foreach ($container->findTaggedServiceIds('kernel.event_listener', true) as $id => $events) { + foreach ($events as $event) { if (!isset($event['event'])) { if ($container->getDefinition($id)->hasTag('kernel.event_subscriber')) { continue; @@ -93,29 +129,33 @@ public function process(ContainerBuilder $container) if (!$r->hasMethod('__invoke')) { throw new InvalidArgumentException(sprintf('None of the "%s" or "__invoke" methods exist for the service "%s". Please define the "method" attribute on "kernel.event_listener" tags.', $event['method'], $id)); } - $event['method'] = '__invoke'; } } - $dispatcherDefinition = $globalDispatcherDefinition; - if (isset($event['dispatcher'])) { - $dispatcherDefinition = $container->findDefinition($event['dispatcher']); - } - - $dispatcherDefinition->addMethodCall('addListener', [$event['event'], [new ServiceClosureArgument(new Reference($id)), $event['method']], $priority]); - - if (isset($this->hotPathEvents[$event['event']])) { - $container->getDefinition($id)->addTag('container.hot_path'); - } elseif (isset($this->noPreloadEvents[$event['event']])) { - ++$noPreload; - } - } - - if ($noPreload && \count($events) === $noPreload) { - $container->getDefinition($id)->addTag('container.no_preload'); + $event['dispatchers'] = [$event['dispatcher'] ?? 'event_dispatcher']; + $event['serviceId'] = $id; + unset($event['dispatcher']); + + yield new ListenerDefinition( + serviceId: $id, + event: $event['event'], + method: $event['method'], + priority: $event['priority'] ?? 0, + dispatchers: $event['dispatchers'], + before: $event['before'] ?? null, + after: $event['after'] ?? null, + ); } } + } + + /** + * @return \Generator}>> + */ + private function collectSubscribers(ContainerBuilder $container): \Generator + { + $aliases = $this->getEventsAliases($container); $extractingDispatcher = new ExtractingEventDispatcher(); @@ -133,43 +173,55 @@ public function process(ContainerBuilder $container) } $class = $r->name; - $dispatcherDefinitions = []; + $dispatchers = []; foreach ($tags as $attributes) { - if (!isset($attributes['dispatcher']) || isset($dispatcherDefinitions[$attributes['dispatcher']])) { + if (!isset($attributes['dispatcher']) || \in_array($attributes['dispatcher'], $dispatchers, true)) { continue; } - $dispatcherDefinitions[$attributes['dispatcher']] = $container->findDefinition($attributes['dispatcher']); + $dispatchers[] = $attributes['dispatcher']; } - - if (!$dispatcherDefinitions) { - $dispatcherDefinitions = [$globalDispatcherDefinition]; + if (!$dispatchers) { + $dispatchers[] = 'event_dispatcher'; } - $noPreload = 0; + sort($dispatchers); + ExtractingEventDispatcher::$aliases = $aliases; ExtractingEventDispatcher::$subscriber = $class; $extractingDispatcher->addSubscriber($extractingDispatcher); - foreach ($extractingDispatcher->listeners as $args) { - $args[1] = [new ServiceClosureArgument(new Reference($id)), $args[1]]; - foreach ($dispatcherDefinitions as $dispatcherDefinition) { - $dispatcherDefinition->addMethodCall('addListener', $args); - } - if (isset($this->hotPathEvents[$args[0]])) { - $container->getDefinition($id)->addTag('container.hot_path'); - } elseif (isset($this->noPreloadEvents[$args[0]])) { - ++$noPreload; - } - } - if ($noPreload && \count($extractingDispatcher->listeners) === $noPreload) { - $container->getDefinition($id)->addTag('container.no_preload'); + foreach ($extractingDispatcher->listeners as $listener) { + yield new ListenerDefinition( + serviceId: $id, + event: $listener[0], + method: $listener[1], + priority: $listener[2], + dispatchers: array_values(array_unique($dispatchers)), + before: null, + after: null, + ); } + $extractingDispatcher->listeners = []; ExtractingEventDispatcher::$aliases = []; } } + /** + * @return array + */ + private function getEventsAliases(ContainerBuilder $container): array + { + $aliases = []; + + if ($container->hasParameter('event_dispatcher.event_aliases')) { + $aliases = $container->getParameter('event_dispatcher.event_aliases') ?? []; + } + + return $aliases; + } + private function getEventFromTypeDeclaration(ContainerBuilder $container, string $id, string $method): string { if ( diff --git a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php index c18d863a98c12..0d511f3648528 100644 --- a/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php +++ b/src/Symfony/Component/EventDispatcher/Tests/DependencyInjection/RegisterListenersPassTest.php @@ -503,6 +503,269 @@ public function testOmitEventNameOnSubscriber() ]; $this->assertEquals($expectedCalls, $definition->getMethodCalls()); } + + public function testBeforeAfterListener() + { + $container = new ContainerBuilder(); + $container->register('listener', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo', 'priority' => 5]); + $container->register('before', InvokableListenerService::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener']); +// $container->register('after', InvokableListenerService::class)->addTag('kernel.event_listener', ['event' => 'foo', 'after' => GenericListener::class]); + $container->register('before_full_definition', InvokableListenerService::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => ['listener', '__invoke']]); + $container->register('event_dispatcher'); + + $registerListenersPass = new RegisterListenersPass(); + $registerListenersPass->process($container); + + $definition = $container->getDefinition('event_dispatcher'); + $expectedCalls = [ + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('listener')), '__invoke'], + 5, + ], + ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('before')), '__invoke'], + 6, + ], + ], +// [ +// 'addListener', +// [ +// 'foo', +// [new ServiceClosureArgument(new Reference('after')), '__invoke'], +// 4, +// ], +// ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('before_full_definition')), '__invoke'], + 6, + ], + ], + ]; + $this->assertEquals($expectedCalls, $definition->getMethodCalls()); + } + + public function testBeforeAfterListenerWithMultipleEvents() + { + $container = new ContainerBuilder(); + $container->register('listener', MultipleListeners::class) + ->addTag('kernel.event_listener', ['event' => 'foo', 'priority' => 0, 'method' => 'onEvent1']) + ->addTag('kernel.event_listener', ['event' => 'bar', 'priority' => 10, 'method' => 'onEvent2']) + ; + $container->register('before', InvokableListenerService::class) + ->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener', 'method' => '__invoke']) +// ->addTag('kernel.event_listener', ['event' => 'bar', 'before' => MultipleListeners::class, 'method' => 'onEvent']) + ; + $container->register('event_dispatcher'); + + $registerListenersPass = new RegisterListenersPass(); + $registerListenersPass->process($container); + + $definition = $container->getDefinition('event_dispatcher'); + $expectedCalls = [ + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('listener')), 'onEvent1'], + 0, + ], + ], + [ + 'addListener', + [ + 'bar', + [new ServiceClosureArgument(new Reference('listener')), 'onEvent2'], + 10, + ], + ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('before')), '__invoke'], + 1, + ], + ], +// [ +// 'addListener', +// [ +// 'bar', +// [new ServiceClosureArgument(new Reference('before')), 'onEvent'], +// 11, +// ], +// ], + ]; + $this->assertEquals($expectedCalls, $definition->getMethodCalls()); + } + + public function testChainedBeforeAfterListener() + { + $container = new ContainerBuilder(); + + $container->register('listener_1', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener_2']); + $container->register('listener_2', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener_3']); + $container->register('listener_3', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo']); + + $container->register('event_dispatcher'); + + $registerListenersPass = new RegisterListenersPass(); + $registerListenersPass->process($container); + + $definition = $container->getDefinition('event_dispatcher'); + $expectedCalls = [ + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('listener_1')), '__invoke'], + 2, + ], + ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('listener_2')), '__invoke'], + 1, + ], + ], + [ + 'addListener', + [ + 'foo', + [new ServiceClosureArgument(new Reference('listener_3')), '__invoke'], + 0, + ], + ], + ]; + $this->assertEquals($expectedCalls, $definition->getMethodCalls()); + } + + /** + * @dataProvider beforeAfterErrorsProvider + */ + public function testBeforeAfterErrors(string $expectedErrorMessage, array $erroneousTagDefinition) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + $container = new ContainerBuilder(); + $container->register('listener', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo']); + $container->register('error_listener', InvokableListenerService::class)->addTag('kernel.event_listener', $erroneousTagDefinition); + $container->register('event_dispatcher'); + + $registerListenersPass = new RegisterListenersPass(); + $registerListenersPass->process($container); + } + + public static function beforeAfterErrorsProvider(): iterable + { + yield [ + 'Invalid before/after definition for service "error_listener": cannot use "after" and "before" at the same time.', + ['event' => 'foo', 'before' => 'listener', 'after' => 'listener'], + ]; + + yield [ + 'Invalid before/after definition for service "error_listener": when declaring as an array, first item must be a service id and second item must be the method.', + ['event' => 'foo', 'before' => []], + ]; + + yield [ + 'Invalid before/after definition for service "error_listener": "stdClass" is not a listener.', + ['event' => 'foo', 'before' => 'stdClass'], + ]; + +// yield [ +// sprintf('Invalid before/after definition for service "error_listener": given definition "%s" does not listen to the same event.', GenericListener::class), +// ['event' => 'bar', 'before' => GenericListener::class], +// ]; +// +// yield [ +// sprintf('Invalid before/after definition for service "error_listener": given definition "%s::foo()" is not a listener.', GenericListener::class), +// ['event' => 'foo', 'before' => [GenericListener::class, 'foo']], +// ]; + + yield [ + 'Invalid before/after definition for service "error_listener": "listener" does not listen to the same event.', + ['event' => 'bar', 'before' => 'listener'], + ]; + + yield [ + 'Invalid before/after definition for service "error_listener": method "listener::foo()" does not exist or is not a listener.', + ['event' => 'foo', 'before' => ['listener', 'foo']], + ]; + + yield [ + 'Invalid before/after definition for service "error_listener": "event_dispatcher" is not a listener.', + ['event' => 'bar', 'before' => 'event_dispatcher'], + ]; + + yield [ + 'Invalid before/after definition for service "error_listener": "listener" is not handled by the same dispatchers.', + ['event' => 'foo', 'before' => 'listener', 'dispatcher' => 'some_dispatcher'], + ]; + } + + /** + * @dataProvider beforeAfterAmbiguousProvider + */ + public function testBeforeAfterAmbiguous(string $expectedErrorMessage, array $ambiguousTagDefinition) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedErrorMessage); + + $container = new ContainerBuilder(); + $container->register('listener', MultipleListeners::class) + ->addTag('kernel.event_listener', ['event' => 'foo', 'method' => 'onEvent1']) + ->addTag('kernel.event_listener', ['event' => 'foo', 'method' => 'onEvent2']) + ; + + $container->register('error_listener', InvokableListenerService::class)->addTag('kernel.event_listener', $ambiguousTagDefinition); + $container->register('event_dispatcher'); + + $registerListenersPass = new RegisterListenersPass(); + $registerListenersPass->process($container); + } + + public static function beforeAfterAmbiguousProvider(): iterable + { + yield [ + 'Invalid before/after definition for service "error_listener": "listener" has multiple methods. Please specify the "method" attribute.', + ['event' => 'foo', 'before' => 'listener'], + ]; + +// yield [ +// sprintf('Invalid before/after definition for service "error_listener": "%s" has multiple methods. Please specify the "method" attribute.', MultipleListeners::class), +// ['event' => 'foo', 'after' => MultipleListeners::class], +// ]; + } + + public function testBeforeAfterCircularError() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid before/after definition for service "listener_1::__invoke": circular reference detected.'); + + $container = new ContainerBuilder(); + + $container->register('listener_1', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener_2']); + $container->register('listener_2', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener_3']); + $container->register('listener_3', GenericListener::class)->addTag('kernel.event_listener', ['event' => 'foo', 'before' => 'listener_1']); + + $container->register('event_dispatcher'); + + $registerListenersPass = new RegisterListenersPass(); + $registerListenersPass->process($container); + } } class SubscriberService implements EventSubscriberInterface @@ -559,6 +822,17 @@ public function __invoke(object $event): void } } +final class MultipleListeners +{ + public function onEvent1(): void + { + } + + public function onEvent2(): void + { + } +} + final class IncompleteSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array