From 030663c978d32fe1205edd8ab533fd2b80a86434 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 12:59:42 +0300 Subject: [PATCH 01/16] Add named database migration sets --- CHANGELOG.md | 2 + README.md | 2 + bin/yii-db-migration | 16 +- bin/yii-db-migration.php | 7 + config/di-console.php | 8 + config/params.php | 1 + docs/guide/en/README.md | 1 + docs/guide/en/multiple-databases.md | 174 +++++++++ docs/guide/en/usage-standalone.md | 2 + docs/guide/en/usage-with-symfony.md | 2 + docs/guide/en/usage-with-yii-console.md | 2 + src/Command/CreateCommand.php | 8 +- src/Command/DatabaseCommand.php | 85 +++++ src/Command/DownCommand.php | 8 +- src/Command/HistoryCommand.php | 10 +- src/Command/NewCommand.php | 10 +- src/Command/RedoCommand.php | 8 +- src/Command/UpdateCommand.php | 8 +- src/DatabaseSet.php | 29 ++ src/DatabaseSetRegistry.php | 90 +++++ tests/Driver/Sqlite/DatabaseCommandsTest.php | 351 +++++++++++++++++++ tests/Migration/BinTest.php | 45 ++- tests/Migration/ConfigTest.php | 20 +- tests/Migration/DatabaseSetRegistryTest.php | 42 +++ 24 files changed, 900 insertions(+), 31 deletions(-) create mode 100644 docs/guide/en/multiple-databases.md create mode 100644 src/Command/DatabaseCommand.php create mode 100644 src/DatabaseSet.php create mode 100644 src/DatabaseSetRegistry.php create mode 100644 tests/Driver/Sqlite/DatabaseCommandsTest.php create mode 100644 tests/Migration/DatabaseSetRegistryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a27d4ca..d564539f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.1.2 under development +- Enh #360: Support named database migration sets with `--db` selection and separate migration history (@samdark) + - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) ## 2.1.1 August 10, 2026 diff --git a/README.md b/README.md index 8b0061f9..26c8fefd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ migrate:up Applies new migrations. The create command allows defining fields for the table being created. +Use `--db=name` to select a [named database migration set](docs/guide/en/multiple-databases.md). + ## Documentation - Guide: [English](docs/guide/en/README.md), [Português - Brasil](docs/guide/pt-BR/README.md) diff --git a/bin/yii-db-migration b/bin/yii-db-migration index c0f12671..0463e2b6 100644 --- a/bin/yii-db-migration +++ b/bin/yii-db-migration @@ -12,6 +12,8 @@ use Yiisoft\Db\Migration\Command\HistoryCommand; use Yiisoft\Db\Migration\Command\NewCommand; use Yiisoft\Db\Migration\Command\RedoCommand; use Yiisoft\Db\Migration\Command\UpdateCommand; +use Yiisoft\Db\Migration\DatabaseSet; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; @@ -42,6 +44,7 @@ if (!file_exists($configFile)) { * @psalm-var array{ * container: ContainerInterface|null, * db: ConnectionInterface|null, + * databases?: array, * useTablePrefix: bool, * historyTable: string, * migrationNameLimit: int|null, @@ -58,6 +61,7 @@ $db = $params['db'] ?? throw new LogicException('DB connection is not configured $injector = new Injector($params['container']); $migrationInformer = new ConsoleMigrationInformer(); +$databases = new DatabaseSetRegistry($injector, $migrationInformer, $params['databases'] ?? []); $migrator = new Migrator( $db, $migrationInformer, @@ -77,12 +81,12 @@ $migrationService->setSourcePaths($params['sourcePaths']); $application = new Application('Yii Database Migration Tool', '1.0.0'); $application->addCommands([ - new CreateCommand($createService, $migrationService, $migrator), - new DownCommand($downRunner, $migrationService, $migrator), - new HistoryCommand($migrationService, $migrator), - new NewCommand($migrationService, $migrator), - new RedoCommand($migrationService, $migrator, $downRunner, $updateRunner), - new UpdateCommand($updateRunner, $migrationService, $migrator), + new CreateCommand($createService, $migrationService, $migrator, $databases), + new DownCommand($downRunner, $migrationService, $migrator, $databases), + new HistoryCommand($migrationService, $migrator, $databases), + new NewCommand($migrationService, $migrator, $databases), + new RedoCommand($migrationService, $migrator, $downRunner, $updateRunner, $databases), + new UpdateCommand($updateRunner, $migrationService, $migrator, $databases), ]); $application->run(); diff --git a/bin/yii-db-migration.php b/bin/yii-db-migration.php index 01adb696..caade834 100644 --- a/bin/yii-db-migration.php +++ b/bin/yii-db-migration.php @@ -17,6 +17,13 @@ */ 'db' => null, + /** + * Additional named database sets (name => Yiisoft\Db\Migration\DatabaseSet instance). + * The options outside this array configure the default set. The name "default" is reserved. + * See docs/guide/en/multiple-databases.md. + */ + 'databases' => [], + /** * Namespace of new migration classes. */ diff --git a/config/di-console.php b/config/di-console.php index 0242e23f..d0df03d1 100644 --- a/config/di-console.php +++ b/config/di-console.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; use Yiisoft\Db\Migration\Service\MigrationService; @@ -9,6 +10,13 @@ /** @var array $params */ return [ + DatabaseSetRegistry::class => [ + 'class' => DatabaseSetRegistry::class, + '__construct()' => [ + 'databases' => $params['yiisoft/db-migration']['databases'] ?? [], + ], + ], + MigrationService::class => [ 'class' => MigrationService::class, 'setNewMigrationNamespace()' => [$params['yiisoft/db-migration']['newMigrationNamespace']], diff --git a/config/params.php b/config/params.php index 30b895f4..4f62b03c 100644 --- a/config/params.php +++ b/config/params.php @@ -22,6 +22,7 @@ ], 'yiisoft/db-migration' => [ + 'databases' => [], 'newMigrationNamespace' => '', 'newMigrationPath' => '', 'sourceNamespaces' => [], diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index ef86fec3..9fe0c0b1 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -3,3 +3,4 @@ - [Yii Console](usage-with-yii-console.md) - [Symfony application](usage-with-symfony.md) - [Standalone](usage-standalone.md) +- [Multiple databases](multiple-databases.md) diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md new file mode 100644 index 00000000..acadf081 --- /dev/null +++ b/docs/guide/en/multiple-databases.md @@ -0,0 +1,174 @@ +# Multiple databases + +A database set groups a connection, migration sources, a destination for new migrations, and history-table settings. +Use this when different parts of your application, such as Maps and Analytics, have their own databases and migrations. + +Your existing connection and migration configuration form the `default` set. Add other sets under names such as `maps` +and `analytics`; the name `default` is reserved. Each set may use a different database driver. + +## Commands + +Select one database with `--db`: + +```shell +./yii migrate:create create_places --db=maps +./yii migrate:up --db=maps +./yii migrate:new --db=maps +./yii migrate:history --db=maps +./yii migrate:down --db=maps +./yii migrate:redo --db=maps +``` + +Replace `./yii` with `php bin/console` for Symfony or `./vendor/bin/yii-db-migration` for standalone use. + +Without `--db`, commands behave as follows: + +| Command | Behavior | +| --- | --- | +| `migrate:up` | Apply migrations to all sets, default first, then additional sets in configuration order. | +| `migrate:new` | Show pending migrations for all sets in the same order. | +| `migrate:history` | Show history for all sets in the same order. | +| `migrate:create` | Create a migration in the default set. | +| `migrate:down`, `migrate:redo` | Require `--db` when additional sets are configured. With only the default set, behave as before. | + +Output identifies each database when multiple sets are configured. Unknown names are rejected before executing any +migration and the error lists the available names. `--db=default` explicitly selects the existing configuration. + +`--limit` applies separately to each selected set. Existing `--path` and `--namespace` options also apply separately to +selected sets; they do not choose a database. Use `--db` to scope those options to one set. + +Execution stops at the first error or nonzero command result. Empty results in `new` and `history` are successful when +listing all sets, so an empty database does not hide results from later ones. Selecting one empty set retains the existing +failure exit code. Each set retains the existing confirmation behavior; `migrate:up --force-yes` skips all confirmations. + +There is no transaction spanning databases. If Maps fails after the default database succeeds, the default database keeps +its changes and history. Later sets are skipped. Correct the problem and rerun the command; applied migrations are skipped. +Order additional sets so that prerequisites run first. All default migrations run before any additional set. + +## History and migration classes + +Each set stores history using its own connection, in `{{%migration}}` by default. Separate databases may use the same table +name. If sets share a database, configure distinct `historyTable` names to keep their histories independent. + +Migration classes remain ordinary implementations of `MigrationInterface`, `RevertibleMigrationInterface`, or +`TransactionalMigrationInterface`. The builder supplied to `up()` and `down()` uses the selected set's connection. +Transactions and history use that same connection. No connection property or special base class is needed. + +Use separate directories and unique class names or namespaces for separate sets. For example: + +```text +config/migrations/default/ +config/migrations/maps/ +config/migrations/analytics/ +``` + +A `DatabaseSet` accepts the existing `newMigrationNamespace`, `newMigrationPath`, `sourceNamespaces`, and `sourcePaths` +options. Choose either a new migration namespace or a new migration path, as with single-database configuration. A +namespace must be resolvable through Composer's PSR-4 configuration, and destination directories must exist. The new +migration destination is also included when discovering migrations. Additional source paths/namespaces can contain shared +package migrations. + +Each set also accepts `historyTable`, `migrationNameLimit`, `useTablePrefix`, and `maxSqlOutputLength`. These use the same +defaults as the standalone configuration; settings from the default set are not inherited by additional sets. + +## Yii Console + +Keep your current default configuration. Add `DatabaseSet` instances to the `databases` parameter: + +```php +use Yiisoft\Db\Migration\DatabaseSet; + +// $mapsConnection and $analyticsConnection implement ConnectionInterface. +return [ + 'yiisoft/db-migration' => [ + 'newMigrationPath' => __DIR__ . '/migrations/default', + 'databases' => [ + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/migrations/maps', + ), + 'analytics' => new DatabaseSet( + $analyticsConnection, + newMigrationPath: __DIR__ . '/migrations/analytics', + ), + ], + ], +]; +``` + +If connections are defined as container services, configure `DatabaseSetRegistry` in your console DI configuration instead: + +```php +use Yiisoft\Db\Migration\DatabaseSet; +use Yiisoft\Db\Migration\DatabaseSetRegistry; +use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; +use Yiisoft\Injector\Injector; +use Psr\Container\ContainerInterface; + +return [ + DatabaseSetRegistry::class => static fn ( + ContainerInterface $container, + Injector $injector, + MigrationInformerInterface $informer, + ) => new DatabaseSetRegistry($injector, $informer, [ + 'maps' => new DatabaseSet( + $container->get('db.maps'), + newMigrationNamespace: 'App\\Migrations\\Maps', + ), + ]), +]; +``` + +## Symfony + +Keep the [standard Symfony setup](usage-with-symfony.md) for the default database. Configure additional sets using your +existing connection services, and inject the registry into the migration commands through autowiring: + +```yaml +# config/services.yaml +services: + app.migrations.maps: + class: Yiisoft\Db\Migration\DatabaseSet + arguments: + $db: '@yii3.connections.maps' + $newMigrationPath: '%kernel.project_dir%/config/migrations/maps' + + app.migrations.analytics: + class: Yiisoft\Db\Migration\DatabaseSet + arguments: + $db: '@yii3.connections.analytics' + $newMigrationPath: '%kernel.project_dir%/config/migrations/analytics' + + Yiisoft\Db\Migration\DatabaseSetRegistry: + autowire: true + arguments: + $databases: + maps: '@app.migrations.maps' + analytics: '@app.migrations.analytics' +``` + +The registry uses the configured `Injector` to instantiate migrations, so constructor dependency injection continues to +work for migrations in additional sets. + +## Standalone + +Add `databases` to your existing `yii-db-migration.php` configuration. Keep the existing `db` and other options for the +default set: + +```php +use Yiisoft\Db\Migration\DatabaseSet; + +return [ + // ... existing options, including the default 'db' connection ... + 'databases' => [ + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/config/migrations/maps', + ), + ], +]; +``` + +For manual command construction, create a `DatabaseSetRegistry` with your injector, informer, and additional sets, then +pass it as the optional final `$databases` constructor argument of each command. Existing constructor calls without a +registry continue to work with the default database only. diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index b4967334..576caaa4 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -72,3 +72,5 @@ $input->setInteractive(false); $this->getMigrateUpdateCommand()->run($input, new NullOutput()); ``` + +For independent migration sets on multiple connections, see [Multiple databases](multiple-databases.md). diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index bee3e1bf..f751926d 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -53,3 +53,5 @@ sqlite_driver: ``` That's it. Now you can use `bin/console migrate:*` commands. + +For independent migration sets on multiple connections, see [Multiple databases](multiple-databases.md). diff --git a/docs/guide/en/usage-with-yii-console.md b/docs/guide/en/usage-with-yii-console.md index 9394fdd0..d17fb3ba 100644 --- a/docs/guide/en/usage-with-yii-console.md +++ b/docs/guide/en/usage-with-yii-console.md @@ -45,3 +45,5 @@ View the list of available commands with `./yii list`: ```shell ./yii list ``` + +For independent migration sets on multiple connections, see [Multiple databases](multiple-databases.md). diff --git a/src/Command/CreateCommand.php b/src/Command/CreateCommand.php index 1e57e263..4b31f82c 100644 --- a/src/Command/CreateCommand.php +++ b/src/Command/CreateCommand.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\Generate\CreateService; use Yiisoft\Db\Migration\Service\MigrationService; @@ -65,7 +66,7 @@ * {@see MigrationService::$newMigrationNamespace} will be used. */ #[AsCommand('migrate:create', 'Creates a new migration.')] -final class CreateCommand extends Command +final class CreateCommand extends DatabaseCommand { private const AVAILABLE_COMMANDS = ['create', 'table', 'dropTable', 'addColumn', 'dropColumn', 'junction']; @@ -73,8 +74,9 @@ public function __construct( private readonly CreateService $createService, private readonly MigrationService $migrationService, private readonly Migrator $migrator, + ?DatabaseSetRegistry $databases = null, ) { - parent::__construct(); + parent::__construct($databases); } protected function configure(): void @@ -90,7 +92,7 @@ protected function configure(): void ->setHelp('This command generates new migration file.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function executeForDatabase(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->migrator->setIo($io); diff --git a/src/Command/DatabaseCommand.php b/src/Command/DatabaseCommand.php new file mode 100644 index 00000000..892a8d1d --- /dev/null +++ b/src/Command/DatabaseCommand.php @@ -0,0 +1,85 @@ +addOption('db', null, InputOption::VALUE_REQUIRED, 'Database configuration to use for migrations.'); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + /** @var string|null $name */ + $name = $input->getOption('db'); + $names = $this->databases?->getNames() ?? ['default']; + $multipleDatabases = count($names) > 1; + $io = new SymfonyStyle($input, $output); + + if ($name !== null && !in_array($name, $names, true)) { + $io->error( + 'Unknown database "' . OutputFormatter::escape($name) . '". Available databases: ' + . OutputFormatter::escape(implode(', ', $names)) . '.', + ); + + return Command::INVALID; + } + + if ($name === null && $multipleDatabases && ($this instanceof DownCommand || $this instanceof RedoCommand)) { + $io->error('The --db option is required when multiple databases are configured.'); + + return Command::INVALID; + } + + if ($name !== null || $this instanceof CreateCommand) { + $names = [$name ?? 'default']; + } + + foreach ($names as $database) { + $command = $this; + if ($database !== 'default' && $this->databases !== null) { + $command = $this->databases->createCommand($database, static::class); + } + + $command->allowEmptyResults = count($names) > 1; + $command->setName($this->getName() ?? ''); + if (($helpers = $this->getHelperSet()) !== null) { + $command->setHelperSet($helpers); + } + + if ($multipleDatabases) { + $io->section('Database: ' . OutputFormatter::escape($database)); + } + + $status = $command->executeForDatabase($input, $output); + if ($status !== Command::SUCCESS) { + return $status; + } + } + + return Command::SUCCESS; + } + + abstract protected function executeForDatabase(InputInterface $input, OutputInterface $output): int; +} diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php index 41a64356..30c11989 100644 --- a/src/Command/DownCommand.php +++ b/src/Command/DownCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; use Yiisoft\Db\Migration\Service\MigrationService; @@ -37,14 +38,15 @@ * ``` */ #[AsCommand('migrate:down', 'Reverts the specified number of latest migrations.')] -final class DownCommand extends Command +final class DownCommand extends DatabaseCommand { public function __construct( private readonly DownRunner $downRunner, private readonly MigrationService $migrationService, private readonly Migrator $migrator, + ?DatabaseSetRegistry $databases = null, ) { - parent::__construct(); + parent::__construct($databases); } protected function configure(): void @@ -57,7 +59,7 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function executeForDatabase(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->migrator->setIo($io); diff --git a/src/Command/HistoryCommand.php b/src/Command/HistoryCommand.php index 471bb027..ca4bdf3c 100644 --- a/src/Command/HistoryCommand.php +++ b/src/Command/HistoryCommand.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; @@ -30,13 +31,14 @@ * ``` */ #[AsCommand('migrate:history', 'Displays the migration history.')] -final class HistoryCommand extends Command +final class HistoryCommand extends DatabaseCommand { public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, + ?DatabaseSetRegistry $databases = null, ) { - parent::__construct(); + parent::__construct($databases); } protected function configure(): void @@ -46,7 +48,7 @@ protected function configure(): void ->addOption('all', 'a', InputOption::VALUE_NONE, 'All migrations.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function executeForDatabase(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->migrator->setIo($io); @@ -71,7 +73,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (empty($migrations)) { $io->warning('No migration has been done before.'); - return Command::FAILURE; + return $this->allowEmptyResults ? Command::SUCCESS : Command::FAILURE; } $countMigrations = count($migrations); diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index 01bfdffe..8f688b02 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -10,6 +10,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; @@ -36,13 +37,14 @@ * ``` */ #[AsCommand('migrate:new', 'Displays not yet applied migrations.')] -final class NewCommand extends Command +final class NewCommand extends DatabaseCommand { public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, + ?DatabaseSetRegistry $databases = null, ) { - parent::__construct(); + parent::__construct($databases); } protected function configure(): void @@ -54,7 +56,7 @@ protected function configure(): void ->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Namespace of migrations to display.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function executeForDatabase(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->migrator->setIo($io); @@ -90,7 +92,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (empty($migrations)) { $io->warning('No new migrations found. Your system is up-to-date.'); - return Command::FAILURE; + return $this->allowEmptyResults ? Command::SUCCESS : Command::FAILURE; } $countMigrations = count($migrations); diff --git a/src/Command/RedoCommand.php b/src/Command/RedoCommand.php index 17a44b34..3a03cd7c 100644 --- a/src/Command/RedoCommand.php +++ b/src/Command/RedoCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; use Yiisoft\Db\Migration\Runner\UpdateRunner; @@ -37,15 +38,16 @@ * ``` */ #[AsCommand('migrate:redo', 'Redoes the last few migrations.')] -final class RedoCommand extends Command +final class RedoCommand extends DatabaseCommand { public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, private readonly DownRunner $downRunner, private readonly UpdateRunner $updateRunner, + ?DatabaseSetRegistry $databases = null, ) { - parent::__construct(); + parent::__construct($databases); } protected function configure(): void @@ -58,7 +60,7 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function executeForDatabase(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->migrator->setIo($io); diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index 52133ab7..945a8171 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\UpdateRunner; use Yiisoft\Db\Migration\Service\MigrationService; @@ -36,14 +37,15 @@ * ``` */ #[AsCommand('migrate:up', 'Applies new migrations.')] -final class UpdateCommand extends Command +final class UpdateCommand extends DatabaseCommand { public function __construct( private readonly UpdateRunner $updateRunner, private readonly MigrationService $migrationService, private readonly Migrator $migrator, + ?DatabaseSetRegistry $databases = null, ) { - parent::__construct(); + parent::__construct($databases); } protected function configure(): void @@ -55,7 +57,7 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } - protected function execute(InputInterface $input, OutputInterface $output): int + protected function executeForDatabase(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $this->migrator->setIo($io); diff --git a/src/DatabaseSet.php b/src/DatabaseSet.php new file mode 100644 index 00000000..abd3438b --- /dev/null +++ b/src/DatabaseSet.php @@ -0,0 +1,29 @@ + */ + private readonly array $databases; + + /** + * @param array $databases Additional sets. The name "default" is reserved. + */ + public function __construct( + private readonly Injector $injector, + private readonly MigrationInformerInterface $informer, + array $databases = [], + ) { + $sets = []; + foreach ($databases as $name => $database) { + if (!is_string($name) || trim($name) === '' || $name === 'default') { + throw new InvalidArgumentException('Database names must be non-empty strings other than "default".'); + } + $sets[$name] = $database; + } + $this->databases = $sets; + } + + /** + * @return list + */ + public function getNames(): array + { + return ['default', ...array_keys($this->databases)]; + } + + /** + * @internal + * @param class-string $class + */ + public function createCommand(string $name, string $class): DatabaseCommand + { + $database = $this->databases[$name] ?? throw new InvalidArgumentException("Unknown database set: $name."); + $migrator = new Migrator( + $database->db, + $this->informer, + $database->historyTable, + $database->migrationNameLimit, + $database->maxSqlOutputLength, + ); + $service = new MigrationService($database->db, $this->injector, $migrator); + $service->setNewMigrationNamespace($database->newMigrationNamespace); + $service->setNewMigrationPath($database->newMigrationPath); + $service->setSourceNamespaces($database->sourceNamespaces); + $service->setSourcePaths($database->sourcePaths); + + return match ($class) { + CreateCommand::class => new CreateCommand(new CreateService($database->db, $database->useTablePrefix), $service, $migrator), + DownCommand::class => new DownCommand(new DownRunner($migrator), $service, $migrator), + HistoryCommand::class => new HistoryCommand($service, $migrator), + NewCommand::class => new NewCommand($service, $migrator), + RedoCommand::class => new RedoCommand($service, $migrator, new DownRunner($migrator), new UpdateRunner($migrator)), + UpdateCommand::class => new UpdateCommand(new UpdateRunner($migrator), $service, $migrator), + default => throw new InvalidArgumentException("Unsupported migration command: $class."), + }; + } +} diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php new file mode 100644 index 00000000..ab74d78f --- /dev/null +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -0,0 +1,351 @@ + */ + private array $connections; + /** @var array */ + private array $sets; + + protected function setUp(): void + { + $this->directory = sys_get_temp_dir() . '/yii-migration-databases-' . uniqid(); + $this->connections = []; + $this->sets = []; + foreach (['default', 'maps', 'analytics'] as $name) { + FileHelper::ensureDirectory($this->directory . '/' . $name); + $this->connections[$name] = new Connection(new Driver('sqlite::memory:')); + $this->sets[$name] = new DatabaseSet( + $this->connections[$name], + newMigrationPath: $this->directory . '/' . $name, + ); + } + } + + protected function tearDown(): void + { + FileHelper::removeDirectory($this->directory); + } + + public function testApplyAllInConfigurationOrderWithIndependentHistory(): void + { + $classes = []; + foreach ($this->sets as $name => $set) { + $classes[$name] = $this->writeMigration($name); + } + // Even when "default" was not inserted first, it must run first. + $this->sets = ['analytics' => $this->sets['analytics'], 'default' => $this->sets['default'], 'maps' => $this->sets['maps']]; + $command = $this->command('up'); + self::assertSame(Command::SUCCESS, $command->execute(['--force-yes' => true])); + foreach ($classes as $name => $class) { + self::assertSame([$class], array_keys($this->migrator($name)->getHistory())); + self::assertNotNull($this->connections[$name]->getSchema()->getTableSchema('example')); + } + $output = $command->getDisplay(); + self::assertLessThan(strpos($output, 'Database: analytics'), strpos($output, 'Database: default')); + self::assertLessThan(strpos($output, 'Database: maps'), strpos($output, 'Database: analytics')); + // Already applied migrations must not run again. + self::assertSame(Command::SUCCESS, $command->execute(['--force-yes' => true])); + } + + public function testSelectDatabaseForUpDownAndRedo(): void + { + foreach (['default', 'maps', 'analytics'] as $name) { + $this->writeMigration($name); + } + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertNull($this->connections['default']->getSchema()->getTableSchema('migration')); + self::assertNull($this->connections['analytics']->getSchema()->getTableSchema('migration')); + self::assertCount(1, $this->migrator('maps')->getHistory()); + self::assertSame(Command::SUCCESS, $this->command('redo')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertCount(1, $this->migrator('maps')->getHistory()); + self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertSame(Command::SUCCESS, $this->command('down')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertSame([], $this->migrator('maps')->getHistory()); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + } + + public static function commands(): array + { + return [['up'], ['down'], ['redo'], ['new'], ['history'], ['create']]; + } + + #[DataProvider('commands')] + public function testUnknownDatabaseIsRejectedBeforeAnyWork(string $name): void + { + $command = $this->command($name); + $input = ['--db' => 'unknown']; + if ($name === 'create') { + $input['name'] = 'Example'; + } + self::assertSame(Command::INVALID, $command->execute($input)); + self::assertStringContainsString('Available databases: default, maps, analytics.', preg_replace('/\s+/', ' ', $command->getDisplay())); + foreach ($this->connections as $connection) { + self::assertNull($connection->getSchema()->getTableSchema('migration')); + } + } + + public static function destructiveCommands(): array + { + return [['down'], ['redo']]; + } + + #[DataProvider('destructiveCommands')] + public function testDestructiveCommandsRequireDatabaseWithMultipleSets(string $name): void + { + $command = $this->command($name); + self::assertSame(Command::INVALID, $command->execute(['--force-yes' => true])); + self::assertStringContainsString('--db option is required', $command->getDisplay()); + foreach ($this->connections as $connection) { + self::assertNull($connection->getSchema()->getTableSchema('migration')); + } + } + + #[DataProvider('destructiveCommands')] + public function testDestructiveCommandsDoNotRequireDatabaseWithOneSet(string $name): void + { + unset($this->sets['maps'], $this->sets['analytics']); + $this->writeMigration('default'); + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--force-yes' => true])); + self::assertSame(Command::SUCCESS, $this->command($name)->execute(['--force-yes' => true])); + } + + public function testCreateDefaultsToDefaultAndCanSelectAnotherSet(): void + { + $command = $this->command('create'); + self::assertSame(Command::SUCCESS, $command->execute(['name' => 'DefaultExample'])); + self::assertCount(1, glob($this->directory . '/default/*.php')); + self::assertSame([], glob($this->directory . '/maps/*.php')); + self::assertSame(Command::SUCCESS, $command->execute(['name' => 'MapsExample', '--db' => 'maps'])); + self::assertCount(1, glob($this->directory . '/maps/*.php')); + self::assertSame([], glob($this->directory . '/analytics/*.php')); + } + + public function testStopsOnExceptionAndRollsBackOnlyTheFailingDatabase(): void + { + $defaultClass = $this->writeMigration('default'); + $this->writeMigration('maps', fail: true); + $this->writeMigration('analytics'); + try { + $this->command('up')->execute(['--force-yes' => true]); + self::fail('An exception must propagate.'); + } catch (RuntimeException $exception) { + self::assertSame('Migration failed.', $exception->getMessage()); + } + self::assertSame([$defaultClass], array_keys($this->migrator('default')->getHistory())); + self::assertNotNull($this->connections['default']->getSchema()->getTableSchema('example')); + self::assertSame([], $this->migrator('maps')->getHistory()); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertNull($this->connections['analytics']->getSchema()->getTableSchema('migration')); + } + + public function testStopsOnNonzeroExitCode(): void + { + $this->writeMigration('default'); + $this->sets['maps'] = new DatabaseSet($this->connections['maps']); + $this->writeMigration('analytics'); + self::assertSame(Command::INVALID, $this->command('up')->execute(['--force-yes' => true])); + self::assertCount(1, $this->migrator('default')->getHistory()); + self::assertNull($this->connections['analytics']->getSchema()->getTableSchema('migration')); + } + + public static function listingCommands(): array + { + return [['new'], ['history']]; + } + + #[DataProvider('listingCommands')] + public function testListingContinuesPastEmptySetsAndCanSelectOne(string $name): void + { + $class = $this->writeMigration('maps'); + if ($name === 'history') { + $this->command('up')->execute(['--db' => 'maps', '--force-yes' => true]); + } + $command = $this->command($name); + self::assertSame(Command::SUCCESS, $command->execute([])); + self::assertStringContainsString($class, $command->getDisplay()); + self::assertStringContainsString('Database: analytics', $command->getDisplay()); + self::assertSame(Command::SUCCESS, $command->execute(['--db' => 'maps'])); + self::assertStringNotContainsString('Database: default', $command->getDisplay()); + // Explicit selection keeps the original empty-result exit code. + self::assertSame(Command::FAILURE, $command->execute(['--db' => 'default'])); + } + + public function testLimitAppliesToEachDatabase(): void + { + foreach (['default', 'maps', 'analytics'] as $name) { + $this->writeMigration($name, 'first'); + $this->writeMigration($name, 'second'); + } + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--limit' => 1, '--force-yes' => true])); + foreach (['default', 'maps', 'analytics'] as $name) { + self::assertCount(1, $this->migrator($name)->getHistory()); + } + } + + public function testSetsCanShareAConnectionWithSeparateHistoryTables(): void + { + $this->connections['maps'] = $this->connections['default']; + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + historyTable: 'maps_history', + ); + $defaultClass = $this->writeMigration('default', 'main_example'); + $mapsClass = $this->writeMigration('maps', 'maps_example'); + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--force-yes' => true])); + self::assertSame([$defaultClass], array_keys($this->migrator('default')->getHistory())); + self::assertSame([$mapsClass], array_keys($this->migrator('maps')->getHistory())); + } + + public function testNamedSetUsesItsConnectionForGeneration(): void + { + $this->connections['default']->createCommand('CREATE TABLE parent (default_key INTEGER PRIMARY KEY)')->execute(); + $this->connections['maps']->createCommand('CREATE TABLE parent (maps_key INTEGER PRIMARY KEY)')->execute(); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + useTablePrefix: false, + ); + self::assertSame(Command::SUCCESS, $this->command('create')->execute([ + 'name' => 'child', + '--command' => 'table', + '--fields' => 'parent_id:integer:foreignKey(parent)', + '--db' => 'maps', + ])); + $files = glob($this->directory . '/maps/*.php'); + self::assertCount(1, $files); + $content = file_get_contents($files[0]); + self::assertStringContainsString("'maps_key'", $content); + self::assertStringNotContainsString("'default_key'", $content); + self::assertStringNotContainsString('{{%parent}}', $content); + } + + public function testNamedSetRespectsItsMigrationNameLimit(): void + { + $this->writeMigration('maps'); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + migrationNameLimit: 5, + ); + self::assertSame(Command::INVALID, $this->command('up')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertSame([], $this->migrator('maps')->getHistory()); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example')); + } + + public function testNamedSetsRetainMultipleMigrationSources(): void + { + $class = $this->writeMigration('maps'); + $extraClass = $this->writeMigration('analytics', 'extra_example'); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + sourceNamespaces: ['Yiisoft\\Db\\Migration\\Tests\\Support\\MigrationsExtra'], + sourcePaths: [$this->directory . '/analytics', dirname(__DIR__, 2) . '/Support/MigrationsExtra2'], + ); + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--db' => 'maps', '--force-yes' => true])); + $history = $this->migrator('maps')->getHistory(); + self::assertArrayHasKey($class, $history); + self::assertArrayHasKey($extraClass, $history); + self::assertArrayHasKey(M231108183919Empty::class, $history); + self::assertArrayHasKey(M231108183919Empty2::class, $history); + self::assertNull($this->connections['analytics']->getSchema()->getTableSchema('extra_example')); + } + + public function testNamedSetCommandOptionsDoNotLeakIntoLaterRuns(): void + { + $mapsClass = $this->writeMigration('maps'); + $extraClass = $this->writeMigration('analytics'); + $command = $this->command('new'); + self::assertSame(Command::SUCCESS, $command->execute(['--db' => 'maps', '--path' => [$this->directory . '/analytics']])); + self::assertStringContainsString($mapsClass, $command->getDisplay()); + self::assertStringContainsString($extraClass, $command->getDisplay()); + self::assertSame(Command::SUCCESS, $command->execute(['--db' => 'maps'])); + self::assertStringContainsString($mapsClass, $command->getDisplay()); + self::assertStringNotContainsString($extraClass, $command->getDisplay()); + } + + private function writeMigration(string $database, string $table = 'example', bool $fail = false): string + { + $class = 'M260930000000' . ucfirst($database) . uniqid(); + $failure = $fail ? "throw new \\RuntimeException('Migration failed.');" : ''; + file_put_contents($this->directory . '/' . $database . '/' . $class . '.php', <<createTable('$table', ['id' => ColumnBuilder::primaryKey()]); + $failure + } + public function down(MigrationBuilder \$b): void + { + \$b->dropTable('$table'); + } + } + PHP); + return $class; + } + + private function migrator(string $name): Migrator + { + return new Migrator($this->connections[$name], new NullMigrationInformer(), $this->sets[$name]->historyTable); + } + + private function command(string $name): CommandTester + { + $sets = $this->sets; + unset($sets['default']); + $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), $sets); + $migrator = $this->migrator('default'); + $service = new MigrationService($this->connections['default'], new Injector(), $migrator); + $service->setNewMigrationPath($this->sets['default']->newMigrationPath); + $command = match ($name) { + 'up' => new UpdateCommand(new UpdateRunner($migrator), $service, $migrator, $registry), + 'down' => new DownCommand(new DownRunner($migrator), $service, $migrator, $registry), + 'redo' => new RedoCommand($service, $migrator, new DownRunner($migrator), new UpdateRunner($migrator), $registry), + 'new' => new NewCommand($service, $migrator, $registry), + 'history' => new HistoryCommand($service, $migrator, $registry), + 'create' => new CreateCommand(new CreateService($this->connections['default']), $service, $migrator, $registry), + }; + return new CommandTester($command); + } +} diff --git a/tests/Migration/BinTest.php b/tests/Migration/BinTest.php index 7edff711..b00e7e9c 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Files\FileHelper; +use PDO; use function dirname; @@ -30,6 +31,7 @@ protected function tearDown(): void public function testBase(): void { + $this->replaceParams("'databases' => [],", ''); $this->replaceParams( "'db' => null,", <<<'PHP' @@ -69,6 +71,45 @@ public function testWithoutConfig(): void ); } + public function testNamedDatabase(): void + { + $this->replaceParams("'db' => null,", <<<'PHP' + 'db' => new \Yiisoft\Db\Sqlite\Connection( + new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/default.sqlite') + ), + PHP); + $this->replaceParams("'databases' => [],", <<<'PHP' + 'databases' => [ + 'maps' => new \Yiisoft\Db\Migration\DatabaseSet( + new \Yiisoft\Db\Sqlite\Connection( + new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/maps.sqlite') + ), + newMigrationPath: __DIR__ . '/maps', + historyTable: 'maps_history', + ), + ], + PHP); + $directory = dirname(__DIR__) . '/runtime/bin'; + FileHelper::ensureDirectory($directory . '/maps'); + file_put_contents($directory . '/maps/M260930000000Maps.php', <<<'PHP' + execute('CREATE TABLE example (id INTEGER)'); + } + } + PHP); + [$output, $exitCode] = $this->runYiiDbMigration(['migrate:up', '--db=maps', '--force-yes']); + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('Database: maps', $output); + $this->assertFileDoesNotExist($directory . '/default.sqlite'); + $pdo = new PDO('sqlite:' . $directory . '/maps.sqlite'); + $this->assertSame('M260930000000Maps', $pdo->query('SELECT name FROM maps_history')->fetchColumn()); + $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM example')->fetchColumn()); + } + private function replaceParams($search, $replace): void { $file = dirname(__DIR__) . '/runtime/bin/yii-db-migration.php'; @@ -78,9 +119,9 @@ private function replaceParams($search, $replace): void ); } - private function runYiiDbMigration(): array + private function runYiiDbMigration(array $arguments = []): array { - exec(__DIR__ . '/bin-runner.php 2>&1', $output, $exitCode); + exec(__DIR__ . '/bin-runner.php ' . implode(' ', array_map(escapeshellarg(...), $arguments)) . ' 2>&1', $output, $exitCode); return [implode("\n", $output), $exitCode]; } } diff --git a/tests/Migration/ConfigTest.php b/tests/Migration/ConfigTest.php index 48591251..ab6efb80 100644 --- a/tests/Migration/ConfigTest.php +++ b/tests/Migration/ConfigTest.php @@ -12,6 +12,9 @@ use Yiisoft\Di\Container; use Yiisoft\Di\ContainerConfig; use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; +use Yiisoft\Db\Migration\DatabaseSet; +use Yiisoft\Db\Migration\DatabaseSetRegistry; +use Symfony\Component\Console\Tester\CommandTester; use Yiisoft\Db\Migration\Command\CreateCommand; use Yiisoft\Db\Migration\Command\DownCommand; use Yiisoft\Db\Migration\Command\HistoryCommand; @@ -62,7 +65,17 @@ public function testBase(): void $this->assertInstanceOf(Migrator::class, $container->get(Migrator::class)); } - private function createConsoleContainer(): Container + public function testAdditionalDatabaseConfiguration(): void + { + $set = new DatabaseSet(new SqLiteConnection(new SqLiteDriver('sqlite::memory:'))); + $container = $this->createConsoleContainer(['maps' => $set]); + $this->assertSame(['default', 'maps'], $container->get(DatabaseSetRegistry::class)->getNames()); + $command = new CommandTester($container->get(DownCommand::class)); + $this->assertSame(2, $command->execute([])); + $this->assertStringContainsString('--db option is required', $command->getDisplay()); + } + + private function createConsoleContainer(array $databases = []): Container { $config = ContainerConfig::create() ->withDefinitions(array_merge( @@ -78,14 +91,15 @@ private function createConsoleContainer(): Container ], ], ], - $this->getConsoleDefinitions(), + $this->getConsoleDefinitions($databases), )); return new Container($config); } - private function getConsoleDefinitions(): array + private function getConsoleDefinitions(array $databases): array { $params = $this->getParams(); + $params['yiisoft/db-migration']['databases'] = $databases; return require dirname(__DIR__, 2) . '/config/di-console.php'; } diff --git a/tests/Migration/DatabaseSetRegistryTest.php b/tests/Migration/DatabaseSetRegistryTest.php new file mode 100644 index 00000000..c2c21cb3 --- /dev/null +++ b/tests/Migration/DatabaseSetRegistryTest.php @@ -0,0 +1,42 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Database names must be non-empty strings other than "default".'); + new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ + $name => new DatabaseSet($this->createMock(ConnectionInterface::class)), + ]); + } + + public function testDefaultIsFirstFollowedByConfigurationOrder(): void + { + $set = new DatabaseSet($this->createMock(ConnectionInterface::class)); + $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ + 'maps' => $set, + 'analytics' => $set, + ]); + self::assertSame(['default', 'maps', 'analytics'], $registry->getNames()); + } +} From a6d074c5f5e3bd4bdc3dc0c3f94f7e8eefcfda63 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:10:53 +0300 Subject: [PATCH 02/16] Fix test connections for CI dependencies --- tests/Driver/Sqlite/DatabaseCommandsTest.php | 4 +++- tests/Migration/BinTest.php | 20 ++++++++++++++------ tests/Migration/ConfigTest.php | 3 ++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index ab74d78f..affe8a44 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -23,6 +23,8 @@ use Yiisoft\Db\Migration\Runner\UpdateRunner; use Yiisoft\Db\Migration\Service\Generate\CreateService; use Yiisoft\Db\Migration\Service\MigrationService; +use Yiisoft\Db\Cache\SchemaCache; +use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; use Yiisoft\Db\Sqlite\Connection; use Yiisoft\Db\Sqlite\Driver; use Yiisoft\Files\FileHelper; @@ -47,7 +49,7 @@ protected function setUp(): void $this->sets = []; foreach (['default', 'maps', 'analytics'] as $name) { FileHelper::ensureDirectory($this->directory . '/' . $name); - $this->connections[$name] = new Connection(new Driver('sqlite::memory:')); + $this->connections[$name] = new Connection(new Driver('sqlite::memory:'), new SchemaCache(new MemorySimpleCache())); $this->sets[$name] = new DatabaseSet( $this->connections[$name], newMigrationPath: $this->directory . '/' . $name, diff --git a/tests/Migration/BinTest.php b/tests/Migration/BinTest.php index b00e7e9c..b9972b83 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -6,7 +6,10 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Files\FileHelper; -use PDO; +use Yiisoft\Db\Cache\SchemaCache; +use Yiisoft\Db\Sqlite\Connection; +use Yiisoft\Db\Sqlite\Driver; +use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; use function dirname; @@ -75,14 +78,16 @@ public function testNamedDatabase(): void { $this->replaceParams("'db' => null,", <<<'PHP' 'db' => new \Yiisoft\Db\Sqlite\Connection( - new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/default.sqlite') + new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/default.sqlite'), + new \Yiisoft\Db\Cache\SchemaCache(new \Yiisoft\Test\Support\SimpleCache\MemorySimpleCache()) ), PHP); $this->replaceParams("'databases' => [],", <<<'PHP' 'databases' => [ 'maps' => new \Yiisoft\Db\Migration\DatabaseSet( new \Yiisoft\Db\Sqlite\Connection( - new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/maps.sqlite') + new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/maps.sqlite'), + new \Yiisoft\Db\Cache\SchemaCache(new \Yiisoft\Test\Support\SimpleCache\MemorySimpleCache()) ), newMigrationPath: __DIR__ . '/maps', historyTable: 'maps_history', @@ -105,9 +110,12 @@ public function up(\Yiisoft\Db\Migration\MigrationBuilder $b): void $this->assertSame(0, $exitCode, $output); $this->assertStringContainsString('Database: maps', $output); $this->assertFileDoesNotExist($directory . '/default.sqlite'); - $pdo = new PDO('sqlite:' . $directory . '/maps.sqlite'); - $this->assertSame('M260930000000Maps', $pdo->query('SELECT name FROM maps_history')->fetchColumn()); - $this->assertSame(0, (int) $pdo->query('SELECT COUNT(*) FROM example')->fetchColumn()); + $db = new Connection( + new Driver('sqlite:' . $directory . '/maps.sqlite'), + new SchemaCache(new MemorySimpleCache()), + ); + $this->assertSame('M260930000000Maps', $db->createCommand('SELECT name FROM maps_history')->queryScalar()); + $this->assertSame(0, (int) $db->createCommand('SELECT COUNT(*) FROM example')->queryScalar()); } private function replaceParams($search, $replace): void diff --git a/tests/Migration/ConfigTest.php b/tests/Migration/ConfigTest.php index ab6efb80..95dd568c 100644 --- a/tests/Migration/ConfigTest.php +++ b/tests/Migration/ConfigTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Psr\SimpleCache\CacheInterface; +use Yiisoft\Db\Cache\SchemaCache; use Yiisoft\Db\Connection\ConnectionInterface; use Yiisoft\Db\Sqlite\Connection as SqLiteConnection; use Yiisoft\Db\Sqlite\Driver as SqLiteDriver; @@ -67,7 +68,7 @@ public function testBase(): void public function testAdditionalDatabaseConfiguration(): void { - $set = new DatabaseSet(new SqLiteConnection(new SqLiteDriver('sqlite::memory:'))); + $set = new DatabaseSet(new SqLiteConnection(new SqLiteDriver('sqlite::memory:'), new SchemaCache(new MemorySimpleCache()))); $container = $this->createConsoleContainer(['maps' => $set]); $this->assertSame(['default', 'maps'], $container->get(DatabaseSetRegistry::class)->getNames()); $command = new CommandTester($container->get(DownCommand::class)); From ece0be73cf8d36e5017972fb73a8f142cc982801 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:18:31 +0300 Subject: [PATCH 03/16] Fix coverage collection and organize framework documentation --- .github/workflows/build.yml | 4 +- docs/guide/en/multiple-databases.md | 93 ++----------------------- docs/guide/en/usage-with-symfony.md | 41 ++++++++++- docs/guide/en/usage-with-yii-console.md | 58 ++++++++++++++- 4 files changed, 106 insertions(+), 90 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 660f0f5f..f93cce73 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,7 +48,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} - ini-values: date.timezone='UTC', pcov.directory=$GITHUB_WORKSPACE, pcov.exclude=#^(?!($GITHUB_WORKSPACE/config/|$GITHUB_WORKSPACE/src/)).*# + ini-values: date.timezone='UTC', pcov.directory=${{ github.workspace }} coverage: pcov - name: Install Composer dependencies + required yiisoft/db and yiisoft/db-sqlite @@ -58,7 +58,7 @@ jobs: ['db', 'db-sqlite'] - name: Run tests with phpunit. - run: vendor/bin/phpunit --testsuite=Migration --coverage-clover=coverage.xml --colors=always --display-warnings --display-deprecations + run: vendor/bin/phpunit --testsuite=Migration,Sqlite --coverage-clover=coverage.xml --colors=always --display-warnings --display-deprecations - name: Upload coverage to Codecov. if: matrix.php == '8.5' diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index acadf081..4399b726 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -11,15 +11,15 @@ and `analytics`; the name `default` is reserved. Each set may use a different da Select one database with `--db`: ```shell -./yii migrate:create create_places --db=maps -./yii migrate:up --db=maps -./yii migrate:new --db=maps -./yii migrate:history --db=maps -./yii migrate:down --db=maps -./yii migrate:redo --db=maps +./vendor/bin/yii-db-migration migrate:create create_places --db=maps +./vendor/bin/yii-db-migration migrate:up --db=maps +./vendor/bin/yii-db-migration migrate:new --db=maps +./vendor/bin/yii-db-migration migrate:history --db=maps +./vendor/bin/yii-db-migration migrate:down --db=maps +./vendor/bin/yii-db-migration migrate:redo --db=maps ``` -Replace `./yii` with `php bin/console` for Symfony or `./vendor/bin/yii-db-migration` for standalone use. +The examples use the standalone executable. Without `--db`, commands behave as follows: @@ -71,85 +71,6 @@ package migrations. Each set also accepts `historyTable`, `migrationNameLimit`, `useTablePrefix`, and `maxSqlOutputLength`. These use the same defaults as the standalone configuration; settings from the default set are not inherited by additional sets. -## Yii Console - -Keep your current default configuration. Add `DatabaseSet` instances to the `databases` parameter: - -```php -use Yiisoft\Db\Migration\DatabaseSet; - -// $mapsConnection and $analyticsConnection implement ConnectionInterface. -return [ - 'yiisoft/db-migration' => [ - 'newMigrationPath' => __DIR__ . '/migrations/default', - 'databases' => [ - 'maps' => new DatabaseSet( - $mapsConnection, - newMigrationPath: __DIR__ . '/migrations/maps', - ), - 'analytics' => new DatabaseSet( - $analyticsConnection, - newMigrationPath: __DIR__ . '/migrations/analytics', - ), - ], - ], -]; -``` - -If connections are defined as container services, configure `DatabaseSetRegistry` in your console DI configuration instead: - -```php -use Yiisoft\Db\Migration\DatabaseSet; -use Yiisoft\Db\Migration\DatabaseSetRegistry; -use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; -use Yiisoft\Injector\Injector; -use Psr\Container\ContainerInterface; - -return [ - DatabaseSetRegistry::class => static fn ( - ContainerInterface $container, - Injector $injector, - MigrationInformerInterface $informer, - ) => new DatabaseSetRegistry($injector, $informer, [ - 'maps' => new DatabaseSet( - $container->get('db.maps'), - newMigrationNamespace: 'App\\Migrations\\Maps', - ), - ]), -]; -``` - -## Symfony - -Keep the [standard Symfony setup](usage-with-symfony.md) for the default database. Configure additional sets using your -existing connection services, and inject the registry into the migration commands through autowiring: - -```yaml -# config/services.yaml -services: - app.migrations.maps: - class: Yiisoft\Db\Migration\DatabaseSet - arguments: - $db: '@yii3.connections.maps' - $newMigrationPath: '%kernel.project_dir%/config/migrations/maps' - - app.migrations.analytics: - class: Yiisoft\Db\Migration\DatabaseSet - arguments: - $db: '@yii3.connections.analytics' - $newMigrationPath: '%kernel.project_dir%/config/migrations/analytics' - - Yiisoft\Db\Migration\DatabaseSetRegistry: - autowire: true - arguments: - $databases: - maps: '@app.migrations.maps' - analytics: '@app.migrations.analytics' -``` - -The registry uses the configured `Injector` to instantiate migrations, so constructor dependency injection continues to -work for migrations in additional sets. - ## Standalone Add `databases` to your existing `yii-db-migration.php` configuration. Keep the existing `db` and other options for the diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index f751926d..f12dfeea 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -54,4 +54,43 @@ sqlite_driver: That's it. Now you can use `bin/console migrate:*` commands. -For independent migration sets on multiple connections, see [Multiple databases](multiple-databases.md). +## Multiple databases + +Keep the setup above for the default database. Configure additional sets using your +existing connection services, and inject the registry into the migration commands through autowiring: + +```yaml +# config/services.yaml +services: + app.migrations.maps: + class: Yiisoft\Db\Migration\DatabaseSet + arguments: + $db: '@yii3.connections.maps' + $newMigrationPath: '%kernel.project_dir%/config/migrations/maps' + + app.migrations.analytics: + class: Yiisoft\Db\Migration\DatabaseSet + arguments: + $db: '@yii3.connections.analytics' + $newMigrationPath: '%kernel.project_dir%/config/migrations/analytics' + + Yiisoft\Db\Migration\DatabaseSetRegistry: + autowire: true + arguments: + $databases: + maps: '@app.migrations.maps' + analytics: '@app.migrations.analytics' +``` + +The registry uses the configured `Injector` to instantiate migrations, so constructor dependency injection continues to +work for migrations in additional sets. + +Select one set with `--db`, or apply all sets with `migrate:up`: + +```shell +php bin/console migrate:create create_places --db=maps +php bin/console migrate:up --db=maps +php bin/console migrate:up +``` + +See [Multiple databases](multiple-databases.md) for command behavior, execution order, and history isolation. diff --git a/docs/guide/en/usage-with-yii-console.md b/docs/guide/en/usage-with-yii-console.md index d17fb3ba..ec7cdd67 100644 --- a/docs/guide/en/usage-with-yii-console.md +++ b/docs/guide/en/usage-with-yii-console.md @@ -46,4 +46,60 @@ View the list of available commands with `./yii list`: ./yii list ``` -For independent migration sets on multiple connections, see [Multiple databases](multiple-databases.md). +## Multiple databases + +Keep your current default configuration. Add `DatabaseSet` instances to the `databases` parameter: + +```php +use Yiisoft\Db\Migration\DatabaseSet; + +// $mapsConnection and $analyticsConnection implement ConnectionInterface. +return [ + 'yiisoft/db-migration' => [ + 'newMigrationPath' => __DIR__ . '/migrations/default', + 'databases' => [ + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/migrations/maps', + ), + 'analytics' => new DatabaseSet( + $analyticsConnection, + newMigrationPath: __DIR__ . '/migrations/analytics', + ), + ], + ], +]; +``` + +If connections are defined as container services, configure `DatabaseSetRegistry` in your console DI configuration instead: + +```php +use Yiisoft\Db\Migration\DatabaseSet; +use Yiisoft\Db\Migration\DatabaseSetRegistry; +use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; +use Yiisoft\Injector\Injector; +use Psr\Container\ContainerInterface; + +return [ + DatabaseSetRegistry::class => static fn ( + ContainerInterface $container, + Injector $injector, + MigrationInformerInterface $informer, + ) => new DatabaseSetRegistry($injector, $informer, [ + 'maps' => new DatabaseSet( + $container->get('db.maps'), + newMigrationNamespace: 'App\\Migrations\\Maps', + ), + ]), +]; +``` + +Select one set with `--db`, or apply all sets with `migrate:up`: + +```shell +./yii migrate:create create_places --db=maps +./yii migrate:up --db=maps +./yii migrate:up +``` + +See [Multiple databases](multiple-databases.md) for command behavior, execution order, and history isolation. From 4d1708c1c565848e778039abd1b11c92fe1f34a3 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:21:13 +0300 Subject: [PATCH 04/16] Rewrite multiple database guide around user workflows --- docs/guide/en/multiple-databases.md | 148 ++++++++++++++++------------ 1 file changed, 83 insertions(+), 65 deletions(-) diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 4399b726..6f1fe58a 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -1,95 +1,113 @@ # Multiple databases -A database set groups a connection, migration sources, a destination for new migrations, and history-table settings. -Use this when different parts of your application, such as Maps and Analytics, have their own databases and migrations. +If your application uses separate databases for Maps, Analytics, or other services, you can keep migrations for each +one in its own directory. Use `--db` to work with one database, or apply migrations to all databases with a single command. +Each database keeps its own migration history. -Your existing connection and migration configuration form the `default` set. Add other sets under names such as `maps` -and `analytics`; the name `default` is reserved. Each set may use a different database driver. +## Configure your databases -## Commands +Your existing database is named `default`. Give each additional database a name, such as `maps` or `analytics`, and a +directory for its migrations. Create the directories before generating migrations: -Select one database with `--db`: +```text +config/migrations/default/ +config/migrations/maps/ +config/migrations/analytics/ +``` + +For the standalone executable, add `databases` to your existing `yii-db-migration.php`. The example below assumes you +have already created the `$mapsConnection` and `$analyticsConnection` database connections, as described in +[Standalone usage](usage-standalone.md#with-configuration-file). + +```php +use Yiisoft\Db\Migration\DatabaseSet; + +return [ + // Keep your existing options, including 'db' for the default database. + 'newMigrationPath' => __DIR__ . '/config/migrations/default', + 'databases' => [ + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/config/migrations/maps', + ), + 'analytics' => new DatabaseSet( + $analyticsConnection, + newMigrationPath: __DIR__ . '/config/migrations/analytics', + ), + ], +]; +``` + +The following examples use the standalone executable. + +## Create a migration + +To create a migration in the Maps directory: ```shell ./vendor/bin/yii-db-migration migrate:create create_places --db=maps -./vendor/bin/yii-db-migration migrate:up --db=maps -./vendor/bin/yii-db-migration migrate:new --db=maps -./vendor/bin/yii-db-migration migrate:history --db=maps -./vendor/bin/yii-db-migration migrate:down --db=maps -./vendor/bin/yii-db-migration migrate:redo --db=maps ``` -The examples use the standalone executable. +Edit the generated file with your migration operations as usual. When you apply it with `--db=maps`, those operations +run against the Maps database. + +Without `--db`, `migrate:create` creates the file in the default database's migration directory. + +## Apply migrations -Without `--db`, commands behave as follows: +Apply pending migrations to Maps only: -| Command | Behavior | -| --- | --- | -| `migrate:up` | Apply migrations to all sets, default first, then additional sets in configuration order. | -| `migrate:new` | Show pending migrations for all sets in the same order. | -| `migrate:history` | Show history for all sets in the same order. | -| `migrate:create` | Create a migration in the default set. | -| `migrate:down`, `migrate:redo` | Require `--db` when additional sets are configured. With only the default set, behave as before. | +```shell +./vendor/bin/yii-db-migration migrate:up --db=maps +``` -Output identifies each database when multiple sets are configured. Unknown names are rejected before executing any -migration and the error lists the available names. `--db=default` explicitly selects the existing configuration. +To apply pending migrations to all configured databases: -`--limit` applies separately to each selected set. Existing `--path` and `--namespace` options also apply separately to -selected sets; they do not choose a database. Use `--db` to scope those options to one set. +```shell +./vendor/bin/yii-db-migration migrate:up +``` -Execution stops at the first error or nonzero command result. Empty results in `new` and `history` are successful when -listing all sets, so an empty database does not hide results from later ones. Selecting one empty set retains the existing -failure exit code. Each set retains the existing confirmation behavior; `migrate:up --force-yes` skips all confirmations. +The default database runs first, followed by the additional databases in configuration order. In the example above, +Maps runs before Analytics. If Analytics needs tables or data created by Maps, keep that order in the configuration. +You confirm migrations separately for each database. Add `--force-yes` to skip these prompts during deployment. -There is no transaction spanning databases. If Maps fails after the default database succeeds, the default database keeps -its changes and history. Later sets are skipped. Correct the problem and rerun the command; applied migrations are skipped. -Order additional sets so that prerequisites run first. All default migrations run before any additional set. +To apply at most two migrations per database: -## History and migration classes +```shell +./vendor/bin/yii-db-migration migrate:up --limit=2 +``` -Each set stores history using its own connection, in `{{%migration}}` by default. Separate databases may use the same table -name. If sets share a database, configure distinct `historyTable` names to keep their histories independent. +If a migration fails, execution stops before proceeding to later databases. Changes already applied to earlier databases +remain. Fix the failing migration and run the command again; migrations recorded as applied are skipped. -Migration classes remain ordinary implementations of `MigrationInterface`, `RevertibleMigrationInterface`, or -`TransactionalMigrationInterface`. The builder supplied to `up()` and `down()` uses the selected set's connection. -Transactions and history use that same connection. No connection property or special base class is needed. +## Check pending migrations and history -Use separate directories and unique class names or namespaces for separate sets. For example: +View pending migrations or applied migrations for Maps: -```text -config/migrations/default/ -config/migrations/maps/ -config/migrations/analytics/ +```shell +./vendor/bin/yii-db-migration migrate:new --db=maps +./vendor/bin/yii-db-migration migrate:history --db=maps ``` -A `DatabaseSet` accepts the existing `newMigrationNamespace`, `newMigrationPath`, `sourceNamespaces`, and `sourcePaths` -options. Choose either a new migration namespace or a new migration path, as with single-database configuration. A -namespace must be resolvable through Composer's PSR-4 configuration, and destination directories must exist. The new -migration destination is also included when discovering migrations. Additional source paths/namespaces can contain shared -package migrations. +Omit `--db` to view all configured databases. Results are grouped by database, including those with no migrations to +show. Use `--all` to show the complete list or `--limit=5` to show up to five migrations per database. -Each set also accepts `historyTable`, `migrationNameLimit`, `useTablePrefix`, and `maxSqlOutputLength`. These use the same -defaults as the standalone configuration; settings from the default set are not inherited by additional sets. +History is stored in each database's own `migration` table, using its configured table prefix. If you configure multiple +names for the same physical database and want independent histories, give each a different `historyTable` value. -## Standalone +## Revert or redo a migration -Add `databases` to your existing `yii-db-migration.php` configuration. Keep the existing `db` and other options for the -default set: +Revert the last Maps migration: -```php -use Yiisoft\Db\Migration\DatabaseSet; +```shell +./vendor/bin/yii-db-migration migrate:down --db=maps +``` -return [ - // ... existing options, including the default 'db' connection ... - 'databases' => [ - 'maps' => new DatabaseSet( - $mapsConnection, - newMigrationPath: __DIR__ . '/config/migrations/maps', - ), - ], -]; +Revert and apply the last Maps migration again: + +```shell +./vendor/bin/yii-db-migration migrate:redo --db=maps ``` -For manual command construction, create a `DatabaseSetRegistry` with your injector, informer, and additional sets, then -pass it as the optional final `$databases` constructor argument of each command. Existing constructor calls without a -registry continue to work with the default database only. +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. From 86d36294b0ad469a5a6a03f1b57955c611641d88 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:21:38 +0300 Subject: [PATCH 05/16] Move standalone database configuration to standalone guide --- docs/guide/en/multiple-databases.md | 31 ++++++------------------- docs/guide/en/usage-standalone.md | 36 ++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 6f1fe58a..a2e03d44 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -15,28 +15,11 @@ config/migrations/maps/ config/migrations/analytics/ ``` -For the standalone executable, add `databases` to your existing `yii-db-migration.php`. The example below assumes you -have already created the `$mapsConnection` and `$analyticsConnection` database connections, as described in -[Standalone usage](usage-standalone.md#with-configuration-file). - -```php -use Yiisoft\Db\Migration\DatabaseSet; - -return [ - // Keep your existing options, including 'db' for the default database. - 'newMigrationPath' => __DIR__ . '/config/migrations/default', - 'databases' => [ - 'maps' => new DatabaseSet( - $mapsConnection, - newMigrationPath: __DIR__ . '/config/migrations/maps', - ), - 'analytics' => new DatabaseSet( - $analyticsConnection, - newMigrationPath: __DIR__ . '/config/migrations/analytics', - ), - ], -]; -``` +Follow the configuration instructions for your application: + +- [Standalone](usage-standalone.md#multiple-databases) +- [Yii Console](usage-with-yii-console.md#multiple-databases) +- [Symfony](usage-with-symfony.md#multiple-databases) The following examples use the standalone executable. @@ -67,8 +50,8 @@ To apply pending migrations to all configured databases: ./vendor/bin/yii-db-migration migrate:up ``` -The default database runs first, followed by the additional databases in configuration order. In the example above, -Maps runs before Analytics. If Analytics needs tables or data created by Maps, keep that order in the configuration. +The default database runs first, followed by the additional databases in configuration order. If Analytics needs tables +or data created by Maps, configure Maps before Analytics. You confirm migrations separately for each database. Add `--force-yes` to skip these prompts during deployment. To apply at most two migrations per database: diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index 576caaa4..749b2497 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -27,6 +27,41 @@ ./vendor/bin/yii-db-migration ``` +## Multiple databases + +Add `databases` to your existing `yii-db-migration.php`. The example below assumes you +have already created the `$mapsConnection` and `$analyticsConnection` database connections, as described in +[With configuration file](#with-configuration-file). + +```php +use Yiisoft\Db\Migration\DatabaseSet; + +return [ + // Keep your existing options, including 'db' for the default database. + 'newMigrationPath' => __DIR__ . '/config/migrations/default', + 'databases' => [ + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/config/migrations/maps', + ), + 'analytics' => new DatabaseSet( + $analyticsConnection, + newMigrationPath: __DIR__ . '/config/migrations/analytics', + ), + ], +]; +``` + +Create the migration directories before generating migrations. Then select a database with `--db`: + +```shell +./vendor/bin/yii-db-migration migrate:create create_places --db=maps +./vendor/bin/yii-db-migration migrate:up --db=maps +``` + +See [Multiple databases](multiple-databases.md) for applying migrations across databases, checking history, and reverting +changes. + ## Without configuration file This can be useful in testing environment and/or when multiple RDBMS are used. @@ -73,4 +108,3 @@ $input->setInteractive(false); $this->getMigrateUpdateCommand()->run($input, new NullOutput()); ``` -For independent migration sets on multiple connections, see [Multiple databases](multiple-databases.md). From 550c1b8ec2c61df2e9f458ca023d66b18bd869df Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:21:48 +0300 Subject: [PATCH 06/16] Remove trailing blank line in standalone guide --- docs/guide/en/usage-standalone.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index 749b2497..b3e1bb3e 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -107,4 +107,3 @@ $input->setInteractive(false); $this->getMigrateUpdateCommand()->run($input, new NullOutput()); ``` - From 7485b2a510eb4c3d72310e909126c413853e1487 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:23:11 +0300 Subject: [PATCH 07/16] Clarify migration set and history isolation in guide --- docs/guide/en/multiple-databases.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index a2e03d44..7db29f16 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -1,8 +1,9 @@ # Multiple databases -If your application uses separate databases for Maps, Analytics, or other services, you can keep migrations for each -one in its own directory. Use `--db` to work with one database, or apply migrations to all databases with a single command. -Each database keeps its own migration history. +If your application uses separate databases for Maps, Analytics, or other services, you can organize their migrations +into isolated sets. Each set has its own migration directory and a separate migration history table in its database, +so migrations applied to one set do not affect the recorded status of migrations in another. Use `--db` to work with +one set, or apply migrations to all sets with a single command. ## Configure your databases From 6ca364ba042af9039b5fdfacddc4fe17cf5533e2 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:25:00 +0300 Subject: [PATCH 08/16] Use exact database and directory names in documentation --- docs/guide/en/multiple-databases.md | 22 +++++++++++----------- docs/guide/en/usage-with-symfony.md | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 7db29f16..aa749982 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -1,6 +1,6 @@ # Multiple databases -If your application uses separate databases for Maps, Analytics, or other services, you can organize their migrations +If your application uses separate databases such as `maps` and `analytics`, you can organize their migrations into isolated sets. Each set has its own migration directory and a separate migration history table in its database, so migrations applied to one set do not affect the recorded status of migrations in another. Use `--db` to work with one set, or apply migrations to all sets with a single command. @@ -26,20 +26,20 @@ The following examples use the standalone executable. ## Create a migration -To create a migration in the Maps directory: +To create a migration in the `maps` directory: ```shell ./vendor/bin/yii-db-migration migrate:create create_places --db=maps ``` Edit the generated file with your migration operations as usual. When you apply it with `--db=maps`, those operations -run against the Maps database. +run against the `maps` database. -Without `--db`, `migrate:create` creates the file in the default database's migration directory. +Without `--db`, `migrate:create` creates the file in the `default` database's migration directory. ## Apply migrations -Apply pending migrations to Maps only: +Apply pending migrations to `maps` only: ```shell ./vendor/bin/yii-db-migration migrate:up --db=maps @@ -51,8 +51,8 @@ To apply pending migrations to all configured databases: ./vendor/bin/yii-db-migration migrate:up ``` -The default database runs first, followed by the additional databases in configuration order. If Analytics needs tables -or data created by Maps, configure Maps before Analytics. +The `default` database runs first, followed by the additional databases in configuration order. If `analytics` needs tables +or data created by `maps`, configure `maps` before `analytics`. You confirm migrations separately for each database. Add `--force-yes` to skip these prompts during deployment. To apply at most two migrations per database: @@ -66,7 +66,7 @@ remain. Fix the failing migration and run the command again; migrations recorded ## Check pending migrations and history -View pending migrations or applied migrations for Maps: +View pending migrations or applied migrations for `maps`: ```shell ./vendor/bin/yii-db-migration migrate:new --db=maps @@ -81,17 +81,17 @@ names for the same physical database and want independent histories, give each a ## Revert or redo a migration -Revert the last Maps migration: +Revert the last `maps` migration: ```shell ./vendor/bin/yii-db-migration migrate:down --db=maps ``` -Revert and apply the last Maps migration again: +Revert and apply the last `maps` migration again: ```shell ./vendor/bin/yii-db-migration migrate:redo --db=maps ``` -When multiple databases are configured, both commands require `--db`. Use `--db=default` to select the default database. +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. diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index f12dfeea..2556540e 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -56,7 +56,7 @@ That's it. Now you can use `bin/console migrate:*` commands. ## Multiple databases -Keep the setup above for the default database. Configure additional sets using your +Keep the setup above for the `default` database. Configure additional sets using your existing connection services, and inject the registry into the migration commands through autowiring: ```yaml From 0acd6540d7a2e5f45cf2e9eed50b100c0f2b5332 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:30:56 +0300 Subject: [PATCH 09/16] Remove extra blank line in changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d564539f..5410cc21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ ## 2.1.2 under development - Enh #360: Support named database migration sets with `--db` selection and separate migration history (@samdark) - - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) ## 2.1.1 August 10, 2026 From 7952902284d7419ce9864797181d60a1d4c4a188 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 13:34:39 +0300 Subject: [PATCH 10/16] Require database selection for migration source overrides --- docs/guide/en/multiple-databases.md | 8 +++ src/Command/DatabaseCommand.php | 11 ++++ tests/Driver/Sqlite/DatabaseCommandsTest.php | 67 ++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index aa749982..03aebb94 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -64,6 +64,14 @@ To apply at most two migrations per database: If a migration fails, execution stops before proceeding to later databases. Changes already applied to earlier databases remain. Fix the failing migration and run the command again; migrations recorded as applied are skipped. +When selecting migration files with `--path` or `--namespace`, also specify `--db` if you have multiple databases: + +```shell +./vendor/bin/yii-db-migration migrate:up --db=analytics --namespace='App\Migrations\Analytics' +``` + +This requirement also applies to `migrate:new`, so the selected files are checked against one database's history. + ## Check pending migrations and history View pending migrations or applied migrations for `maps`: diff --git a/src/Command/DatabaseCommand.php b/src/Command/DatabaseCommand.php index 892a8d1d..f56f52c4 100644 --- a/src/Command/DatabaseCommand.php +++ b/src/Command/DatabaseCommand.php @@ -52,6 +52,17 @@ final protected function execute(InputInterface $input, OutputInterface $output) return Command::INVALID; } + if ( + $name === null + && $multipleDatabases + && ($this instanceof UpdateCommand || $this instanceof NewCommand) + && ($input->getOption('path') !== [] || $input->getOption('namespace') !== []) + ) { + $io->error('The --db option is required with --path or --namespace when multiple databases are configured.'); + + return Command::INVALID; + } + if ($name !== null || $this instanceof CreateCommand) { $names = [$name ?? 'default']; } diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index affe8a44..cec10576 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -145,6 +145,73 @@ public function testDestructiveCommandsDoNotRequireDatabaseWithOneSet(string $na self::assertSame(Command::SUCCESS, $this->command($name)->execute(['--force-yes' => true])); } + public static function sourceOverrides(): array + { + return [ + ['up', '--path'], + ['up', '--namespace'], + ['new', '--path'], + ['new', '--namespace'], + ]; + } + + #[DataProvider('sourceOverrides')] + public function testSourceOverrideRequiresDatabaseBeforeAnyWork(string $name, string $option): void + { + $this->writeMigration('analytics'); + $input = [$option => [$option === '--path' + ? $this->directory . '/analytics' + : 'Yiisoft\\Db\\Migration\\Tests\\Support\\MigrationsExtra']]; + if ($name === 'up') { + $input['--force-yes'] = true; + } + $command = $this->command($name); + self::assertSame(Command::INVALID, $command->execute($input)); + self::assertStringContainsString( + 'The --db option is required with --path or --namespace', + preg_replace('/\\s+/', ' ', $command->getDisplay()), + ); + foreach ($this->connections as $connection) { + self::assertSame([], $connection->getSchema()->getTableNames()); + } + } + + #[DataProvider('sourceOverrides')] + public function testSourceOverrideWorksWithExplicitDatabase(string $name, string $option): void + { + $class = $this->writeMigration('analytics'); + $input = ['--db' => 'analytics', $option => [$option === '--path' + ? $this->directory . '/analytics' + : 'Yiisoft\\Db\\Migration\\Tests\\Support\\MigrationsExtra']]; + if ($name === 'up') { + $input['--force-yes'] = true; + } + $command = $this->command($name); + self::assertSame(Command::SUCCESS, $command->execute($input)); + self::assertStringContainsString($class, $command->getDisplay()); + self::assertSame([], $this->connections['default']->getSchema()->getTableNames()); + self::assertSame([], $this->connections['maps']->getSchema()->getTableNames()); + if ($name === 'up') { + self::assertArrayHasKey($class, $this->migrator('analytics')->getHistory()); + } + } + + #[DataProvider('sourceOverrides')] + public function testSourceOverrideWorksWithoutDatabaseWithOneSet(string $name, string $option): void + { + unset($this->sets['maps'], $this->sets['analytics']); + $class = $this->writeMigration('default'); + $input = [$option => [$option === '--path' + ? $this->directory . '/default' + : 'Yiisoft\\Db\\Migration\\Tests\\Support\\MigrationsExtra']]; + if ($name === 'up') { + $input['--force-yes'] = true; + } + $command = $this->command($name); + self::assertSame(Command::SUCCESS, $command->execute($input)); + self::assertStringContainsString($class, $command->getDisplay()); + } + public function testCreateDefaultsToDefaultAndCanSelectAnotherSet(): void { $command = $this->command('create'); From 36c3078bdbb12c0ce6a71740bf361ba6cc1ed70d Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 18:55:57 +0300 Subject: [PATCH 11/16] Decouple database registry and aggregate listing results --- CHANGELOG.md | 2 +- docs/guide/en/multiple-databases.md | 6 +- src/Command/CreateCommand.php | 36 ++++++---- src/Command/DatabaseCommand.php | 32 +++++---- src/Command/DownCommand.php | 32 +++++---- src/Command/HistoryCommand.php | 23 +++--- src/Command/NewCommand.php | 27 ++++--- src/Command/RedoCommand.php | 37 ++++++---- src/Command/UpdateCommand.php | 34 +++++---- src/DatabaseContext.php | 26 +++++++ src/DatabaseSetRegistry.php | 26 +++---- tests/Driver/Sqlite/DatabaseCommandsTest.php | 74 ++++++++++++++++++++ 12 files changed, 248 insertions(+), 107 deletions(-) create mode 100644 src/DatabaseContext.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5410cc21..f6735b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 2.1.2 under development -- Enh #360: Support named database migration sets with `--db` selection and separate migration history (@samdark) +- New #360: Support named database migration sets with `--db` selection and separate migration history (@samdark) - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) ## 2.1.1 August 10, 2026 diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 03aebb94..25d947ad 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -81,8 +81,10 @@ View pending migrations or applied migrations for `maps`: ./vendor/bin/yii-db-migration migrate:history --db=maps ``` -Omit `--db` to view all configured databases. Results are grouped by database, including those with no migrations to -show. Use `--all` to show the complete list or `--limit=5` to show up to five migrations per database. +Omit `--db` to view all configured databases. Results are grouped by database. The command succeeds if at least one +database has migrations to show, and returns a failure exit code if all results are empty. An error stops execution even +if an earlier database had results. Use `--all` to show the complete list or `--limit=5` to show up to five migrations per +database. History is stored in each database's own `migration` table, using its configured table prefix. If you configure multiple names for the same physical database and want independent histories, give each a different `historyTable` value. diff --git a/src/Command/CreateCommand.php b/src/Command/CreateCommand.php index 4b31f82c..4e98dc84 100644 --- a/src/Command/CreateCommand.php +++ b/src/Command/CreateCommand.php @@ -12,6 +12,7 @@ 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\Generate\CreateService; @@ -92,14 +93,21 @@ protected function configure(): void ->setHelp('This command generates new migration file.'); } - protected function executeForDatabase(InputInterface $input, OutputInterface $output): int - { + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context?->migrator ?? $this->migrator; + $migrationService = $context?->migrationService ?? $this->migrationService; + $createService = $context?->createService ?? $this->createService; + $io = new SymfonyStyle($input, $output); - $this->migrator->setIo($io); - $this->migrationService->setIo($io); - $this->createService->setIo($io); + $migrator->setIo($io); + $migrationService->setIo($io); + $createService->setIo($io); - $this->migrationService->databaseConnection(); + $migrationService->databaseConnection(); /** @var string|null $path */ $path = $input->getOption('path'); @@ -108,13 +116,13 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $namespace = $input->getOption('namespace'); if ($path !== null || $namespace !== null) { - $this->migrationService->setNewMigrationPath((string) $path); - $this->migrationService->setNewMigrationNamespace((string) $namespace); + $migrationService->setNewMigrationPath((string) $path); + $migrationService->setNewMigrationNamespace((string) $namespace); } else { - $namespace = $this->migrationService->getNewMigrationNamespace(); + $namespace = $migrationService->getNewMigrationNamespace(); } - if ($this->migrationService->before($this->getName() ?? '') === Command::INVALID) { + if ($migrationService->before($this->getName() ?? '') === Command::INVALID) { return Command::INVALID; } @@ -144,8 +152,8 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $and = $input->getOption('and'); $name = $this->generateName($command, $table, $and); - $className = $this->migrationService->generateClassName($name); - $nameLimit = $this->migrator->getMigrationNameLimit(); + $className = $migrationService->generateClassName($name); + $nameLimit = $migrator->getMigrationNameLimit(); if ($nameLimit !== 0 && strlen($className) > $nameLimit) { $io->error('The migration name is too long.'); @@ -154,7 +162,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou } try { - $migrationPath = $this->migrationService->findMigrationPath(); + $migrationPath = $migrationService->findMigrationPath(); } catch (LogicException $e) { $io->error($e->getMessage()); @@ -172,7 +180,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou /** @var string|null $tableComment */ $tableComment = $input->getOption('table-comment'); - $content = $this->createService->run( + $content = $createService->run( $command, $table, $className, diff --git a/src/Command/DatabaseCommand.php b/src/Command/DatabaseCommand.php index f56f52c4..eafe4cb5 100644 --- a/src/Command/DatabaseCommand.php +++ b/src/Command/DatabaseCommand.php @@ -10,6 +10,7 @@ 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 function count; @@ -21,8 +22,6 @@ */ abstract class DatabaseCommand extends Command { - protected bool $allowEmptyResults = false; - public function __construct(private readonly ?DatabaseSetRegistry $databases = null) { parent::__construct(); @@ -67,30 +66,33 @@ final protected function execute(InputInterface $input, OutputInterface $output) $names = [$name ?? 'default']; } - foreach ($names as $database) { - $command = $this; - if ($database !== 'default' && $this->databases !== null) { - $command = $this->databases->createCommand($database, static::class); - } + $isListing = $this instanceof NewCommand || $this instanceof HistoryCommand; + $hasResults = false; - $command->allowEmptyResults = count($names) > 1; - $command->setName($this->getName() ?? ''); - if (($helpers = $this->getHelperSet()) !== null) { - $command->setHelperSet($helpers); - } + foreach ($names as $database) { + $context = $database === 'default' ? null : $this->databases?->createContext($database); if ($multipleDatabases) { $io->section('Database: ' . OutputFormatter::escape($database)); } - $status = $command->executeForDatabase($input, $output); + $status = $this->executeForDatabase($input, $output, $context); + // Listing commands return FAILURE for an empty set; other sets may still have results. + if ($isListing && $status === Command::FAILURE) { + continue; + } if ($status !== Command::SUCCESS) { return $status; } + $hasResults = true; } - return Command::SUCCESS; + return $isListing && !$hasResults ? Command::FAILURE : Command::SUCCESS; } - abstract protected function executeForDatabase(InputInterface $input, OutputInterface $output): int; + abstract protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int; } diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php index 30c11989..1364407d 100644 --- a/src/Command/DownCommand.php +++ b/src/Command/DownCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use Yiisoft\Db\Migration\DatabaseContext; use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; @@ -59,16 +60,23 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } - protected function executeForDatabase(InputInterface $input, OutputInterface $output): int - { + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context?->migrator ?? $this->migrator; + $migrationService = $context?->migrationService ?? $this->migrationService; + $downRunner = $context?->downRunner ?? $this->downRunner; + $io = new SymfonyStyle($input, $output); - $this->migrator->setIo($io); - $this->migrationService->setIo($io); - $this->downRunner->setIo($io); + $migrator->setIo($io); + $migrationService->setIo($io); + $downRunner->setIo($io); - $this->migrationService->databaseConnection(); + $migrationService->databaseConnection(); - $this->migrationService->before($this->getName() ?? ''); + $migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -86,9 +94,9 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $migrations = $this->migrator->getHistory(); + $migrations = $migrator->getHistory(); $migrations = array_keys($migrations); - $migrations = $this->migrationService->filterMigrations($migrations, $namespaces, $paths); + $migrations = $migrationService->filterMigrations($migrations, $namespaces, $paths); if (empty($migrations)) { $io->warning('No applied migrations found.'); @@ -100,7 +108,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $migrations = array_slice($migrations, 0, $limit); } } else { - $migrations = $this->migrator->getHistory($limit); + $migrations = $migrator->getHistory($limit); if (empty($migrations)) { $output->writeln("Apply at least one migration first.\n"); @@ -124,12 +132,12 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou } if ($input->getOption('force-yes') || $io->confirm("Revert the above $migrationWord?")) { - $instances = $this->migrationService->makeRevertibleMigrations($migrations); + $instances = $migrationService->makeRevertibleMigrations($migrations); $migrationWas = ($countMigrations === 1 ? 'migration was' : 'migrations were'); foreach ($instances as $i => $instance) { try { - $this->downRunner->run($instance, $i + 1); + $downRunner->run($instance, $i + 1); } catch (Throwable $e) { $output->writeln("\nTotal $i out of $countMigrations $migrationWas reverted.\n"); $io->error($i > 0 ? 'Partially reverted.' : 'Not reverted.'); diff --git a/src/Command/HistoryCommand.php b/src/Command/HistoryCommand.php index ca4bdf3c..652bdd23 100644 --- a/src/Command/HistoryCommand.php +++ b/src/Command/HistoryCommand.php @@ -10,6 +10,7 @@ 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; @@ -48,15 +49,21 @@ protected function configure(): void ->addOption('all', 'a', InputOption::VALUE_NONE, 'All migrations.'); } - protected function executeForDatabase(InputInterface $input, OutputInterface $output): int - { + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context?->migrator ?? $this->migrator; + $migrationService = $context?->migrationService ?? $this->migrationService; + $io = new SymfonyStyle($input, $output); - $this->migrator->setIo($io); - $this->migrationService->setIo($io); + $migrator->setIo($io); + $migrationService->setIo($io); - $this->migrationService->databaseConnection(); + $migrationService->databaseConnection(); - $this->migrationService->before($this->getName() ?? ''); + $migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -68,12 +75,12 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou return Command::INVALID; } - $migrations = $this->migrator->getHistory($limit); + $migrations = $migrator->getHistory($limit); if (empty($migrations)) { $io->warning('No migration has been done before.'); - return $this->allowEmptyResults ? Command::SUCCESS : Command::FAILURE; + return Command::FAILURE; } $countMigrations = count($migrations); diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index 8f688b02..2c186af7 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -10,6 +10,7 @@ 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; @@ -56,13 +57,19 @@ protected function configure(): void ->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Namespace of migrations to display.'); } - protected function executeForDatabase(InputInterface $input, OutputInterface $output): int - { + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context?->migrator ?? $this->migrator; + $migrationService = $context?->migrationService ?? $this->migrationService; + $io = new SymfonyStyle($input, $output); - $this->migrator->setIo($io); - $this->migrationService->setIo($io); + $migrator->setIo($io); + $migrationService->setIo($io); - $this->migrationService->databaseConnection(); + $migrationService->databaseConnection(); /** @var string[] $paths */ $paths = $input->getOption('path'); @@ -71,11 +78,11 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $this->migrationService->setSourcePaths($paths); - $this->migrationService->setSourceNamespaces($namespaces); + $migrationService->setSourcePaths($paths); + $migrationService->setSourceNamespaces($namespaces); } - $this->migrationService->before($this->getName() ?? ''); + $migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -87,12 +94,12 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou return Command::INVALID; } - $migrations = $this->migrationService->getNewMigrations(); + $migrations = $migrationService->getNewMigrations(); if (empty($migrations)) { $io->warning('No new migrations found. Your system is up-to-date.'); - return $this->allowEmptyResults ? Command::SUCCESS : Command::FAILURE; + return Command::FAILURE; } $countMigrations = count($migrations); diff --git a/src/Command/RedoCommand.php b/src/Command/RedoCommand.php index 3a03cd7c..4959437d 100644 --- a/src/Command/RedoCommand.php +++ b/src/Command/RedoCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use Yiisoft\Db\Migration\DatabaseContext; use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; @@ -60,17 +61,25 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } - protected function executeForDatabase(InputInterface $input, OutputInterface $output): int - { + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context?->migrator ?? $this->migrator; + $migrationService = $context?->migrationService ?? $this->migrationService; + $downRunner = $context?->downRunner ?? $this->downRunner; + $updateRunner = $context?->updateRunner ?? $this->updateRunner; + $io = new SymfonyStyle($input, $output); - $this->migrator->setIo($io); - $this->migrationService->setIo($io); - $this->downRunner->setIo($io); - $this->updateRunner->setIo($io); + $migrator->setIo($io); + $migrationService->setIo($io); + $downRunner->setIo($io); + $updateRunner->setIo($io); - $this->migrationService->databaseConnection(); + $migrationService->databaseConnection(); - $this->migrationService->before($this->getName() ?? ''); + $migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -88,9 +97,9 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $migrations = $this->migrator->getHistory(); + $migrations = $migrator->getHistory(); $migrations = array_keys($migrations); - $migrations = $this->migrationService->filterMigrations($migrations, $namespaces, $paths); + $migrations = $migrationService->filterMigrations($migrations, $namespaces, $paths); if (empty($migrations)) { $io->warning('No applied migrations found.'); @@ -102,7 +111,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $migrations = array_slice($migrations, 0, $limit); } } else { - $migrations = $this->migrator->getHistory($limit); + $migrations = $migrator->getHistory($limit); if (empty($migrations)) { $io->warning('No migration has been done before.'); @@ -123,12 +132,12 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou } if ($input->getOption('force-yes') || $io->confirm("Redo the above $migrationWord?")) { - $instances = $this->migrationService->makeRevertibleMigrations($migrations); + $instances = $migrationService->makeRevertibleMigrations($migrations); $migrationWas = ($countMigrations === 1 ? 'migration was' : 'migrations were'); foreach ($instances as $i => $instance) { try { - $this->downRunner->run($instance, $i + 1); + $downRunner->run($instance, $i + 1); } catch (Throwable $e) { $output->writeln("\nTotal $i out of $countMigrations $migrationWas reverted.\n"); $io->error($i > 0 ? 'Partially reverted.' : 'Not reverted.'); @@ -139,7 +148,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou foreach (array_reverse($instances) as $i => $instance) { try { - $this->updateRunner->run($instance, $countMigrations - $i); + $updateRunner->run($instance, $countMigrations - $i); } catch (Throwable $e) { $output->writeln("\nTotal $i out of $countMigrations $migrationWas applied.\n"); $io->error($i > 0 ? 'Reverted but partially applied.' : 'Reverted but not applied.'); diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index 945a8171..bcedc727 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; +use Yiisoft\Db\Migration\DatabaseContext; use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\UpdateRunner; @@ -57,14 +58,21 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } - protected function executeForDatabase(InputInterface $input, OutputInterface $output): int - { + protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int { + $migrator = $context?->migrator ?? $this->migrator; + $migrationService = $context?->migrationService ?? $this->migrationService; + $updateRunner = $context?->updateRunner ?? $this->updateRunner; + $io = new SymfonyStyle($input, $output); - $this->migrator->setIo($io); - $this->migrationService->setIo($io); - $this->updateRunner->setIo($io); + $migrator->setIo($io); + $migrationService->setIo($io); + $updateRunner->setIo($io); - $this->migrationService->databaseConnection(); + $migrationService->databaseConnection(); /** @var string[] $paths */ $paths = $input->getOption('path'); @@ -73,11 +81,11 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $this->migrationService->setSourcePaths($paths); - $this->migrationService->setSourceNamespaces($namespaces); + $migrationService->setSourcePaths($paths); + $migrationService->setSourceNamespaces($namespaces); } - if ($this->migrationService->before($this->getName() ?? '') === Command::INVALID) { + if ($migrationService->before($this->getName() ?? '') === Command::INVALID) { return Command::INVALID; } @@ -93,7 +101,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou } } - $migrations = $this->migrationService->getNewMigrations(); + $migrations = $migrationService->getNewMigrations(); if (empty($migrations)) { $output->writeln("No new migrations found.\n"); @@ -114,7 +122,7 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou } foreach ($migrations as $i => $migration) { - $nameLimit = $this->migrator->getMigrationNameLimit(); + $nameLimit = $migrator->getMigrationNameLimit(); if (strlen($migration) > $nameLimit) { $output->writeln( @@ -129,12 +137,12 @@ protected function executeForDatabase(InputInterface $input, OutputInterface $ou } if ($input->getOption('force-yes') || $io->confirm("Apply the above $migrationWord?")) { - $instances = $this->migrationService->makeMigrations($migrations); + $instances = $migrationService->makeMigrations($migrations); $migrationWas = ($migrationsCount === 1 ? 'migration was' : 'migrations were'); foreach ($instances as $i => $instance) { try { - $this->updateRunner->run($instance, $i + 1); + $updateRunner->run($instance, $i + 1); } catch (Throwable $e) { $output->writeln("\nTotal $i out of $migrationsCount new $migrationWas applied.\n"); $io->error($i > 0 ? 'Partially updated.' : 'Not updated.'); diff --git a/src/DatabaseContext.php b/src/DatabaseContext.php new file mode 100644 index 00000000..462811f9 --- /dev/null +++ b/src/DatabaseContext.php @@ -0,0 +1,26 @@ + $class */ - public function createCommand(string $name, string $class): DatabaseCommand + public function createContext(string $name): DatabaseContext { $database = $this->databases[$name] ?? throw new InvalidArgumentException("Unknown database set: $name."); $migrator = new Migrator( @@ -77,14 +69,12 @@ public function createCommand(string $name, string $class): DatabaseCommand $service->setSourceNamespaces($database->sourceNamespaces); $service->setSourcePaths($database->sourcePaths); - return match ($class) { - CreateCommand::class => new CreateCommand(new CreateService($database->db, $database->useTablePrefix), $service, $migrator), - DownCommand::class => new DownCommand(new DownRunner($migrator), $service, $migrator), - HistoryCommand::class => new HistoryCommand($service, $migrator), - NewCommand::class => new NewCommand($service, $migrator), - RedoCommand::class => new RedoCommand($service, $migrator, new DownRunner($migrator), new UpdateRunner($migrator)), - UpdateCommand::class => new UpdateCommand(new UpdateRunner($migrator), $service, $migrator), - default => throw new InvalidArgumentException("Unsupported migration command: $class."), - }; + return new DatabaseContext( + $migrator, + $service, + new CreateService($database->db, $database->useTablePrefix), + new DownRunner($migrator), + new UpdateRunner($migrator), + ); } } diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index cec10576..88a3c053 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; +use Yiisoft\Db\Exception\Exception as DbException; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use Yiisoft\Db\Migration\Command\CreateCommand; @@ -273,6 +274,79 @@ public function testListingContinuesPastEmptySetsAndCanSelectOne(string $name): self::assertSame(Command::FAILURE, $command->execute(['--db' => 'default'])); } + #[DataProvider('listingCommands')] + public function testListingAllEmptySetsFailsAfterVisitingEverySet(string $name): void + { + $command = $this->command($name); + self::assertSame(Command::FAILURE, $command->execute([])); + foreach (['default', 'maps', 'analytics'] as $database) { + self::assertStringContainsString('Database: ' . $database, $command->getDisplay()); + } + } + + #[DataProvider('listingCommands')] + public function testListingSucceedsWhenOnlyTheLastSetHasResults(string $name): void + { + $class = $this->writeMigration('analytics'); + if ($name === 'history') { + $this->command('up')->execute(['--db' => 'analytics', '--force-yes' => true]); + } + $command = $this->command($name); + self::assertSame(Command::SUCCESS, $command->execute([])); + self::assertStringContainsString($class, $command->getDisplay()); + } + + #[DataProvider('listingCommands')] + public function testListingAllNonemptySetsSucceeds(string $name): void + { + $classes = []; + foreach (['default', 'maps', 'analytics'] as $database) { + $classes[] = $this->writeMigration($database); + } + if ($name === 'history') { + $this->command('up')->execute(['--force-yes' => true]); + } + $command = $this->command($name); + self::assertSame(Command::SUCCESS, $command->execute([])); + foreach ($classes as $class) { + self::assertStringContainsString($class, $command->getDisplay()); + } + } + + #[DataProvider('listingCommands')] + public function testListingStopsOnErrorEvenAfterFindingResults(string $name): void + { + $this->writeMigration('default'); + if ($name === 'history') { + $this->command('up')->execute(['--db' => 'default', '--force-yes' => true]); + } + $this->sets['maps'] = new DatabaseSet( + new Connection( + new Driver('sqlite:' . $this->directory . '/missing/maps.sqlite'), + new SchemaCache(new MemorySimpleCache()), + ), + newMigrationPath: $this->directory . '/maps', + ); + $command = $this->command($name); + try { + $command->execute([]); + self::fail('The database error must propagate.'); + } catch (DbException) { + self::assertStringContainsString('Database: maps', $command->getDisplay()); + self::assertStringNotContainsString('Database: analytics', $command->getDisplay()); + } + self::assertSame([], $this->connections['analytics']->getSchema()->getTableNames()); + } + + #[DataProvider('listingCommands')] + public function testListingRejectsInvalidOptionsBeforeVisitingLaterSets(string $name): void + { + $command = $this->command($name); + self::assertSame(Command::INVALID, $command->execute(['--limit' => 0])); + self::assertStringNotContainsString('Database: maps', $command->getDisplay()); + self::assertStringNotContainsString('Database: analytics', $command->getDisplay()); + } + public function testLimitAppliesToEachDatabase(): void { foreach (['default', 'maps', 'analytics'] as $name) { From 4c5f9979cc57e794f221a12855847252d012c000 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 19:17:59 +0300 Subject: [PATCH 12/16] Cover surviving mutations and include named database tests in mutation CI --- .github/workflows/mutation.yml | 39 ++++++++++++++++++- src/Command/CreateCommand.php | 6 +-- src/Command/DownCommand.php | 6 +-- src/Command/HistoryCommand.php | 4 +- src/Command/NewCommand.php | 4 +- src/Command/RedoCommand.php | 8 ++-- src/Command/UpdateCommand.php | 6 +-- .../Command/AbstractCreateCommandTest.php | 2 + .../Command/AbstractUpdateCommandTest.php | 1 + .../Service/AbstractMigrationServiceTest.php | 19 ++++++++- .../Generate/AbstractCreateServiceTest.php | 10 +++++ 11 files changed, 86 insertions(+), 19 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index a1415330..53d52616 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -24,4 +24,41 @@ concurrency: jobs: tests: - uses: yiisoft/actions/.github/workflows/db-mutation.yml@master + name: PHP 8.5 + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: root + POSTGRES_DB: yiitest + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install PHP with extensions. + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.5' + extensions: pdo_pgsql, pdo_sqlite + ini-values: date.timezone='UTC' + tools: infection + coverage: pcov + + - name: Install Composer dependencies and database packages. + uses: yiisoft/actions/install-packages@master + with: + packages: >- + ['db', 'db-pgsql', 'db-sqlite'] + + - name: Run infection. + # Command tests share generated migration files and database tables. + run: infection --threads=1 --ignore-msi-with-no-mutations --test-framework-options="--testsuite=Migration,Sqlite,Pgsql" diff --git a/src/Command/CreateCommand.php b/src/Command/CreateCommand.php index 4e98dc84..1a84cc62 100644 --- a/src/Command/CreateCommand.php +++ b/src/Command/CreateCommand.php @@ -98,9 +98,9 @@ protected function executeForDatabase( OutputInterface $output, ?DatabaseContext $context, ): int { - $migrator = $context?->migrator ?? $this->migrator; - $migrationService = $context?->migrationService ?? $this->migrationService; - $createService = $context?->createService ?? $this->createService; + $migrator = $context->migrator ?? $this->migrator; + $migrationService = $context->migrationService ?? $this->migrationService; + $createService = $context->createService ?? $this->createService; $io = new SymfonyStyle($input, $output); $migrator->setIo($io); diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php index 1364407d..a507c433 100644 --- a/src/Command/DownCommand.php +++ b/src/Command/DownCommand.php @@ -65,9 +65,9 @@ protected function executeForDatabase( OutputInterface $output, ?DatabaseContext $context, ): int { - $migrator = $context?->migrator ?? $this->migrator; - $migrationService = $context?->migrationService ?? $this->migrationService; - $downRunner = $context?->downRunner ?? $this->downRunner; + $migrator = $context->migrator ?? $this->migrator; + $migrationService = $context->migrationService ?? $this->migrationService; + $downRunner = $context->downRunner ?? $this->downRunner; $io = new SymfonyStyle($input, $output); $migrator->setIo($io); diff --git a/src/Command/HistoryCommand.php b/src/Command/HistoryCommand.php index 652bdd23..bb65a0d6 100644 --- a/src/Command/HistoryCommand.php +++ b/src/Command/HistoryCommand.php @@ -54,8 +54,8 @@ protected function executeForDatabase( OutputInterface $output, ?DatabaseContext $context, ): int { - $migrator = $context?->migrator ?? $this->migrator; - $migrationService = $context?->migrationService ?? $this->migrationService; + $migrator = $context->migrator ?? $this->migrator; + $migrationService = $context->migrationService ?? $this->migrationService; $io = new SymfonyStyle($input, $output); $migrator->setIo($io); diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index 2c186af7..22ec668b 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -62,8 +62,8 @@ protected function executeForDatabase( OutputInterface $output, ?DatabaseContext $context, ): int { - $migrator = $context?->migrator ?? $this->migrator; - $migrationService = $context?->migrationService ?? $this->migrationService; + $migrator = $context->migrator ?? $this->migrator; + $migrationService = $context->migrationService ?? $this->migrationService; $io = new SymfonyStyle($input, $output); $migrator->setIo($io); diff --git a/src/Command/RedoCommand.php b/src/Command/RedoCommand.php index 4959437d..55f1f8f0 100644 --- a/src/Command/RedoCommand.php +++ b/src/Command/RedoCommand.php @@ -66,10 +66,10 @@ protected function executeForDatabase( OutputInterface $output, ?DatabaseContext $context, ): int { - $migrator = $context?->migrator ?? $this->migrator; - $migrationService = $context?->migrationService ?? $this->migrationService; - $downRunner = $context?->downRunner ?? $this->downRunner; - $updateRunner = $context?->updateRunner ?? $this->updateRunner; + $migrator = $context->migrator ?? $this->migrator; + $migrationService = $context->migrationService ?? $this->migrationService; + $downRunner = $context->downRunner ?? $this->downRunner; + $updateRunner = $context->updateRunner ?? $this->updateRunner; $io = new SymfonyStyle($input, $output); $migrator->setIo($io); diff --git a/src/Command/UpdateCommand.php b/src/Command/UpdateCommand.php index bcedc727..b63ee251 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -63,9 +63,9 @@ protected function executeForDatabase( OutputInterface $output, ?DatabaseContext $context, ): int { - $migrator = $context?->migrator ?? $this->migrator; - $migrationService = $context?->migrationService ?? $this->migrationService; - $updateRunner = $context?->updateRunner ?? $this->updateRunner; + $migrator = $context->migrator ?? $this->migrator; + $migrationService = $context->migrationService ?? $this->migrationService; + $updateRunner = $context->updateRunner ?? $this->updateRunner; $io = new SymfonyStyle($input, $output); $migrator->setIo($io); diff --git a/tests/Common/Command/AbstractCreateCommandTest.php b/tests/Common/Command/AbstractCreateCommandTest.php index b6d12d24..0faf9b25 100644 --- a/tests/Common/Command/AbstractCreateCommandTest.php +++ b/tests/Common/Command/AbstractCreateCommandTest.php @@ -635,6 +635,7 @@ public function testDropTable(): void $output = preg_replace('/(\R|\s)+/', ' ', $command->getDisplay(true)); $className = MigrationHelper::findMigrationClassNameInOutput($output); + $this->assertMatchesRegularExpression('/^M[0-9]{12}DropPostTable$/', $className); $namespace = MigrationHelper::NAMESPACE; $expectedMigrationCode = <<getDisplay(true)); $className = MigrationHelper::findMigrationClassNameInOutput($output); + $this->assertMatchesRegularExpression('/^M[0-9]{12}JunctionTableForPostAndTagTables$/', $className); $namespace = MigrationHelper::NAMESPACE; $expectedMigrationCode = <<getTableSchema('department'); $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Database connection: ' . $db->getDriverName() . '.', $output); /** Check create table department columns*/ $this->assertCount(2, $departmentSchema->getColumns()); diff --git a/tests/Common/Service/AbstractMigrationServiceTest.php b/tests/Common/Service/AbstractMigrationServiceTest.php index ee454eb4..69ef98b8 100644 --- a/tests/Common/Service/AbstractMigrationServiceTest.php +++ b/tests/Common/Service/AbstractMigrationServiceTest.php @@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use ReflectionMethod; +use Symfony\Component\Console\Command\Command; +use Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty; use Yiisoft\Db\Migration\Service\MigrationService; use Yiisoft\Db\Migration\Tests\Support\Helper\MigrationHelper; @@ -18,6 +20,15 @@ abstract class AbstractMigrationServiceTest extends TestCase { protected ContainerInterface $container; + public function testCreateRequiresANewMigrationLocation(): void + { + $service = $this->container->get(MigrationService::class); + $service->setNewMigrationNamespace(''); + $service->setNewMigrationPath(''); + + $this->assertSame(Command::INVALID, $service->before('migrate:create')); + } + public function testVersion(): void { $service = $this->container->get(MigrationService::class); @@ -309,6 +320,12 @@ public function testFilterMigrationsWithoutNamespace(): void { $migrationService = $this->container->get(MigrationService::class); - $this->assertSame([], $migrationService->filterMigrations(['ClassNameWithoutNamespace'])); + $this->assertSame( + [M231108183919Empty::class], + $migrationService->filterMigrations( + ['ClassNameWithoutNamespace', M231108183919Empty::class], + ['Yiisoft\Db\Migration\Tests\Support\MigrationsExtra'], + ), + ); } } diff --git a/tests/Common/Service/Generate/AbstractCreateServiceTest.php b/tests/Common/Service/Generate/AbstractCreateServiceTest.php index 9b621059..c92c6100 100644 --- a/tests/Common/Service/Generate/AbstractCreateServiceTest.php +++ b/tests/Common/Service/Generate/AbstractCreateServiceTest.php @@ -13,6 +13,16 @@ abstract class AbstractCreateServiceTest extends TestCase { protected ContainerInterface $container; + public function testJunctionWithoutRelatedTable(): void + { + $service = $this->container->get(CreateService::class); + + $this->assertSame( + $service->run('junction', 'post', 'CreateJunction', and: ''), + $service->run('junction', 'post', 'CreateJunction'), + ); + } + public function testSetTemplate(): void { $service = $this->container->get(CreateService::class); From 565b93df3079fb41253f823efb10a654ea4c7a0b Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Wed, 30 Sep 2026 19:28:43 +0300 Subject: [PATCH 13/16] Cover database set defaults and pin mutation test image --- .github/workflows/mutation.yml | 2 +- tests/Driver/Sqlite/DatabaseCommandsTest.php | 50 +++++++++++++++++++- tests/Migration/DatabaseSetRegistryTest.php | 9 ++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 53d52616..0d5abca6 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -29,7 +29,7 @@ jobs: services: postgres: - image: postgres:18 + image: postgres:18@sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722 env: POSTGRES_USER: root POSTGRES_PASSWORD: root diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index 88a3c053..930deedd 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -115,7 +115,7 @@ public function testUnknownDatabaseIsRejectedBeforeAnyWork(string $name): void $input['name'] = 'Example'; } self::assertSame(Command::INVALID, $command->execute($input)); - self::assertStringContainsString('Available databases: default, maps, analytics.', preg_replace('/\s+/', ' ', $command->getDisplay())); + self::assertStringContainsString('Unknown database "unknown". Available databases: default, maps, analytics.', preg_replace('/\s+/', ' ', $command->getDisplay())); foreach ($this->connections as $connection) { self::assertNull($connection->getSchema()->getTableSchema('migration')); } @@ -397,6 +397,54 @@ public function testNamedSetUsesItsConnectionForGeneration(): void self::assertStringNotContainsString('{{%parent}}', $content); } + public function testCreateUsesNamedSetMigrationNameLimit(): void + { + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + migrationNameLimit: 5, + ); + self::assertSame(Command::INVALID, $this->command('create')->execute([ + 'name' => 'Example', + '--db' => 'maps', + ])); + self::assertSame([], glob($this->directory . '/maps/*.php')); + } + + public function testNamedSetDefaultGenerationSettings(): void + { + $command = $this->command('create'); + self::assertSame(Command::SUCCESS, $command->execute([ + 'name' => str_repeat('a', 167), + '--db' => 'maps', + '--command' => 'create', + ])); + self::assertSame(Command::INVALID, $command->execute([ + 'name' => str_repeat('a', 168), + '--db' => 'maps', + ])); + self::assertSame(Command::SUCCESS, $command->execute([ + 'name' => 'child', + '--command' => 'table', + '--fields' => 'parent_id:integer:foreignKey(parent)', + '--db' => 'maps', + ])); + $files = glob($this->directory . '/maps/*CreateChildTable.php'); + self::assertCount(1, $files); + self::assertStringContainsString('{{%parent}}', file_get_contents($files[0])); + } + + public function testNamedSetDiscoversMigrationsInItsNewMigrationNamespace(): void + { + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationNamespace: 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra', + ); + $command = $this->command('new'); + self::assertSame(Command::SUCCESS, $command->execute(['--db' => 'maps'])); + self::assertStringContainsString(M231108183919Empty::class, $command->getDisplay()); + } + public function testNamedSetRespectsItsMigrationNameLimit(): void { $this->writeMigration('maps'); diff --git a/tests/Migration/DatabaseSetRegistryTest.php b/tests/Migration/DatabaseSetRegistryTest.php index c2c21cb3..96d9dc1a 100644 --- a/tests/Migration/DatabaseSetRegistryTest.php +++ b/tests/Migration/DatabaseSetRegistryTest.php @@ -30,6 +30,15 @@ public function testRejectsInvalidNames(string|int $name): void ]); } + public function testCannotCreateContextForUnknownDatabase(): void + { + $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown database set: missing.'); + $registry->createContext('missing'); + } + public function testDefaultIsFirstFollowedByConfigurationOrder(): void { $set = new DatabaseSet($this->createMock(ConnectionInterface::class)); From 5594e21690044ce81316fe23958351a4c09ab4d4 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 1 Oct 2026 01:04:14 +0300 Subject: [PATCH 14/16] Allow explicit default migration sets and clarify Symfony wiring --- CHANGELOG.md | 2 +- bin/yii-db-migration.php | 4 +- docs/guide/en/multiple-databases.md | 3 +- docs/guide/en/usage-standalone.md | 13 +++ docs/guide/en/usage-with-symfony.md | 108 +++++++++++-------- docs/guide/en/usage-with-yii-console.md | 13 +++ src/Command/DatabaseCommand.php | 2 +- src/DatabaseSet.php | 2 +- src/DatabaseSetRegistry.php | 16 +-- tests/Driver/Sqlite/DatabaseCommandsTest.php | 40 ++++++- tests/Migration/DatabaseSetRegistryTest.php | 25 ++++- 11 files changed, 170 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6735b0b..1ec565e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 2.1.2 under development -- New #360: Support named database migration sets with `--db` selection and separate migration history (@samdark) +- New #360: Support named database migration sets with `--db` selection, an optional explicit `default` set, and separate migration history (@samdark) - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) ## 2.1.1 August 10, 2026 diff --git a/bin/yii-db-migration.php b/bin/yii-db-migration.php index caade834..bcf3c599 100644 --- a/bin/yii-db-migration.php +++ b/bin/yii-db-migration.php @@ -18,8 +18,8 @@ 'db' => null, /** - * Additional named database sets (name => Yiisoft\Db\Migration\DatabaseSet instance). - * The options outside this array configure the default set. The name "default" is reserved. + * Named database sets (name => Yiisoft\Db\Migration\DatabaseSet instance). + * The options outside this array supply default settings unless a "default" set is provided. * See docs/guide/en/multiple-databases.md. */ 'databases' => [], diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 25d947ad..335e61de 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -8,7 +8,8 @@ one set, or apply migrations to all sets with a single command. ## Configure your databases Your existing database is named `default`. Give each additional database a name, such as `maps` or `analytics`, and a -directory for its migrations. Create the directories before generating migrations: +directory for its migrations. You can also explicitly configure `default` with its own connection, migration directory, +and history table. If you omit that set, your existing database and migration settings remain in use. Create the directories before generating migrations: ```text config/migrations/default/ diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index b3e1bb3e..bf8ef38d 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -52,6 +52,19 @@ return [ ]; ``` +To override the `default` database, add a `default` entry alongside `maps` and `analytics` in `databases`: + +```php +'default' => new DatabaseSet( + $defaultConnection, + newMigrationPath: __DIR__ . '/config/migrations/default', + historyTable: 'default_migration', +), +``` + +Here `$defaultConnection` is the connection you want to use for that set. Without this entry, the existing connection +and migration settings continue to supply `default`. Keep the existing base configuration in place. + Create the migration directories before generating migrations. Then select a database with `--db`: ```shell diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index 2556540e..2c4ea3e8 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -10,58 +10,78 @@ composer require yiisoft/db-sqlite Configure migrations and database connection in your `config/services.yml`: ```yaml -Yiisoft\Db\Migration\: - resource: '../vendor/yiisoft/db-migration/src/' - -Yiisoft\Db\Migration\Informer\MigrationInformerInterface: - class: 'Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer' - -Yiisoft\Injector\Injector: - arguments: - - '@service_container' - -Yiisoft\Db\Migration\Service\MigrationService: - calls: - - setNewMigrationNamespace: ['App\Migrations'] - - setNewMigrationPath: [''] - - setSourceNamespaces: [['App\Migrations']] - - setSourcePaths: [[]] - -Yiisoft\Db\: - resource: '../vendor/yiisoft/db/src/' - exclude: - - '../vendor/yiisoft/db/src/Debug/' - -cache.app.simple: - class: 'Symfony\Component\Cache\Psr16Cache' - arguments: - - '@cache.app' - -Yiisoft\Db\Cache\SchemaCache: - arguments: - - '@cache.app.simple' - -Yiisoft\Db\Connection\ConnectionInterface: - class: '\Yiisoft\Db\Sqlite\Connection' - arguments: - - '@sqlite_driver' - -sqlite_driver: - class: '\Yiisoft\Db\Sqlite\Driver' - arguments: - - 'sqlite:./var/migrations.sq3' +services: + _defaults: + autowire: true + autoconfigure: true + + Yiisoft\Db\Migration\: + resource: '../vendor/yiisoft/db-migration/src/' + exclude: + - '../vendor/yiisoft/db-migration/src/DatabaseSetRegistry.php' + - '../vendor/yiisoft/db-migration/src/DatabaseSet.php' + - '../vendor/yiisoft/db-migration/src/DatabaseContext.php' + + Yiisoft\Db\Migration\Informer\MigrationInformerInterface: + class: 'Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer' + + Yiisoft\Injector\Injector: + arguments: + - '@service_container' + + Yiisoft\Db\Migration\Service\MigrationService: + calls: + - setNewMigrationNamespace: ['App\Migrations'] + - setNewMigrationPath: [''] + - setSourceNamespaces: [['App\Migrations']] + - setSourcePaths: [[]] + + Yiisoft\Db\: + resource: '../vendor/yiisoft/db/src/' + exclude: + - '../vendor/yiisoft/db/src/Debug/' + + cache.app.simple: + class: 'Symfony\Component\Cache\Psr16Cache' + arguments: + - '@cache.app' + + Yiisoft\Db\Cache\SchemaCache: + arguments: + - '@cache.app.simple' + + Yiisoft\Db\Connection\ConnectionInterface: + class: '\Yiisoft\Db\Sqlite\Connection' + arguments: + - '@sqlite_driver' + + sqlite_driver: + class: '\Yiisoft\Db\Sqlite\Driver' + arguments: + - 'sqlite:./var/migrations.sq3' ``` That's it. Now you can use `bin/console migrate:*` commands. ## Multiple databases -Keep the setup above for the `default` database. Configure additional sets using your -existing connection services, and inject the registry into the migration commands through autowiring: +The setup above supplies the `default` database unless you explicitly configure a `default` set in the registry. +The example below uses existing connection services and gives each database its own migration directory. +Merge these definitions into the same `services` section as the setup above. + +Keep `DatabaseSetRegistry` excluded from resource discovery and define it explicitly as shown below. Otherwise, a later +resource registration can overwrite your configured registry with an empty one. `DatabaseSet` and `DatabaseContext` are +also excluded because they are configuration and runtime objects, rather than services to discover automatically: ```yaml # config/services.yaml services: + app.migrations.default: + class: Yiisoft\Db\Migration\DatabaseSet + arguments: + $db: '@yii3.connections.default' + $newMigrationPath: '%kernel.project_dir%/config/migrations/default' + app.migrations.maps: class: Yiisoft\Db\Migration\DatabaseSet arguments: @@ -78,10 +98,14 @@ services: autowire: true arguments: $databases: + default: '@app.migrations.default' maps: '@app.migrations.maps' analytics: '@app.migrations.analytics' ``` +Omit `app.migrations.default` and its registry entry to keep using the original connection and migration settings. +An explicit `default` set can use a different connection, directory, and history table. It still runs first. + The registry uses the configured `Injector` to instantiate migrations, so constructor dependency injection continues to work for migrations in additional sets. diff --git a/docs/guide/en/usage-with-yii-console.md b/docs/guide/en/usage-with-yii-console.md index ec7cdd67..675b448f 100644 --- a/docs/guide/en/usage-with-yii-console.md +++ b/docs/guide/en/usage-with-yii-console.md @@ -71,6 +71,19 @@ return [ ]; ``` +To override the `default` database, add a `default` entry alongside `maps` and `analytics` in `databases`: + +```php +'default' => new DatabaseSet( + $defaultConnection, + newMigrationPath: __DIR__ . '/migrations/default', + historyTable: 'default_migration', +), +``` + +Here `$defaultConnection` is the connection you want to use for that set. Without this entry, the existing connection +and migration settings continue to supply `default`. Keep the existing base configuration in place. + If connections are defined as container services, configure `DatabaseSetRegistry` in your console DI configuration instead: ```php diff --git a/src/Command/DatabaseCommand.php b/src/Command/DatabaseCommand.php index eafe4cb5..34d85c6e 100644 --- a/src/Command/DatabaseCommand.php +++ b/src/Command/DatabaseCommand.php @@ -70,7 +70,7 @@ final protected function execute(InputInterface $input, OutputInterface $output) $hasResults = false; foreach ($names as $database) { - $context = $database === 'default' ? null : $this->databases?->createContext($database); + $context = $this->databases?->createContext($database); if ($multipleDatabases) { $io->section('Database: ' . OutputFormatter::escape($database)); diff --git a/src/DatabaseSet.php b/src/DatabaseSet.php index abd3438b..7625032f 100644 --- a/src/DatabaseSet.php +++ b/src/DatabaseSet.php @@ -7,7 +7,7 @@ use Yiisoft\Db\Connection\ConnectionInterface; /** - * Configuration of an additional database and its migrations. + * Configuration of a named database and its migrations. */ final class DatabaseSet { diff --git a/src/DatabaseSetRegistry.php b/src/DatabaseSetRegistry.php index 5c48b6f9..8ffd5699 100644 --- a/src/DatabaseSetRegistry.php +++ b/src/DatabaseSetRegistry.php @@ -17,7 +17,7 @@ use function trim; /** - * Additional database sets, in execution order. The existing command dependencies form the default set. + * Named database sets, in execution order. Existing command dependencies are used when no default set is configured. */ final class DatabaseSetRegistry { @@ -25,7 +25,7 @@ final class DatabaseSetRegistry private readonly array $databases; /** - * @param array $databases Additional sets. The name "default" is reserved. + * @param array $databases Named sets, optionally including "default". */ public function __construct( private readonly Injector $injector, @@ -34,8 +34,8 @@ public function __construct( ) { $sets = []; foreach ($databases as $name => $database) { - if (!is_string($name) || trim($name) === '' || $name === 'default') { - throw new InvalidArgumentException('Database names must be non-empty strings other than "default".'); + if (!is_string($name) || trim($name) === '') { + throw new InvalidArgumentException('Database names must be non-empty strings.'); } $sets[$name] = $database; } @@ -47,14 +47,18 @@ public function __construct( */ public function getNames(): array { - return ['default', ...array_keys($this->databases)]; + return array_keys(['default' => null, ...$this->databases]); } /** * @internal */ - public function createContext(string $name): DatabaseContext + public function createContext(string $name): ?DatabaseContext { + if ($name === 'default' && !isset($this->databases[$name])) { + return null; + } + $database = $this->databases[$name] ?? throw new InvalidArgumentException("Unknown database set: $name."); $migrator = new Migrator( $database->db, diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index 930deedd..e8b8c065 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -101,6 +101,41 @@ public function testSelectDatabaseForUpDownAndRedo(): void self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); } + #[DataProvider('defaultSelection')] + public function testExplicitDefaultOverridesConnectionSourcesAndHistory(bool $selectDefault): void + { + $class = $this->writeMigration('maps'); + $default = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + historyTable: 'custom_history', + ); + unset($this->sets['maps'], $this->sets['analytics']); + $input = $selectDefault ? ['--db' => 'default'] : []; + $new = $this->command('new', $default); + self::assertSame(Command::SUCCESS, $new->execute($input)); + self::assertStringContainsString($class, $new->getDisplay()); + self::assertSame(Command::SUCCESS, $this->command('up', $default)->execute($input + ['--force-yes' => true])); + $history = $this->command('history', $default); + self::assertSame(Command::SUCCESS, $history->execute($input)); + self::assertStringContainsString($class, $history->getDisplay()); + self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('custom_history')); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('migration')); + self::assertSame(Command::SUCCESS, $this->command('redo', $default)->execute($input + ['--force-yes' => true])); + self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertSame(Command::SUCCESS, $this->command('down', $default)->execute($input + ['--force-yes' => true])); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertSame(Command::SUCCESS, $this->command('create', $default)->execute($input + ['name' => 'CustomDefault'])); + self::assertCount(1, glob($this->directory . '/maps/*CustomDefault.php')); + self::assertSame([], glob($this->directory . '/default/*.php')); + self::assertSame([], $this->connections['default']->getSchema()->getTableNames()); + } + + public static function defaultSelection(): array + { + return [[false], [true]]; + } + public static function commands(): array { return [['up'], ['down'], ['redo'], ['new'], ['history'], ['create']]; @@ -521,10 +556,13 @@ private function migrator(string $name): Migrator return new Migrator($this->connections[$name], new NullMigrationInformer(), $this->sets[$name]->historyTable); } - private function command(string $name): CommandTester + private function command(string $name, ?DatabaseSet $default = null): CommandTester { $sets = $this->sets; unset($sets['default']); + if ($default !== null) { + $sets['default'] = $default; + } $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), $sets); $migrator = $this->migrator('default'); $service = new MigrationService($this->connections['default'], new Injector(), $migrator); diff --git a/tests/Migration/DatabaseSetRegistryTest.php b/tests/Migration/DatabaseSetRegistryTest.php index 96d9dc1a..19c28863 100644 --- a/tests/Migration/DatabaseSetRegistryTest.php +++ b/tests/Migration/DatabaseSetRegistryTest.php @@ -17,19 +17,30 @@ final class DatabaseSetRegistryTest extends TestCase { public static function invalidNames(): array { - return [['default'], [''], [' '], [0]]; + return [[''], [' '], [0]]; } #[DataProvider('invalidNames')] public function testRejectsInvalidNames(string|int $name): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Database names must be non-empty strings other than "default".'); + $this->expectExceptionMessage('Database names must be non-empty strings.'); new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ $name => new DatabaseSet($this->createMock(ConnectionInterface::class)), ]); } + public static function defaultConfiguration(): array + { + return [[false], [true]]; + } + + public function testUnconfiguredDefaultUsesExistingServices(): void + { + $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer()); + self::assertNull($registry->createContext('default')); + } + public function testCannotCreateContextForUnknownDatabase(): void { $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer()); @@ -39,13 +50,21 @@ public function testCannotCreateContextForUnknownDatabase(): void $registry->createContext('missing'); } - public function testDefaultIsFirstFollowedByConfigurationOrder(): void + #[DataProvider('defaultConfiguration')] + public function testDefaultIsFirstFollowedByConfigurationOrder(bool $explicitDefault): void { $set = new DatabaseSet($this->createMock(ConnectionInterface::class)); $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ 'maps' => $set, 'analytics' => $set, ]); + if ($explicitDefault) { + $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ + 'maps' => $set, + 'default' => $set, + 'analytics' => $set, + ]); + } self::assertSame(['default', 'maps', 'analytics'], $registry->getNames()); } } From 86b7d779bc14e9287b8ccf5f1657bc307b3d3ab5 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Thu, 1 Oct 2026 18:08:10 +0300 Subject: [PATCH 15/16] Use one configuration source for default migrations --- CHANGELOG.md | 2 +- bin/yii-db-migration | 93 +++++++++----------- bin/yii-db-migration.php | 3 +- config/di-console.php | 27 ++++-- config/params.php | 4 - docs/guide/en/multiple-databases.md | 5 +- docs/guide/en/usage-standalone.md | 27 +++--- docs/guide/en/usage-with-symfony.md | 59 ++++++++++--- docs/guide/en/usage-with-yii-console.md | 51 ++++------- src/Command/CommandFactory.php | 35 ++++++++ tests/Driver/Sqlite/DatabaseCommandsTest.php | 6 +- tests/Migration/BinTest.php | 57 ++++++++++++ tests/Migration/CommandFactoryTest.php | 36 ++++++++ tests/Migration/ConfigTest.php | 34 +++++++ 14 files changed, 311 insertions(+), 128 deletions(-) create mode 100644 src/Command/CommandFactory.php create mode 100644 tests/Migration/CommandFactoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec565e8..3a103133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 2.1.2 under development -- New #360: Support named database migration sets with `--db` selection, an optional explicit `default` set, and separate migration history (@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) ## 2.1.1 August 10, 2026 diff --git a/bin/yii-db-migration b/bin/yii-db-migration index 0463e2b6..badfaeff 100644 --- a/bin/yii-db-migration +++ b/bin/yii-db-migration @@ -6,20 +6,10 @@ declare(strict_types=1); use Psr\Container\ContainerInterface; use Symfony\Component\Console\Application; use Yiisoft\Db\Connection\ConnectionInterface; -use Yiisoft\Db\Migration\Command\CreateCommand; -use Yiisoft\Db\Migration\Command\DownCommand; -use Yiisoft\Db\Migration\Command\HistoryCommand; -use Yiisoft\Db\Migration\Command\NewCommand; -use Yiisoft\Db\Migration\Command\RedoCommand; -use Yiisoft\Db\Migration\Command\UpdateCommand; +use Yiisoft\Db\Migration\Command\CommandFactory; use Yiisoft\Db\Migration\DatabaseSet; use Yiisoft\Db\Migration\DatabaseSetRegistry; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; -use Yiisoft\Db\Migration\Migrator; -use Yiisoft\Db\Migration\Runner\DownRunner; -use Yiisoft\Db\Migration\Runner\UpdateRunner; -use Yiisoft\Db\Migration\Service\Generate\CreateService; -use Yiisoft\Db\Migration\Service\MigrationService; use Yiisoft\Injector\Injector; $rootPath = dirname(__DIR__, 4); @@ -42,51 +32,54 @@ if (!file_exists($configFile)) { /** * @psalm-suppress MissingFile * @psalm-var array{ - * container: ContainerInterface|null, - * db: ConnectionInterface|null, + * container?: ContainerInterface|null, + * db?: ConnectionInterface|null, * databases?: array, - * useTablePrefix: bool, - * historyTable: string, - * migrationNameLimit: int|null, - * maxSqlOutputLength: int|null, - * newMigrationNamespace: string, - * newMigrationPath: string, - * sourceNamespaces: list, - * sourcePaths: list, + * useTablePrefix?: bool, + * historyTable?: string, + * migrationNameLimit?: int|null, + * maxSqlOutputLength?: int|null, + * newMigrationNamespace?: string, + * newMigrationPath?: string, + * sourceNamespaces?: list, + * sourcePaths?: list, * } $params */ $params = require $configFile; -$db = $params['db'] ?? throw new LogicException('DB connection is not configured.'); +$sets = $params['databases'] ?? []; +$legacyOptions = [ + 'db', 'newMigrationNamespace', 'newMigrationPath', 'sourceNamespaces', 'sourcePaths', + 'historyTable', 'migrationNameLimit', 'useTablePrefix', 'maxSqlOutputLength', +]; +if (isset($sets['default'])) { + foreach ($legacyOptions as $option) { + if (array_key_exists($option, $params)) { + throw new LogicException('Configure default migrations either in databases["default"] or with top-level options, not both. Conflicting option: ' . $option . '.'); + } + } +} else { + $sets['default'] = new DatabaseSet( + $params['db'] ?? throw new LogicException('DB connection is not configured.'), + newMigrationNamespace: $params['newMigrationNamespace'] ?? '', + newMigrationPath: $params['newMigrationPath'] ?? '', + sourceNamespaces: $params['sourceNamespaces'] ?? [], + sourcePaths: $params['sourcePaths'] ?? [], + historyTable: $params['historyTable'] ?? '{{%migration}}', + migrationNameLimit: array_key_exists('migrationNameLimit', $params) ? $params['migrationNameLimit'] : 180, + useTablePrefix: $params['useTablePrefix'] ?? true, + maxSqlOutputLength: $params['maxSqlOutputLength'] ?? null, + ); +} -$injector = new Injector($params['container']); -$migrationInformer = new ConsoleMigrationInformer(); -$databases = new DatabaseSetRegistry($injector, $migrationInformer, $params['databases'] ?? []); -$migrator = new Migrator( - $db, - $migrationInformer, - $params['historyTable'], - $params['migrationNameLimit'], - $params['maxSqlOutputLength'] +$databases = new DatabaseSetRegistry( + new Injector($params['container'] ?? null), + new ConsoleMigrationInformer(), + $sets, ); -$createService = new CreateService($db, $params['useTablePrefix']); -$downRunner = new DownRunner($migrator); -$updateRunner = new UpdateRunner($migrator); - -$migrationService = new MigrationService($db, $injector, $migrator); -$migrationService->setNewMigrationNamespace($params['newMigrationNamespace']); -$migrationService->setNewMigrationPath($params['newMigrationPath']); -$migrationService->setSourceNamespaces($params['sourceNamespaces']); -$migrationService->setSourcePaths($params['sourcePaths']); - +$factory = new CommandFactory($databases); $application = new Application('Yii Database Migration Tool', '1.0.0'); -$application->addCommands([ - new CreateCommand($createService, $migrationService, $migrator, $databases), - new DownCommand($downRunner, $migrationService, $migrator, $databases), - new HistoryCommand($migrationService, $migrator, $databases), - new NewCommand($migrationService, $migrator, $databases), - new RedoCommand($migrationService, $migrator, $downRunner, $updateRunner, $databases), - new UpdateCommand($updateRunner, $migrationService, $migrator, $databases), -]); - +foreach (['create', 'down', 'history', 'new', 'redo', 'up'] as $name) { + $application->add($factory->create($name)); +} $application->run(); diff --git a/bin/yii-db-migration.php b/bin/yii-db-migration.php index bcf3c599..3fa96244 100644 --- a/bin/yii-db-migration.php +++ b/bin/yii-db-migration.php @@ -19,7 +19,8 @@ /** * Named database sets (name => Yiisoft\Db\Migration\DatabaseSet instance). - * The options outside this array supply default settings unless a "default" set is provided. + * For a named "default" set, move the legacy options below and "db" into that set, removing their top-level entries. + * Without a "default" entry, the top-level options configure default migrations. Do not supply both forms. * See docs/guide/en/multiple-databases.md. */ 'databases' => [], diff --git a/config/di-console.php b/config/di-console.php index d0df03d1..e3dba47c 100644 --- a/config/di-console.php +++ b/config/di-console.php @@ -3,13 +3,15 @@ declare(strict_types=1); use Yiisoft\Db\Migration\DatabaseSetRegistry; +use Yiisoft\Db\Migration\Command\CommandFactory; +use Yiisoft\Db\Migration\Command\DatabaseCommand; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; use Yiisoft\Db\Migration\Service\MigrationService; /** @var array $params */ -return [ +$definitions = [ DatabaseSetRegistry::class => [ 'class' => DatabaseSetRegistry::class, '__construct()' => [ @@ -19,11 +21,26 @@ MigrationService::class => [ 'class' => MigrationService::class, - 'setNewMigrationNamespace()' => [$params['yiisoft/db-migration']['newMigrationNamespace']], - 'setSourceNamespaces()' => [$params['yiisoft/db-migration']['sourceNamespaces']], - 'setNewMigrationPath()' => [$params['yiisoft/db-migration']['newMigrationPath']], - 'setSourcePaths()' => [$params['yiisoft/db-migration']['sourcePaths']], + 'setNewMigrationNamespace()' => [($params['yiisoft/db-migration']['newMigrationNamespace'] ?? '')], + 'setSourceNamespaces()' => [($params['yiisoft/db-migration']['sourceNamespaces'] ?? [])], + 'setNewMigrationPath()' => [($params['yiisoft/db-migration']['newMigrationPath'] ?? '')], + 'setSourcePaths()' => [($params['yiisoft/db-migration']['sourcePaths'] ?? [])], ], MigrationInformerInterface::class => ConsoleMigrationInformer::class, ]; + +if (isset($params['yiisoft/db-migration']['databases']['default'])) { + foreach (['newMigrationNamespace', 'newMigrationPath', 'sourceNamespaces', 'sourcePaths'] as $option) { + if (\array_key_exists($option, $params['yiisoft/db-migration'])) { + throw new LogicException('Configure default migrations either in databases["default"] or with migration parameters, not both. Conflicting option: ' . $option . '.'); + } + } + unset($definitions[MigrationService::class]); + foreach (['Create' => 'create', 'Down' => 'down', 'History' => 'history', 'New' => 'new', 'Redo' => 'redo', 'Update' => 'up'] as $class => $name) { + $definitions['Yiisoft\\Db\\Migration\\Command\\' . $class . 'Command'] + = static fn(CommandFactory $factory): DatabaseCommand => $factory->create($name); + } +} + +return $definitions; diff --git a/config/params.php b/config/params.php index 4f62b03c..d68d3067 100644 --- a/config/params.php +++ b/config/params.php @@ -23,9 +23,5 @@ 'yiisoft/db-migration' => [ 'databases' => [], - 'newMigrationNamespace' => '', - 'newMigrationPath' => '', - 'sourceNamespaces' => [], - 'sourcePaths' => [], ], ]; diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 335e61de..42c2e781 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -8,8 +8,9 @@ one set, or apply migrations to all sets with a single command. ## Configure your databases Your existing database is named `default`. Give each additional database a name, such as `maps` or `analytics`, and a -directory for its migrations. You can also explicitly configure `default` with its own connection, migration directory, -and history table. If you omit that set, your existing database and migration settings remain in use. Create the directories before generating migrations: +directory for its migrations. Define `default` in the same way when using named sets, with its own connection, +migration directory, and history table. Alternatively, keep your existing configuration for `default` and configure only +the additional sets. Choose one form for `default`; do not configure it in both places. Create the directories before generating migrations: ```text config/migrations/default/ diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index bf8ef38d..dd60e65b 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -29,17 +29,19 @@ ## Multiple databases -Add `databases` to your existing `yii-db-migration.php`. The example below assumes you -have already created the `$mapsConnection` and `$analyticsConnection` database connections, as described in +Define all migration sets, including `default`, in `databases`. The example below assumes you have already created +`$defaultConnection`, `$mapsConnection`, and `$analyticsConnection`, as described in [With configuration file](#with-configuration-file). ```php use Yiisoft\Db\Migration\DatabaseSet; return [ - // Keep your existing options, including 'db' for the default database. - 'newMigrationPath' => __DIR__ . '/config/migrations/default', 'databases' => [ + 'default' => new DatabaseSet( + $defaultConnection, + newMigrationPath: __DIR__ . '/config/migrations/default', + ), 'maps' => new DatabaseSet( $mapsConnection, newMigrationPath: __DIR__ . '/config/migrations/maps', @@ -52,18 +54,13 @@ return [ ]; ``` -To override the `default` database, add a `default` entry alongside `maps` and `analytics` in `databases`: - -```php -'default' => new DatabaseSet( - $defaultConnection, - newMigrationPath: __DIR__ . '/config/migrations/default', - historyTable: 'default_migration', -), -``` +When moving an existing configuration to this form, move `db` and its migration options into the `default` set and +remove the top-level entries. Supplying both forms raises a configuration error, including when a top-level option has +an empty or default value. The optional `container` setting remains at the top level. -Here `$defaultConnection` is the connection you want to use for that set. Without this entry, the existing connection -and migration settings continue to supply `default`. Keep the existing base configuration in place. +Existing configurations remain supported: if `databases` does not contain `default`, the top-level `db`, migration +paths, namespaces, history, and generation options configure that set. You can still add `maps` and `analytics` to +`databases` while keeping this legacy form for `default`. Create the migration directories before generating migrations. Then select a database with `--db`: diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index 2c4ea3e8..6b7b20b2 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -21,6 +21,7 @@ services: - '../vendor/yiisoft/db-migration/src/DatabaseSetRegistry.php' - '../vendor/yiisoft/db-migration/src/DatabaseSet.php' - '../vendor/yiisoft/db-migration/src/DatabaseContext.php' + - '../vendor/yiisoft/db-migration/src/Command/CommandFactory.php' Yiisoft\Db\Migration\Informer\MigrationInformerInterface: class: 'Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer' @@ -65,17 +66,26 @@ That's it. Now you can use `bin/console migrate:*` commands. ## Multiple databases -The setup above supplies the `default` database unless you explicitly configure a `default` set in the registry. -The example below uses existing connection services and gives each database its own migration directory. -Merge these definitions into the same `services` section as the setup above. +Use the following migration service configuration in place of the migration resource registration and +`MigrationService` configuration above. It assumes the three connection services already exist. Each connection, +migration directory, and history setting is defined in its `DatabaseSet` only; no separate default migration services +or application-wide `ConnectionInterface` binding are needed. -Keep `DatabaseSetRegistry` excluded from resource discovery and define it explicitly as shown below. Otherwise, a later -resource registration can overwrite your configured registry with an empty one. `DatabaseSet` and `DatabaseContext` are -also excluded because they are configuration and runtime objects, rather than services to discover automatically: +Do not register the migration package through a broad `resource` rule alongside these definitions. A later resource +registration can replace explicit service definitions, including the configured registry. ```yaml -# config/services.yaml services: + _defaults: + autowire: true + autoconfigure: true + + Yiisoft\Injector\Injector: + arguments: ['@service_container'] + + Yiisoft\Db\Migration\Informer\MigrationInformerInterface: + class: Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer + app.migrations.default: class: Yiisoft\Db\Migration\DatabaseSet arguments: @@ -95,19 +105,42 @@ services: $newMigrationPath: '%kernel.project_dir%/config/migrations/analytics' Yiisoft\Db\Migration\DatabaseSetRegistry: - autowire: true arguments: $databases: default: '@app.migrations.default' maps: '@app.migrations.maps' analytics: '@app.migrations.analytics' -``` -Omit `app.migrations.default` and its registry entry to keep using the original connection and migration settings. -An explicit `default` set can use a different connection, directory, and history table. It still runs first. + Yiisoft\Db\Migration\Command\CommandFactory: ~ + + Yiisoft\Db\Migration\Command\CreateCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['create'] + + Yiisoft\Db\Migration\Command\DownCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['down'] + + Yiisoft\Db\Migration\Command\HistoryCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['history'] + + Yiisoft\Db\Migration\Command\NewCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['new'] + + Yiisoft\Db\Migration\Command\RedoCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['redo'] + + Yiisoft\Db\Migration\Command\UpdateCommand: + factory: ['@Yiisoft\Db\Migration\Command\CommandFactory', 'create'] + arguments: ['up'] +``` -The registry uses the configured `Injector` to instantiate migrations, so constructor dependency injection continues to -work for migrations in additional sets. +To retain your legacy default configuration instead, keep the single-database setup above, omit the `default` entry +from the registry, and use the original autowired command services instead of `CommandFactory`. The registry can still +contain additional sets. Choose one wiring style; do not keep both configurations for the default migration services. Select one set with `--db`, or apply all sets with `migrate:up`: diff --git a/docs/guide/en/usage-with-yii-console.md b/docs/guide/en/usage-with-yii-console.md index 675b448f..a5686889 100644 --- a/docs/guide/en/usage-with-yii-console.md +++ b/docs/guide/en/usage-with-yii-console.md @@ -48,16 +48,19 @@ View the list of available commands with `./yii list`: ## Multiple databases -Keep your current default configuration. Add `DatabaseSet` instances to the `databases` parameter: +Define your migration sets, including `default`, in the `databases` parameter: ```php use Yiisoft\Db\Migration\DatabaseSet; -// $mapsConnection and $analyticsConnection implement ConnectionInterface. +// These connection objects implement ConnectionInterface. return [ 'yiisoft/db-migration' => [ - 'newMigrationPath' => __DIR__ . '/migrations/default', 'databases' => [ + 'default' => new DatabaseSet( + $defaultConnection, + newMigrationPath: __DIR__ . '/migrations/default', + ), 'maps' => new DatabaseSet( $mapsConnection, newMigrationPath: __DIR__ . '/migrations/maps', @@ -71,41 +74,17 @@ return [ ]; ``` -To override the `default` database, add a `default` entry alongside `maps` and `analytics` in `databases`: - -```php -'default' => new DatabaseSet( - $defaultConnection, - newMigrationPath: __DIR__ . '/migrations/default', - historyTable: 'default_migration', -), -``` - -Here `$defaultConnection` is the connection you want to use for that set. Without this entry, the existing connection -and migration settings continue to supply `default`. Keep the existing base configuration in place. +Move existing `newMigrationNamespace`, `newMigrationPath`, `sourceNamespaces`, and `sourcePaths` parameters into the +`default` set and remove the original entries. Supplying both forms raises a configuration error. No application-wide +`ConnectionInterface` binding is required for migration commands in this form; each set supplies its connection. +Other parts of your application can continue to use their own database services. -If connections are defined as container services, configure `DatabaseSetRegistry` in your console DI configuration instead: +If you keep the legacy form, omit `databases['default']`. The existing migration service configuration and connection +then supply `default`, and `databases` can contain additional sets. -```php -use Yiisoft\Db\Migration\DatabaseSet; -use Yiisoft\Db\Migration\DatabaseSetRegistry; -use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; -use Yiisoft\Injector\Injector; -use Psr\Container\ContainerInterface; - -return [ - DatabaseSetRegistry::class => static fn ( - ContainerInterface $container, - Injector $injector, - MigrationInformerInterface $informer, - ) => new DatabaseSetRegistry($injector, $informer, [ - 'maps' => new DatabaseSet( - $container->get('db.maps'), - newMigrationNamespace: 'App\\Migrations\\Maps', - ), - ]), -]; -``` +For custom DI wiring, use `CommandFactory` with a registry containing `default` to build commands directly from the +sets. Replace the old migration service wiring when switching to this form; configuring those services separately +would leave unused settings. The [Symfony example](usage-with-symfony.md#multiple-databases) shows this approach. Select one set with `--db`, or apply all sets with `migrate:up`: diff --git a/src/Command/CommandFactory.php b/src/Command/CommandFactory.php new file mode 100644 index 00000000..c1264c47 --- /dev/null +++ b/src/Command/CommandFactory.php @@ -0,0 +1,35 @@ +databases->createContext('default') + ?? throw new LogicException('Configure databases["default"] when using CommandFactory.'); + $service = $context->migrationService; + $migrator = $context->migrator; + + return match ($name) { + '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), + '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), + default => throw new InvalidArgumentException('Unknown migration command: ' . $name . '.'), + }; + } +} diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index e8b8c065..3459c45c 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use Yiisoft\Db\Migration\Command\CreateCommand; +use Yiisoft\Db\Migration\Command\CommandFactory; use Yiisoft\Db\Migration\Command\DownCommand; use Yiisoft\Db\Migration\Command\HistoryCommand; use Yiisoft\Db\Migration\Command\NewCommand; @@ -102,7 +103,7 @@ public function testSelectDatabaseForUpDownAndRedo(): void } #[DataProvider('defaultSelection')] - public function testExplicitDefaultOverridesConnectionSourcesAndHistory(bool $selectDefault): void + public function testNamedDefaultSuppliesConnectionSourcesAndHistory(bool $selectDefault): void { $class = $this->writeMigration('maps'); $default = new DatabaseSet( @@ -564,6 +565,9 @@ private function command(string $name, ?DatabaseSet $default = null): CommandTes $sets['default'] = $default; } $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), $sets); + if ($default !== null) { + return new CommandTester((new CommandFactory($registry))->create($name)); + } $migrator = $this->migrator('default'); $service = new MigrationService($this->connections['default'], new Injector(), $migrator); $service->setNewMigrationPath($this->sets['default']->newMigrationPath); diff --git a/tests/Migration/BinTest.php b/tests/Migration/BinTest.php index b9972b83..e0b91928 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Db\Migration\Tests\Migration; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Files\FileHelper; use Yiisoft\Db\Cache\SchemaCache; use Yiisoft\Db\Sqlite\Connection; @@ -118,6 +119,62 @@ public function up(\Yiisoft\Db\Migration\MigrationBuilder $b): void $this->assertSame(0, (int) $db->createCommand('SELECT COUNT(*) FROM example')->queryScalar()); } + public function testNamedDefaultWithoutLegacyOptions(): void + { + $directory = dirname(__DIR__) . '/runtime/bin'; + FileHelper::ensureDirectory($directory . '/migrations'); + file_put_contents($directory . '/yii-db-migration.php', $this->namedDefaultConfig()); + [$output, $exitCode] = $this->runYiiDbMigration(['migrate:create', 'Example']); + self::assertSame(0, $exitCode, $output); + self::assertCount(1, glob($directory . '/migrations/*Example.php')); + [$output, $exitCode] = $this->runYiiDbMigration(['migrate:up', '-y']); + self::assertSame(0, $exitCode, $output); + $db = new Connection(new Driver('sqlite:' . $directory . '/named.sqlite'), new SchemaCache(new MemorySimpleCache())); + self::assertCount(1, $db->createCommand('SELECT name FROM named_history')->queryColumn()); + } + + public static function legacyOptions(): array + { + return array_map(static fn(string $option): array => [$option], [ + 'db', 'newMigrationNamespace', 'newMigrationPath', 'sourceNamespaces', 'sourcePaths', + 'historyTable', 'migrationNameLimit', 'useTablePrefix', 'maxSqlOutputLength', + ]); + } + + #[DataProvider('legacyOptions')] + public function testRejectsDuplicateDefaultConfiguration(string $option): void + { + $directory = dirname(__DIR__) . '/runtime/bin'; + file_put_contents($directory . '/yii-db-migration.php', str_replace( + 'return [', + "return ['$option' => null,", + $this->namedDefaultConfig(), + )); + [$output, $exitCode] = $this->runYiiDbMigration(['migrate:up', '-y']); + self::assertNotSame(0, $exitCode); + self::assertStringContainsString('Conflicting option: ' . $option . '.', $output); + self::assertFileDoesNotExist($directory . '/named.sqlite'); + } + + private function namedDefaultConfig(): string + { + return <<<'PHP' + [ + 'default' => new \Yiisoft\Db\Migration\DatabaseSet( + new \Yiisoft\Db\Sqlite\Connection( + new \Yiisoft\Db\Sqlite\Driver('sqlite:' . __DIR__ . '/named.sqlite'), + new \Yiisoft\Db\Cache\SchemaCache(new \Yiisoft\Test\Support\SimpleCache\MemorySimpleCache()) + ), + newMigrationPath: __DIR__ . '/migrations', + historyTable: 'named_history', + ), + ], + ]; + PHP; + } + private function replaceParams($search, $replace): void { $file = dirname(__DIR__) . '/runtime/bin/yii-db-migration.php'; diff --git a/tests/Migration/CommandFactoryTest.php b/tests/Migration/CommandFactoryTest.php new file mode 100644 index 00000000..d704b7b5 --- /dev/null +++ b/tests/Migration/CommandFactoryTest.php @@ -0,0 +1,36 @@ +expectException(LogicException::class); + $this->expectExceptionMessage('Configure databases["default"] when using CommandFactory.'); + $factory->create('up'); + } + + public function testUnknownCommand(): void + { + $factory = new CommandFactory(new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ + 'default' => new DatabaseSet($this->createMock(ConnectionInterface::class)), + ])); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown migration command: unknown.'); + $factory->create('unknown'); + } +} diff --git a/tests/Migration/ConfigTest.php b/tests/Migration/ConfigTest.php index 95dd568c..3ba9e329 100644 --- a/tests/Migration/ConfigTest.php +++ b/tests/Migration/ConfigTest.php @@ -5,6 +5,8 @@ namespace Yiisoft\Db\Migration\Tests\Migration; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use LogicException; use Psr\SimpleCache\CacheInterface; use Yiisoft\Db\Cache\SchemaCache; use Yiisoft\Db\Connection\ConnectionInterface; @@ -76,6 +78,38 @@ public function testAdditionalDatabaseConfiguration(): void $this->assertStringContainsString('--db option is required', $command->getDisplay()); } + public function testNamedDefaultNeedsNoLegacyConnection(): void + { + $db = new SqLiteConnection(new SqLiteDriver('sqlite::memory:'), new SchemaCache(new MemorySimpleCache())); + $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) { + self::assertInstanceOf($class, $container->get($class)); + } + $command = new CommandTester($container->get(NewCommand::class)); + self::assertSame(0, $command->execute([])); + self::assertStringContainsString('M231108183919Empty', $command->getDisplay()); + } + + public static function legacyMigrationOptions(): array + { + return [['newMigrationNamespace'], ['newMigrationPath'], ['sourceNamespaces'], ['sourcePaths']]; + } + + #[DataProvider('legacyMigrationOptions')] + public function testRejectsDuplicateDefaultConfiguration(string $option): void + { + $params = $this->getParams(); + $params['yiisoft/db-migration']['databases']['default'] = new DatabaseSet( + new SqLiteConnection(new SqLiteDriver('sqlite::memory:'), new SchemaCache(new MemorySimpleCache())), + ); + $params['yiisoft/db-migration'][$option] = ''; + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Conflicting option: ' . $option . '.'); + require dirname(__DIR__, 2) . '/config/di-console.php'; + } + private function createConsoleContainer(array $databases = []): Container { $config = ContainerConfig::create() From 2432d7d0cbf8eb28af8d655467c45c093d3f7ad9 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Fri, 2 Oct 2026 22:35:05 +0300 Subject: [PATCH 16/16] Restore Symfony Console compatible command registration --- bin/yii-db-migration | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/yii-db-migration b/bin/yii-db-migration index badfaeff..55730ba9 100644 --- a/bin/yii-db-migration +++ b/bin/yii-db-migration @@ -79,7 +79,9 @@ $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) { - $application->add($factory->create($name)); + $commands[] = $factory->create($name); } +$application->addCommands($commands); $application->run();