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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion bin/yii-db-migration
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion config/di-console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 2 additions & 0 deletions config/params.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/guide/en/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
43 changes: 43 additions & 0 deletions docs/guide/en/migration-history.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 10 additions & 0 deletions docs/guide/en/multiple-databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions docs/guide/en/usage-with-symfony.md
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
1 change: 1 addition & 0 deletions src/Command/CommandFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/Command/DatabaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
161 changes: 161 additions & 0 deletions src/Command/MarkCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Db\Migration\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Yiisoft\Db\Migration\DatabaseContext;
use Yiisoft\Db\Migration\DatabaseSetRegistry;
use Yiisoft\Db\Migration\Migrator;
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;
use function trim;

/**
* Moves migration history to a version without executing up() or down().
Comment thread
samdark marked this conversation as resolved.
*
* 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
{
public const BASE_MIGRATION = 'm000000_000000_base';

public function __construct(
private readonly MigrationService $migrationService,
private readonly Migrator $migrator,
?DatabaseSetRegistry $databases = null,
) {
parent::__construct($databases);
}

protected function configure(): void
{
$this
->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;
}
}
44 changes: 33 additions & 11 deletions src/Migrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $add Migration names to record as applied.
* @param list<string> $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) {
Expand Down Expand Up @@ -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);
}
}
Loading
Loading