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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy