diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index a1415330..0d5abca6 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@sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722 + 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/CHANGELOG.md b/CHANGELOG.md index 3a27d4ca..3a103133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.1.2 under development +- 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/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..55730ba9 100644 --- a/bin/yii-db-migration +++ b/bin/yii-db-migration @@ -6,18 +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); @@ -40,49 +32,56 @@ if (!file_exists($configFile)) { /** * @psalm-suppress MissingFile * @psalm-var array{ - * container: ContainerInterface|null, - * db: ConnectionInterface|null, - * useTablePrefix: bool, - * historyTable: string, - * migrationNameLimit: int|null, - * maxSqlOutputLength: int|null, - * newMigrationNamespace: string, - * newMigrationPath: string, - * sourceNamespaces: list, - * sourcePaths: list, + * 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, * } $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(); -$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), - new DownCommand($downRunner, $migrationService, $migrator), - new HistoryCommand($migrationService, $migrator), - new NewCommand($migrationService, $migrator), - new RedoCommand($migrationService, $migrator, $downRunner, $updateRunner), - new UpdateCommand($updateRunner, $migrationService, $migrator), -]); - +$commands = []; +foreach (['create', 'down', 'history', 'new', 'redo', 'up'] as $name) { + $commands[] = $factory->create($name); +} +$application->addCommands($commands); $application->run(); diff --git a/bin/yii-db-migration.php b/bin/yii-db-migration.php index 01adb696..3fa96244 100644 --- a/bin/yii-db-migration.php +++ b/bin/yii-db-migration.php @@ -17,6 +17,14 @@ */ 'db' => null, + /** + * Named database sets (name => Yiisoft\Db\Migration\DatabaseSet instance). + * 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' => [], + /** * Namespace of new migration classes. */ diff --git a/config/di-console.php b/config/di-console.php index 0242e23f..e3dba47c 100644 --- a/config/di-console.php +++ b/config/di-console.php @@ -2,20 +2,45 @@ 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()' => [ + 'databases' => $params['yiisoft/db-migration']['databases'] ?? [], + ], + ], + 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 30b895f4..d68d3067 100644 --- a/config/params.php +++ b/config/params.php @@ -22,9 +22,6 @@ ], 'yiisoft/db-migration' => [ - 'newMigrationNamespace' => '', - 'newMigrationPath' => '', - 'sourceNamespaces' => [], - 'sourcePaths' => [], + 'databases' => [], ], ]; 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..42c2e781 --- /dev/null +++ b/docs/guide/en/multiple-databases.md @@ -0,0 +1,109 @@ +# Multiple databases + +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. + +## 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. 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/ +config/migrations/maps/ +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. + +## Create a migration + +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. + +Without `--db`, `migrate:create` creates the file in the `default` database's migration directory. + +## Apply migrations + +Apply pending migrations to `maps` only: + +```shell +./vendor/bin/yii-db-migration migrate:up --db=maps +``` + +To apply pending migrations to all configured databases: + +```shell +./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`. +You confirm migrations separately for each database. Add `--force-yes` to skip these prompts during deployment. + +To apply at most two migrations per database: + +```shell +./vendor/bin/yii-db-migration migrate:up --limit=2 +``` + +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`: + +```shell +./vendor/bin/yii-db-migration migrate:new --db=maps +./vendor/bin/yii-db-migration migrate:history --db=maps +``` + +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. + +## Revert or redo a migration + +Revert the last `maps` migration: + +```shell +./vendor/bin/yii-db-migration migrate:down --db=maps +``` + +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. +With only one database configured, you can continue to omit the option. diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index b4967334..dd60e65b 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -27,6 +27,51 @@ ./vendor/bin/yii-db-migration ``` +## Multiple databases + +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 [ + 'databases' => [ + 'default' => new DatabaseSet( + $defaultConnection, + newMigrationPath: __DIR__ . '/config/migrations/default', + ), + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/config/migrations/maps', + ), + 'analytics' => new DatabaseSet( + $analyticsConnection, + newMigrationPath: __DIR__ . '/config/migrations/analytics', + ), + ], +]; +``` + +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. + +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`: + +```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. diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index bee3e1bf..6b7b20b2 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -10,46 +10,144 @@ 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' + - '../vendor/yiisoft/db-migration/src/Command/CommandFactory.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 + +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. + +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 +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: + $db: '@yii3.connections.default' + $newMigrationPath: '%kernel.project_dir%/config/migrations/default' + + 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: + arguments: + $databases: + default: '@app.migrations.default' + maps: '@app.migrations.maps' + analytics: '@app.migrations.analytics' + + 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'] +``` + +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`: + +```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 9394fdd0..a5686889 100644 --- a/docs/guide/en/usage-with-yii-console.md +++ b/docs/guide/en/usage-with-yii-console.md @@ -45,3 +45,53 @@ View the list of available commands with `./yii list`: ```shell ./yii list ``` + +## Multiple databases + +Define your migration sets, including `default`, in the `databases` parameter: + +```php +use Yiisoft\Db\Migration\DatabaseSet; + +// These connection objects implement ConnectionInterface. +return [ + 'yiisoft/db-migration' => [ + 'databases' => [ + 'default' => new DatabaseSet( + $defaultConnection, + newMigrationPath: __DIR__ . '/migrations/default', + ), + 'maps' => new DatabaseSet( + $mapsConnection, + newMigrationPath: __DIR__ . '/migrations/maps', + ), + 'analytics' => new DatabaseSet( + $analyticsConnection, + newMigrationPath: __DIR__ . '/migrations/analytics', + ), + ], + ], +]; +``` + +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 you keep the legacy form, omit `databases['default']`. The existing migration service configuration and connection +then supply `default`, and `databases` can contain additional sets. + +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`: + +```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. 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/src/Command/CreateCommand.php b/src/Command/CreateCommand.php index 1e57e263..1a84cc62 100644 --- a/src/Command/CreateCommand.php +++ b/src/Command/CreateCommand.php @@ -12,6 +12,8 @@ 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; use Yiisoft\Db\Migration\Service\MigrationService; @@ -65,7 +67,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 +75,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,14 +93,21 @@ 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, + ?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'); @@ -106,13 +116,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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; } @@ -142,8 +152,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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.'); @@ -152,7 +162,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } try { - $migrationPath = $this->migrationService->findMigrationPath(); + $migrationPath = $migrationService->findMigrationPath(); } catch (LogicException $e) { $io->error($e->getMessage()); @@ -170,7 +180,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @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 new file mode 100644 index 00000000..34d85c6e --- /dev/null +++ b/src/Command/DatabaseCommand.php @@ -0,0 +1,98 @@ +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 + && $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']; + } + + $isListing = $this instanceof NewCommand || $this instanceof HistoryCommand; + $hasResults = false; + + foreach ($names as $database) { + $context = $this->databases?->createContext($database); + + if ($multipleDatabases) { + $io->section('Database: ' . OutputFormatter::escape($database)); + } + + $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 $isListing && !$hasResults ? Command::FAILURE : Command::SUCCESS; + } + + abstract protected function executeForDatabase( + InputInterface $input, + OutputInterface $output, + ?DatabaseContext $context, + ): int; +} diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php index 41a64356..a507c433 100644 --- a/src/Command/DownCommand.php +++ b/src/Command/DownCommand.php @@ -11,6 +11,8 @@ 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; use Yiisoft\Db\Migration\Service\MigrationService; @@ -37,14 +39,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,16 +60,23 @@ 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, + ?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') @@ -84,9 +94,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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.'); @@ -98,7 +108,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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"); @@ -122,12 +132,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } 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 471bb027..bb65a0d6 100644 --- a/src/Command/HistoryCommand.php +++ b/src/Command/HistoryCommand.php @@ -10,6 +10,8 @@ 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; @@ -30,13 +32,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,15 +49,21 @@ 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, + ?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') @@ -66,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::INVALID; } - $migrations = $this->migrator->getHistory($limit); + $migrations = $migrator->getHistory($limit); if (empty($migrations)) { $io->warning('No migration has been done before.'); diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index 01bfdffe..22ec668b 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -10,6 +10,8 @@ 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; @@ -36,13 +38,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,13 +57,19 @@ 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, + ?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'); @@ -69,11 +78,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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') @@ -85,7 +94,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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.'); diff --git a/src/Command/RedoCommand.php b/src/Command/RedoCommand.php index 17a44b34..55f1f8f0 100644 --- a/src/Command/RedoCommand.php +++ b/src/Command/RedoCommand.php @@ -11,6 +11,8 @@ 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; use Yiisoft\Db\Migration\Runner\UpdateRunner; @@ -37,15 +39,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,17 +61,25 @@ 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, + ?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') @@ -86,9 +97,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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.'); @@ -121,12 +132,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } 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.'); @@ -137,7 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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 52133ab7..b63ee251 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -11,6 +11,8 @@ 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; use Yiisoft\Db\Migration\Service\MigrationService; @@ -36,14 +38,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,14 +58,21 @@ 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, + ?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'); @@ -71,11 +81,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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; } @@ -91,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $migrations = $this->migrationService->getNewMigrations(); + $migrations = $migrationService->getNewMigrations(); if (empty($migrations)) { $output->writeln("No new migrations found.\n"); @@ -112,7 +122,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } foreach ($migrations as $i => $migration) { - $nameLimit = $this->migrator->getMigrationNameLimit(); + $nameLimit = $migrator->getMigrationNameLimit(); if (strlen($migration) > $nameLimit) { $output->writeln( @@ -127,12 +137,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int } 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 @@ + */ + private readonly array $databases; + + /** + * @param array $databases Named sets, optionally including "default". + */ + 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) === '') { + throw new InvalidArgumentException('Database names must be non-empty strings.'); + } + $sets[$name] = $database; + } + $this->databases = $sets; + } + + /** + * @return list + */ + public function getNames(): array + { + return array_keys(['default' => null, ...$this->databases]); + } + + /** + * @internal + */ + 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, + $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 new DatabaseContext( + $migrator, + $service, + new CreateService($database->db, $database->useTablePrefix), + new DownRunner($migrator), + new UpdateRunner($migrator), + ); + } +} 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); diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php new file mode 100644 index 00000000..3459c45c --- /dev/null +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -0,0 +1,584 @@ + */ + 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:'), new SchemaCache(new MemorySimpleCache())); + $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)); + } + + #[DataProvider('defaultSelection')] + public function testNamedDefaultSuppliesConnectionSourcesAndHistory(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']]; + } + + #[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('Unknown database "unknown". 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 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'); + 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'])); + } + + #[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) { + $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 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'); + $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, ?DatabaseSet $default = null): CommandTester + { + $sets = $this->sets; + unset($sets['default']); + if ($default !== null) { + $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); + $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..e0b91928 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -5,7 +5,12 @@ 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; +use Yiisoft\Db\Sqlite\Driver; +use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; use function dirname; @@ -30,6 +35,7 @@ protected function tearDown(): void public function testBase(): void { + $this->replaceParams("'databases' => [],", ''); $this->replaceParams( "'db' => null,", <<<'PHP' @@ -69,6 +75,106 @@ 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'), + 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\Cache\SchemaCache(new \Yiisoft\Test\Support\SimpleCache\MemorySimpleCache()) + ), + 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'); + $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()); + } + + 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'; @@ -78,9 +184,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/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 48591251..3ba9e329 100644 --- a/tests/Migration/ConfigTest.php +++ b/tests/Migration/ConfigTest.php @@ -5,13 +5,19 @@ 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; use Yiisoft\Db\Sqlite\Connection as SqLiteConnection; use Yiisoft\Db\Sqlite\Driver as SqLiteDriver; 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 +68,49 @@ 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:'), 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)); + $this->assertSame(2, $command->execute([])); + $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() ->withDefinitions(array_merge( @@ -78,14 +126,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..19c28863 --- /dev/null +++ b/tests/Migration/DatabaseSetRegistryTest.php @@ -0,0 +1,70 @@ +expectException(InvalidArgumentException::class); + $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()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown database set: missing.'); + $registry->createContext('missing'); + } + + #[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()); + } +}