diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 2f5264a..0ce6c89 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -222,22 +222,9 @@ - - - - - - - - - - - - - @@ -254,7 +241,6 @@ - diff --git a/src/Migrator.php b/src/Migrator.php index 22dd179..63b4ce5 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -8,6 +8,7 @@ use Cycle\Database\DatabaseInterface; use Cycle\Database\DatabaseManager; use Cycle\Database\DatabaseProviderInterface; +use Cycle\Database\Schema\AbstractTable; use Cycle\Database\Table; use Cycle\Migrations\Config\MigrationConfig; use Cycle\Migrations\Exception\MigrationException; @@ -62,19 +63,12 @@ public function configure(): void } foreach ($this->getDatabases() as $db) { - $schema = $db->table($this->config->getTable())->getSchema(); + $table = $db->table($this->config->getTable()); + \assert($table instanceof Table); + $schema = $table->getSchema(); // Schema update will automatically sync all needed data - $schema->primary('id'); - $schema->string('migration', 191)->nullable(false); - $schema->datetime('time_executed')->datetime(); - $schema->datetime('created_at')->datetime(); - $schema->index(['migration', 'created_at']) - ->unique(true); - - if ($schema->hasIndex(['migration'])) { - $schema->dropIndex(['migration']); - } + $this->declareMigrationTableSchema($schema); $schema->save(); } @@ -229,7 +223,16 @@ protected function checkMigrationTableStructure(Database $db): bool } } - return !(!$table->hasIndex(['migration', 'created_at'])); + if (!$table->hasIndex(['migration', 'created_at'])) { + return false; + } + + // The table may have been created by a previous version of the package + // with a different column definition (e.g. datetime precision). + $schema = $table->getSchema(); + $this->declareMigrationTableSchema($schema); + + return !$schema->getComparator()->hasChanges(); } /** @@ -242,7 +245,7 @@ protected function fetchMigrationData(MigrationInterface $migration): ?array ->where( [ 'migration' => $migration->getState()->getName(), - 'created_at' => $this->getMigrationCreatedAtForDb($migration)->format(self::DB_DATE_FORMAT), + 'created_at' => $this->getMigrationCreatedAtForDb($migration), ], ) ->run() @@ -307,6 +310,26 @@ protected function getMigrationCreatedAtForDb(MigrationInterface $migration): \D ); } + /** + * Declare the desired structure of the migration table on the given schema. + */ + private function declareMigrationTableSchema(AbstractTable $schema): void + { + $schema->primary('id'); + $schema->string('migration', 191)->nullable(false); + // Second precision is enough for migrations; size is set to keep the column + // type compatible with the `withDatetimeMicroseconds` driver option + // (on SQL Server the legacy DATETIME type rejects values with microseconds) + $schema->datetime('time_executed')->datetime(6); + $schema->datetime('created_at')->datetime(6); + $schema->index(['migration', 'created_at']) + ->unique(true); + + if ($schema->hasIndex(['migration'])) { + $schema->dropIndex(['migration']); + } + } + /** * @return iterable */ diff --git a/tests/Migrations/DatetimeMicrosecondsTest.php b/tests/Migrations/DatetimeMicrosecondsTest.php new file mode 100644 index 0000000..e5aeaa5 --- /dev/null +++ b/tests/Migrations/DatetimeMicrosecondsTest.php @@ -0,0 +1,110 @@ +driver)) { + $config = clone self::$config[static::DRIVER]; + $config->options['withDatetimeMicroseconds'] = true; + + $this->driver = $config->driver::create($config); + } + + if (self::$config['debug'] && $this->driver instanceof LoggerAwareInterface) { + $this->driver->setLogger(new TestLogger()); + } + + return $this->driver; + } + + public function testMigrationResolvedAsExecutedAfterRun(): void + { + $this->migrator->configure(); + + $schema = $this->schema('sample'); + $schema->primary('id'); + $schema->integer('value'); + $this->atomize('migration1', [$schema]); + + $migration = $this->migrator->run(); + + $this->assertInstanceOf(Migration::class, $migration); + $this->assertSame(State::STATUS_EXECUTED, $migration->getState()->getStatus()); + } + + public function testSecondRunHasNothingToExecute(): void + { + $this->migrator->configure(); + + $schema = $this->schema('sample'); + $schema->primary('id'); + $schema->integer('value'); + $this->atomize('migration1', [$schema]); + + $this->migrator->run(); + + // The only migration has been executed, nothing is pending + $this->assertNull($this->migrator->run()); + } + + public function testUpgradeFromLegacyTableStructure(): void + { + // The migration table as it was created by previous versions of the package: + // datetime columns without precision + $schema = $this->db->table('migrations')->getSchema(); + $schema->primary('id'); + $schema->string('migration', 191)->nullable(false); + $schema->datetime('time_executed')->datetime(); + $schema->datetime('created_at')->datetime(); + $schema->index(['migration', 'created_at'])->unique(true); + $schema->save(); + + $this->migrator->configure(); + $this->assertTrue($this->migrator->isConfigured()); + + $schema = $this->schema('sample'); + $schema->primary('id'); + $schema->integer('value'); + $this->atomize('migration1', [$schema]); + + $migration = $this->migrator->run(); + + $this->assertInstanceOf(Migration::class, $migration); + $this->assertSame(State::STATUS_EXECUTED, $migration->getState()->getStatus()); + $this->assertNull($this->migrator->run()); + } + + public function testRollbackAfterRun(): void + { + $this->migrator->configure(); + + $schema = $this->schema('sample'); + $schema->primary('id'); + $schema->integer('value'); + $this->atomize('migration1', [$schema]); + + $this->migrator->run(); + $this->assertTrue($this->db->hasTable('sample')); + + $migration = $this->migrator->rollback(); + + $this->assertInstanceOf(Migration::class, $migration); + $this->assertFalse($this->db->hasTable('sample')); + } +} diff --git a/tests/Migrations/MySQL/DatetimeMicrosecondsTest.php b/tests/Migrations/MySQL/DatetimeMicrosecondsTest.php new file mode 100644 index 0000000..569b616 --- /dev/null +++ b/tests/Migrations/MySQL/DatetimeMicrosecondsTest.php @@ -0,0 +1,14 @@ +