diff --git a/config/help/MakeDoctrineListener.txt b/config/help/MakeDoctrineListener.txt new file mode 100644 index 000000000..1057ae65c --- /dev/null +++ b/config/help/MakeDoctrineListener.txt @@ -0,0 +1,5 @@ +The %command.name% command generates a new event or entity listener class. + +php %command.full_name% UserListener + +If the argument is missing, the command will ask for the class name interactively. diff --git a/config/makers.xml b/config/makers.xml index ad7d45483..4592d2041 100644 --- a/config/makers.xml +++ b/config/makers.xml @@ -69,6 +69,12 @@ + + + + + + diff --git a/config/services.xml b/config/services.xml index 8f0b2a209..12ccb9029 100644 --- a/config/services.xml +++ b/config/services.xml @@ -31,6 +31,8 @@ + + diff --git a/src/Doctrine/DoctrineEventRegistry.php b/src/Doctrine/DoctrineEventRegistry.php new file mode 100644 index 000000000..4d482ef59 --- /dev/null +++ b/src/Doctrine/DoctrineEventRegistry.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Doctrine; + +use Doctrine\Migrations\Event\MigrationsEventArgs; +use Doctrine\Migrations\Event\MigrationsVersionEventArgs; +use Doctrine\Migrations\Events as MigrationsEvents; +use Doctrine\ORM\Events; +use Doctrine\ORM\Tools\ToolEvents; + +/** + * @internal + */ +class DoctrineEventRegistry +{ + private array $lifecycleEvents; + + private ?array $eventsMap = null; + + public function __construct() + { + $this->lifecycleEvents = [ + Events::prePersist => true, + Events::postPersist => true, + Events::preUpdate => true, + Events::postUpdate => true, + Events::preRemove => true, + Events::postRemove => true, + Events::preFlush => true, + Events::postLoad => true, + ]; + } + + public function isLifecycleEvent(string $event): bool + { + return isset($this->lifecycleEvents[$event]); + } + + /** + * Returns all known event names. + */ + public function getAllEvents(): array + { + return array_keys($this->getEventsMap()); + } + + /** + * Attempts to get the event class for a given event. + */ + public function getEventClassName(string $event): ?string + { + return $this->getEventsMap()[$event]['event_class'] ?? null; + } + + /** + * Attempts to find the class that defines the given event name as a constant. + */ + public function getEventConstantClassName(string $event): ?string + { + return $this->getEventsMap()[$event]['const_class'] ?? null; + } + + private function getEventsMap(): array + { + return $this->eventsMap ??= self::findEvents(); + } + + private static function findEvents(): array + { + $eventsMap = []; + + foreach ((new \ReflectionClass(Events::class))->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $event) { + $eventsMap[$event] = [ + 'const_class' => Events::class, + 'event_class' => \sprintf('Doctrine\ORM\Event\%sEventArgs', ucfirst($event)), + ]; + } + + foreach ((new \ReflectionClass(ToolEvents::class))->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $event) { + $eventsMap[$event] = [ + 'const_class' => ToolEvents::class, + 'event_class' => \sprintf('Doctrine\ORM\Tools\Event\%sEventArgs', substr($event, 4)), + ]; + } + + if (class_exists(MigrationsEvents::class)) { + foreach ((new \ReflectionClass(MigrationsEvents::class))->getConstants(\ReflectionClassConstant::IS_PUBLIC) as $event) { + $eventsMap[$event] = [ + 'const_class' => MigrationsEvents::class, + 'event_class' => str_contains($event, 'Version') ? MigrationsVersionEventArgs::class : MigrationsEventArgs::class, + ]; + } + } + + ksort($eventsMap); + + return $eventsMap; + } +} diff --git a/src/Maker/MakeDoctrineListener.php b/src/Maker/MakeDoctrineListener.php new file mode 100644 index 000000000..302add3c9 --- /dev/null +++ b/src/Maker/MakeDoctrineListener.php @@ -0,0 +1,198 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker; + +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\Common\EventArgs; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Doctrine\DoctrineEventRegistry; +use Symfony\Bundle\MakerBundle\Doctrine\DoctrineHelper; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Str; +use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; +use Symfony\Bundle\MakerBundle\Validator; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; + +final class MakeDoctrineListener extends AbstractMaker +{ + public function __construct( + private readonly DoctrineEventRegistry $doctrineEventRegistry, + private readonly DoctrineHelper $doctrineHelper, + ) { + } + + public static function getCommandName(): string + { + return 'make:doctrine:listener'; + } + + public static function getCommandDescription(): string + { + return 'Creates a new doctrine event or entity listener class'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument('name', InputArgument::OPTIONAL, 'Choose a class name for your doctrine event or entity listener') + ->addArgument('event', InputArgument::OPTIONAL, 'What event do you want to listen to?') + ->addArgument('entity', InputArgument::OPTIONAL, 'What entity should the event be associate with?') + ->setHelp($this->getHelpFileContents('MakeDoctrineListener.txt')); + + $inputConfig->setArgumentAsNonInteractive('event'); + $inputConfig->setArgumentAsNonInteractive('entity'); + } + + public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void + { + $io->writeln(''); + + $event = $input->getArgument('event'); + + if (!$event) { + $events = $this->doctrineEventRegistry->getAllEvents(); + + $io->writeln(' Suggested Events:'); + $io->listing(array_map(function (string $event): string { + if ($this->doctrineEventRegistry->isLifecycleEvent($event)) { + $event .= ' (Lifecycle)'; + } + + return $event; + }, $events)); + + $question = new Question($command->getDefinition()->getArgument('event')->getDescription()); + $question->setAutocompleterValues($events); + $question->setValidator(Validator::notBlank(...)); + + $input->setArgument('event', $event = $io->askQuestion($question)); + + if ($this->doctrineEventRegistry->isLifecycleEvent($event) && !$input->getArgument('entity')) { + $question = new ConfirmationQuestion(\sprintf('The "%s" event is a lifecycle event, would you like to associate it with a specific entity (entity listener)?', $event)); + + if ($io->askQuestion($question)) { + $question = new Question($command->getDefinition()->getArgument('entity')->getDescription()); + $question->setValidator(Validator::notBlank(...)); + $question->setAutocompleterValues($this->doctrineHelper->getEntitiesForAutocomplete()); + + $input->setArgument('entity', $io->askQuestion($question)); + } + } + } + + if (!$this->doctrineEventRegistry->isLifecycleEvent($event) && $input->getArgument('entity')) { + throw new RuntimeCommandException(\sprintf('The "%s" event is not a lifecycle event and cannot be associated with a specific entity.', $event)); + } + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $name = $input->getArgument('name'); + $event = $input->getArgument('event'); + + $eventFullClassName = $this->doctrineEventRegistry->getEventClassName($event) ?? EventArgs::class; + $eventClassName = Str::getShortClassName($eventFullClassName); + + $useStatements = new UseStatementGenerator([ + $eventFullClassName, + ]); + + $eventConstFullClassName = $this->doctrineEventRegistry->getEventConstantClassName($event); + $eventConstClassName = $eventConstFullClassName ? Str::getShortClassName($eventConstFullClassName) : null; + + if ($eventConstFullClassName) { + $useStatements->addUseStatement($eventConstFullClassName); + } + + $className = $generator->createClassNameDetails( + $name, + 'EventListener\\', + 'Listener', + )->getFullName(); + + $templateVars = [ + 'use_statements' => $useStatements, + 'method_name' => $event, + 'event' => $eventConstClassName ? \sprintf('%s::%s', $eventConstClassName, $event) : "'$event'", + 'event_arg' => \sprintf('%s $event', $eventClassName), + ]; + + if ($input->getArgument('entity')) { + $this->generateEntityListenerClass($useStatements, $generator, $className, $templateVars, $input->getArgument('entity')); + } else { + $this->generateEventListenerClass($useStatements, $generator, $className, $templateVars); + } + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + + $io->text([ + 'Next: Open your new listener class and start customizing it.', + 'Find the documentation at https://symfony.com/doc/current/doctrine/events.html', + ]); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + $dependencies->addClassDependency( + DoctrineBundle::class, + 'doctrine/doctrine-bundle', + ); + } + + /** + * @param array $templateVars + */ + private function generateEntityListenerClass(UseStatementGenerator $useStatements, Generator $generator, string $className, array $templateVars, string $entityClassName): void + { + $entityClassDetails = $generator->createClassNameDetails( + $entityClassName, + 'Entity\\', + ); + + $useStatements->addUseStatement(AsEntityListener::class); + $useStatements->addUseStatement($entityClassDetails->getFullName()); + + $generator->generateClass( + $className, + 'doctrine/EntityListener.tpl.php', + $templateVars + [ + 'entity' => $entityClassName, + 'entity_arg' => \sprintf('%s $entity', $entityClassName), + ], + ); + } + + /** + * @param array $templateVars + */ + private function generateEventListenerClass(UseStatementGenerator $useStatements, Generator $generator, string $className, array $templateVars): void + { + $useStatements->addUseStatement(AsDoctrineListener::class); + + $generator->generateClass( + $className, + 'doctrine/EventListener.tpl.php', + $templateVars, + ); + } +} diff --git a/templates/doctrine/EntityListener.tpl.php b/templates/doctrine/EntityListener.tpl.php new file mode 100644 index 000000000..82e36fdf9 --- /dev/null +++ b/templates/doctrine/EntityListener.tpl.php @@ -0,0 +1,14 @@ + + +namespace ; + + + +#[AsEntityListener(event: , entity: ::class)] +final class +{ + public function __invoke(, ): void + { + // ... + } +} diff --git a/templates/doctrine/EventListener.tpl.php b/templates/doctrine/EventListener.tpl.php new file mode 100644 index 000000000..5ed67de6c --- /dev/null +++ b/templates/doctrine/EventListener.tpl.php @@ -0,0 +1,14 @@ + + +namespace ; + + + +#[AsDoctrineListener(event: )] +final class +{ + public function (): void + { + // ... + } +} diff --git a/tests/Doctrine/DoctrineEventRegistryTest.php b/tests/Doctrine/DoctrineEventRegistryTest.php new file mode 100644 index 000000000..8a0f533f7 --- /dev/null +++ b/tests/Doctrine/DoctrineEventRegistryTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Doctrine; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\MakerBundle\Doctrine\DoctrineEventRegistry; + +class DoctrineEventRegistryTest extends TestCase +{ + private static ?DoctrineEventRegistry $doctrineEventRegistry = null; + + public static function setUpBeforeClass(): void + { + self::$doctrineEventRegistry = new DoctrineEventRegistry(); + } + + public static function tearDownAfterClass(): void + { + self::$doctrineEventRegistry = null; + } + + /** + * @testWith ["prePersist", true] + * ["preUpdate", true] + * ["preFlush", true] + * ["loadClassMetadata", false] + * ["onFlush", false] + * ["postFlush", false] + */ + public function testIsLifecycleEvent(string $event, bool $expected) + { + self::assertSame($expected, self::$doctrineEventRegistry->isLifecycleEvent($event)); + } + + /** + * @testWith ["preUpdate", "Doctrine\\ORM\\Event\\PreUpdateEventArgs"] + * ["preFlush", "Doctrine\\ORM\\Event\\PreFlushEventArgs"] + * ["onFlush", "Doctrine\\ORM\\Event\\OnFlushEventArgs"] + * ["postGenerateSchemaTable", "Doctrine\\ORM\\Tools\\Event\\GenerateSchemaTableEventArgs"] + * ["foo", null] + * ["bar", null] + */ + public function testGetEventClassName(string $event, ?string $expected) + { + self::assertSame($expected, self::$doctrineEventRegistry->getEventClassName($event)); + } + + /** + * @testWith ["preUpdate", "Doctrine\\ORM\\Events"] + * ["preFlush", "Doctrine\\ORM\\Events"] + * ["onFlush", "Doctrine\\ORM\\Events"] + * ["postGenerateSchemaTable", "Doctrine\\ORM\\Tools\\ToolEvents"] + * ["foo", null] + * ["bar", null] + */ + public function testGetEventConstantClassName(string $event, ?string $expected) + { + self::assertSame($expected, self::$doctrineEventRegistry->getEventConstantClassName($event)); + } +} diff --git a/tests/Maker/MakeDoctrineListenerTest.php b/tests/Maker/MakeDoctrineListenerTest.php new file mode 100644 index 000000000..3c68d3af2 --- /dev/null +++ b/tests/Maker/MakeDoctrineListenerTest.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Tests\Maker; + +use Symfony\Bundle\MakerBundle\Maker\MakeDoctrineListener; +use Symfony\Bundle\MakerBundle\Test\MakerTestCase; +use Symfony\Bundle\MakerBundle\Test\MakerTestDetails; +use Symfony\Bundle\MakerBundle\Test\MakerTestRunner; + +class MakeDoctrineListenerTest extends MakerTestCase +{ + private const EXPECTED_LISTENER_PATH = __DIR__.'/../../tests/fixtures/make-doctrine-listener/tests/EventListener/'; + + private function createMakeDoctrineListenerTest(): MakerTestDetails + { + return $this->createMakerTest() + ->addExtraDependencies('doctrine/orm') + ; + } + + public function getTestDetails(): \Generator + { + yield 'it_make_event_listener_without_conventional_name' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'foo', + // event name + 'preUpdate', + // associate with entity? + 'n', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'FooListener.php', + $runner->getPath('src/EventListener/FooListener.php'), + ); + }), + ]; + + yield 'it_make_entity_listener_without_conventional_name' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + 'fooEntity', + // event name + 'preUpdate', + // associate with entity? + 'y', + // entity name + 'User', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'FooEntityListener.php', + $runner->getPath('src/EventListener/FooEntityListener.php'), + ); + }), + ]; + + yield 'it_makes_event_listener_for_known_event' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + // listener name + 'FooListener', + // event name + 'preUpdate', + // associate with entity? + 'n', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'FooListener.php', + $runner->getPath('src/EventListener/FooListener.php'), + ); + }), + ]; + + yield 'it_makes_entity_listener_for_known_event' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + // listener name + 'FooEntityListener', + // event name + 'preUpdate', + // associate with entity? + 'y', + // entity name + 'User', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'FooEntityListener.php', + $runner->getPath('src/EventListener/FooEntityListener.php'), + ); + }), + ]; + + yield 'it_does_not_make_entity_listener_for_non_lifecycle_event' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + // listener name + 'BarListener', + // event name + 'postFlush', + // associate with entity? + 'y', + // entity name + 'User', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'BarListener.php', + $runner->getPath('src/EventListener/BarListener.php'), + ); + }), + ]; + + yield 'it_makes_event_listener_for_custom_event' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + // listener name + 'BazListener', + // event name + 'onFoo', + // associate with entity? + 'n', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'BazListener.php', + $runner->getPath('src/EventListener/BazListener.php'), + ); + }), + ]; + + yield 'it_does_not_make_entity_listener_for_custom_event' => [$this->createMakeDoctrineListenerTest() + ->run(function (MakerTestRunner $runner) { + $runner->runMaker( + [ + // listener name + 'BazListener', + // event name + 'onFoo', + // associate with entity? + 'y', + // entity name + 'User', + ], + ); + + self::assertFileEquals( + self::EXPECTED_LISTENER_PATH.'BazListener.php', + $runner->getPath('src/EventListener/BazListener.php'), + ); + }), + ]; + } + + protected function getMakerClass(): string + { + return MakeDoctrineListener::class; + } +} diff --git a/tests/fixtures/make-doctrine-listener/entities/attributes/User.php b/tests/fixtures/make-doctrine-listener/entities/attributes/User.php new file mode 100644 index 000000000..53b10d49a --- /dev/null +++ b/tests/fixtures/make-doctrine-listener/entities/attributes/User.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class User +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $firstName = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $lastName = null; + + public function getId() + { + return $this->id; + } + + public function getFirstName(): string + { + return $this->firstName; + } + + public function setFirstName(?string $firstName) + { + $this->firstName = $firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function setLastName(?string $lastName) + { + $this->lastName = $lastName; + } +} diff --git a/tests/fixtures/make-doctrine-listener/tests/EventListener/BarListener.php b/tests/fixtures/make-doctrine-listener/tests/EventListener/BarListener.php new file mode 100644 index 000000000..f9e6f3358 --- /dev/null +++ b/tests/fixtures/make-doctrine-listener/tests/EventListener/BarListener.php @@ -0,0 +1,16 @@ +