diff --git a/docs/en/reference/tools.rst b/docs/en/reference/tools.rst index def53e4da7e..101c51ad010 100644 --- a/docs/en/reference/tools.rst +++ b/docs/en/reference/tools.rst @@ -96,6 +96,10 @@ The following Commands are currently available: - ``orm:schema-tool:update`` Processes the schema and either update the database schema of EntityManager Storage Connection or generate the SQL output. +- ``orm:debug:event-manager`` Lists event listeners for an entity + manager, optionally filtered by event name. +- ``orm:debug:entity-listeners`` Lists entity listeners for a given + entity, optionally filtered by event name. The following alias is defined: diff --git a/src/Tools/Console/Command/Debug/AbstractCommand.php b/src/Tools/Console/Command/Debug/AbstractCommand.php new file mode 100644 index 00000000000..55794b81294 --- /dev/null +++ b/src/Tools/Console/Command/Debug/AbstractCommand.php @@ -0,0 +1,34 @@ +getManagerRegistry()->getManager($name); + + assert($manager instanceof EntityManagerInterface); + + return $manager; + } + + final protected function getManagerRegistry(): ManagerRegistry + { + return $this->managerRegistry; + } +} diff --git a/src/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommand.php b/src/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommand.php new file mode 100644 index 00000000000..ba8d19ac45c --- /dev/null +++ b/src/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommand.php @@ -0,0 +1,162 @@ +setName('orm:debug:entity-listeners') + ->setDescription('Lists entity listeners for a given entity') + ->addArgument('entity', InputArgument::OPTIONAL, 'The fully-qualified entity class name') + ->addArgument('event', InputArgument::OPTIONAL, 'The event name to filter by (e.g. postPersist)') + ->setHelp(<<<'EOT' +The %command.name% command lists all entity listeners for a given entity: + + php %command.full_name% 'App\Entity\User' + +To show only listeners for a specific event, pass the event name: + + php %command.full_name% 'App\Entity\User' postPersist +EOT); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + /** @var class-string|null $entityName */ + $entityName = $input->getArgument('entity'); + + if ($entityName === null) { + $choices = $this->listAllEntities(); + + if ($choices === []) { + $io->error('No entities are configured.'); + + return self::FAILURE; + } + + /** @var class-string $entityName */ + $entityName = $io->choice('Which entity do you want to list listeners for?', $choices); + } + + $entityName = ltrim($entityName, '\\'); + $entityManager = $this->getManagerRegistry()->getManagerForClass($entityName); + + if ($entityManager === null) { + $io->error(sprintf('No entity manager found for class "%s".', $entityName)); + + return self::FAILURE; + } + + $classMetadata = $entityManager->getClassMetadata($entityName); + assert($classMetadata instanceof ClassMetadata); + + $eventName = $input->getArgument('event'); + + if ($eventName === null) { + $allListeners = $classMetadata->entityListeners; + if (! $allListeners) { + $io->info(sprintf('No listeners are configured for the "%s" entity.', $entityName)); + + return self::SUCCESS; + } + + ksort($allListeners); + } else { + if (! isset($classMetadata->entityListeners[$eventName])) { + $io->info(sprintf('No listeners are configured for the "%s" event.', $eventName)); + + return self::SUCCESS; + } + + $allListeners = [$eventName => $classMetadata->entityListeners[$eventName]]; + } + + $io->title(sprintf('Entity listeners for %s', $entityName)); + + $rows = []; + foreach ($allListeners as $event => $listeners) { + if ($rows) { + $rows[] = new TableSeparator(); + } + + foreach ($listeners as $order => $listener) { + $rows[] = [$order === 0 ? $event : '', sprintf('#%d', ++$order), sprintf('%s::%s()', $listener['class'], $listener['method'])]; + } + } + + $io->table(['Event', 'Order', 'Listener'], $rows); + + return self::SUCCESS; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('entity')) { + $suggestions->suggestValues($this->listAllEntities()); + + return; + } + + if ($input->mustSuggestArgumentValuesFor('event')) { + $entityName = ltrim($input->getArgument('entity'), '\\'); + + if (! class_exists($entityName)) { + return; + } + + $entityManager = $this->getManagerRegistry()->getManagerForClass($entityName); + + if ($entityManager === null) { + return; + } + + $classMetadata = $entityManager->getClassMetadata($entityName); + assert($classMetadata instanceof ClassMetadata); + + $suggestions->suggestValues(array_keys($classMetadata->entityListeners)); + + return; + } + } + + /** @return list */ + private function listAllEntities(): array + { + $entities = []; + foreach (array_keys($this->getManagerRegistry()->getManagerNames()) as $managerName) { + $entities[] = $this->getEntityManager($managerName)->getConfiguration()->getMetadataDriverImpl()->getAllClassNames(); + } + + $entities = array_values(array_unique(array_merge(...$entities))); + + sort($entities); + + return $entities; + } +} diff --git a/src/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommand.php b/src/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommand.php new file mode 100644 index 00000000000..09fccfc3c3e --- /dev/null +++ b/src/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommand.php @@ -0,0 +1,111 @@ +setName('orm:debug:event-manager') + ->setDescription('Lists event listeners for an entity manager') + ->addArgument('event', InputArgument::OPTIONAL, 'The event name to filter by (e.g. postPersist)') + ->addOption('em', null, InputOption::VALUE_REQUIRED, 'The entity manager to use for this command') + ->setHelp(<<<'EOT' +The %command.name% command lists all event listeners for the default entity manager: + + php %command.full_name% + +You can also specify an entity manager: + + php %command.full_name% --em=default + +To show only listeners for a specific event, pass the event name as an argument: + + php %command.full_name% postPersist +EOT); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $entityManagerName = $input->getOption('em') ?: $this->getManagerRegistry()->getDefaultManagerName(); + $eventManager = $this->getEntityManager($entityManagerName)->getEventManager(); + + $eventName = $input->getArgument('event'); + + if ($eventName === null) { + $allListeners = $eventManager->getAllListeners(); + if (! $allListeners) { + $io->info(sprintf('No listeners are configured for the "%s" entity manager.', $entityManagerName)); + + return self::SUCCESS; + } + + ksort($allListeners); + } else { + $listeners = $eventManager->hasListeners($eventName) ? $eventManager->getListeners($eventName) : []; + if (! $listeners) { + $io->info(sprintf('No listeners are configured for the "%s" event.', $eventName)); + + return self::SUCCESS; + } + + $allListeners = [$eventName => $listeners]; + } + + $io->title(sprintf('Event listeners for %s entity manager', $entityManagerName)); + + $rows = []; + foreach ($allListeners as $event => $listeners) { + if ($rows) { + $rows[] = new TableSeparator(); + } + + foreach (array_values($listeners) as $order => $listener) { + $method = method_exists($listener, '__invoke') ? '__invoke' : $event; + $rows[] = [$order === 0 ? $event : '', sprintf('#%d', ++$order), sprintf('%s::%s()', $listener::class, $method)]; + } + } + + $io->table(['Event', 'Order', 'Listener'], $rows); + + return self::SUCCESS; + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('event')) { + $entityManagerName = $input->getOption('em') ?: $this->getManagerRegistry()->getDefaultManagerName(); + $eventManager = $this->getEntityManager($entityManagerName)->getEventManager(); + + $suggestions->suggestValues(array_keys($eventManager->getAllListeners())); + + return; + } + + if ($input->mustSuggestOptionValuesFor('em')) { + $suggestions->suggestValues(array_keys($this->getManagerRegistry()->getManagerNames())); + + return; + } + } +} diff --git a/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommandTest.php b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommandTest.php new file mode 100644 index 00000000000..892d9e3a081 --- /dev/null +++ b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEntityListenersDoctrineCommandTest.php @@ -0,0 +1,152 @@ +command = new DebugEntityListenersDoctrineCommand($this->getMockManagerRegistry()); + + self::addCommandToApplication($application, $this->command); + } + + public function testExecute(): void + { + $commandTester = new CommandTester($this->command); + $commandTester->execute( + ['command' => $this->command->getName(), 'entity' => self::class], + ); + + self::assertSame(<<<'TXT' + +Entity listeners for Doctrine\Tests\ORM\Tools\Console\Command\Debug\DebugEntityListenersDoctrineCommandTest +=========================================================================================================== + + ------------- ------- ------------------------------------------------------------------------------------ + Event Order Listener + ------------- ------- ------------------------------------------------------------------------------------ + postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist() + ------------- ------- ------------------------------------------------------------------------------------ + preUpdate #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\FooListener::preUpdate() + #2 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BarListener::__invoke() + ------------- ------- ------------------------------------------------------------------------------------ + + +TXT + , $commandTester->getDisplay(true)); + } + + public function testExecuteWithEvent(): void + { + $commandTester = new CommandTester($this->command); + $commandTester->execute( + ['command' => $this->command->getName(), 'entity' => self::class, 'event' => 'postPersist'], + ); + + self::assertSame(<<<'TXT' + +Entity listeners for Doctrine\Tests\ORM\Tools\Console\Command\Debug\DebugEntityListenersDoctrineCommandTest +=========================================================================================================== + + ------------- ------- ------------------------------------------------------------------------------------ + Event Order Listener + ------------- ------- ------------------------------------------------------------------------------------ + postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist() + ------------- ------- ------------------------------------------------------------------------------------ + + +TXT + , $commandTester->getDisplay(true)); + } + + public function testExecuteWithMissingEvent(): void + { + $commandTester = new CommandTester($this->command); + $commandTester->execute( + ['command' => $this->command->getName(), 'entity' => self::class, 'event' => 'preRemove'], + ); + + self::assertSame(<<<'TXT' + + [INFO] No listeners are configured for the "preRemove" event. + + +TXT + , $commandTester->getDisplay(true)); + } + + /** + * @param list $args + * @param list $expectedSuggestions + */ + #[TestWith([['console'], 1, [self::class]])] + #[TestWith([['console', self::class], 2, ['preUpdate', 'postPersist']])] + #[TestWith([['console', 'NonExistentEntity'], 2, []])] + public function testComplete(array $args, int $currentIndex, array $expectedSuggestions): void + { + $input = CompletionInput::fromTokens($args, $currentIndex); + $input->bind($this->command->getDefinition()); + $suggestions = new CompletionSuggestions(); + + $this->command->complete($input, $suggestions); + + self::assertSame($expectedSuggestions, array_map(static fn (Suggestion $suggestion) => $suggestion->getValue(), $suggestions->getValueSuggestions())); + } + + /** @return MockObject&ManagerRegistry */ + private function getMockManagerRegistry(): ManagerRegistry + { + $mappingDriverMock = $this->createMock(MappingDriver::class); + $mappingDriverMock->method('getAllClassNames')->willReturn([self::class]); + + $config = new Configuration(); + $config->setMetadataDriverImpl($mappingDriverMock); + + $classMetadata = new ClassMetadata(self::class); + $classMetadata->addEntityListener('preUpdate', FooListener::class, 'preUpdate'); + $classMetadata->addEntityListener('preUpdate', BarListener::class, '__invoke'); + $classMetadata->addEntityListener('postPersist', BazListener::class, 'postPersist'); + + $emMock = $this->createMock(EntityManagerInterface::class); + $emMock->method('getConfiguration')->willReturn($config); + $emMock->method('getClassMetadata')->willReturn($classMetadata); + + $doctrineMock = $this->createMock(ManagerRegistry::class); + $doctrineMock->method('getManagerNames')->willReturn(['default' => 'entity_manager.default']); + $doctrineMock->method('getManager')->willReturn($emMock); + $doctrineMock->method('getManagerForClass')->willReturn($emMock); + + return $doctrineMock; + } +} diff --git a/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommandTest.php b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommandTest.php new file mode 100644 index 00000000000..f98dfdb873f --- /dev/null +++ b/tests/Tests/ORM/Tools/Console/Command/Debug/DebugEventManagerDoctrineCommandTest.php @@ -0,0 +1,142 @@ +command = new DebugEventManagerDoctrineCommand($this->getMockManagerRegistry()); + + self::addCommandToApplication($application, $this->command); + } + + public function testExecute(): void + { + $commandTester = new CommandTester($this->command); + $commandTester->execute( + ['command' => $this->command->getName()], + ); + + self::assertSame(<<<'TXT' + +Event listeners for default entity manager +========================================== + + ------------- ------- ------------------------------------------------------------------------------------ + Event Order Listener + ------------- ------- ------------------------------------------------------------------------------------ + postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist() + ------------- ------- ------------------------------------------------------------------------------------ + preUpdate #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\FooListener::preUpdate() + #2 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BarListener::__invoke() + ------------- ------- ------------------------------------------------------------------------------------ + + +TXT + , $commandTester->getDisplay(true)); + } + + public function testExecuteWithEvent(): void + { + $commandTester = new CommandTester($this->command); + $commandTester->execute( + ['command' => $this->command->getName(), 'event' => 'postPersist'], + ); + + self::assertSame(<<<'TXT' + +Event listeners for default entity manager +========================================== + + ------------- ------- ------------------------------------------------------------------------------------ + Event Order Listener + ------------- ------- ------------------------------------------------------------------------------------ + postPersist #1 Doctrine\Tests\ORM\Tools\Console\Command\Debug\Fixtures\BazListener::postPersist() + ------------- ------- ------------------------------------------------------------------------------------ + + +TXT + , $commandTester->getDisplay(true)); + } + + public function testExecuteWithMissingEvent(): void + { + $commandTester = new CommandTester($this->command); + $commandTester->execute( + ['command' => $this->command->getName(), 'event' => 'preRemove'], + ); + + self::assertSame(<<<'TXT' + + [INFO] No listeners are configured for the "preRemove" event. + + +TXT + , $commandTester->getDisplay(true)); + } + + /** + * @param list $args + * @param list $expectedSuggestions + */ + #[TestWith([['console'], 1, ['preUpdate', 'postPersist']])] + #[TestWith([['console', '--em'], 1, ['default']])] + public function testComplete(array $args, int $currentIndex, array $expectedSuggestions): void + { + $input = CompletionInput::fromTokens($args, $currentIndex); + $input->bind($this->command->getDefinition()); + $suggestions = new CompletionSuggestions(); + + $this->command->complete($input, $suggestions); + + self::assertSame($expectedSuggestions, array_map(static fn (Suggestion $suggestion) => $suggestion->getValue(), $suggestions->getValueSuggestions())); + } + + /** @return MockObject&ManagerRegistry */ + private function getMockManagerRegistry(): ManagerRegistry + { + $eventManager = new EventManager(); + $eventManager->addEventListener('preUpdate', new FooListener()); + $eventManager->addEventListener('preUpdate', new BarListener()); + $eventManager->addEventListener('postPersist', new BazListener()); + + $emMock = $this->createMock(EntityManagerInterface::class); + $emMock->method('getEventManager')->willReturn($eventManager); + + $doctrineMock = $this->createMock(ManagerRegistry::class); + $doctrineMock->method('getDefaultManagerName')->willReturn('default'); + $doctrineMock->method('getManager')->willReturn($emMock); + $doctrineMock->method('getManagerNames')->willReturn(['default' => 'entity_manager.default']); + + return $doctrineMock; + } +} diff --git a/tests/Tests/ORM/Tools/Console/Command/Debug/Fixtures/BarListener.php b/tests/Tests/ORM/Tools/Console/Command/Debug/Fixtures/BarListener.php new file mode 100644 index 00000000000..bb6e0e2079c --- /dev/null +++ b/tests/Tests/ORM/Tools/Console/Command/Debug/Fixtures/BarListener.php @@ -0,0 +1,12 @@ +