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..aa1dfc45 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. 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/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..34755aa5 --- /dev/null +++ b/docs/guide/en/migration-history.md @@ -0,0 +1,43 @@ +# 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()`: + +```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 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. +- 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 the 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`. 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..e55d0437 --- /dev/null +++ b/src/Command/MarkCommand.php @@ -0,0 +1,161 @@ +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', $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; + $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)) { + $add = array_slice($pending, 0, $i + 1); + $found = true; + break; + } + } + } + + if (!$found) { + $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)) { + $migrator->updateHistory($add, $remove); + $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..03ea5522 100644 --- a/src/Migrator.php +++ b/src/Migrator.php @@ -97,30 +97,52 @@ public function getHistoryTable(): string return $this->historyTable; } - private function addMigrationToHistory(MigrationInterface $migration): void + /** + * 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); + } + }); + } + + /** + * Records a migration as applied without executing it. + */ + private function addMigrationToHistory(string $name): void { $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. + */ + private function removeMigrationFromHistory(string $name): void { $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 +190,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..df334800 --- /dev/null +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -0,0 +1,216 @@ +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 static function migrationNameVersions(): array + { + return [ + ['M260101000001', 'M260101000001', 'MarkTimestampOnly'], + ['260101000001', 'M260101000001', 'MarkTimestampOnly'], + ['M260101000002123Fix', 'M260101000002123Fix', 'MarkDigitSuffix'], + ['260101000002', 'M260101000002123Fix', 'MarkDigitSuffix'], + ['260101_000002', 'M260101000002123Fix', 'MarkDigitSuffix'], + ]; + } + + #[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/' . $directory, + ]); + self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); + self::assertSame([$class], array_keys($this->migrator()->getHistory())); + self::assertSame([], $this->container->get(MigrationService::class)->getNewMigrations()); + + $this->migrator()->updateHistory([self::THIRD], []); + self::assertSame(Command::SUCCESS, $command->execute(['version' => $version, '-y' => true])); + self::assertSame([$class], array_keys($this->migrator()->getHistory())); + } + + public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void + { + // Application order may differ from filename order, and recorded files may no longer exist. + $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()); + } + + public function testRecordedTargetDoesNotNeedFile(): void + { + $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())); + } + + 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()->updateHistory([NamespacedSecond::class], []); + $this->migrator()->updateHistory([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()->updateHistory([self::FIRST], []); + $before = $this->migrator()->getHistory(); + if ($hasNewerMigration) { + $this->migrator()->updateHistory([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()->updateHistory([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()->updateHistory([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()->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(); + 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()->updateHistory([self::FIRST], []); + $this->migrator()->updateHistory([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()->updateHistory([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..66c6365a --- /dev/null +++ b/tests/Driver/Sqlite/MarkCommandTest.php @@ -0,0 +1,92 @@ +container = SqLiteFactory::createContainer(); + } + + 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->updateHistory([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/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/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 @@ +