From f6c87197615c6d48524d40f9f4d9118e616ef65e Mon Sep 17 00:00:00 2001 From: Jesse Rushlow <40327885+jrushlow@users.noreply.github.com> Date: Fri, 22 Mar 2024 03:32:31 -0400 Subject: [PATCH] feature #1488 allow the option to use ulid's for entity id's --- src/Doctrine/EntityClassGenerator.php | 16 ++++- src/Maker/Common/EntityIdTypeEnum.php | 24 ++++++++ src/Maker/Common/UidTrait.php | 44 +++++++++++--- src/Maker/MakeEntity.php | 10 ++-- src/Maker/MakeResetPassword.php | 7 ++- src/Maker/MakeUser.php | 8 +-- src/Resources/help/_WithUid.txt | 10 ++++ .../skeleton/doctrine/Entity.tpl.php | 18 +++++- tests/Maker/MakeEntityTest.php | 20 +++++++ tests/Maker/MakeResetPasswordTest.php | 60 +++++++++++++++++++ tests/Maker/MakeUserTest.php | 21 +++++++ ...enerates_entity_with_password_and_ulid.php | 56 +++++++++++++++++ 12 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 src/Maker/Common/EntityIdTypeEnum.php create mode 100644 src/Resources/help/_WithUid.txt create mode 100644 tests/fixtures/make-user/tests/it_generates_entity_with_password_and_ulid.php diff --git a/src/Doctrine/EntityClassGenerator.php b/src/Doctrine/EntityClassGenerator.php index ffa47da11..a54790b77 100644 --- a/src/Doctrine/EntityClassGenerator.php +++ b/src/Doctrine/EntityClassGenerator.php @@ -14,8 +14,10 @@ use ApiPlatform\Metadata\ApiResource; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Bridge\Doctrine\Types\UlidType; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\Maker\Common\EntityIdTypeEnum; use Symfony\Bundle\MakerBundle\Str; use Symfony\Bundle\MakerBundle\Util\ClassNameDetails; use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator; @@ -23,6 +25,7 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; use Symfony\Component\Security\Core\User\UserInterface; +use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; use Symfony\UX\Turbo\Attribute\Broadcast; @@ -37,7 +40,7 @@ public function __construct( ) { } - public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $apiResource, bool $withPasswordUpgrade = false, bool $generateRepositoryClass = true, bool $broadcast = false, bool $useUuidIdentifier = false): string + public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $apiResource, bool $withPasswordUpgrade = false, bool $generateRepositoryClass = true, bool $broadcast = false, EntityIdTypeEnum $useUuidIdentifier = EntityIdTypeEnum::INT): string { $repoClassDetails = $this->generator->createClassNameDetails( $entityClassDetails->getRelativeName(), @@ -60,13 +63,20 @@ public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $ $useStatements->addUseStatement(ApiResource::class); } - if ($useUuidIdentifier) { + if (EntityIdTypeEnum::UUID === $useUuidIdentifier) { $useStatements->addUseStatement([ Uuid::class, UuidType::class, ]); } + if (EntityIdTypeEnum::ULID === $useUuidIdentifier) { + $useStatements->addUseStatement([ + Ulid::class, + UlidType::class, + ]); + } + $entityPath = $this->generator->generateClass( $entityClassDetails->getFullName(), 'doctrine/Entity.tpl.php', @@ -77,7 +87,7 @@ public function generateEntityClass(ClassNameDetails $entityClassDetails, bool $ 'broadcast' => $broadcast, 'should_escape_table_name' => $this->doctrineHelper->isKeyword($tableName), 'table_name' => $tableName, - 'uses_uuid' => $useUuidIdentifier, + 'id_type' => $useUuidIdentifier, ] ); diff --git a/src/Maker/Common/EntityIdTypeEnum.php b/src/Maker/Common/EntityIdTypeEnum.php new file mode 100644 index 000000000..9e8d6ff86 --- /dev/null +++ b/src/Maker/Common/EntityIdTypeEnum.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\MakerBundle\Maker\Common; + +/** + * @author Jesse Rushlow + * + * @internal + */ +enum EntityIdTypeEnum +{ + case INT; + case UUID; + case ULID; +} diff --git a/src/Maker/Common/UidTrait.php b/src/Maker/Common/UidTrait.php index 7c1894688..25e8269de 100644 --- a/src/Maker/Common/UidTrait.php +++ b/src/Maker/Common/UidTrait.php @@ -11,9 +11,11 @@ namespace Symfony\Bundle\MakerBundle\Maker\Common; +use Symfony\Bundle\MakerBundle\Exception\RuntimeCommandException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; /** @@ -23,18 +25,23 @@ */ trait UidTrait { - /** - * Set by calling checkIsUsingUuid(). - * Use in a maker's generate() to determine if entity wants to use uuid's. - */ - protected bool $usesUid = false; + private bool $usesUuid = false; + private bool $usesUlid = false; /** * Call this in a maker's configure() to consistently allow entity's with UUID's. + * This should be called after you calling "setHelp()" in the maker. */ protected function addWithUuidOption(Command $command): Command { - $command->addOption('with-uuid', 'u', InputOption::VALUE_NONE, 'Use UUID for entity "id"'); + $uidHelp = file_get_contents(\dirname(__DIR__, 2).'/Resources/help/_WithUid.txt'); + $help = $command->getHelp()."\n".$uidHelp; + + $command + ->addOption(name: 'with-uuid', mode: InputOption::VALUE_NONE, description: 'Use UUID for entity "id"') + ->addOption('with-ulid', mode: InputOption::VALUE_NONE, description: 'Use ULID for entity "id"') + ->setHelp($help) + ; return $command; } @@ -44,8 +51,29 @@ protected function addWithUuidOption(Command $command): Command */ protected function checkIsUsingUid(InputInterface $input): void { - if (($this->usesUid = $input->getOption('with-uuid')) && !class_exists(Uuid::class)) { - throw new \RuntimeException('You must install symfony/uid to use Uuid\'s as "id" (composer require symfony/uid)'); + if (($this->usesUuid = $input->getOption('with-uuid')) && !class_exists(Uuid::class)) { + throw new RuntimeCommandException('You must install symfony/uid to use Uuid\'s as "id" (composer require symfony/uid)'); + } + + if (($this->usesUlid = $input->getOption('with-ulid')) && !class_exists(Ulid::class)) { + throw new RuntimeCommandException('You must install symfony/uid to use Ulid\'s as "id" (composer require symfony/uid)'); + } + + if ($this->usesUuid && $this->usesUlid) { + throw new RuntimeCommandException('Setting --with-uuid & --with-ulid at the same time is not allowed. Please choose only one.'); + } + } + + protected function getIdType(): EntityIdTypeEnum + { + if ($this->usesUuid) { + return EntityIdTypeEnum::UUID; } + + if ($this->usesUlid) { + return EntityIdTypeEnum::ULID; + } + + return EntityIdTypeEnum::INT; } } diff --git a/src/Maker/MakeEntity.php b/src/Maker/MakeEntity.php index b1c3697c4..ff37f585a 100644 --- a/src/Maker/MakeEntity.php +++ b/src/Maker/MakeEntity.php @@ -191,12 +191,10 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen if (!$classExists) { $broadcast = $input->getOption('broadcast'); $entityPath = $this->entityClassGenerator->generateEntityClass( - $entityClassDetails, - $input->getOption('api-resource'), - false, - true, - $broadcast, - $this->usesUid + entityClassDetails: $entityClassDetails, + apiResource: $input->getOption('api-resource'), + broadcast: $broadcast, + useUuidIdentifier: $this->getIdType(), ); if ($broadcast) { diff --git a/src/Maker/MakeResetPassword.php b/src/Maker/MakeResetPassword.php index d3bbb8f36..0ea360658 100644 --- a/src/Maker/MakeResetPassword.php +++ b/src/Maker/MakeResetPassword.php @@ -407,7 +407,12 @@ private function successMessage(ConsoleStyle $io, string $requestClassName): voi private function generateRequestEntity(Generator $generator, ClassNameDetails $requestClassNameDetails, ClassNameDetails $repositoryClassNameDetails): void { - $requestEntityPath = $this->entityClassGenerator->generateEntityClass($requestClassNameDetails, false, generateRepositoryClass: false, useUuidIdentifier: $this->usesUid); + $requestEntityPath = $this->entityClassGenerator->generateEntityClass( + entityClassDetails: $requestClassNameDetails, + apiResource: false, + generateRepositoryClass: false, + useUuidIdentifier: $this->getIdType() + ); $generator->writeChanges(); diff --git a/src/Maker/MakeUser.php b/src/Maker/MakeUser.php index 81e99416c..04e83ce34 100644 --- a/src/Maker/MakeUser.php +++ b/src/Maker/MakeUser.php @@ -135,10 +135,10 @@ public function generate(InputInterface $input, ConsoleStyle $io, Generator $gen // A) Generate the User class if ($userClassConfiguration->isEntity()) { $classPath = $this->entityClassGenerator->generateEntityClass( - $userClassNameDetails, - false, // api resource - $userClassConfiguration->hasPassword(), // security user - useUuidIdentifier: $this->usesUid + entityClassDetails: $userClassNameDetails, + apiResource: false, // api resource + withPasswordUpgrade: $userClassConfiguration->hasPassword(), // security user + useUuidIdentifier: $this->getIdType() ); } else { $classPath = $generator->generateClass($userClassNameDetails->getFullName(), 'Class.tpl.php'); diff --git a/src/Resources/help/_WithUid.txt b/src/Resources/help/_WithUid.txt new file mode 100644 index 000000000..c980a72cb --- /dev/null +++ b/src/Resources/help/_WithUid.txt @@ -0,0 +1,10 @@ +Instead of using the default "int" type for the entity's "id", you can use the +UUID type from Symfony's Uid component. +https://symfony.com/doc/current/components/uid.html#storing-uuids-in-databases + +php %command.full_name% --with-uuid + +Or you can use the ULID type from Symfony's Uid component. +https://symfony.com/doc/current/components/uid.html#storing-ulids-in-databases + +php %command.full_name% --with-ulid diff --git a/src/Resources/skeleton/doctrine/Entity.tpl.php b/src/Resources/skeleton/doctrine/Entity.tpl.php index dd37a9ffc..00d08aa90 100644 --- a/src/Resources/skeleton/doctrine/Entity.tpl.php +++ b/src/Resources/skeleton/doctrine/Entity.tpl.php @@ -1,3 +1,8 @@ + namespace ; @@ -15,7 +20,7 @@ class { - + #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] #[ORM\GeneratedValue(strategy: 'CUSTOM')] @@ -26,6 +31,17 @@ public function getId(): ?Uuid { return $this->id; } + + #[ORM\Id] + #[ORM\Column(type: UlidType::NAME, unique: true)] + #[ORM\GeneratedValue(strategy: 'CUSTOM')] + #[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')] + private ?Ulid $id = null; + + public function getId(): ?Ulid + { + return $this->id; + } #[ORM\Id] #[ORM\GeneratedValue] diff --git a/tests/Maker/MakeEntityTest.php b/tests/Maker/MakeEntityTest.php index d409e8937..2b5605b39 100644 --- a/tests/Maker/MakeEntityTest.php +++ b/tests/Maker/MakeEntityTest.php @@ -159,6 +159,26 @@ public function getTestDetails(): \Generator }), ]; + yield 'it_creates_a_new_class_with_ulid' => [$this->createMakeEntityTest() + ->addExtraDependencies('symfony/uid') + ->run(function (MakerTestRunner $runner) { + $runner->runMaker([ + // entity class name + 'User', + // add not additional fields + '', + ], '--with-ulid'); + + $this->assertFileExists($runner->getPath('src/Entity/User.php')); + + $content = file_get_contents($runner->getPath('src/Entity/User.php')); + $this->assertStringContainsString('use Symfony\Component\Uid\Ulid;', $content); + $this->assertStringContainsString('[ORM\CustomIdGenerator(class: \'doctrine.ulid_generator\')]', $content); + + $this->runEntityTest($runner); + }), + ]; + yield 'it_creates_a_new_class_with_fields' => [$this->createMakeEntityTest() ->run(function (MakerTestRunner $runner) { $runner->runMaker([ diff --git a/tests/Maker/MakeResetPasswordTest.php b/tests/Maker/MakeResetPasswordTest.php index 2886cbd5b..ac0825369 100644 --- a/tests/Maker/MakeResetPasswordTest.php +++ b/tests/Maker/MakeResetPasswordTest.php @@ -142,6 +142,66 @@ public function getTestDetails(): \Generator }), ]; + yield 'it_generates_with_ulid' => [$this->createMakerTest() + ->setSkippedPhpVersions(80100, 80109) + ->addExtraDependencies('symfony/uid') + ->run(function (MakerTestRunner $runner) { + $this->makeUser($runner); + + $output = $runner->runMaker([ + 'App\Entity\User', + 'app_home', + 'jr@rushlow.dev', + 'SymfonyCasts', + ], '--with-ulid'); + + $this->assertStringContainsString('Success', $output); + + $generatedFiles = [ + 'src/Controller/ResetPasswordController.php', + 'src/Entity/ResetPasswordRequest.php', + 'src/Form/ChangePasswordFormType.php', + 'src/Form/ResetPasswordRequestFormType.php', + 'src/Repository/ResetPasswordRequestRepository.php', + 'templates/reset_password/check_email.html.twig', + 'templates/reset_password/email.html.twig', + 'templates/reset_password/request.html.twig', + 'templates/reset_password/reset.html.twig', + ]; + + foreach ($generatedFiles as $file) { + $this->assertFileExists($runner->getPath($file)); + } + + $resetPasswordRequestEntityContents = file_get_contents($runner->getPath('src/Entity/ResetPasswordRequest.php')); + $this->assertStringContainsString('use Symfony\Component\Uid\Ulid;', $resetPasswordRequestEntityContents); + $this->assertStringContainsString('[ORM\CustomIdGenerator(class: \'doctrine.ulid_generator\')]', $resetPasswordRequestEntityContents); + + $configFileContents = file_get_contents($runner->getPath('config/packages/reset_password.yaml')); + + // Flex recipe adds comments in reset_password.yaml, check file was replaced by maker + $this->assertStringNotContainsString('#', $configFileContents); + + $resetPasswordConfig = $runner->readYaml('config/packages/reset_password.yaml'); + + $this->assertSame('App\Repository\ResetPasswordRequestRepository', $resetPasswordConfig['symfonycasts_reset_password']['request_password_repository']); + + $runner->writeFile( + 'config/packages/mailer.yaml', + Yaml::dump(['framework' => [ + 'mailer' => ['dsn' => 'null://null'], + ]]) + ); + + $runner->copy( + 'make-reset-password/tests/it_generates_with_normal_setup.php', + 'tests/ResetPasswordFunctionalTest.php' + ); + + $runner->runTests(); + }), + ]; + yield 'it_generates_with_translator_installed' => [$this->createMakerTest() // @legacy - drop skipped versions when PHP 8.1 is no longer supported. ->setSkippedPhpVersions(80100, 80109) diff --git a/tests/Maker/MakeUserTest.php b/tests/Maker/MakeUserTest.php index d27582023..9cf1a861d 100644 --- a/tests/Maker/MakeUserTest.php +++ b/tests/Maker/MakeUserTest.php @@ -66,6 +66,27 @@ public function getTestDetails(): \Generator }), ]; + yield 'it_generates_entity_with_password_and_ulid' => [$this->createMakerTest() + ->addExtraDependencies('doctrine') + ->addExtraDependencies('symfony/uid') + ->run(function (MakerTestRunner $runner) { + $runner->copy( + 'make-user/standard_setup', + '' + ); + + $runner->runMaker([ + // user class name + 'User', + 'y', // entity + 'email', // identity property + 'y', // with password + ], '--with-ulid'); + + $this->runUserTest($runner, 'it_generates_entity_with_password_and_ulid.php'); + }), + ]; + yield 'it_generates_non_entity_no_password' => [$this->createMakerTest() ->addExtraDependencies('doctrine') ->run(function (MakerTestRunner $runner) { diff --git a/tests/fixtures/make-user/tests/it_generates_entity_with_password_and_ulid.php b/tests/fixtures/make-user/tests/it_generates_entity_with_password_and_ulid.php new file mode 100644 index 000000000..502d95135 --- /dev/null +++ b/tests/fixtures/make-user/tests/it_generates_entity_with_password_and_ulid.php @@ -0,0 +1,56 @@ +getContainer() + ->get('doctrine') + ->getManager(); + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::$kernel->getContainer() + ->get('test_password_hasher'); + + $em->createQuery('DELETE FROM App\\Entity\\User u') + ->execute(); + + $user = new User(); + $user->setEmail('foo@example.com'); + $user->setPassword($hasher->hashPassword($user, 'pa$$')); + + $reflectedUser = new \ReflectionClass(User::class); + self::assertTrue($reflectedUser->implementsInterface(PasswordAuthenticatedUserInterface::class)); + self::assertTrue($reflectedUser->hasMethod('getPassword')); + + $idTypeValue = $reflectedUser + ->getProperty('id') + ->getAttributes('Doctrine\ORM\Mapping\CustomIdGenerator')[0] + ->getArguments()['class'] + ; + + $this->assertSame('doctrine.ulid_generator', $idTypeValue); + + $em->persist($user); + $em->flush(); + + // login then access a protected page + $client->request('GET', '/login?email=foo@example.com'); + + $this->assertSame(302, $client->getResponse()->getStatusCode()); + $client->followRedirect(); + $this->assertSame(200, $client->getResponse()->getStatusCode()); + $this->assertSame('Homepage Success. Hello: foo@example.com', $client->getResponse()->getContent()); + } +}