diff --git a/CHANGELOG.md b/CHANGELOG.md index 4220a2ad..2eeec44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 2.1.2 under development -- New #363: Add `migrate:mark` to move migration history to a version without executing migrations (@samdark) +- New #363, #373: Add `migrate:mark` to move migration history to a version without executing migrations (@samdark, + @vjik) - New #360, #366, #371: Add support for additional named databases with separate migration history and `--db` option to select database (@samdark, @vjik) - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) diff --git a/docs/guide/en/migration-history.md b/docs/guide/en/migration-history.md index 34755aa5..38fe07fc 100644 --- a/docs/guide/en/migration-history.md +++ b/docs/guide/en/migration-history.md @@ -15,14 +15,13 @@ to move migration history to a particular version without executing `up()` or `d ```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 +./vendor/bin/yii-db-migration migrate:mark M260101000003AddColumn +./vendor/bin/yii-db-migration migrate:mark 260101000004 ``` -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. +Use the full class name, including its namespace when present, or a 12-digit migration timestamp. 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: @@ -33,11 +32,12 @@ The command modifies history as follows: - 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: +To clear the entire history, use the `--clear` (`-c`) option: ```shell -./vendor/bin/yii-db-migration migrate:mark m000000_000000_base +./vendor/bin/yii-db-migration migrate:mark --clear ``` -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`. +The command lists the entries to be added and removed, and asks for confirmation before changing history. Use +`--force-yes` (`-y`) for unattended execution. With multiple databases configured, select one explicitly using +`--db=maps`. diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index 1553f4dd..e8cf6438 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -21,9 +21,9 @@ use function array_keys; use function array_map; use function array_slice; +use function count; use function in_array; use function preg_match; -use function str_replace; use function strlen; use function trim; @@ -36,9 +36,9 @@ * * ```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 + * ./yii migrate:mark M260101000003AddColumn # use an unqualified migration class name + * ./yii migrate:mark 260101000004 # select a migration by its timestamp + * ./yii migrate:mark --clear # remove all migration history entries * ``` * * A pending target is recorded along with all earlier pending migrations. A recorded target removes newer history @@ -49,8 +49,6 @@ #[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'; - public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, @@ -62,7 +60,8 @@ public function __construct( protected function configure(): void { $this - ->addArgument('version', InputArgument::REQUIRED, 'Migration class name, timestamp, or m000000_000000_base to clear history.') + ->addArgument('version', InputArgument::OPTIONAL, 'Migration class name or timestamp.') + ->addOption('clear', 'c', InputOption::VALUE_NONE, 'Remove all migration history entries.') ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } @@ -86,25 +85,46 @@ protected function executeForDatabase( $context->migrationService->setIo($io); $context->migrationService->databaseConnection(); - /** @var string $version */ + /** @var string|null $version */ $version = $input->getArgument('version'); + $clear = (bool) $input->getOption('clear'); + + if ($clear === ($version !== null)) { + $io->error('Specify either a version or the "--clear" option.'); + return Command::INVALID; + } + + $history = array_keys($context->migrator->getHistory()); + + if ($version === null) { + if ($history === []) { + $io->success('The migration history is already empty. Nothing needs to be done.'); + return Command::SUCCESS; + } + + $this->writeMigrations($output, $history, 'to be removed from history'); + + $io->note('Only migration history will change. No migrations will be reverted.'); + if ($input->getOption('force-yes') || $io->confirm('Remove all migration history entries?', false)) { + $context->migrator->updateHistory([], $history); + $io->success('The migration history is cleared. No actual migration was performed.'); + } + + return Command::SUCCESS; + } + $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 . '.'); + $timestamp = preg_match('/^\d{12}$/D', $version) === 1 ? $version : null; + + if ($timestamp === null && preg_match('/^(?:\w+\\\\)*M\d{12}.*$/D', $version) !== 1) { + $io->error('The version must be a migration class name or a migration timestamp.'); return Command::INVALID; } $add = []; $remove = []; $found = false; - $history = array_keys($context->migrator->getHistory()); $isRecordedClass = $timestamp === null && in_array( $version, array_map(static fn(string $name): string => trim($name, '\\'), $history), @@ -112,7 +132,7 @@ protected function executeForDatabase( ); // Exact recorded classes do not need source files. Timestamps still select pending targets first. - if ($version !== self::BASE_MIGRATION && !$isRecordedClass) { + if (!$isRecordedClass) { $pending = $context->migrationService->getNewMigrations(); foreach ($pending as $i => $migration) { if ($this->matches($migration, $version, $timestamp)) { @@ -124,7 +144,6 @@ protected function executeForDatabase( } if (!$found) { - $history[] = self::BASE_MIGRATION; foreach ($history as $i => $migration) { if ($this->matches($migration, $version, $timestamp)) { $remove = array_slice($history, 0, $i); @@ -152,6 +171,9 @@ protected function executeForDatabase( } } + $this->writeMigrations($output, $add, 'to be marked as applied'); + $this->writeMigrations($output, $remove, 'to be removed from history'); + $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)) { $context->migrator->updateHistory($add, $remove); @@ -161,6 +183,27 @@ protected function executeForDatabase( return Command::SUCCESS; } + /** + * @psalm-param list $migrations + */ + private function writeMigrations(OutputInterface $output, array $migrations, string $action): void + { + if ($migrations === []) { + return; + } + + $count = count($migrations); + $migrationWord = $count === 1 ? 'migration' : 'migrations'; + + $output->writeln("Total $count $migrationWord $action:\n"); + + foreach ($migrations as $i => $migration) { + $output->writeln("\t" . ($i + 1) . '. ' . OutputFormatter::escape($migration) . ''); + } + + $output->writeln(''); + } + private function matches(string $migration, string $version, ?string $timestamp): bool { if ($timestamp === null) { diff --git a/tests/Common/Command/AbstractDatabaseCommandsTest.php b/tests/Common/Command/AbstractDatabaseCommandsTest.php index f0371774..2bd39b2f 100644 --- a/tests/Common/Command/AbstractDatabaseCommandsTest.php +++ b/tests/Common/Command/AbstractDatabaseCommandsTest.php @@ -141,7 +141,7 @@ public function testResetHistoryDoesNotRevertSchema(): void 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, + '--clear' => true, '-y' => true, ])); self::assertSame([], $this->migrator('maps')->getHistory()); diff --git a/tests/Common/Command/AbstractMarkCommandTest.php b/tests/Common/Command/AbstractMarkCommandTest.php index df334800..6708b72b 100644 --- a/tests/Common/Command/AbstractMarkCommandTest.php +++ b/tests/Common/Command/AbstractMarkCommandTest.php @@ -30,7 +30,7 @@ abstract class AbstractMarkCommandTest extends TestCase public static function versions(): array { - return [[self::SECOND], ['\\' . self::SECOND], ['260101000002'], ['260101_000002']]; + return [[self::SECOND], ['\\' . self::SECOND], ['260101000002']]; } #[DataProvider('versions')] @@ -50,7 +50,6 @@ public static function migrationNameVersions(): array ['260101000001', 'M260101000001', 'MarkTimestampOnly'], ['M260101000002123Fix', 'M260101000002123Fix', 'MarkDigitSuffix'], ['260101000002', 'M260101000002123Fix', 'MarkDigitSuffix'], - ['260101_000002', 'M260101000002123Fix', 'MarkDigitSuffix'], ]; } @@ -70,6 +69,46 @@ public function testMigrationNamesCanBeMarked(string $version, string $class, st self::assertSame([$class], array_keys($this->migrator()->getHistory())); } + public function testMarkUpShowsMigrationsToBeAdded(): void + { + $command = $this->command(); + $command->setInputs(['no']); + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::SECOND])); + $display = preg_replace('/\s+/', ' ', $command->getDisplay()); + self::assertStringContainsString( + 'Total 2 migrations to be marked as applied: 1. ' . self::FIRST . ' 2. ' . self::SECOND . ' ', + $display, + ); + self::assertStringNotContainsString('to be removed from history', $display); + } + + public function testMarkDownShowsMigrationsToBeRemoved(): void + { + $this->migrator()->updateHistory([self::FIRST], []); + $this->migrator()->updateHistory([self::SECOND], []); + $this->migrator()->updateHistory([self::THIRD], []); + $command = $this->command(); + $command->setInputs(['no']); + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::FIRST])); + $display = preg_replace('/\s+/', ' ', $command->getDisplay()); + self::assertStringContainsString( + 'Total 2 migrations to be removed from history: 1. ' . self::THIRD . ' 2. ' . self::SECOND . ' ', + $display, + ); + self::assertStringNotContainsString('to be marked as applied', $display); + } + + public function testMarkShowsSingleMigration(): void + { + $command = $this->command(); + $command->setInputs(['no']); + self::assertSame(Command::SUCCESS, $command->execute(['version' => self::FIRST])); + self::assertStringContainsString( + 'Total 1 migration to be marked as applied: 1. ' . self::FIRST . ' ', + preg_replace('/\s+/', ' ', $command->getDisplay()), + ); + } + public function testMarkDownUsesHistoryOrderAndPreservesTarget(): void { // Application order may differ from filename order, and recorded files may no longer exist. @@ -133,13 +172,44 @@ public function testRecordedClassDoesNotNeedSourceDirectory(bool $hasNewerMigrat public function testResetHistory(): void { $this->migrator()->updateHistory([self::FIRST], []); + $this->migrator()->updateHistory([self::SECOND], []); $command = $this->command(); - self::assertSame(Command::SUCCESS, $command->execute(['version' => MarkCommand::BASE_MIGRATION, '-y' => true])); + self::assertSame(Command::SUCCESS, $command->execute(['--clear' => true, '-y' => true])); self::assertSame([], $this->migrator()->getHistory()); - self::assertSame(Command::SUCCESS, $command->execute(['version' => MarkCommand::BASE_MIGRATION])); + self::assertStringContainsString( + 'Total 2 migrations to be removed from history: 1. ' . self::SECOND . ' 2. ' . self::FIRST . ' ', + preg_replace('/\s+/', ' ', $command->getDisplay()), + ); + self::assertSame(Command::SUCCESS, $command->execute(['--clear' => true])); self::assertStringContainsString('Nothing needs to be done', $command->getDisplay()); } + public function testDecliningResetHistoryKeepsHistory(): void + { + $this->migrator()->updateHistory([self::FIRST], []); + $before = $this->migrator()->getHistory(); + $command = $this->command(); + $command->setInputs(['no']); + self::assertSame(Command::SUCCESS, $command->execute(['--clear' => true])); + self::assertSame($before, $this->migrator()->getHistory()); + } + + public static function invalidArguments(): array + { + return [[[]], [['version' => self::FIRST, '--clear' => true]]]; + } + + #[DataProvider('invalidArguments')] + public function testVersionOrAllIsRequired(array $arguments): void + { + $this->migrator()->updateHistory([self::FIRST], []); + $before = $this->migrator()->getHistory(); + $command = $this->command(); + self::assertSame(Command::INVALID, $command->execute([...$arguments, '-y' => true])); + self::assertStringContainsString('Specify either a version or the "--clear" option.', $command->getDisplay()); + self::assertSame($before, $this->migrator()->getHistory()); + } + public function testAlreadyAtTarget(): void { $this->migrator()->updateHistory([self::SECOND], []); @@ -176,7 +246,16 @@ public function testDecliningMarkDownKeepsHistory(): void public static function invalidVersions(): array { - return [[''], ['invalid'], ['2026-01-01'], ['1767225600'], ['260101000099'], ['M260101000002Unknown']]; + return [ + [''], + ['invalid'], + ['2026-01-01'], + ['1767225600'], + ['260101000099'], + ['260101_000002'], + ['M260101000002Unknown'], + ['m000000_000000_base'], + ]; } #[DataProvider('invalidVersions')] diff --git a/tests/Driver/Sqlite/MarkCommandTest.php b/tests/Driver/Sqlite/MarkCommandTest.php index 66c6365a..7468375d 100644 --- a/tests/Driver/Sqlite/MarkCommandTest.php +++ b/tests/Driver/Sqlite/MarkCommandTest.php @@ -59,7 +59,7 @@ public function testFailedHistoryChangeIsRolledBack(string $operation, string $r try { $command->execute([ - 'version' => $operation === 'INSERT' ? M260101000003Third::class : MarkCommand::BASE_MIGRATION, + ...($operation === 'INSERT' ? ['version' => M260101000003Third::class] : ['--clear' => true]), '-y' => true, ]); self::fail('Expected the history change to fail.');