From 0ac1a47301783ccdf1244181e13b3ce268e89ffb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 14:43:20 +0400 Subject: [PATCH 1/3] Add failing tests for the withDatetimeMicroseconds driver option The Migrator cannot resolve executed migrations when the driver is configured with withDatetimeMicroseconds: fetchMigrationData() compares created_at against a string formatted without microseconds, while the inserted value is formatted by the driver with them. Fails on SQLite (text comparison) and SQL Server (DATETIME rejects 6 fractional digits on insert); MySQL and Postgres pass. See https://github.com/cycle/migrations/issues/66 Assisted-By: Claude Fable 5 --- tests/Migrations/DatetimeMicrosecondsTest.php | 83 +++++++++++++++++++ .../MySQL/DatetimeMicrosecondsTest.php | 14 ++++ .../Postgres/DatetimeMicrosecondsTest.php | 14 ++++ .../SQLServer/DatetimeMicrosecondsTest.php | 14 ++++ .../SQLite/DatetimeMicrosecondsTest.php | 14 ++++ 5 files changed, 139 insertions(+) create mode 100644 tests/Migrations/DatetimeMicrosecondsTest.php create mode 100644 tests/Migrations/MySQL/DatetimeMicrosecondsTest.php create mode 100644 tests/Migrations/Postgres/DatetimeMicrosecondsTest.php create mode 100644 tests/Migrations/SQLServer/DatetimeMicrosecondsTest.php create mode 100644 tests/Migrations/SQLite/DatetimeMicrosecondsTest.php diff --git a/tests/Migrations/DatetimeMicrosecondsTest.php b/tests/Migrations/DatetimeMicrosecondsTest.php new file mode 100644 index 0000000..1b921f4 --- /dev/null +++ b/tests/Migrations/DatetimeMicrosecondsTest.php @@ -0,0 +1,83 @@ +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 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 @@ + Date: Wed, 12 Aug 2026 14:54:13 +0400 Subject: [PATCH 2/3] Fix resolution of executed migrations with withDatetimeMicroseconds Pass created_at to the WHERE clause as a DateTimeInterface instead of a pre-formatted string, so the driver formats it the same way as on insert. A string formatted without microseconds never matches the stored value on SQLite, where datetime comparison is textual. Fixes https://github.com/cycle/migrations/issues/66 Assisted-By: Claude Fable 5 --- src/Migrator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Migrator.php b/src/Migrator.php index 22dd179..13d34ba 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -242,7 +242,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() From 7532343cc36315b254f6b123bacdd1340bbf7cf7 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Wed, 12 Aug 2026 15:47:46 +0400 Subject: [PATCH 3/3] Create migration table datetime columns with microsecond precision On SQL Server the legacy DATETIME type rejects values with six fractional digits, so inserting into the migration table fails when the driver is configured with withDatetimeMicroseconds. datetime(6) maps to datetime2(6) there and keeps the other drivers' behavior intact. isConfigured() now also compares the existing table with the declared schema, so tables created by previous versions are upgraded on configure(). The comparison is driver-aware for free: SQL Server and Postgres detect the precision change and alter once; MySQL and SQLite exclude size from column comparison, keep their old columns and work either way. Assisted-By: Claude Fable 5 --- psalm-baseline.xml | 14 ------ src/Migrator.php | 47 ++++++++++++++----- tests/Migrations/DatetimeMicrosecondsTest.php | 27 +++++++++++ 3 files changed, 62 insertions(+), 26 deletions(-) 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 13d34ba..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(); } /** @@ -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 index 1b921f4..e5aeaa5 100644 --- a/tests/Migrations/DatetimeMicrosecondsTest.php +++ b/tests/Migrations/DatetimeMicrosecondsTest.php @@ -63,6 +63,33 @@ public function testSecondRunHasNothingToExecute(): void $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();