From f03ccd82b1fb40397cc5cff46b77ca768c7b20d5 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 3 Oct 2026 13:44:26 +0300 Subject: [PATCH 1/8] Add migrate:mark command to manage migration history --- CHANGELOG.md | 1 + README.md | 1 + bin/yii-db-migration | 2 +- config/di-console.php | 2 +- config/params.php | 2 + docs/guide/en/README.md | 1 + docs/guide/en/migration-history.md | 36 +++++ docs/guide/en/multiple-databases.md | 10 ++ docs/guide/en/usage-with-symfony.md | 4 + src/Command/CommandFactory.php | 1 + src/Command/DatabaseCommand.php | 2 +- src/Command/MarkCommand.php | 145 ++++++++++++++++++ src/Migrator.php | 27 ++-- .../Command/AbstractMarkCommandTest.php | 143 +++++++++++++++++ tests/Driver/Mssql/MarkCommandTest.php | 26 ++++ tests/Driver/Mysql/MarkCommandTest.php | 26 ++++ tests/Driver/Oracle/MarkCommandTest.php | 26 ++++ tests/Driver/Pgsql/MarkCommandTest.php | 26 ++++ tests/Driver/Sqlite/DatabaseCommandsTest.php | 50 ++++++ tests/Driver/Sqlite/MarkCommandTest.php | 26 ++++ tests/Migration/BinTest.php | 1 + tests/Migration/ConfigTest.php | 4 +- .../MarkMigrations/M260101000001First.php | 27 ++++ .../MarkMigrations/M260101000002Second.php | 27 ++++ .../MarkMigrations/M260101000003Third.php | 27 ++++ 25 files changed, 628 insertions(+), 15 deletions(-) create mode 100644 docs/guide/en/migration-history.md create mode 100644 src/Command/MarkCommand.php create mode 100644 tests/Common/Command/AbstractMarkCommandTest.php create mode 100644 tests/Driver/Mssql/MarkCommandTest.php create mode 100644 tests/Driver/Mysql/MarkCommandTest.php create mode 100644 tests/Driver/Oracle/MarkCommandTest.php create mode 100644 tests/Driver/Pgsql/MarkCommandTest.php create mode 100644 tests/Driver/Sqlite/MarkCommandTest.php create mode 100644 tests/Support/MarkMigrations/M260101000001First.php create mode 100644 tests/Support/MarkMigrations/M260101000002Second.php create mode 100644 tests/Support/MarkMigrations/M260101000003Third.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a103133..0efbe396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.1.2 under development +- New #363: Add `migrate:mark` to move migration history to a version without executing migrations (@samdark) - New #360: Support named database migration sets with `--db` selection, a single configuration source for `default`, and separate migration history (@samdark) - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) diff --git a/README.md b/README.md index 26c8fefd..f84189bb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ composer require yiisoft/db-migration migrate:create Creates a new migration. migrate:down Reverts the specified number of latest migrations. migrate:history Displays the migration history. +migrate:mark Modifies migration history without executing migrations. migrate:new Displays not yet applied migrations. migrate:redo Redoes the last few migrations. migrate:up Applies new migrations. diff --git a/bin/yii-db-migration b/bin/yii-db-migration index 55730ba9..68f0d335 100644 --- a/bin/yii-db-migration +++ b/bin/yii-db-migration @@ -80,7 +80,7 @@ $databases = new DatabaseSetRegistry( $factory = new CommandFactory($databases); $application = new Application('Yii Database Migration Tool', '1.0.0'); $commands = []; -foreach (['create', 'down', 'history', 'new', 'redo', 'up'] as $name) { +foreach (['create', 'down', 'history', 'mark', 'new', 'redo', 'up'] as $name) { $commands[] = $factory->create($name); } $application->addCommands($commands); diff --git a/config/di-console.php b/config/di-console.php index e3dba47c..fc0205de 100644 --- a/config/di-console.php +++ b/config/di-console.php @@ -37,7 +37,7 @@ } } unset($definitions[MigrationService::class]); - foreach (['Create' => 'create', 'Down' => 'down', 'History' => 'history', 'New' => 'new', 'Redo' => 'redo', 'Update' => 'up'] as $class => $name) { + foreach (['Create' => 'create', 'Down' => 'down', 'History' => 'history', 'Mark' => 'mark', 'New' => 'new', 'Redo' => 'redo', 'Update' => 'up'] as $class => $name) { $definitions['Yiisoft\\Db\\Migration\\Command\\' . $class . 'Command'] = static fn(CommandFactory $factory): DatabaseCommand => $factory->create($name); } diff --git a/config/params.php b/config/params.php index d68d3067..04815034 100644 --- a/config/params.php +++ b/config/params.php @@ -5,6 +5,7 @@ use Yiisoft\Db\Migration\Command\CreateCommand; use Yiisoft\Db\Migration\Command\DownCommand; use Yiisoft\Db\Migration\Command\HistoryCommand; +use Yiisoft\Db\Migration\Command\MarkCommand; use Yiisoft\Db\Migration\Command\NewCommand; use Yiisoft\Db\Migration\Command\RedoCommand; use Yiisoft\Db\Migration\Command\UpdateCommand; @@ -15,6 +16,7 @@ 'migrate:create' => CreateCommand::class, 'migrate:down' => DownCommand::class, 'migrate:history' => HistoryCommand::class, + 'migrate:mark' => MarkCommand::class, 'migrate:new' => NewCommand::class, 'migrate:redo' => RedoCommand::class, 'migrate:up' => UpdateCommand::class, diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index 9fe0c0b1..d73f339f 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -4,3 +4,4 @@ - [Symfony application](usage-with-symfony.md) - [Standalone](usage-standalone.md) - [Multiple databases](multiple-databases.md) +- [Modifying migration history](migration-history.md) diff --git a/docs/guide/en/migration-history.md b/docs/guide/en/migration-history.md new file mode 100644 index 00000000..ac0bda0f --- /dev/null +++ b/docs/guide/en/migration-history.md @@ -0,0 +1,36 @@ +# Modifying migration history + +When adopting migrations for an existing database, or after making a migration's changes manually, use `migrate:mark` +to move migration history to a particular version without executing `up()` or `down()`: + +```shell +./vendor/bin/yii-db-migration migrate:mark 'App\Migrations\M260101000002CreateIndex' +./vendor/bin/yii-db-migration migrate:mark M260101000002CreateIndex +./vendor/bin/yii-db-migration migrate:mark 260101000002 +``` + +Use the full class name, including its namespace when present, or a 12-digit migration timestamp. A timestamp with an +underscore between date and time (`260101_000002`) is also accepted. These are migration filename timestamps, not Unix +timestamps or date strings. If several migrations share a timestamp, the first matching migration in the relevant list +is selected; use its full class name to select an exact target. + +The behavior follows Yii2's `migrate/mark`: + +- If the target is pending, record all pending migrations in discovery order through and including the target. + Existing history entries remain unchanged. +- If the target is already recorded, remove entries newer than it in migration history, keeping the target and older + entries. This uses application order, not filename order, and does not require the recorded migration files to exist. +- If the target is already the latest recorded entry, nothing changes. +- If the target is unknown, report an error without adding or removing history entries. + +To clear the entire history, use Yii2's special base version: + +```shell +./vendor/bin/yii-db-migration migrate:mark m000000_000000_base +``` + +The command asks for confirmation before adding or removing entries. Use `--force-yes` (`-y`) for unattended execution. +With multiple databases configured, select one explicitly using `--db=maps`. + +Marking changes only migration history; it does not verify that the database matches the target. A later `migrate:up` +will execute migrations whose records were removed, and `migrate:down` can revert migrations marked as applied. diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 42c2e781..c1db692e 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -107,3 +107,13 @@ Revert and apply the last `maps` migration again: When multiple databases are configured, both commands require `--db`. Use `--db=default` to select the `default` database. With only one database configured, you can continue to omit the option. + +## Modify migration history + +Use `migrate:mark` to record migrations without executing them. With multiple databases, `--db` is required: + +```shell +./vendor/bin/yii-db-migration migrate:mark 'App\Migrations\Maps\M260101000002CreateIndex' --db=maps +``` + +See [Modifying migration history](migration-history.md) for target selection and resetting history. diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index 6b7b20b2..afbc8edc 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -125,6 +125,10 @@ services: factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] arguments: ['history'] + Yiisoft\Db\Migration\Command\MarkCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['mark'] + Yiisoft\Db\Migration\Command\NewCommand: factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] arguments: ['new'] diff --git a/src/Command/CommandFactory.php b/src/Command/CommandFactory.php index c1264c47..b9aaaa7b 100644 --- a/src/Command/CommandFactory.php +++ b/src/Command/CommandFactory.php @@ -26,6 +26,7 @@ public function create(string $name): DatabaseCommand 'create' => new CreateCommand($context->createService, $service, $migrator, $this->databases), 'down' => new DownCommand($context->downRunner, $service, $migrator, $this->databases), 'history' => new HistoryCommand($service, $migrator, $this->databases), + 'mark' => new MarkCommand($service, $migrator, $this->databases), 'new' => new NewCommand($service, $migrator, $this->databases), 'redo' => new RedoCommand($service, $migrator, $context->downRunner, $context->updateRunner, $this->databases), 'up' => new UpdateCommand($context->updateRunner, $service, $migrator, $this->databases), diff --git a/src/Command/DatabaseCommand.php b/src/Command/DatabaseCommand.php index 34d85c6e..f6bb7a48 100644 --- a/src/Command/DatabaseCommand.php +++ b/src/Command/DatabaseCommand.php @@ -45,7 +45,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) return Command::INVALID; } - if ($name === null && $multipleDatabases && ($this instanceof DownCommand || $this instanceof RedoCommand)) { + if ($name === null && $multipleDatabases && ($this instanceof DownCommand || $this instanceof RedoCommand || $this instanceof MarkCommand)) { $io->error('The --db option is required when multiple databases are configured.'); return Command::INVALID; diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php new file mode 100644 index 00000000..66ad0cb7 --- /dev/null +++ b/src/Command/MarkCommand.php @@ -0,0 +1,145 @@ +addArgument('version', InputArgument::REQUIRED, 'Migration class name, timestamp, or m000000_000000_base to clear history.') + ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); + } + + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context->migrator ?? $this->migrator; + $service = $context->migrationService ?? $this->migrationService; + $io = new SymfonyStyle($input, $output); + $migrator->setIo($io); + $service->setIo($io); + $service->databaseConnection(); + + /** @var string $version */ + $version = $input->getArgument('version'); + $version = trim($version, '\\'); + $label = OutputFormatter::escape($version); + $timestamp = preg_match('/^\d{6}_?\d{6}$/D', $version) === 1 + ? str_replace('_', '', $version) + : null; + + if ($version !== self::BASE_MIGRATION && $timestamp === null + && preg_match('/^(?:\w+\\\\)*M\d{12}\D.*$/D', $version) !== 1 + ) { + $io->error('The version must be a migration class name, a migration timestamp, or ' . self::BASE_MIGRATION . '.'); + return Command::INVALID; + } + + $add = []; + $remove = []; + $found = false; + + // Like Yii2, first look for a pending target, then for a recorded target. + if ($version !== self::BASE_MIGRATION) { + $pending = $service->getNewMigrations(); + foreach ($pending as $i => $migration) { + if ($this->matches($migration, $version, $timestamp)) { + $add = array_slice($pending, 0, $i + 1); + $found = true; + break; + } + } + } + + if (!$found) { + $history = array_keys($migrator->getHistory()); + $history[] = self::BASE_MIGRATION; + foreach ($history as $i => $migration) { + if ($this->matches($migration, $version, $timestamp)) { + $remove = array_slice($history, 0, $i); + $found = true; + break; + } + } + } + + if (!$found) { + $io->error("Unable to find the version '$label'."); + return Command::INVALID; + } + + if ($add === [] && $remove === []) { + $io->success("Already at '$label'. Nothing needs to be done."); + return Command::SUCCESS; + } + + $limit = $migrator->getMigrationNameLimit(); + foreach ($add as $migration) { + if ($limit !== null && strlen($migration) > $limit) { + $io->error('The migration name "' . OutputFormatter::escape($migration) . '" is too long.'); + return Command::INVALID; + } + } + + $io->note('Only migration history will change. No migrations will be applied or reverted.'); + if ($input->getOption('force-yes') || $io->confirm("Set migration history at $label?", false)) { + foreach ($add as $migration) { + $migrator->addMigrationToHistory($migration); + } + foreach ($remove as $migration) { + $migrator->removeMigrationFromHistory($migration); + } + $io->success("The migration history is set at $label. No actual migration was performed."); + } + + return Command::SUCCESS; + } + + private function matches(string $migration, string $version, ?string $timestamp): bool + { + if ($timestamp === null) { + return trim($migration, '\\') === $version; + } + + return preg_match('/(?:^|\\\\)M' . $timestamp . '\D/', $migration) === 1; + } +} diff --git a/src/Migrator.php b/src/Migrator.php index 53f9f5c5..c8e95da8 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -97,30 +97,35 @@ public function getHistoryTable(): string return $this->historyTable; } - private function addMigrationToHistory(MigrationInterface $migration): void + /** + * Records a migration as applied without executing it. + */ + public function addMigrationToHistory(string $name): void { + $this->checkMigrationHistoryTable(); + $this->db->createCommand()->insert( $this->historyTable, [ - 'name' => $this->getMigrationName($migration), + 'name' => $name, 'apply_time' => time(), ], )->execute(); } - private function removeMigrationFromHistory(MigrationInterface $migration): void + /** + * Removes a migration record without reverting it. + */ + public function removeMigrationFromHistory(string $name): void { + $this->checkMigrationHistoryTable(); + $command = $this->db->createCommand(); $command->delete($this->historyTable, [ - 'name' => $this->getMigrationName($migration), + 'name' => $name, ])->execute(); } - private function getMigrationName(MigrationInterface $migration): string - { - return $migration::class; - } - private function checkMigrationHistoryTable(): void { if (!$this->checkMigrationHistoryTable) { @@ -168,12 +173,12 @@ private function createBuilder(?MigrationInformerInterface $informer = null): Mi private function migrateUp(MigrationInterface $migration): void { $migration->up($this->createBuilder()); - $this->addMigrationToHistory($migration); + $this->addMigrationToHistory($migration::class); } private function migrateDown(RevertibleMigrationInterface $migration): void { $migration->down($this->createBuilder()); - $this->removeMigrationFromHistory($migration); + $this->removeMigrationFromHistory($migration::class); } } diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php new file mode 100644 index 00000000..19534842 --- /dev/null +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -0,0 +1,143 @@ +command(); + self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); + self::assertSame([self::SECOND, self::FIRST], array_keys($this->migrator()->getHistory())); + self::assertSame([self::THIRD], $this->container->get(MigrationService::class)->getNewMigrations()); + self::assertStringContainsString('No actual migration was performed', preg_replace('/\s+/', ' ', $command->getDisplay())); + } + + public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void + { + // Application order may differ from filename order, and recorded files may no longer exist. + $this->migrator()->addMigrationToHistory(self::SECOND); + $this->migrator()->addMigrationToHistory(self::FIRST); + $this->migrator()->addMigrationToHistory('M260101000004Missing'); + $before = $this->migrator()->getHistory(); + self::assertSame(Command::SUCCESS, $this->command()->execute(['version' => self::SECOND, '-y' => true])); + self::assertSame([self::SECOND => $before[self::SECOND]], $this->migrator()->getHistory()); + } + + public function testRecordedTargetDoesNotNeedFile(): void + { + $this->migrator()->addMigrationToHistory('M260101000004Missing'); + $this->migrator()->addMigrationToHistory(self::THIRD); + self::assertSame(Command::SUCCESS, $this->command()->execute(['version' => 'M260101000004Missing', '-y' => true])); + self::assertSame(['M260101000004Missing'], array_keys($this->migrator()->getHistory())); + } + + public function testResetHistory(): void + { + $this->migrator()->addMigrationToHistory(self::FIRST); + $command = $this->command(); + self::assertSame(Command::SUCCESS, $command->execute(['version' => MarkCommand::BASE_MIGRATION, '-y' => true])); + self::assertSame([], $this->migrator()->getHistory()); + self::assertSame(Command::SUCCESS, $command->execute(['version' => MarkCommand::BASE_MIGRATION])); + self::assertStringContainsString('Nothing needs to be done', $command->getDisplay()); + } + + public function testAlreadyAtTarget(): void + { + $this->migrator()->addMigrationToHistory(self::SECOND); + $before = $this->migrator()->getHistory(); + $command = $this->command(); + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::SECOND, '-y' => true])); + self::assertSame($before, $this->migrator()->getHistory()); + self::assertStringContainsString('Nothing needs to be done', $command->getDisplay()); + } + + public function testMarkUpPreservesExistingHistory(): void + { + $this->migrator()->addMigrationToHistory(self::FIRST); + $this->migrator()->addMigrationToHistory(self::THIRD); + $before = $this->migrator()->getHistory(); + self::assertSame(Command::SUCCESS, $this->command()->execute(['version' => self::SECOND, '-y' => true])); + $after = $this->migrator()->getHistory(); + self::assertCount(3, $after); + self::assertSame($before[self::FIRST], $after[self::FIRST]); + self::assertSame($before[self::THIRD], $after[self::THIRD]); + self::assertSame(self::SECOND, array_keys($after)[0]); + } + + public function testDecliningMarkDownKeepsHistory(): void + { + $this->migrator()->addMigrationToHistory(self::FIRST); + $this->migrator()->addMigrationToHistory(self::SECOND); + $before = $this->migrator()->getHistory(); + $command = $this->command(); + $command->setInputs(['no']); + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::FIRST])); + self::assertSame($before, $this->migrator()->getHistory()); + } + + public static function invalidVersions(): array + { + return [[''], ['invalid'], ['2026-01-01'], ['1767225600'], ['260101000099'], ['M260101000002Unknown']]; + } + + #[DataProvider('invalidVersions')] + public function testInvalidOrMissingTarget(string $version): void + { + $this->migrator()->addMigrationToHistory(self::FIRST); + $before = $this->migrator()->getHistory(); + self::assertSame(Command::INVALID, $this->command()->execute(['version' => $version, '-y' => true])); + self::assertSame($before, $this->migrator()->getHistory()); + } + + public static function answers(): array + { + return [['no', false], ['', false], ['yes', true]]; + } + + #[DataProvider('answers')] + public function testConfirmation(string $answer, bool $marked): void + { + $command = $this->command(); + $command->setInputs([$answer]); + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::FIRST])); + self::assertSame($marked ? [self::FIRST] : [], array_keys($this->migrator()->getHistory())); + } + + private function command(): CommandTester + { + $service = $this->container->get(MigrationService::class); + $service->setSourcePaths([dirname(__DIR__, 2) . '/Support/MarkMigrations']); + return new CommandTester(new MarkCommand($service, $this->migrator())); + } + + private function migrator(): Migrator + { + return $this->container->get(Migrator::class); + } +} diff --git a/tests/Driver/Mssql/MarkCommandTest.php b/tests/Driver/Mssql/MarkCommandTest.php new file mode 100644 index 00000000..f879e423 --- /dev/null +++ b/tests/Driver/Mssql/MarkCommandTest.php @@ -0,0 +1,26 @@ +container = MssqlFactory::createContainer(); + } + + public function tearDown(): void + { + parent::tearDown(); + MssqlFactory::clearDatabase($this->container); + } +} diff --git a/tests/Driver/Mysql/MarkCommandTest.php b/tests/Driver/Mysql/MarkCommandTest.php new file mode 100644 index 00000000..8b6096db --- /dev/null +++ b/tests/Driver/Mysql/MarkCommandTest.php @@ -0,0 +1,26 @@ +container = MysqlFactory::createContainer(); + } + + public function tearDown(): void + { + parent::tearDown(); + MysqlFactory::clearDatabase($this->container); + } +} diff --git a/tests/Driver/Oracle/MarkCommandTest.php b/tests/Driver/Oracle/MarkCommandTest.php new file mode 100644 index 00000000..ad95b2e3 --- /dev/null +++ b/tests/Driver/Oracle/MarkCommandTest.php @@ -0,0 +1,26 @@ +container = OracleFactory::createContainer(); + } + + public function tearDown(): void + { + parent::tearDown(); + OracleFactory::clearDatabase($this->container); + } +} diff --git a/tests/Driver/Pgsql/MarkCommandTest.php b/tests/Driver/Pgsql/MarkCommandTest.php new file mode 100644 index 00000000..f49b795c --- /dev/null +++ b/tests/Driver/Pgsql/MarkCommandTest.php @@ -0,0 +1,26 @@ +container = PostgreSqlFactory::createContainer(); + } + + public function tearDown(): void + { + parent::tearDown(); + PostgreSqlFactory::clearDatabase($this->container); + } +} diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index 3459c45c..60df3721 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -14,6 +14,7 @@ use Yiisoft\Db\Migration\Command\CommandFactory; use Yiisoft\Db\Migration\Command\DownCommand; use Yiisoft\Db\Migration\Command\HistoryCommand; +use Yiisoft\Db\Migration\Command\MarkCommand; use Yiisoft\Db\Migration\Command\NewCommand; use Yiisoft\Db\Migration\Command\RedoCommand; use Yiisoft\Db\Migration\Command\UpdateCommand; @@ -102,6 +103,54 @@ public function testSelectDatabaseForUpDownAndRedo(): void self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); } + public function testMarkRequiresDatabaseAndChangesOnlyItsHistory(): void + { + $class = $this->writeMigration('maps'); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + historyTable: 'maps_history', + ); + $command = $this->command('mark'); + self::assertSame(Command::INVALID, $command->execute(['version' => $class, '-y' => true])); + self::assertStringContainsString('--db option is required', $command->getDisplay()); + self::assertSame([], $this->connections['maps']->getSchema()->getTableNames()); + + self::assertSame(Command::SUCCESS, $command->execute(['version' => $class, '--db' => 'maps', '-y' => true])); + self::assertSame([$class], array_keys($this->migrator('maps')->getHistory())); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('migration', true)); + self::assertSame([], $this->connections['default']->getSchema()->getTableNames()); + self::assertSame([], $this->connections['analytics']->getSchema()->getTableNames()); + } + + public function testMarkRejectsLongNamesBeforeAddingAnyHistory(): void + { + $class = $this->writeMigration('maps'); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + migrationNameLimit: 10, + ); + $command = $this->command('mark'); + self::assertSame(Command::INVALID, $command->execute(['version' => $class, '--db' => 'maps', '-y' => true])); + self::assertStringContainsString('too long', $command->getDisplay()); + self::assertSame([], $this->migrator('maps')->getHistory()); + } + + public function testResetHistoryDoesNotRevertSchema(): void + { + $this->writeMigration('maps'); + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--db' => 'maps', '-y' => true])); + self::assertSame(Command::SUCCESS, $this->command('mark')->execute([ + '--db' => 'maps', + 'version' => MarkCommand::BASE_MIGRATION, + '-y' => true, + ])); + self::assertSame([], $this->migrator('maps')->getHistory()); + self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + } + #[DataProvider('defaultSelection')] public function testNamedDefaultSuppliesConnectionSourcesAndHistory(bool $selectDefault): void { @@ -577,6 +626,7 @@ private function command(string $name, ?DatabaseSet $default = null): CommandTes 'redo' => new RedoCommand($service, $migrator, new DownRunner($migrator), new UpdateRunner($migrator), $registry), 'new' => new NewCommand($service, $migrator, $registry), 'history' => new HistoryCommand($service, $migrator, $registry), + 'mark' => new MarkCommand($service, $migrator, $registry), 'create' => new CreateCommand(new CreateService($this->connections['default']), $service, $migrator, $registry), }; return new CommandTester($command); diff --git a/tests/Driver/Sqlite/MarkCommandTest.php b/tests/Driver/Sqlite/MarkCommandTest.php new file mode 100644 index 00000000..710ec652 --- /dev/null +++ b/tests/Driver/Sqlite/MarkCommandTest.php @@ -0,0 +1,26 @@ +container = SqLiteFactory::createContainer(); + } + + public function tearDown(): void + { + parent::tearDown(); + SqLiteFactory::clearDatabase($this->container); + } +} diff --git a/tests/Migration/BinTest.php b/tests/Migration/BinTest.php index e0b91928..84fff891 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -50,6 +50,7 @@ public function testBase(): void $this->assertSame(0, $exitCode); $this->assertStringContainsString('Yii Database Migration Tool', $output); + $this->assertStringContainsString('migrate:mark', $output); } public function testWithoutConnection(): void diff --git a/tests/Migration/ConfigTest.php b/tests/Migration/ConfigTest.php index 3ba9e329..4be77901 100644 --- a/tests/Migration/ConfigTest.php +++ b/tests/Migration/ConfigTest.php @@ -21,6 +21,7 @@ use Yiisoft\Db\Migration\Command\CreateCommand; use Yiisoft\Db\Migration\Command\DownCommand; use Yiisoft\Db\Migration\Command\HistoryCommand; +use Yiisoft\Db\Migration\Command\MarkCommand; use Yiisoft\Db\Migration\Command\NewCommand; use Yiisoft\Db\Migration\Command\RedoCommand; use Yiisoft\Db\Migration\Command\UpdateCommand; @@ -46,6 +47,7 @@ public function testBase(): void $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); $this->assertInstanceOf(DownCommand::class, $container->get(DownCommand::class)); $this->assertInstanceOf(HistoryCommand::class, $container->get(HistoryCommand::class)); + $this->assertInstanceOf(MarkCommand::class, $container->get(MarkCommand::class)); $this->assertInstanceOf(NewCommand::class, $container->get(NewCommand::class)); $this->assertInstanceOf(RedoCommand::class, $container->get(RedoCommand::class)); $this->assertInstanceOf(UpdateCommand::class, $container->get(UpdateCommand::class)); @@ -84,7 +86,7 @@ public function testNamedDefaultNeedsNoLegacyConnection(): void $container = new Container(ContainerConfig::create()->withDefinitions($this->getConsoleDefinitions([ 'default' => new DatabaseSet($db, newMigrationPath: dirname(__DIR__) . '/Support/MigrationsExtra'), ]))); - foreach ([CreateCommand::class, DownCommand::class, HistoryCommand::class, NewCommand::class, RedoCommand::class, UpdateCommand::class] as $class) { + foreach ([CreateCommand::class, DownCommand::class, HistoryCommand::class, MarkCommand::class, NewCommand::class, RedoCommand::class, UpdateCommand::class] as $class) { self::assertInstanceOf($class, $container->get($class)); } $command = new CommandTester($container->get(NewCommand::class)); diff --git a/tests/Support/MarkMigrations/M260101000001First.php b/tests/Support/MarkMigrations/M260101000001First.php new file mode 100644 index 00000000..890ebf6f --- /dev/null +++ b/tests/Support/MarkMigrations/M260101000001First.php @@ -0,0 +1,27 @@ + Date: Sat, 3 Oct 2026 10:46:04 +0000 Subject: [PATCH 2/8] Apply PHP CS Fixer and Rector changes (CI) --- tests/Common/Command/AbstractMarkCommandTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php index 19534842..aa702c7c 100644 --- a/tests/Common/Command/AbstractMarkCommandTest.php +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -12,15 +12,18 @@ use Yiisoft\Db\Migration\Command\MarkCommand; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; +use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000001First; +use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000002Second; +use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000003Third; use function array_keys; use function dirname; abstract class AbstractMarkCommandTest extends TestCase { - private const FIRST = 'Yiisoft\\Db\\Migration\\Tests\\Support\\MarkMigrations\\M260101000001First'; - private const SECOND = 'Yiisoft\\Db\\Migration\\Tests\\Support\\MarkMigrations\\M260101000002Second'; - private const THIRD = 'Yiisoft\\Db\\Migration\\Tests\\Support\\MarkMigrations\\M260101000003Third'; + private const FIRST = M260101000001First::class; + private const SECOND = M260101000002Second::class; + private const THIRD = M260101000003Third::class; protected ContainerInterface $container; public static function versions(): array From 23e528fe015daef3bf724397dbc95133c0a2e745 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 3 Oct 2026 13:50:31 +0300 Subject: [PATCH 3/8] Warn about risks of modifying migration history --- README.md | 2 +- docs/guide/en/migration-history.md | 17 ++++++++++++----- src/Command/MarkCommand.php | 2 +- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f84189bb..aa1dfc45 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ composer require yiisoft/db-migration migrate:create Creates a new migration. migrate:down Reverts the specified number of latest migrations. migrate:history Displays the migration history. -migrate:mark Modifies migration history without executing migrations. +migrate:mark Modifies migration history without executing migrations. WARNING: Use only if you understand the consequences; incorrect history can cause data loss. migrate:new Displays not yet applied migrations. migrate:redo Redoes the last few migrations. migrate:up Applies new migrations. diff --git a/docs/guide/en/migration-history.md b/docs/guide/en/migration-history.md index ac0bda0f..34755aa5 100644 --- a/docs/guide/en/migration-history.md +++ b/docs/guide/en/migration-history.md @@ -1,5 +1,15 @@ # Modifying migration history +> [!WARNING] +> **Do not use `migrate:mark` unless you understand exactly how changing migration history will affect your database.** +> +> This command changes history records without changing or checking the database schema or data. Incorrectly marking +> migrations can leave history out of sync with the database, skip required changes, or cause later migration commands +> to fail or lose data. A later `migrate:up` will execute migrations whose records were removed, and `migrate:down` can +> revert migrations marked as applied even though this command never executed them. +> +> Before proceeding, verify that the database already matches the intended migration state and make a backup. + When adopting migrations for an existing database, or after making a migration's changes manually, use `migrate:mark` to move migration history to a particular version without executing `up()` or `down()`: @@ -14,7 +24,7 @@ underscore between date and time (`260101_000002`) is also accepted. These are m timestamps or date strings. If several migrations share a timestamp, the first matching migration in the relevant list is selected; use its full class name to select an exact target. -The behavior follows Yii2's `migrate/mark`: +The command modifies history as follows: - If the target is pending, record all pending migrations in discovery order through and including the target. Existing history entries remain unchanged. @@ -23,7 +33,7 @@ The behavior follows Yii2's `migrate/mark`: - If the target is already the latest recorded entry, nothing changes. - If the target is unknown, report an error without adding or removing history entries. -To clear the entire history, use Yii2's special base version: +To clear the entire history, use the special base version: ```shell ./vendor/bin/yii-db-migration migrate:mark m000000_000000_base @@ -31,6 +41,3 @@ To clear the entire history, use Yii2's special base version: The command asks for confirmation before adding or removing entries. Use `--force-yes` (`-y`) for unattended execution. With multiple databases configured, select one explicitly using `--db=maps`. - -Marking changes only migration history; it does not verify that the database matches the target. A later `migrate:up` -will execute migrations whose records were removed, and `migrate:down` can revert migrations marked as applied. diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index 66ad0cb7..774f481d 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -27,7 +27,7 @@ /** * Moves migration history to a version without executing up() or down(). */ -#[AsCommand('migrate:mark', 'Modifies migration history without executing migrations.')] +#[AsCommand('migrate:mark', 'Modifies migration history without executing migrations. WARNING: Use only if you understand the consequences; incorrect history can cause data loss.')] final class MarkCommand extends DatabaseCommand { public const BASE_MIGRATION = 'm000000_000000_base'; From cbd80cfb78fe2af6923ccbc569ae8dafcba70038 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 3 Oct 2026 13:54:52 +0300 Subject: [PATCH 4/8] Fix timestamp-only marking and make history updates atomic --- src/Command/MarkCommand.php | 11 +--- src/Migrator.php | 21 ++++++ .../Command/AbstractMarkCommandTest.php | 21 ++++++ tests/Driver/Sqlite/MarkCommandTest.php | 66 +++++++++++++++++++ tests/Migration/BinTest.php | 1 - .../MarkTimestampOnly/M260101000001.php | 24 +++++++ 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 tests/Support/MarkTimestampOnly/M260101000001.php diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index 774f481d..46ccbbd3 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -68,7 +68,7 @@ protected function executeForDatabase( : null; if ($version !== self::BASE_MIGRATION && $timestamp === null - && preg_match('/^(?:\w+\\\\)*M\d{12}\D.*$/D', $version) !== 1 + && preg_match('/^(?:\w+\\\\)*M\d{12}(?:\D|$).*$/D', $version) !== 1 ) { $io->error('The version must be a migration class name, a migration timestamp, or ' . self::BASE_MIGRATION . '.'); return Command::INVALID; @@ -122,12 +122,7 @@ protected function executeForDatabase( $io->note('Only migration history will change. No migrations will be applied or reverted.'); if ($input->getOption('force-yes') || $io->confirm("Set migration history at $label?", false)) { - foreach ($add as $migration) { - $migrator->addMigrationToHistory($migration); - } - foreach ($remove as $migration) { - $migrator->removeMigrationFromHistory($migration); - } + $migrator->updateHistory($add, $remove); $io->success("The migration history is set at $label. No actual migration was performed."); } @@ -140,6 +135,6 @@ private function matches(string $migration, string $version, ?string $timestamp) return trim($migration, '\\') === $version; } - return preg_match('/(?:^|\\\\)M' . $timestamp . '\D/', $migration) === 1; + return preg_match('/(?:^|\\\\)M' . $timestamp . '(?:\D|$)/', $migration) === 1; } } diff --git a/src/Migrator.php b/src/Migrator.php index c8e95da8..6f2c16fb 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -126,6 +126,27 @@ public function removeMigrationFromHistory(string $name): void ])->execute(); } + /** + * Adds and removes migration records atomically without executing migrations. + * + * @param list $add Migration names to record as applied. + * @param list $remove Migration names to remove from history. + */ + public function updateHistory(array $add, array $remove): void + { + // Create the table before starting the transaction: DDL may implicitly commit it. + $this->checkMigrationHistoryTable(); + + $this->db->transaction(function () use ($add, $remove): void { + foreach ($add as $name) { + $this->addMigrationToHistory($name); + } + foreach ($remove as $name) { + $this->removeMigrationFromHistory($name); + } + }); + } + private function checkMigrationHistoryTable(): void { if (!$this->checkMigrationHistoryTable) { diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php index aa702c7c..35fe41f5 100644 --- a/tests/Common/Command/AbstractMarkCommandTest.php +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -41,6 +41,27 @@ public function testMarkUpWithoutExecutingMigrations(string $version): void self::assertStringContainsString('No actual migration was performed', preg_replace('/\s+/', ' ', $command->getDisplay())); } + public static function timestampOnlyVersions(): array + { + return [['M260101000001'], ['260101000001']]; + } + + #[DataProvider('timestampOnlyVersions')] + public function testTimestampOnlyClassCanBeMarked(string $version): void + { + $command = $this->command(); + $this->container->get(MigrationService::class)->setSourcePaths([ + dirname(__DIR__, 2) . '/Support/MarkTimestampOnly', + ]); + self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); + self::assertSame(['M260101000001'], array_keys($this->migrator()->getHistory())); + self::assertSame([], $this->container->get(MigrationService::class)->getNewMigrations()); + + $this->migrator()->addMigrationToHistory(self::SECOND); + self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); + self::assertSame(['M260101000001'], array_keys($this->migrator()->getHistory())); + } + public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void { // Application order may differ from filename order, and recorded files may no longer exist. diff --git a/tests/Driver/Sqlite/MarkCommandTest.php b/tests/Driver/Sqlite/MarkCommandTest.php index 710ec652..2b3e9e59 100644 --- a/tests/Driver/Sqlite/MarkCommandTest.php +++ b/tests/Driver/Sqlite/MarkCommandTest.php @@ -4,8 +4,19 @@ namespace Yiisoft\Db\Migration\Tests\Driver\Sqlite; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Console\Tester\CommandTester; +use Yiisoft\Db\Sqlite\Connection; +use Yiisoft\Db\Exception\Exception; +use Yiisoft\Db\Migration\Command\MarkCommand; +use Yiisoft\Db\Migration\Migrator; +use Yiisoft\Db\Migration\Service\MigrationService; use Yiisoft\Db\Migration\Tests\Common\Command\AbstractMarkCommandTest; use Yiisoft\Db\Migration\Tests\Support\Factory\SqLiteFactory; +use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000002Second; +use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000003Third; + +use function dirname; /** * @group sqlite @@ -23,4 +34,59 @@ public function tearDown(): void parent::tearDown(); SqLiteFactory::clearDatabase($this->container); } + + public static function historyOperations(): array + { + return [['INSERT', 'NEW'], ['DELETE', 'OLD']]; + } + + #[DataProvider('historyOperations')] + public function testFailedHistoryChangeIsRolledBack(string $operation, string $row): void + { + $db = $this->container->get(Connection::class); + $migrator = $this->container->get(Migrator::class); + $service = $this->container->get(MigrationService::class); + $service->setSourcePaths([dirname(__DIR__, 2) . '/Support/MarkMigrations']); + $command = new CommandTester(new MarkCommand($service, $migrator)); + if ($operation === 'DELETE') { + $command->execute(['version' => M260101000003Third::class, '-y' => true]); + } + $before = $migrator->getHistory(); + // Fail on the second change, after the first INSERT or DELETE has succeeded. + $db->getActivePdo()->exec("CREATE TRIGGER fail_history_change BEFORE $operation ON migration + WHEN $row.name LIKE '%Second' + BEGIN SELECT RAISE(ABORT, 'History change failed'); END"); + + try { + $command->execute([ + 'version' => $operation === 'INSERT' ? M260101000003Third::class : MarkCommand::BASE_MIGRATION, + '-y' => true, + ]); + self::fail('Expected the history change to fail.'); + } catch (Exception $exception) { + self::assertStringContainsString('History change failed', $exception->getMessage()); + } + + self::assertSame($before, $migrator->getHistory()); + } + + public function testHistoryTransactionCoversBothAdditionsAndRemovals(): void + { + $migrator = $this->container->get(Migrator::class); + $migrator->addMigrationToHistory(M260101000002Second::class); + $before = $migrator->getHistory(); + $this->container->get(Connection::class)->getActivePdo()->exec( + "CREATE TRIGGER fail_history_delete BEFORE DELETE ON migration + BEGIN SELECT RAISE(ABORT, 'History deletion failed'); END", + ); + + try { + $migrator->updateHistory([M260101000003Third::class], [M260101000002Second::class]); + self::fail('Expected the history deletion to fail.'); + } catch (Exception $exception) { + self::assertStringContainsString('History deletion failed', $exception->getMessage()); + } + + self::assertSame($before, $migrator->getHistory()); + } } diff --git a/tests/Migration/BinTest.php b/tests/Migration/BinTest.php index 84fff891..e0b91928 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -50,7 +50,6 @@ public function testBase(): void $this->assertSame(0, $exitCode); $this->assertStringContainsString('Yii Database Migration Tool', $output); - $this->assertStringContainsString('migrate:mark', $output); } public function testWithoutConnection(): void diff --git a/tests/Support/MarkTimestampOnly/M260101000001.php b/tests/Support/MarkTimestampOnly/M260101000001.php new file mode 100644 index 00000000..59590504 --- /dev/null +++ b/tests/Support/MarkTimestampOnly/M260101000001.php @@ -0,0 +1,24 @@ + Date: Sat, 3 Oct 2026 13:58:50 +0300 Subject: [PATCH 5/8] Accept digit-leading migration suffixes in migrate:mark --- src/Command/MarkCommand.php | 4 ++-- .../Command/AbstractMarkCommandTest.php | 22 ++++++++++------- .../MarkDigitSuffix/M260101000002123Fix.php | 24 +++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 tests/Support/MarkDigitSuffix/M260101000002123Fix.php diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index 46ccbbd3..c6a538d8 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -68,7 +68,7 @@ protected function executeForDatabase( : null; if ($version !== self::BASE_MIGRATION && $timestamp === null - && preg_match('/^(?:\w+\\\\)*M\d{12}(?:\D|$).*$/D', $version) !== 1 + && preg_match('/^(?:\w+\\\\)*M\d{12}.*$/D', $version) !== 1 ) { $io->error('The version must be a migration class name, a migration timestamp, or ' . self::BASE_MIGRATION . '.'); return Command::INVALID; @@ -135,6 +135,6 @@ private function matches(string $migration, string $version, ?string $timestamp) return trim($migration, '\\') === $version; } - return preg_match('/(?:^|\\\\)M' . $timestamp . '(?:\D|$)/', $migration) === 1; + return preg_match('/(?:^|\\\\)M' . $timestamp . '/', $migration) === 1; } } diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php index 35fe41f5..3f2faa43 100644 --- a/tests/Common/Command/AbstractMarkCommandTest.php +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -41,25 +41,31 @@ public function testMarkUpWithoutExecutingMigrations(string $version): void self::assertStringContainsString('No actual migration was performed', preg_replace('/\s+/', ' ', $command->getDisplay())); } - public static function timestampOnlyVersions(): array + public static function migrationNameVersions(): array { - return [['M260101000001'], ['260101000001']]; + return [ + ['M260101000001', 'M260101000001', 'MarkTimestampOnly'], + ['260101000001', 'M260101000001', 'MarkTimestampOnly'], + ['M260101000002123Fix', 'M260101000002123Fix', 'MarkDigitSuffix'], + ['260101000002', 'M260101000002123Fix', 'MarkDigitSuffix'], + ['260101_000002', 'M260101000002123Fix', 'MarkDigitSuffix'], + ]; } - #[DataProvider('timestampOnlyVersions')] - public function testTimestampOnlyClassCanBeMarked(string $version): void + #[DataProvider('migrationNameVersions')] + public function testMigrationNamesCanBeMarked(string $version, string $class, string $directory): void { $command = $this->command(); $this->container->get(MigrationService::class)->setSourcePaths([ - dirname(__DIR__, 2) . '/Support/MarkTimestampOnly', + dirname(__DIR__, 2) . '/Support/' . $directory, ]); self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); - self::assertSame(['M260101000001'], array_keys($this->migrator()->getHistory())); + self::assertSame([$class], array_keys($this->migrator()->getHistory())); self::assertSame([], $this->container->get(MigrationService::class)->getNewMigrations()); - $this->migrator()->addMigrationToHistory(self::SECOND); + $this->migrator()->addMigrationToHistory(self::THIRD); self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); - self::assertSame(['M260101000001'], array_keys($this->migrator()->getHistory())); + self::assertSame([$class], array_keys($this->migrator()->getHistory())); } public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void diff --git a/tests/Support/MarkDigitSuffix/M260101000002123Fix.php b/tests/Support/MarkDigitSuffix/M260101000002123Fix.php new file mode 100644 index 00000000..88c07b77 --- /dev/null +++ b/tests/Support/MarkDigitSuffix/M260101000002123Fix.php @@ -0,0 +1,24 @@ + Date: Sat, 3 Oct 2026 14:02:47 +0300 Subject: [PATCH 6/8] Fix namespace timestamp matching and recorded target lookup --- src/Command/MarkCommand.php | 17 +++++--- .../Command/AbstractMarkCommandTest.php | 43 +++++++++++++++++++ .../M260101000002Group/M260101000001First.php | 27 ++++++++++++ .../M260101000002Second.php | 27 ++++++++++++ 4 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 tests/Support/M260101000002Group/M260101000001First.php create mode 100644 tests/Support/M260101000002Group/M260101000002Second.php diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index c6a538d8..68432a3a 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -18,7 +18,9 @@ use Yiisoft\Db\Migration\Service\MigrationService; use function array_keys; +use function array_map; use function array_slice; +use function in_array; use function preg_match; use function str_replace; use function strlen; @@ -77,9 +79,15 @@ protected function executeForDatabase( $add = []; $remove = []; $found = false; - - // Like Yii2, first look for a pending target, then for a recorded target. - if ($version !== self::BASE_MIGRATION) { + $history = array_keys($migrator->getHistory()); + $isRecordedClass = $timestamp === null && in_array( + $version, + array_map(static fn(string $name): string => trim($name, '\\'), $history), + true, + ); + + // Exact recorded classes do not need source files. Timestamps still select pending targets first. + if ($version !== self::BASE_MIGRATION && !$isRecordedClass) { $pending = $service->getNewMigrations(); foreach ($pending as $i => $migration) { if ($this->matches($migration, $version, $timestamp)) { @@ -91,7 +99,6 @@ protected function executeForDatabase( } if (!$found) { - $history = array_keys($migrator->getHistory()); $history[] = self::BASE_MIGRATION; foreach ($history as $i => $migration) { if ($this->matches($migration, $version, $timestamp)) { @@ -135,6 +142,6 @@ private function matches(string $migration, string $version, ?string $timestamp) return trim($migration, '\\') === $version; } - return preg_match('/(?:^|\\\\)M' . $timestamp . '/', $migration) === 1; + return preg_match('/(?:^|\\\\)M' . $timestamp . '[^\\\\]*$/D', $migration) === 1; } } diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php index 3f2faa43..8a0586a6 100644 --- a/tests/Common/Command/AbstractMarkCommandTest.php +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -15,6 +15,8 @@ use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000001First; use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000002Second; use Yiisoft\Db\Migration\Tests\Support\MarkMigrations\M260101000003Third; +use Yiisoft\Db\Migration\Tests\Support\M260101000002Group\M260101000001First as NamespacedFirst; +use Yiisoft\Db\Migration\Tests\Support\M260101000002Group\M260101000002Second as NamespacedSecond; use function array_keys; use function dirname; @@ -87,6 +89,47 @@ public function testRecordedTargetDoesNotNeedFile(): void self::assertSame(['M260101000004Missing'], array_keys($this->migrator()->getHistory())); } + public static function recordedTargets(): array + { + return [[false], [true]]; + } + + #[DataProvider('recordedTargets')] + public function testTimestampMatchesOnlyClassName(bool $recorded): void + { + $command = $this->command(); + $this->container->get(MigrationService::class)->setSourcePaths([ + dirname(__DIR__, 2) . '/Support/M260101000002Group', + ]); + if ($recorded) { + $this->migrator()->addMigrationToHistory(NamespacedSecond::class); + $this->migrator()->addMigrationToHistory(NamespacedFirst::class); + } + + self::assertSame(Command::SUCCESS, $command->execute(['version' => '260101000002', '-y' => true])); + self::assertSame( + $recorded ? [NamespacedSecond::class] : [NamespacedSecond::class, NamespacedFirst::class], + array_keys($this->migrator()->getHistory()), + ); + } + + #[DataProvider('recordedTargets')] + public function testRecordedClassDoesNotNeedSourceDirectory(bool $hasNewerMigration): void + { + $this->migrator()->addMigrationToHistory(self::FIRST); + $before = $this->migrator()->getHistory(); + if ($hasNewerMigration) { + $this->migrator()->addMigrationToHistory(self::SECOND); + } + $command = $this->command(); + $this->container->get(MigrationService::class)->setSourcePaths([ + dirname(__DIR__, 2) . '/Support/nonexistent-mark-migrations', + ]); + + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::FIRST, '-y' => true])); + self::assertSame($before, $this->migrator()->getHistory()); + } + public function testResetHistory(): void { $this->migrator()->addMigrationToHistory(self::FIRST); diff --git a/tests/Support/M260101000002Group/M260101000001First.php b/tests/Support/M260101000002Group/M260101000001First.php new file mode 100644 index 00000000..f8384aa6 --- /dev/null +++ b/tests/Support/M260101000002Group/M260101000001First.php @@ -0,0 +1,27 @@ + Date: Tue, 6 Oct 2026 21:30:40 +0300 Subject: [PATCH 7/8] Keep history helpers private and centralize table checks --- src/Migrator.php | 48 +++++++++---------- .../Command/AbstractMarkCommandTest.php | 34 ++++++------- tests/Driver/Sqlite/MarkCommandTest.php | 2 +- 3 files changed, 40 insertions(+), 44 deletions(-) diff --git a/src/Migrator.php b/src/Migrator.php index 6f2c16fb..03ea5522 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -98,12 +98,31 @@ public function getHistoryTable(): string } /** - * Records a migration as applied without executing it. + * Adds and removes migration records atomically without executing migrations. + * + * @param list $add Migration names to record as applied. + * @param list $remove Migration names to remove from history. */ - public function addMigrationToHistory(string $name): void + public function updateHistory(array $add, array $remove): void { + // Create the table before starting the transaction: DDL may implicitly commit it. $this->checkMigrationHistoryTable(); + $this->db->transaction(function () use ($add, $remove): void { + foreach ($add as $name) { + $this->addMigrationToHistory($name); + } + foreach ($remove as $name) { + $this->removeMigrationFromHistory($name); + } + }); + } + + /** + * Records a migration as applied without executing it. + */ + private function addMigrationToHistory(string $name): void + { $this->db->createCommand()->insert( $this->historyTable, [ @@ -116,37 +135,14 @@ public function addMigrationToHistory(string $name): void /** * Removes a migration record without reverting it. */ - public function removeMigrationFromHistory(string $name): void + private function removeMigrationFromHistory(string $name): void { - $this->checkMigrationHistoryTable(); - $command = $this->db->createCommand(); $command->delete($this->historyTable, [ 'name' => $name, ])->execute(); } - /** - * Adds and removes migration records atomically without executing migrations. - * - * @param list $add Migration names to record as applied. - * @param list $remove Migration names to remove from history. - */ - public function updateHistory(array $add, array $remove): void - { - // Create the table before starting the transaction: DDL may implicitly commit it. - $this->checkMigrationHistoryTable(); - - $this->db->transaction(function () use ($add, $remove): void { - foreach ($add as $name) { - $this->addMigrationToHistory($name); - } - foreach ($remove as $name) { - $this->removeMigrationFromHistory($name); - } - }); - } - private function checkMigrationHistoryTable(): void { if (!$this->checkMigrationHistoryTable) { diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php index 8a0586a6..df334800 100644 --- a/tests/Common/Command/AbstractMarkCommandTest.php +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -65,7 +65,7 @@ public function testMigrationNamesCanBeMarked(string $version, string $class, st self::assertSame([$class], array_keys($this->migrator()->getHistory())); self::assertSame([], $this->container->get(MigrationService::class)->getNewMigrations()); - $this->migrator()->addMigrationToHistory(self::THIRD); + $this->migrator()->updateHistory([self::THIRD], []); self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); self::assertSame([$class], array_keys($this->migrator()->getHistory())); } @@ -73,9 +73,9 @@ public function testMigrationNamesCanBeMarked(string $version, string $class, st public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void { // Application order may differ from filename order, and recorded files may no longer exist. - $this->migrator()->addMigrationToHistory(self::SECOND); - $this->migrator()->addMigrationToHistory(self::FIRST); - $this->migrator()->addMigrationToHistory('M260101000004Missing'); + $this->migrator()->updateHistory([self::SECOND], []); + $this->migrator()->updateHistory([self::FIRST], []); + $this->migrator()->updateHistory(['M260101000004Missing'], []); $before = $this->migrator()->getHistory(); self::assertSame(Command::SUCCESS, $this->command()->execute(['version' => self::SECOND, '-y' => true])); self::assertSame([self::SECOND => $before[self::SECOND]], $this->migrator()->getHistory()); @@ -83,8 +83,8 @@ public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void public function testRecordedTargetDoesNotNeedFile(): void { - $this->migrator()->addMigrationToHistory('M260101000004Missing'); - $this->migrator()->addMigrationToHistory(self::THIRD); + $this->migrator()->updateHistory(['M260101000004Missing'], []); + $this->migrator()->updateHistory([self::THIRD], []); self::assertSame(Command::SUCCESS, $this->command()->execute(['version' => 'M260101000004Missing', '-y' => true])); self::assertSame(['M260101000004Missing'], array_keys($this->migrator()->getHistory())); } @@ -102,8 +102,8 @@ public function testTimestampMatchesOnlyClassName(bool $recorded): void dirname(__DIR__, 2) . '/Support/M260101000002Group', ]); if ($recorded) { - $this->migrator()->addMigrationToHistory(NamespacedSecond::class); - $this->migrator()->addMigrationToHistory(NamespacedFirst::class); + $this->migrator()->updateHistory([NamespacedSecond::class], []); + $this->migrator()->updateHistory([NamespacedFirst::class], []); } self::assertSame(Command::SUCCESS, $command->execute(['version' => '260101000002', '-y' => true])); @@ -116,10 +116,10 @@ public function testTimestampMatchesOnlyClassName(bool $recorded): void #[DataProvider('recordedTargets')] public function testRecordedClassDoesNotNeedSourceDirectory(bool $hasNewerMigration): void { - $this->migrator()->addMigrationToHistory(self::FIRST); + $this->migrator()->updateHistory([self::FIRST], []); $before = $this->migrator()->getHistory(); if ($hasNewerMigration) { - $this->migrator()->addMigrationToHistory(self::SECOND); + $this->migrator()->updateHistory([self::SECOND], []); } $command = $this->command(); $this->container->get(MigrationService::class)->setSourcePaths([ @@ -132,7 +132,7 @@ public function testRecordedClassDoesNotNeedSourceDirectory(bool $hasNewerMigrat public function testResetHistory(): void { - $this->migrator()->addMigrationToHistory(self::FIRST); + $this->migrator()->updateHistory([self::FIRST], []); $command = $this->command(); self::assertSame(Command::SUCCESS, $command->execute(['version' => MarkCommand::BASE_MIGRATION, '-y' => true])); self::assertSame([], $this->migrator()->getHistory()); @@ -142,7 +142,7 @@ public function testResetHistory(): void public function testAlreadyAtTarget(): void { - $this->migrator()->addMigrationToHistory(self::SECOND); + $this->migrator()->updateHistory([self::SECOND], []); $before = $this->migrator()->getHistory(); $command = $this->command(); self::assertSame(Command::SUCCESS, $command->execute(['version' => self::SECOND, '-y' => true])); @@ -152,8 +152,8 @@ public function testAlreadyAtTarget(): void public function testMarkUpPreservesExistingHistory(): void { - $this->migrator()->addMigrationToHistory(self::FIRST); - $this->migrator()->addMigrationToHistory(self::THIRD); + $this->migrator()->updateHistory([self::FIRST], []); + $this->migrator()->updateHistory([self::THIRD], []); $before = $this->migrator()->getHistory(); self::assertSame(Command::SUCCESS, $this->command()->execute(['version' => self::SECOND, '-y' => true])); $after = $this->migrator()->getHistory(); @@ -165,8 +165,8 @@ public function testMarkUpPreservesExistingHistory(): void public function testDecliningMarkDownKeepsHistory(): void { - $this->migrator()->addMigrationToHistory(self::FIRST); - $this->migrator()->addMigrationToHistory(self::SECOND); + $this->migrator()->updateHistory([self::FIRST], []); + $this->migrator()->updateHistory([self::SECOND], []); $before = $this->migrator()->getHistory(); $command = $this->command(); $command->setInputs(['no']); @@ -182,7 +182,7 @@ public static function invalidVersions(): array #[DataProvider('invalidVersions')] public function testInvalidOrMissingTarget(string $version): void { - $this->migrator()->addMigrationToHistory(self::FIRST); + $this->migrator()->updateHistory([self::FIRST], []); $before = $this->migrator()->getHistory(); self::assertSame(Command::INVALID, $this->command()->execute(['version' => $version, '-y' => true])); self::assertSame($before, $this->migrator()->getHistory()); diff --git a/tests/Driver/Sqlite/MarkCommandTest.php b/tests/Driver/Sqlite/MarkCommandTest.php index 2b3e9e59..66c6365a 100644 --- a/tests/Driver/Sqlite/MarkCommandTest.php +++ b/tests/Driver/Sqlite/MarkCommandTest.php @@ -73,7 +73,7 @@ public function testFailedHistoryChangeIsRolledBack(string $operation, string $r public function testHistoryTransactionCoversBothAdditionsAndRemovals(): void { $migrator = $this->container->get(Migrator::class); - $migrator->addMigrationToHistory(M260101000002Second::class); + $migrator->updateHistory([M260101000002Second::class], []); $before = $migrator->getHistory(); $this->container->get(Connection::class)->getActivePdo()->exec( "CREATE TRIGGER fail_history_delete BEFORE DELETE ON migration From d1fd8f2791cd1d2f15032ec170ed711f5002b855 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 7 Oct 2026 10:02:54 +0300 Subject: [PATCH 8/8] Add migrate mark command examples --- src/Command/MarkCommand.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index 68432a3a..e55d0437 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -28,6 +28,20 @@ /** * Moves migration history to a version without executing up() or down(). + * + * Use this command only after verifying that the database schema already matches the target migration state. + * + * For example, + * + * ```shell + * ./yii migrate:mark 'App\Migrations\M260101000002CreateIndex' # record pending migrations through this class + * ./yii migrate:mark M260101000002CreateIndex # use an unqualified migration class name + * ./yii migrate:mark 260101000002 # select a migration by its timestamp + * ./yii migrate:mark m000000_000000_base # remove all migration history entries + * ``` + * + * A pending target is recorded along with all earlier pending migrations. A recorded target removes newer history + * entries. Use the full class name when several migrations share a timestamp. */ #[AsCommand('migrate:mark', 'Modifies migration history without executing migrations. WARNING: Use only if you understand the consequences; incorrect history can cause data loss.')] final class MarkCommand extends DatabaseCommand