diff --git a/composer.json b/composer.json index 91c6d72f..143b30c7 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "require": { "php": "^8.0", "doctrine/common": "^2.12 || ^3.0", - "doctrine/dbal": "^2.6 || ^3.0", + "doctrine/dbal": "^3.2", "doctrine/event-manager": "^1.0", "doctrine/orm": "^2.10", "doctrine/persistence": "^1.3 || ^2.0", @@ -74,6 +74,9 @@ } }, "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true + }, "sort-packages": true } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ed4fb543..474fec88 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -135,3 +135,8 @@ parameters: count: 1 path: tests/Fixtures/DemoEntityManager.php + - + message: "#^Call to function method_exists\\(\\) with Doctrine\\\\DBAL\\\\Connection and 'createSchemaManager' will always evaluate to true\\.$#" + count: 1 + path: tests/Migration/IdToUuidMigrationTest.php + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4e88a2fa..5d0c5b23 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,6 +6,9 @@ + + + diff --git a/src/Migration/IdToUuidMigration.php b/src/Migration/IdToUuidMigration.php index 2c16def8..1a1afaf5 100644 --- a/src/Migration/IdToUuidMigration.php +++ b/src/Migration/IdToUuidMigration.php @@ -15,7 +15,7 @@ use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\Table; -use Exception; +use Doctrine\DBAL\Types\Types; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -35,6 +35,12 @@ */ final class IdToUuidMigration implements LoggerAwareInterface { + public const UUID_FIELD = 'uuid'; + + public const UUID_TYPE = Types::STRING; + + public const UUID_LENGTH = 36; + /** * @var array */ @@ -57,7 +63,7 @@ final class IdToUuidMigration implements LoggerAwareInterface private LoggerInterface $logger; - public function __construct(Connection $connection, ?LoggerInterface $logger) + public function __construct(Connection $connection, ?LoggerInterface $logger = null) { $this->connection = $connection; $this->schemaManager = method_exists($this->connection, 'createSchemaManager') @@ -76,37 +82,20 @@ public function setLogger(LoggerInterface $logger): void */ public function migrate(string $tableName, string $idField = 'id', callable $callback = null): void { - $this->writeln(sprintf('Migrating %s.%s field to UUID...', $tableName, $idField)); + $this->section(sprintf('Migrating %s.%s field to UUID', $tableName, $idField)); + $this->prepare($tableName, $idField); $this->addUuidFields(); $this->generateUuidsToReplaceIds(); $this->addThoseUuidsToTablesWithFK(); - if (null !== $callback) { - $this->handleCallback($callback); - } + $this->handleCallback($callback); $this->deletePreviousFKs(); - $this->renameNewFKsToPreviousNames(); + $this->deletePrimaryKeys(); + $this->recreateIdFields(); + $this->syncIdFields(); + $this->dropTemporyForeignKeyUuidFields(); $this->dropIdPrimaryKeyAndSetUuidToPrimaryKey(); $this->restoreConstraintsAndIndexes(); - $this->writeln(sprintf('Successfully migrated %s.%s to UUID', $tableName, $idField)); - } - - private function writeln(string $message): void - { - $this->logger->notice($message, [ - 'migration' => $this, - ]); - } - - private function isForeignKeyNullable(Table $table, string $key): bool - { - foreach ($table->getColumns() as $column) { - if ($column->getName() === $key) { - return !$column->getNotnull(); - } - } - - throw new RuntimeException('Unable to find '.$key.'in '.$table->getName()); } private function prepare(string $tableName, string $idField): void @@ -146,25 +135,40 @@ private function prepare(string $tableName, string $idField): void } if (\count($this->foreignKeys) > 0) { - $this->writeln('-> Detected foreign keys:'); + $this->section('Detected foreign keys:'); foreach ($this->foreignKeys as $meta) { - $this->writeln(' * '.$meta['table'].'.'.$meta['key']); + $this->section(' * '.$meta['table'].'.'.$meta['key']); } return; } - $this->writeln('-> No foreign keys detected.'); + $this->info('No foreign keys detected'); } private function addUuidFields(): void { - $this->connection->executeQuery('ALTER TABLE '.$this->table.' ADD uuid VARCHAR(36) FIRST'); + $this->section('Adding new "uuid" fields'); + + $schema = $this->schemaManager->createSchema(); + + $table = $schema->getTable($this->table); + $table->addColumn(self::UUID_FIELD, self::UUID_TYPE, [ + 'length' => self::UUID_LENGTH, + 'notnull' => false, + ]); foreach ($this->foreignKeys as $foreignKey) { - $this->connection->executeQuery('ALTER TABLE '.$foreignKey['table'].' ADD '.$foreignKey['tmpKey'].' VARCHAR(36)'); + $table = $schema->getTable($foreignKey['table']); + $table->addColumn($foreignKey['tmpKey'], self::UUID_TYPE, [ + 'length' => self::UUID_LENGTH, + 'notnull' => false, + 'customSchemaOptions' => ['FIRST'], + ]); } + + $this->schemaManager->migrateSchema($schema); } private function generateUuidsToReplaceIds(): void @@ -175,32 +179,20 @@ private function generateUuidsToReplaceIds(): void return; } - $this->writeln('-> Generating '.\count($fetchs).' UUID(s)...'); + $this->section('Generating '.\count($fetchs).' UUIDs'); foreach ($fetchs as $fetch) { $id = $fetch[$this->idField]; $uuid = Uuid::v4()->toRfc4122(); $this->idToUuidMap[$id] = $uuid; $this->connection->update($this->table, [ - 'uuid' => $uuid, + self::UUID_FIELD => $uuid, ], [ $this->idField => $id, ]); } } - /** - * @param callable(mixed $id, string $uuid): void $callback - */ - private function handleCallback(callable $callback): void - { - $this->writeln('-> Executing callback'); - - foreach ($this->idToUuidMap as $old => $new) { - $callback($old, $new); - } - } - /** * @SuppressWarnings(PHPMD.NPathComplexity) */ @@ -210,7 +202,7 @@ private function addThoseUuidsToTablesWithFK(): void return; } - $this->writeln('-> Adding UUIDs to tables with foreign keys...'); + $this->section('Adding UUIDs to tables with foreign keys'); foreach ($this->foreignKeys as $foreignKey) { $primaryKeys = array_map(static fn (Column $column) => $column->getName(), $foreignKey['primaryKey']); @@ -223,7 +215,7 @@ private function addThoseUuidsToTablesWithFK(): void continue; } - $this->writeln(' * Adding '.\count($fetchs).' UUIDs to "'.$foreignKey['table'].'.'.$foreignKey['key'].'"...'); + $this->debug('Adding '.\count($fetchs).' UUIDs to "'.$foreignKey['table'].'.'.$foreignKey['key']); foreach ($fetchs as $fetch) { if (null === $fetch[$foreignKey['key']]) { @@ -242,61 +234,179 @@ private function addThoseUuidsToTablesWithFK(): void } } + /** + * @param null|callable(mixed $id, string $uuid): void $callback + */ + private function handleCallback(?callable $callback): void + { + if (null === $callback) { + return; + } + + $this->section('Executing callback'); + + foreach ($this->idToUuidMap as $old => $new) { + $callback($old, $new); + } + } + private function deletePreviousFKs(): void { - $this->writeln('-> Deleting previous foreign keys...'); + $this->section('Deleting previous foreign keys'); + + $schema = $this->schemaManager->createSchema(); foreach ($this->foreignKeys as $foreignKey) { + $table = $schema->getTable($foreignKey['table']); + + $table->removeForeignKey($foreignKey['name']); + $table->dropColumn($foreignKey['key']); + } + + $this->schemaManager->migrateSchema($schema); + } + + private function deletePrimaryKeys(): void + { + $this->section('Deleting previous primary keys'); + + $schema = $this->schemaManager->createSchema(); + + foreach ($this->foreignKeys as $foreignKey) { + $table = $schema->getTable($foreignKey['table']); + if ([] !== $foreignKey['primaryKey']) { - try { - // drop primary key if not already dropped - $this->connection->executeQuery('ALTER TABLE '.$foreignKey['table'].' DROP PRIMARY KEY'); - } catch (Exception) { + if ($table->hasPrimaryKey()) { + $table->dropPrimaryKey(); } } + } + + $this->schemaManager->migrateSchema($schema); + } + + private function recreateIdFields(): void + { + $this->section('Recreate id fields'); + + $schema = $this->schemaManager->createSchema(); - $this->connection->executeQuery('ALTER TABLE '.$foreignKey['table'].' DROP FOREIGN KEY '.$foreignKey['name']); - $this->connection->executeQuery('ALTER TABLE '.$foreignKey['table'].' DROP COLUMN '.$foreignKey['key']); + $table = $schema->getTable($this->table); + $table->dropColumn($this->idField); + $table->addColumn($this->idField, self::UUID_TYPE, [ + 'length' => self::UUID_LENGTH, + 'notnull' => false, + 'customSchemaOptions' => ['FIRST'], + ]); + + foreach ($this->foreignKeys as $foreignKey) { + $table = $schema->getTable($foreignKey['table']); + + $table->dropColumn($foreignKey['key']); + $table->addColumn($foreignKey['key'], self::UUID_TYPE, [ + 'length' => self::UUID_LENGTH, + 'notnull' => false, + ]); } + + $this->schemaManager->migrateSchema($schema); } - private function renameNewFKsToPreviousNames(): void + private function syncIdFields(): void { - $this->writeln('-> Renaming temporary foreign keys to previous foreign keys names...'); + $this->section('Copy UUIDs to recreated ids fields'); + + $this->connection->executeQuery(sprintf('UPDATE %s SET %s = %s', $this->table, $this->idField, self::UUID_FIELD)); - foreach ($this->foreignKeys as $fk) { - $this->connection->executeQuery('ALTER TABLE '.$fk['table'].' CHANGE '.$fk['tmpKey'].' '.$fk['key'].' VARCHAR(36) '.(true === $fk['nullable'] ? '' : 'NOT NULL ')); + foreach ($this->foreignKeys as $foreignKey) { + $this->connection->executeQuery(sprintf('UPDATE %s SET %s = %s', $foreignKey['table'], $foreignKey['key'], $foreignKey['tmpKey'])); + } + } + + private function dropTemporyForeignKeyUuidFields(): void + { + $this->section('Drop temporary foreign key uuid fields'); + + $schema = $this->schemaManager->createSchema(); + + foreach ($this->foreignKeys as $foreignKey) { + $table = $schema->getTable($foreignKey['table']); + + $table->dropColumn($foreignKey['tmpKey']); } + + $this->schemaManager->migrateSchema($schema); } private function dropIdPrimaryKeyAndSetUuidToPrimaryKey(): void { - $this->writeln('-> Creating the new primary key...'); + $this->section('Creating the new primary key'); + + $schema = $this->schemaManager->createSchema(); + $table = $schema->getTable($this->table); - $this->connection->executeQuery('ALTER TABLE '.$this->table.' DROP PRIMARY KEY, DROP COLUMN '.$this->idField); - $this->connection->executeQuery('ALTER TABLE '.$this->table.' CHANGE uuid '.$this->idField.' VARCHAR(36) NOT NULL'); - $this->connection->executeQuery('ALTER TABLE '.$this->table.' ADD PRIMARY KEY ('.$this->idField.')'); + $table->dropPrimaryKey(); + $table->dropColumn(self::UUID_FIELD); + + $table->setPrimaryKey([$this->idField]); + + $this->schemaManager->migrateSchema($schema); } private function restoreConstraintsAndIndexes(): void { + $schema = $this->schemaManager->createSchema(); + foreach ($this->foreignKeys as $foreignKey) { + $table = $schema->getTable($foreignKey['table']); + if ([] !== $foreignKey['primaryKey']) { $primaryKeys = array_map(static fn (Column $column) => $column->getName(), $foreignKey['primaryKey']); - try { - // restore primary key if not already restored - $this->connection->executeQuery('ALTER TABLE '.$foreignKey['table'].' ADD PRIMARY KEY ('.implode(',', $primaryKeys).')'); - } catch (Exception) { + if (!$table->hasPrimaryKey()) { + $table->setPrimaryKey($primaryKeys); } } - $this->connection->executeQuery( - 'ALTER TABLE '.$foreignKey['table'].' ADD CONSTRAINT '.$foreignKey['name'].' FOREIGN KEY ('.$foreignKey['key'].') REFERENCES '.$this->table.' ('.$this->idField.')'. - (isset($foreignKey['onDelete']) ? ' ON DELETE '.$foreignKey['onDelete'] : '') - ); + $table->addForeignKeyConstraint($this->table, [$foreignKey['key']], [$this->idField], [ + 'onDelete' => $foreignKey['onDelete'] ?? null, + ]); + $table->addIndex([$foreignKey['key']]); + } + + $this->schemaManager->migrateSchema($schema); + } + + private function section(string $message): void + { + $this->writeLn(sprintf('%s', $message)); + } + + private function info(string $message): void + { + $this->writeLn(sprintf('-> %s', $message)); + } + + private function debug(string $message): void + { + $this->writeLn(sprintf(' * %s', $message)); + } + + private function writeLn(string $message): void + { + $this->logger->notice($message, [ + 'migration' => $this, + ]); + } - $this->connection->executeQuery('CREATE INDEX '.str_replace('FK_', 'IDX_', $foreignKey['name']).' ON '.$foreignKey['table'].' ('.$foreignKey['key'].')'); + private function isForeignKeyNullable(Table $table, string $key): bool + { + foreach ($table->getColumns() as $column) { + if ($column->getName() === $key) { + return !$column->getNotnull(); + } } + + throw new RuntimeException('Unable to find '.$key.'in '.$table->getName()); } } diff --git a/tests/Bridge/Symfony/App/AppKernel.php b/tests/Bridge/Symfony/App/AppKernel.php index cd1d11ab..48892313 100644 --- a/tests/Bridge/Symfony/App/AppKernel.php +++ b/tests/Bridge/Symfony/App/AppKernel.php @@ -68,12 +68,12 @@ protected function configureRoutes($routes): void protected function configureContainer($container, $loader): void { if ($container instanceof ContainerConfigurator) { - $container->import(__DIR__.'/config/config.yaml'); + $container->import(__DIR__.'/config/config.php'); return; } - $loader->load(__DIR__.'/config/config.yaml'); + $loader->load(__DIR__.'/config/config.php'); } private function getBaseDir(): string diff --git a/tests/Bridge/Symfony/App/config/config.php b/tests/Bridge/Symfony/App/config/config.php new file mode 100644 index 00000000..2aadb99a --- /dev/null +++ b/tests/Bridge/Symfony/App/config/config.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Nucleos\Doctrine\Tests\Bridge\Symfony\App\Controller\SampleTestController; + +return static function (ContainerConfigurator $containerConfigurator): void { + $containerConfigurator->extension('framework', ['secret' => 'MySecret']); + + $containerConfigurator->extension('framework', ['test' => true]); + + $containerConfigurator->extension('doctrine', ['dbal' => ['url' => 'sqlite:///:memory:', 'logging' => false]]); + + $services = $containerConfigurator->services(); + + $services->defaults() + ->autowire() + ->autoconfigure() + ; + + $services + ->set(SampleTestController::class) + ->tag('controller.service_arguments') + ; +}; diff --git a/tests/Bridge/Symfony/App/config/config.yaml b/tests/Bridge/Symfony/App/config/config.yaml deleted file mode 100644 index 004d1367..00000000 --- a/tests/Bridge/Symfony/App/config/config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -framework: - secret: secret - -services: - _defaults: - autowire: true - autoconfigure: true - - Nucleos\Doctrine\Tests\Bridge\Symfony\App\Controller\SampleTestController: - tags: - - controller.service_arguments diff --git a/tests/Migration/IdToUuidMigrationTest.php b/tests/Migration/IdToUuidMigrationTest.php new file mode 100644 index 00000000..987ba03d --- /dev/null +++ b/tests/Migration/IdToUuidMigrationTest.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nucleos\Doctrine\Tests\Migration; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Types; +use Doctrine\Persistence\AbstractManagerRegistry; +use Nucleos\Doctrine\Migration\IdToUuidMigration; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +final class IdToUuidMigrationTest extends KernelTestCase +{ + public function testMigrate(): void + { + $kernel = self::createKernel(); + $kernel->boot(); + + $connection = $this->getConnection(); + + $this->createSchema($connection); + $this->insertData($connection); + + $migration = new IdToUuidMigration($connection); + $migration->migrate('category'); + + $this->verifyCategoryData($connection); + $this->verifyItemData($connection); + } + + private function createCategory(Schema $schema): void + { + $table = $schema->createTable('category'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + ]); + $table->addColumn('parent_id', Types::INTEGER, [ + 'notnull' => false, + ]); + $table->addColumn('name', Types::STRING, [ + 'length' => 50, + ]); + $table->addForeignKeyConstraint('category', ['parent_id'], ['id'], [ + 'onDelete' => 'SET NULL', + ]); + $table->addIndex(['parent_id']); + $table->setPrimaryKey(['id']); + } + + private function createItem(Schema $schema): void + { + $table = $schema->createTable('item'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + ]); + $table->addColumn('category_id', Types::INTEGER); + $table->addColumn('name', Types::STRING, [ + 'length' => 50, + ]); + $table->addForeignKeyConstraint('category', ['category_id'], ['id'], [ + 'onDelete' => 'SET NULL', + ]); + $table->addIndex(['category_id']); + $table->setPrimaryKey(['id']); + } + + private function getConnection(): Connection + { + $registry = self::getContainer()->get('doctrine'); + + \assert($registry instanceof AbstractManagerRegistry); + + $connection = $registry->getConnection(); + + \assert($connection instanceof Connection); + + return $connection; + } + + private function createSchema(Connection $connection): void + { + $schemaManager = method_exists($connection, 'createSchemaManager') + ? $connection->createSchemaManager() + : $connection->getSchemaManager(); + $schema = $schemaManager->createSchema(); + $this->createCategory($schema); + $this->createItem($schema); + $schemaManager->migrateSchema($schema); + + static::assertTrue($schemaManager->tablesExist('category')); + static::assertTrue($schemaManager->tablesExist('item')); + } + + /** + * @throws \Doctrine\DBAL\Exception + */ + private function insertData(Connection $connection): void + { + $connection->insert('category', [ + 'id' => 1, + 'name' => 'Main', + 'parent_id' => null, + ]); + $connection->insert('category', [ + 'id' => 2, + 'name' => 'Sub 1', + 'parent_id' => 1, + ]); + $connection->insert('category', [ + 'id' => 3, + 'name' => 'Sub 2', + 'parent_id' => 1, + ]); + $connection->insert('category', [ + 'id' => 4, + 'name' => 'Sub Sub', + 'parent_id' => 3, + ]); + + $connection->insert('item', [ + 'id' => 1, + 'name' => 'Item 1', + 'category_id' => 1, + ]); + $connection->insert('item', [ + 'id' => 2, + 'name' => 'Item 2', + 'category_id' => 2, + ]); + $connection->insert('item', [ + 'id' => 3, + 'name' => 'Item 3', + 'category_id' => 3, + ]); + } + + private function verifyCategoryData(Connection $connection): void + { + $result = $connection->fetchAllAssociative('SELECT id, parent_id FROM category'); + static::assertCount(4, $result); + + foreach ($result as $data) { + static::assertTrue(36 === \strlen($data['id'])); + } + } + + private function verifyItemData(Connection $connection): void + { + $result = $connection->fetchAllAssociative('SELECT id, category_id FROM item'); + static::assertCount(3, $result); + + foreach ($result as $data) { + static::assertFalse(36 === \strlen($data['id'])); + } + } +}