Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 10 additions & 10 deletions docs/guide/en/migration-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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`.
81 changes: 62 additions & 19 deletions src/Command/MarkCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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.');
}

Expand All @@ -86,33 +85,54 @@ 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),
true,
);

// 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)) {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -161,6 +183,27 @@ protected function executeForDatabase(
return Command::SUCCESS;
}

/**
* @psalm-param list<string> $migrations
*/
private function writeMigrations(OutputInterface $output, array $migrations, string $action): void
{
if ($migrations === []) {
return;
}

$count = count($migrations);
$migrationWord = $count === 1 ? 'migration' : 'migrations';

$output->writeln("<fg=yellow>Total $count $migrationWord $action:</>\n");

foreach ($migrations as $i => $migration) {
$output->writeln("\t<fg=yellow>" . ($i + 1) . '. ' . OutputFormatter::escape($migration) . '</>');
}

$output->writeln('');
}

private function matches(string $migration, string $version, ?string $timestamp): bool
{
if ($timestamp === null) {
Expand Down
2 changes: 1 addition & 1 deletion tests/Common/Command/AbstractDatabaseCommandsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
89 changes: 84 additions & 5 deletions tests/Common/Command/AbstractMarkCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')]
Expand All @@ -50,7 +50,6 @@ public static function migrationNameVersions(): array
['260101000001', 'M260101000001', 'MarkTimestampOnly'],
['M260101000002123Fix', 'M260101000002123Fix', 'MarkDigitSuffix'],
['260101000002', 'M260101000002123Fix', 'MarkDigitSuffix'],
['260101_000002', 'M260101000002123Fix', 'MarkDigitSuffix'],
];
}

Expand All @@ -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.
Expand Down Expand Up @@ -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], []);
Expand Down Expand Up @@ -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')]
Expand Down
2 changes: 1 addition & 1 deletion tests/Driver/Sqlite/MarkCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Loading