diff --git a/.github/workflows/after_spliting_test.yaml b/.github/workflows/after_spliting_test.yaml index 1600212c6..19543bc2d 100644 --- a/.github/workflows/after_spliting_test.yaml +++ b/.github/workflows/after_spliting_test.yaml @@ -19,6 +19,7 @@ jobs: - console - core - doctrine-extra + - entity-migrator - fixer - framework-extra-bundle - log diff --git a/app/migrations/Version20231023001449.php b/app/migrations/Version20231023001449.php new file mode 100644 index 000000000..c7d49d037 --- /dev/null +++ b/app/migrations/Version20231023001449.php @@ -0,0 +1,59 @@ +addSql('CREATE TABLE draw_entity_migrator__migration ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + state VARCHAR(255) NOT NULL, + UNIQUE INDEX name (name), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE user_migration ( + id BIGINT AUTO_INCREMENT NOT NULL, + entity_id CHAR(36) NOT NULL COMMENT \'(DC2Type:guid)\', + migration_id INT NOT NULL, + state VARCHAR(255) DEFAULT \'new\' NOT NULL, + transition_logs JSON DEFAULT NULL, + created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', + INDEX IDX_C3FC382681257D5D (entity_id), + INDEX IDX_C3FC382679D9816F (migration_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE + user_migration + ADD + CONSTRAINT FK_C3FC382681257D5D FOREIGN KEY (entity_id) REFERENCES draw_acme__user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE + user_migration + ADD + CONSTRAINT FK_C3FC382679D9816F FOREIGN KEY (migration_id) REFERENCES draw_entity_migrator__migration (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE user_migration DROP FOREIGN KEY FK_C3FC382681257D5D'); + $this->addSql('ALTER TABLE user_migration DROP FOREIGN KEY FK_C3FC382679D9816F'); + $this->addSql('DROP TABLE draw_entity_migrator__migration'); + $this->addSql('DROP TABLE user_migration'); + } +} diff --git a/app/src/Entity/User.php b/app/src/Entity/User.php index 4375564a1..e5eb9d380 100644 --- a/app/src/Entity/User.php +++ b/app/src/Entity/User.php @@ -20,6 +20,7 @@ use Draw\Bundle\UserBundle\Security\TwoFactorAuthentication\Entity\ByTimeBaseOneTimePasswordTrait; use Draw\Bundle\UserBundle\Security\TwoFactorAuthentication\Entity\ConfigurationTrait; use Draw\Bundle\UserBundle\Security\TwoFactorAuthentication\Entity\TwoFactorAuthenticationUserInterface; +use Draw\Component\EntityMigrator\MigrationTargetEntityInterface; use Draw\Component\Messenger\DoctrineMessageBusHook\Entity\MessageHolderInterface; use Draw\Component\Messenger\DoctrineMessageBusHook\Entity\MessageHolderTrait; use Draw\DoctrineExtra\Common\Collections\CollectionUtil; @@ -33,7 +34,7 @@ #[ORM\Table(name: 'draw_acme__user')] #[ORM\HasLifecycleCallbacks] #[UniqueEntity(fields: ['email'])] -class User implements MessageHolderInterface, SecurityUserInterface, TwoFactorAuthenticationUserInterface, PasswordChangeUserInterface, LockableUserInterface, TwoFactorInterface, ByEmailInterface, ByTimeBaseOneTimePasswordInterface +class User implements MessageHolderInterface, SecurityUserInterface, TwoFactorAuthenticationUserInterface, PasswordChangeUserInterface, LockableUserInterface, TwoFactorInterface, ByEmailInterface, ByTimeBaseOneTimePasswordInterface, MigrationTargetEntityInterface { use ByEmailTrait; use ByTimeBaseOneTimePasswordTrait; @@ -398,4 +399,9 @@ public function setRequiredReadOnly(string $requiredReadOnly): static return $this; } + + public static function getEntityMigrationClass(): string + { + return UserMigration::class; + } } diff --git a/app/src/Entity/UserMigration.php b/app/src/Entity/UserMigration.php new file mode 100644 index 000000000..6cc9f1e38 --- /dev/null +++ b/app/src/Entity/UserMigration.php @@ -0,0 +1,20 @@ + + */ +class UserSetCommentNullMigration implements MigrationInterface +{ + public static function getName(): string + { + return 'user-set-comment-null'; + } + + public function __construct(private ManagerRegistry $managerRegistry) + { + + } + + public static function getTargetEntityClass(): string + { + return User::class; + } + + public function migrate(MigrationTargetEntityInterface $entity): void + { + $entity->setComment(''); + } + + public function needMigration(MigrationTargetEntityInterface $entity): bool + { + return '' !== $entity->getComment(); + } + + public function findAllThatNeedMigration(): iterable + { + $manager = $this->managerRegistry->getManagerForClass(User::class); + \assert($manager instanceof EntityManagerInterface); + + $query = $manager + ->createQuery('SELECT user.id FROM '.User::class.' user WHERE user.comment != :comment'); + + foreach ($query->toIterable(['comment' => ''], $query::HYDRATE_SCALAR) as $userId) { + yield $manager->getReference(User::class, $userId['id']); + } + } + + public function countAllThatNeedMigration(): ?int + { + $manager = $this->managerRegistry->getManagerForClass(User::class); + \assert($manager instanceof EntityManagerInterface); + + return (int) $manager + ->createQuery('SELECT count(user) FROM '.User::class.' user WHERE user.comment != :comment') + ->setParameter('comment', '') + ->getSingleScalarResult(); + } + + public function migrationIsCompleted(): bool + { + $repository = $this->managerRegistry->getRepository(User::class); + \assert($repository instanceof EntityRepository); + + return null === $repository + ->createQueryBuilder('user') + ->select('user.id') + ->where('user.comment != ""') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} diff --git a/composer.json b/composer.json index 39a184fc7..90f39442f 100644 --- a/composer.json +++ b/composer.json @@ -124,6 +124,7 @@ "draw/contracts": "self.version", "draw/core": "self.version", "draw/doctrine-extra": "self.version", + "draw/entity-migrator": "self.version", "draw/fixer": "self.version", "draw/framework-extra-bundle": "self.version", "draw/log": "self.version", @@ -168,6 +169,7 @@ "Draw\\Component\\AwsToolKit\\": "packages/aws-tool-kit/", "Draw\\Component\\Console\\": "packages/console/", "Draw\\Component\\Core\\": "packages/core/", + "Draw\\Component\\EntityMigrator\\": "packages/entity-migrator/", "Draw\\Component\\Log\\": "packages/log/", "Draw\\Component\\Mailer\\": "packages/mailer/", "Draw\\Component\\Messenger\\": "packages/messenger/", diff --git a/config/packages/draw_framework_extra.yaml b/config/packages/draw_framework_extra.yaml index e2e478add..7168e5569 100644 --- a/config/packages/draw_framework_extra.yaml +++ b/config/packages/draw_framework_extra.yaml @@ -9,6 +9,8 @@ draw_framework_extra: doctrine_extra: ~ + entity_migrator: ~ + feature: ~ log: @@ -90,3 +92,5 @@ draw_framework_extra: validator: ~ versioning: ~ + + workflow: ~ diff --git a/packages/console/Tests/Output/BufferedConsoleOutputTest.php b/packages/console/Tests/Output/BufferedConsoleOutputTest.php index 23f293014..ce2b11e07 100644 --- a/packages/console/Tests/Output/BufferedConsoleOutputTest.php +++ b/packages/console/Tests/Output/BufferedConsoleOutputTest.php @@ -3,6 +3,7 @@ namespace Draw\Component\Console\Tests\Output; use Draw\Component\Console\Output\BufferedConsoleOutput; +use Draw\Component\Core\Reflection\ReflectionAccessor; use Draw\Component\Tester\MockTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -75,6 +76,12 @@ public function testFetch(): void $message = uniqid('message-'); + ReflectionAccessor::setPropertyValue( + $this->object, + 'stream', + tmpfile() + ); + $this->object->write($message, true); static::assertSame( diff --git a/packages/entity-migrator/.gitignore b/packages/entity-migrator/.gitignore new file mode 100644 index 000000000..3cd4cac91 --- /dev/null +++ b/packages/entity-migrator/.gitignore @@ -0,0 +1,8 @@ +/vendor/ +/.idea/ +composer.lock + +###> phpunit/phpunit ### +/phpunit.xml +.phpunit.result.cache +###< phpunit/phpunit ### \ No newline at end of file diff --git a/packages/entity-migrator/Command/MigrateAllCommand.php b/packages/entity-migrator/Command/MigrateAllCommand.php new file mode 100644 index 000000000..c6b7aca88 --- /dev/null +++ b/packages/entity-migrator/Command/MigrateAllCommand.php @@ -0,0 +1,119 @@ +addArgument('migration-name', null, 'The migration name to migrate') + ->addOption('now', null, InputOption::VALUE_NONE, 'Execute the migration now'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $io = new SymfonyStyle($input, $output); + + if (!$input->getArgument('migration-name')) { + $action = $input->getOption('now') ? 'process' : 'queue'; + $io->block( + sprintf( + 'Which migration do you want to %s?', + $action + ), + null, + 'fg=white;bg=blue', + ' ', + true + ); + + $question = new ChoiceQuestion( + 'Select which migration', + array_map( + fn (Migration $migration) => $migration->getName(), + $this->managerRegistry->getRepository(Migration::class)->findAll() + ) + ); + + $input->setArgument('migration-name', $io->askQuestion($question)); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle( + $input, + $output + ); + + $now = (bool) $input->getOption('now'); + + $migration = $this->migrator->getMigration($input->getArgument('migration-name')); + + $migrationEntity = $this->managerRegistry + ->getRepository(Migration::class) + ->findOneBy(['name' => $migration::getName()]); + + $count = $migration->countAllThatNeedMigration(); + + if (0 === $count) { + $io->warning('No entity need migration'); + + return Command::SUCCESS; + } + + $progress = $io->createProgressBar($count ?? 0); + + $realCount = 0; + foreach ($migration->findAllThatNeedMigration() as $entity) { + $entityMigration = $this->entityMigrationRepository->load($entity, $migrationEntity); + + if ($now) { + $this->migrator->migrate($entityMigration); + } else { + $this->migrator->queue($entityMigration); + } + + ++$realCount; + $progress->advance(); + } + + $progress->finish(); + + $io->newLine(); + + $io->success(sprintf( + 'Migration %s %s for %d entities', + $migration::getName(), + $now ? 'processed' : 'queued', + $realCount + )); + + return Command::SUCCESS; + } +} diff --git a/packages/entity-migrator/Command/SetupCommand.php b/packages/entity-migrator/Command/SetupCommand.php new file mode 100644 index 000000000..a258e0b5d --- /dev/null +++ b/packages/entity-migrator/Command/SetupCommand.php @@ -0,0 +1,47 @@ +managerRegistry->getManagerForClass(Migration::class); + $repository = $manager->getRepository(Migration::class); + + foreach ($this->migrator->getMigrations() as $name => $migration) { + if ($repository->findOneBy(['name' => $name])) { + continue; + } + + $manager->persist( + (new Migration()) + ->setName($name) + ->setState('new') + ); + } + + $manager->flush(); + + return 0; + } +} diff --git a/packages/entity-migrator/Entity/BaseEntityMigration.php b/packages/entity-migrator/Entity/BaseEntityMigration.php new file mode 100644 index 000000000..7317cbb59 --- /dev/null +++ b/packages/entity-migrator/Entity/BaseEntityMigration.php @@ -0,0 +1,103 @@ + 'new']) + ] + protected string $state = 'new'; + + #[ + ORM\Column(type: 'json', nullable: true) + ] + protected array $transitionLogs = []; + + #[ + ORM\Column(type: 'datetime_immutable', nullable: false) + ] + protected \DateTimeImmutable $createdAt; + + public function __construct(MigrationTargetEntityInterface $entity, Migration $migration) + { + $this->entity = $entity; + $this->migration = $migration; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getEntity(): MigrationTargetEntityInterface + { + return $this->entity; + } + + public function getMigration(): Migration + { + return $this->migration; + } + + public function getCreatedAt(): \DateTimeInterface + { + return $this->createdAt; + } + + public function getState(): string + { + return $this->state; + } + + public function setState(string $state, array $context = []): static + { + $previousState = $this->state; + + $this->state = $state; + + $transitionName = $context['_transitionName'] ?? null; + + if ($transitionName) { + $user = $context['_user'] ?? null; + + $createdBy = null; + if ($user instanceof UserInterface) { + $createdBy = $user->getUserIdentifier(); + } + + $this->transitionLogs[] = [ + 'transition' => $transitionName, + 'from' => $previousState, + 'to' => $state, + 'createdAt' => time(), + 'createdBy' => $createdBy, + ]; + } + + return $this; + } + + public function getTransitionLogs(): array + { + return $this->transitionLogs; + } +} diff --git a/packages/entity-migrator/Entity/EntityMigrationInterface.php b/packages/entity-migrator/Entity/EntityMigrationInterface.php new file mode 100644 index 000000000..7af394206 --- /dev/null +++ b/packages/entity-migrator/Entity/EntityMigrationInterface.php @@ -0,0 +1,16 @@ +id; + } + + public function setId(int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getState(): string + { + return $this->state; + } + + public function setState(string $state): static + { + $this->state = $state; + + return $this; + } + + public function isPaused(): bool + { + return 'paused' === $this->state; + } +} diff --git a/packages/entity-migrator/EventListener/WorkflowListener.php b/packages/entity-migrator/EventListener/WorkflowListener.php new file mode 100644 index 000000000..562a2e613 --- /dev/null +++ b/packages/entity-migrator/EventListener/WorkflowListener.php @@ -0,0 +1,89 @@ +getSubject($event)->getMigration()->isPaused()) { + $event->setBlocked(true, 'Migration is not paused'); + } + } + + #[AsEventListener('workflow.entity_migration.guard.skip')] + public function canBeSkip(GuardEvent $event): void + { + $subject = $this->getSubject($event); + $migration = $this->getMigration($event); + + if ($migration->needMigration($subject->getEntity())) { + $event->setBlocked(true, 'Migration is needed'); + } + } + + #[AsEventListener('workflow.entity_migration.guard.process')] + public function canBeProcess(GuardEvent $event): void + { + // lock the process using locker + } + + #[AsEventListener('workflow.entity_migration.entered.processing')] + public function process(Event $event): void + { + $subject = $this->getSubject($event); + + $this + ->getMigration($event) + ->migrate($subject->getEntity()); + } + + #[AsEventListener('workflow.entity_migration.completed.queue')] + public function queued(Event $event): void + { + $this->messageBus->dispatch( + new MigrateEntityCommand($this->getSubject($event)) + ); + } + + #[AsEventListener('workflow.entity_migration.completed')] + public function flush(Event $event): void + { + $this->managerRegistry + ->getManagerForClass( + $this->getSubject($event)->getEntity()::class + )->flush(); + } + + private function getSubject(GuardEvent|Event $event): EntityMigrationInterface + { + $subject = $event->getSubject(); + + \assert($subject instanceof EntityMigrationInterface); + + return $subject; + } + + private function getMigration(GuardEvent|Event $event): MigrationInterface + { + return $this->migrator->getMigration($this->getSubject($event)->getMigration()->getName()); + } +} diff --git a/packages/entity-migrator/Message/MigrateEntityCommand.php b/packages/entity-migrator/Message/MigrateEntityCommand.php new file mode 100644 index 000000000..fce3b74fb --- /dev/null +++ b/packages/entity-migrator/Message/MigrateEntityCommand.php @@ -0,0 +1,41 @@ +entity = $entity; + } + + public function getEntity(): ?EntityMigrationInterface + { + if (null === $this->entity) { + throw new UnrecoverableMessageHandlingException('Entity not found'); + } + + return $this->entity; + } + + public function getPropertiesWithDoctrineObject(): array + { + return ['entity']; + } + + public function stamp(Envelope $envelope): Envelope + { + return $envelope->with( + DelayStamp::delayUntil(new \DateTimeImmutable('+1 minute')) + ); + } +} diff --git a/packages/entity-migrator/MessageHandler/MigrateEntityCommandHandler.php b/packages/entity-migrator/MessageHandler/MigrateEntityCommandHandler.php new file mode 100644 index 000000000..bfad30b55 --- /dev/null +++ b/packages/entity-migrator/MessageHandler/MigrateEntityCommandHandler.php @@ -0,0 +1,20 @@ +migrator->migrate($command->getEntity()); + } +} diff --git a/packages/entity-migrator/MigrationInterface.php b/packages/entity-migrator/MigrationInterface.php new file mode 100644 index 000000000..1f8d99957 --- /dev/null +++ b/packages/entity-migrator/MigrationInterface.php @@ -0,0 +1,40 @@ + + */ + public function findAllThatNeedMigration(): iterable; + + /** + * Return the number of entities that need migration or null if unknown. + */ + public function countAllThatNeedMigration(): ?int; + + /** + * Return a boolean to indicate that no more entities need migration. + */ + public function migrationIsCompleted(): bool; +} diff --git a/packages/entity-migrator/MigrationTargetEntityInterface.php b/packages/entity-migrator/MigrationTargetEntityInterface.php new file mode 100644 index 000000000..01f865457 --- /dev/null +++ b/packages/entity-migrator/MigrationTargetEntityInterface.php @@ -0,0 +1,13 @@ + + */ + public static function getEntityMigrationClass(): string; +} diff --git a/packages/entity-migrator/Migrator.php b/packages/entity-migrator/Migrator.php new file mode 100644 index 000000000..439132c54 --- /dev/null +++ b/packages/entity-migrator/Migrator.php @@ -0,0 +1,84 @@ +entityMigrationStateMachine->can($entity, 'queue')) { + $this->entityMigrationStateMachine->apply($entity, 'queue'); + } + } + + public function migrateEntity(MigrationTargetEntityInterface $entity, string $migrationName): void + { + if (class_exists($migrationName)) { + \assert(is_a($migrationName, MigrationInterface::class, true)); + + $migrationName = $migrationName::getName(); + } + + $migration = $this->managerRegistry + ->getRepository(Migration::class) + ->findOneBy(['name' => $migrationName]); + + $entityMigration = $this->entityMigrationRepository->load($entity, $migration); + + $this->migrate($entityMigration); + } + + public function migrate(EntityMigrationInterface $entityMigration): void + { + foreach (['paused', 'skip', 'process'] as $transition) { + if (!$this->entityMigrationStateMachine->can($entityMigration, $transition)) { + continue; + } + + try { + $this->entityMigrationStateMachine->apply($entityMigration, $transition); + + break; + } catch (\Throwable $error) { + $this->entityMigrationStateMachine->apply($entityMigration, 'fail', ['error' => $error]); + + return; + } + } + + if ($this->entityMigrationStateMachine->can($entityMigration, 'complete')) { + $this->entityMigrationStateMachine->apply($entityMigration, 'complete'); + } + } + + public function getMigration(string $name): MigrationInterface + { + return $this->migrations->get($name); + } + + /** + * @return iterable + */ + public function getMigrations(): iterable + { + foreach ($this->migrationNames as $name) { + yield $name => $this->getMigration($name); + } + } +} diff --git a/packages/entity-migrator/Repository/EntityMigrationRepository.php b/packages/entity-migrator/Repository/EntityMigrationRepository.php new file mode 100644 index 000000000..9df53c341 --- /dev/null +++ b/packages/entity-migrator/Repository/EntityMigrationRepository.php @@ -0,0 +1,34 @@ +managerRegistry->getRepository($entityMigrationClass) + ->findOneBy(['entity' => $entity, 'migration' => $migration]); + + if (null === $entityMigration) { + $entityMigration = new $entityMigrationClass($entity, $migration); + $manager = $this->managerRegistry->getManagerForClass($entityMigrationClass); + $manager->persist($entityMigration); + $manager->flush(); + } + + return $entityMigration; + } +} diff --git a/packages/entity-migrator/composer.json b/packages/entity-migrator/composer.json new file mode 100644 index 000000000..bfcf820ab --- /dev/null +++ b/packages/entity-migrator/composer.json @@ -0,0 +1,35 @@ +{ + "name": "draw/entity-migrator", + "license": "MIT", + "type": "library", + "keywords": ["draw", "migration", "symfony", "doctrine"], + "authors": [ + { + "name": "Martin Poirier Theoret", + "email": "mpoiriert@gmail.com" + } + ], + "require": { + "draw/messenger": "^0.10", + "symfony/console": "^5.4.3", + "symfony/event-dispatcher": "^5.4.3", + "symfony/messenger": "^5.4.3", + "symfony/workflow": "^5.4.3" + }, + "require-dev": { + "draw/tester": "^0.10", + "phpunit/phpunit": "^9.0 || ^10.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "Draw\\Component\\EntityMigrator\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-master": "0.10-dev" + } + } +} diff --git a/packages/entity-migrator/phpunit.xml.dist b/packages/entity-migrator/phpunit.xml.dist new file mode 100644 index 000000000..c23e36d05 --- /dev/null +++ b/packages/entity-migrator/phpunit.xml.dist @@ -0,0 +1,9 @@ + + + + ./Tests + + + \ No newline at end of file diff --git a/packages/framework-extra-bundle/DependencyInjection/Compiler/EntityMigratorCompilerPass.php b/packages/framework-extra-bundle/DependencyInjection/Compiler/EntityMigratorCompilerPass.php new file mode 100644 index 000000000..e074e0894 --- /dev/null +++ b/packages/framework-extra-bundle/DependencyInjection/Compiler/EntityMigratorCompilerPass.php @@ -0,0 +1,35 @@ +findTaggedServiceIds(MigrationInterface::class)) as $id) { + $name = $container + ->getDefinition($id) + ->getClass()::getName(); + + $references[$name] = new Reference($id); + } + + $container + ->getDefinition('draw.entity_migrator.migrator') + ->setArgument( + '$migrations', + ServiceLocatorTagPass::register($container, $references) + ) + ->setArgument( + '$migrationNames', + array_keys($references) + ); + } +} diff --git a/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php b/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php index 11b725174..2d6ad3887 100644 --- a/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php +++ b/packages/framework-extra-bundle/DependencyInjection/DrawFrameworkExtraExtension.php @@ -7,6 +7,7 @@ use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\ConsoleIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\CronIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\DoctrineExtraIntegration; +use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\EntityMigratorIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\FeatureIntegration; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\IntegrationInterface; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Integration\LoggerIntegration; @@ -52,6 +53,7 @@ private function registerDefaultIntegrations(): void $this->integrations[] = new ConsoleIntegration(); $this->integrations[] = new CronIntegration(); $this->integrations[] = new DoctrineExtraIntegration(); + $this->integrations[] = new EntityMigratorIntegration(); $this->integrations[] = new FeatureIntegration(); $this->integrations[] = new LoggerIntegration(); $this->integrations[] = new LogIntegration(); diff --git a/packages/framework-extra-bundle/DependencyInjection/Integration/EntityMigratorIntegration.php b/packages/framework-extra-bundle/DependencyInjection/Integration/EntityMigratorIntegration.php new file mode 100644 index 000000000..a77bfaddb --- /dev/null +++ b/packages/framework-extra-bundle/DependencyInjection/Integration/EntityMigratorIntegration.php @@ -0,0 +1,172 @@ +registerForAutoconfiguration(MigrationInterface::class) + ->addTag(MigrationInterface::class); + + $this->registerClasses( + $loader, + $namespace = 'Draw\\Component\\EntityMigrator\\', + \dirname((new \ReflectionClass(Migrator::class))->getFileName()), + ); + + $this->renameDefinitions( + $container, + $namespace, + 'draw.entity_migrator.' + ); + } + + public function addConfiguration(ArrayNodeDefinition $node): void + { + $node + ->children() + ->scalarNode('transport')->defaultValue('async_low_priority')->end() + ->end(); + } + + public function prepend(ContainerBuilder $container, array $config): void + { + $this->assertHasExtension($container, 'doctrine'); + + $reflection = new \ReflectionClass(Migration::class); + + $container->prependExtensionConfig( + 'framework', + [ + 'messenger' => [ + 'routing' => [ + MigrateEntityCommand::class => $config['transport'], + ], + ], + ] + ); + + $container->prependExtensionConfig( + 'doctrine', + [ + 'orm' => [ + 'mappings' => [ + 'DrawEntityMigrator' => [ + 'is_bundle' => false, + 'type' => 'attribute', + 'dir' => \dirname($reflection->getFileName()), + 'prefix' => $reflection->getNamespaceName(), + ], + ], + ], + ] + ); + + $container->prependExtensionConfig( + 'framework', + [ + 'workflows' => [ + 'entity_migration' => [ + 'type' => 'state_machine', + 'marking_store' => [ + 'type' => 'method', + 'property' => 'state', + ], + 'supports' => [ + EntityMigrationInterface::class, + ], + 'initial_marking' => 'new', + 'places' => [ + 'new', + 'queued', + 'processing', + 'errored', + 'completed', + 'paused', + 'skipped', + ], + 'transitions' => [ + 'queue' => [ + 'from' => [ + 'new', + ], + 'to' => 'queued', + ], + 'pause' => [ + 'from' => [ + 'new', + 'queued', + ], + 'to' => 'paused', + ], + 'skip' => [ + 'from' => [ + 'new', + 'queued', + ], + 'to' => 'skipped', + ], + 'process' => [ + 'from' => [ + 'new', + 'queued', + ], + 'to' => 'processing', + ], + 'error' => [ + 'from' => [ + 'processing', + ], + 'to' => 'errored', + ], + 'complete' => [ + 'from' => [ + 'processing', + ], + 'to' => 'completed', + ], + 'retry' => [ + 'from' => [ + 'errored', + ], + 'to' => 'queued', + ], + 'reprocess' => [ + 'from' => [ + 'completed', + ], + 'to' => 'queued', + ], + 're_queue' => [ + 'from' => [ + 'paused', + 'processing', + 'queued', + ], + 'to' => 'queued', + ], + ], + ], + ], + ], + ); + } +} diff --git a/packages/framework-extra-bundle/DrawFrameworkExtraBundle.php b/packages/framework-extra-bundle/DrawFrameworkExtraBundle.php index daf433836..e5504a1ed 100644 --- a/packages/framework-extra-bundle/DrawFrameworkExtraBundle.php +++ b/packages/framework-extra-bundle/DrawFrameworkExtraBundle.php @@ -5,6 +5,7 @@ use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\AddCommandExecutionOptionsCompilerPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\AddNewestInstanceRoleCommandOptionPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\EmailWriterCompilerPass; +use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\EntityMigratorCompilerPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\JmsDoctrineObjectConstructionCompilerPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\LoggerDecoratorPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\MessengerBrokerCompilerPass; @@ -14,6 +15,7 @@ use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Factory\Security\JwtAuthenticatorFactory; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Factory\Security\MessengerMessageAuthenticatorFactory; use Draw\Component\Console\EventListener\CommandFlowListener; +use Draw\Component\EntityMigrator\Migrator; use Draw\Component\Mailer\EmailWriter\EmailWriterInterface; use Draw\Component\Messenger\Broker\Broker; use Draw\Component\Messenger\Expirable\Command\PurgeExpiredMessageCommand; @@ -55,6 +57,10 @@ public function build(ContainerBuilder $container): void $container->addCompilerPass(new EmailWriterCompilerPass()); } + if (class_exists(Migrator::class)) { + $container->addCompilerPass(new EntityMigratorCompilerPass()); + } + if (class_exists(PurgeExpiredMessageCommand::class)) { $container->addCompilerPass( new MessengerTransportNamesCompilerPass(), diff --git a/packages/framework-extra-bundle/Tests/DrawFrameworkExtraBundleTest.php b/packages/framework-extra-bundle/Tests/DrawFrameworkExtraBundleTest.php index add419c3d..731a81b89 100644 --- a/packages/framework-extra-bundle/Tests/DrawFrameworkExtraBundleTest.php +++ b/packages/framework-extra-bundle/Tests/DrawFrameworkExtraBundleTest.php @@ -5,6 +5,7 @@ use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\AddCommandExecutionOptionsCompilerPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\AddNewestInstanceRoleCommandOptionPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\EmailWriterCompilerPass; +use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\EntityMigratorCompilerPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\JmsDoctrineObjectConstructionCompilerPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\LoggerDecoratorPass; use Draw\Bundle\FrameworkExtraBundle\DependencyInjection\Compiler\MessengerBrokerCompilerPass; @@ -36,7 +37,7 @@ public function testBuild(): void $containerBuilder = $this->createMock(ContainerBuilder::class); $containerBuilder - ->expects(static::exactly(9)) + ->expects(static::exactly(10)) ->method('addCompilerPass') ->with( ...static::withConsecutive( @@ -75,6 +76,11 @@ public function testBuild(): void PassConfig::TYPE_BEFORE_OPTIMIZATION, 0, ], + [ + static::isInstanceOf(EntityMigratorCompilerPass::class), + PassConfig::TYPE_BEFORE_OPTIMIZATION, + 0, + ], [ static::isInstanceOf(MessengerTransportNamesCompilerPass::class), PassConfig::TYPE_BEFORE_OPTIMIZATION, diff --git a/packages/framework-extra-bundle/composer.json b/packages/framework-extra-bundle/composer.json index 39cc64387..4d081dbab 100644 --- a/packages/framework-extra-bundle/composer.json +++ b/packages/framework-extra-bundle/composer.json @@ -24,6 +24,7 @@ "draw/console": "^0.10", "draw/contracts": "^0.10", "draw/doctrine-extra": "^0.10", + "draw/entity-migrator": "^0.10", "draw/log": "^0.10", "draw/mailer": "^0.10", "draw/messenger": "^0.10",