diff --git a/CHANGELOG.md b/CHANGELOG.md index fe32a236..4220a2ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ ## 2.1.2 under development - New #363: Add `migrate:mark` to move migration history to a version without executing migrations (@samdark) -- New #360, #366: Add support for additional named databases with separate migration history and `--db` option to select - database (@samdark, @vjik) +- New #360, #366, #371: Add support for additional named databases with separate migration history and `--db` + option to select database (@samdark, @vjik) - Enh #255: Show friendly error on run `./vendor/bin/yii-db-migration` without configuration file (@KalimeroMK) ## 2.1.1 August 10, 2026 diff --git a/README.md b/README.md index aa1dfc45..134eb84b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ 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). +Use `--db=name` to select one of [multiple databases](docs/guide/en/multiple-databases.md). ## Documentation diff --git a/bin/yii-db-migration b/bin/yii-db-migration index 7ebbfd4a..152234ff 100644 --- a/bin/yii-db-migration +++ b/bin/yii-db-migration @@ -13,8 +13,8 @@ use Yiisoft\Db\Migration\Command\MarkCommand; 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\DatabaseConfig; +use Yiisoft\Db\Migration\DatabaseRegistry; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; @@ -45,7 +45,7 @@ if (!file_exists($configFile)) { * @psalm-var array{ * container: ContainerInterface|null, * db: ConnectionInterface|null, - * databases?: array, + * databases?: array, * useTablePrefix: bool, * historyTable: string, * migrationNameLimit: int|null, @@ -79,7 +79,7 @@ $migrationService->setNewMigrationPath($params['newMigrationPath']); $migrationService->setSourceNamespaces($params['sourceNamespaces']); $migrationService->setSourcePaths($params['sourcePaths']); -$databases = new DatabaseSetRegistry($injector, $migrationInformer, $params['databases'] ?? []); +$databases = new DatabaseRegistry($injector, $migrationInformer, $params['databases'] ?? []); $application = new Application('Yii Database Migration Tool', '1.0.0'); $application->addCommands([ diff --git a/bin/yii-db-migration.php b/bin/yii-db-migration.php index 2039bdac..63b8877c 100644 --- a/bin/yii-db-migration.php +++ b/bin/yii-db-migration.php @@ -18,7 +18,7 @@ 'db' => null, /** - * Additional named databases (name => Yiisoft\Db\Migration\DatabaseSet instance). + * Additional named databases (name => Yiisoft\Db\Migration\DatabaseConfig instance). * The "default" database is configured by the other options and can't be defined here. * See docs/guide/en/multiple-databases.md. */ diff --git a/config/di-console.php b/config/di-console.php index cd84c342..4d78c527 100644 --- a/config/di-console.php +++ b/config/di-console.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Yiisoft\Db\Migration\DatabaseSet; -use Yiisoft\Db\Migration\DatabaseSetRegistry; +use Yiisoft\Db\Migration\DatabaseConfig; +use Yiisoft\Db\Migration\DatabaseRegistry; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; use Yiisoft\Db\Migration\Informer\MigrationInformerInterface; use Yiisoft\Db\Migration\Service\MigrationService; @@ -13,12 +13,11 @@ /** @var array $params */ return [ - DatabaseSetRegistry::class => [ - 'class' => DatabaseSetRegistry::class, + DatabaseRegistry::class => [ '__construct()' => [ 'databases' => array_map( static fn(array $database): DynamicReference => DynamicReference::to([ - 'class' => DatabaseSet::class, + 'class' => DatabaseConfig::class, '__construct()' => ['db' => Reference::to($database['db'])] + $database, ]), $params['yiisoft/db-migration']['databases'], diff --git a/docs/guide/en/multiple-databases.md b/docs/guide/en/multiple-databases.md index 61b6d873..1a1b9710 100644 --- a/docs/guide/en/multiple-databases.md +++ b/docs/guide/en/multiple-databases.md @@ -1,9 +1,9 @@ # 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. +If your application uses separate databases such as `maps` and `analytics`, you can keep their migrations +isolated. Each database has its own migration directory and a separate migration history table, so migrations applied +to one database do not affect the recorded status of migrations in another. Use `--db` to work with one database, +or apply migrations to all databases with a single command. ## Configure your databases diff --git a/docs/guide/en/usage-standalone.md b/docs/guide/en/usage-standalone.md index 9a7a1a90..22fcf39c 100644 --- a/docs/guide/en/usage-standalone.md +++ b/docs/guide/en/usage-standalone.md @@ -34,16 +34,16 @@ each additional database in `databases`. The example below assumes you have alre `$analyticsConnection` in the same way as `db`, as described in [With configuration file](#with-configuration-file). ```php -use Yiisoft\Db\Migration\DatabaseSet; +use Yiisoft\Db\Migration\DatabaseConfig; return [ // ... 'databases' => [ - 'maps' => new DatabaseSet( + 'maps' => new DatabaseConfig( $mapsConnection, newMigrationPath: __DIR__ . '/config/migrations/maps', ), - 'analytics' => new DatabaseSet( + 'analytics' => new DatabaseConfig( $analyticsConnection, newMigrationPath: __DIR__ . '/config/migrations/analytics', ), diff --git a/docs/guide/en/usage-with-symfony.md b/docs/guide/en/usage-with-symfony.md index a804d811..8392deed 100644 --- a/docs/guide/en/usage-with-symfony.md +++ b/docs/guide/en/usage-with-symfony.md @@ -18,9 +18,10 @@ services: 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/DatabaseRegistry.php' + - '../vendor/yiisoft/db-migration/src/DatabaseConfig.php' + - '../vendor/yiisoft/db-migration/src/DatabaseSetup.php' + - '../vendor/yiisoft/db-migration/src/Command/Context/' Yiisoft\Db\Migration\Informer\MigrationInformerInterface: class: 'Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer' @@ -66,27 +67,27 @@ That's it. Now you can use `bin/console migrate:*` commands. ## Multiple databases The `default` database is configured as described above: it uses the `ConnectionInterface` service and the -`MigrationService` configuration. Add a `DatabaseSet` service for each additional database and pass them to -`DatabaseSetRegistry`. The commands receive the registry through autowiring. The example below assumes that the -`app.db.maps` and `app.db.analytics` connection services already exist. +`MigrationService` configuration. Add a `DatabaseConfig` service for each additional database and pass them +to `DatabaseRegistry`. The commands receive the registry through autowiring. The example below assumes that +the `app.db.maps` and `app.db.analytics` connection services already exist. ```yaml services: # ... app.migrations.maps: - class: Yiisoft\Db\Migration\DatabaseSet + class: Yiisoft\Db\Migration\DatabaseConfig arguments: $db: '@app.db.maps' $newMigrationPath: '%kernel.project_dir%/config/migrations/maps' app.migrations.analytics: - class: Yiisoft\Db\Migration\DatabaseSet + class: Yiisoft\Db\Migration\DatabaseConfig arguments: $db: '@app.db.analytics' $newMigrationPath: '%kernel.project_dir%/config/migrations/analytics' - Yiisoft\Db\Migration\DatabaseSetRegistry: + Yiisoft\Db\Migration\DatabaseRegistry: arguments: $databases: maps: '@app.migrations.maps' @@ -95,7 +96,7 @@ services: The name `default` is reserved and can't be used in the registry. -Select one set with `--db`, or apply all sets with `migrate:up`: +Select one database with `--db`, or apply migrations to all databases with `migrate:up`: ```shell php bin/console migrate:create create_places --db=maps diff --git a/docs/guide/en/usage-with-yii-console.md b/docs/guide/en/usage-with-yii-console.md index 151ec568..6bc1e71c 100644 --- a/docs/guide/en/usage-with-yii-console.md +++ b/docs/guide/en/usage-with-yii-console.md @@ -70,8 +70,8 @@ a DI container service ID: ], ``` -Each set is an array of `Yiisoft\Db\Migration\DatabaseSet` constructor arguments, where `db` is the ID of a connection -service. Define these connections in the DI container, for example, in `config/common/db.php`: +Each database configuration is an array of `Yiisoft\Db\Migration\DatabaseConfig` constructor arguments, where `db` is the ID of +a connection service. Define these connections in the DI container, for example, in `config/common/db.php`: ```php use Yiisoft\Db\Sqlite\Connection as SqliteConnection; @@ -96,7 +96,7 @@ return [ The name `default` is reserved and can't be used in `databases`. -Select one set with `--db`, or apply all sets with `migrate:up`: +Select one database with `--db`, or apply migrations to all databases with `migrate:up`: ```shell ./yii migrate:create create_places --db=maps diff --git a/src/Command/Context/CreateContext.php b/src/Command/Context/CreateContext.php new file mode 100644 index 00000000..b3602bb7 --- /dev/null +++ b/src/Command/Context/CreateContext.php @@ -0,0 +1,23 @@ + */ #[AsCommand('migrate:create', 'Creates a new migration.')] final class CreateCommand extends DatabaseCommand @@ -75,9 +78,9 @@ public function __construct( private readonly CreateService $createService, private readonly MigrationService $migrationService, private readonly Migrator $migrator, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -93,21 +96,36 @@ protected function configure(): void ->setHelp('This command generates new migration file.'); } + protected function resolveDatabaseNamesWithMissingOption(InputInterface $input): array + { + return [DatabaseRegistry::DEFAULT_DATABASE]; + } + + protected function createDefaultContext(): CreateContext + { + return new CreateContext($this->migrator, $this->migrationService, $this->createService); + } + + protected function createContext(DatabaseSetup $setup): CreateContext + { + return new CreateContext( + $setup->migrator, + $setup->migrationService, + new CreateService($setup->config->db, $setup->config->useTablePrefix), + ); + } + protected function executeForDatabase( InputInterface $input, OutputInterface $output, - ?DatabaseContext $context, + object $context, ): int { - $migrator = $context->migrator ?? $this->migrator; - $migrationService = $context->migrationService ?? $this->migrationService; - $createService = $context->createService ?? $this->createService; - $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $migrationService->setIo($io); - $createService->setIo($io); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); + $context->createService->setIo($io); - $migrationService->databaseConnection(); + $context->migrationService->databaseConnection(); /** @var string|null $path */ $path = $input->getOption('path'); @@ -116,13 +134,13 @@ protected function executeForDatabase( $namespace = $input->getOption('namespace'); if ($path !== null || $namespace !== null) { - $migrationService->setNewMigrationPath((string) $path); - $migrationService->setNewMigrationNamespace((string) $namespace); + $context->migrationService->setNewMigrationPath((string) $path); + $context->migrationService->setNewMigrationNamespace((string) $namespace); } else { - $namespace = $migrationService->getNewMigrationNamespace(); + $namespace = $context->migrationService->getNewMigrationNamespace(); } - if ($migrationService->before($this->getName() ?? '') === Command::INVALID) { + if ($context->migrationService->before($this->getName() ?? '') === Command::INVALID) { return Command::INVALID; } @@ -152,8 +170,8 @@ protected function executeForDatabase( $and = $input->getOption('and'); $name = $this->generateName($command, $table, $and); - $className = $migrationService->generateClassName($name); - $nameLimit = $migrator->getMigrationNameLimit(); + $className = $context->migrationService->generateClassName($name); + $nameLimit = $context->migrator->getMigrationNameLimit(); if ($nameLimit !== 0 && strlen($className) > $nameLimit) { $io->error('The migration name is too long.'); @@ -162,7 +180,7 @@ protected function executeForDatabase( } try { - $migrationPath = $migrationService->findMigrationPath(); + $migrationPath = $context->migrationService->findMigrationPath(); } catch (LogicException $e) { $io->error($e->getMessage()); @@ -180,7 +198,7 @@ protected function executeForDatabase( /** @var string|null $tableComment */ $tableComment = $input->getOption('table-comment'); - $content = $createService->run( + $content = $context->createService->run( $command, $table, $className, diff --git a/src/Command/DatabaseCommand.php b/src/Command/DatabaseCommand.php index 6f8c4fbb..8d271563 100644 --- a/src/Command/DatabaseCommand.php +++ b/src/Command/DatabaseCommand.php @@ -10,19 +10,21 @@ 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\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; -use function count; use function implode; use function in_array; +use function is_string; /** * @internal + * + * @template TContext of object */ abstract class DatabaseCommand extends Command { - public function __construct(private readonly ?DatabaseSetRegistry $databases = null) + public function __construct(private readonly ?DatabaseRegistry $additionalDatabases = null) { parent::__construct(); $this->addOption('db', null, InputOption::VALUE_REQUIRED, 'Database configuration to use for migrations.'); @@ -30,55 +32,31 @@ public function __construct(private readonly ?DatabaseSetRegistry $databases = n final protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var string|null $name */ - $name = $input->getOption('db'); - $names = $this->databases?->getNames() ?? [DatabaseSetRegistry::DEFAULT_DATABASE]; - $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 || $this instanceof MarkCommand)) { - $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.'); - + $names = $this->resolveDatabaseNames($input); + if (is_string($names)) { + $io->error($names); return Command::INVALID; } - if ($name !== null || $this instanceof CreateCommand) { - $names = [$name ?? DatabaseSetRegistry::DEFAULT_DATABASE]; - } - - $isListing = $this instanceof NewCommand || $this instanceof HistoryCommand; + $multipleDatabases = $this->isMultipleDatabases(); $hasResults = false; - foreach ($names as $database) { - $context = $this->databases?->createContext($database); + foreach ($names as $name) { + if (!$multipleDatabases || $name === DatabaseRegistry::DEFAULT_DATABASE) { + $context = $this->createDefaultContext(); + } else { + /** @psalm-var DatabaseRegistry $this->additionalDatabases */ + $context = $this->createContext($this->additionalDatabases->createSetup($name)); + } if ($multipleDatabases) { - $io->section('Database: ' . OutputFormatter::escape($database)); + $io->section('Database: ' . OutputFormatter::escape($name)); } $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) { + if ($status === null) { continue; } if ($status !== Command::SUCCESS) { @@ -87,12 +65,92 @@ final protected function execute(InputInterface $input, OutputInterface $output) $hasResults = true; } - return $isListing && !$hasResults ? Command::FAILURE : Command::SUCCESS; + return $hasResults ? Command::SUCCESS : Command::FAILURE; } + /** + * Resolves the database names or returns a message indicating that an option is missing if multiple databases + * are configured. + * + * @param InputInterface $input The input interface containing the command options and arguments. + * + * @return string[]|string + * @psalm-return non-empty-list|string + */ + protected function resolveDatabaseNamesWithMissingOption(InputInterface $input): array|string + { + if (!$this->isMultipleDatabases()) { + return [DatabaseRegistry::DEFAULT_DATABASE]; + } + + return 'The --db option is required when multiple databases are configured.'; + } + + /** + * Creates the command context for the default database from the command dependencies. + * + * @psalm-return TContext + */ + abstract protected function createDefaultContext(): object; + + /** + * Creates the command context for a named database. + * + * @psalm-return TContext + */ + abstract protected function createContext(DatabaseSetup $setup): object; + + /** + * Executes the command for a single database. + * + * @psalm-param TContext $context + * + * @return int|null The command exit status, or `null` if the database has an empty result. The command fails if + * all processed databases have empty results. + */ abstract protected function executeForDatabase( InputInterface $input, OutputInterface $output, - ?DatabaseContext $context, - ): int; + object $context, + ): ?int; + + /** + * @return string[] + * @psalm-return non-empty-list + */ + protected function getAvailableDatabaseNames(): array + { + return [DatabaseRegistry::DEFAULT_DATABASE, ...($this->additionalDatabases?->getNames() ?? [])]; + } + + protected function isMultipleDatabases(): bool + { + return $this->additionalDatabases !== null && $this->additionalDatabases->getNames() !== []; + } + + /** + * Resolves names of databases to process. + * + * @return string[]|string The database names, or an error message if the databases can't be resolved. + * + * @psalm-return non-empty-list|string + */ + private function resolveDatabaseNames(InputInterface $input): array|string + { + /** @var string|null $name */ + $name = $input->getOption('db'); + + if ($name === null) { + return $this->resolveDatabaseNamesWithMissingOption($input); + } + + $availableNames = $this->getAvailableDatabaseNames(); + + if (!in_array($name, $availableNames, true)) { + return 'Unknown database "' . OutputFormatter::escape($name) . '". Available databases: ' + . OutputFormatter::escape(implode(', ', $availableNames)) . '.'; + } + + return [$name]; + } } diff --git a/src/Command/DownCommand.php b/src/Command/DownCommand.php index a507c433..0277929b 100644 --- a/src/Command/DownCommand.php +++ b/src/Command/DownCommand.php @@ -11,8 +11,9 @@ 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\Command\Context\DownContext; +use Yiisoft\Db\Migration\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; use Yiisoft\Db\Migration\Service\MigrationService; @@ -37,6 +38,8 @@ * ./yii migrate:down --path=@vendor/yiisoft/rbac-db/migrations --path=@vendor/yiisoft/cache-db/migrations * ./yii migrate:down --namespace=Yiisoft\\Rbac\\Db\\Migrations --namespace=Yiisoft\\Cache\\Db\\Migrations * ``` + * + * @extends DatabaseCommand */ #[AsCommand('migrate:down', 'Reverts the specified number of latest migrations.')] final class DownCommand extends DatabaseCommand @@ -45,9 +48,9 @@ public function __construct( private readonly DownRunner $downRunner, private readonly MigrationService $migrationService, private readonly Migrator $migrator, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -60,23 +63,29 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } + protected function createDefaultContext(): DownContext + { + return new DownContext($this->migrator, $this->migrationService, $this->downRunner); + } + + protected function createContext(DatabaseSetup $setup): DownContext + { + return new DownContext($setup->migrator, $setup->migrationService, new DownRunner($setup->migrator)); + } + 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; - + object $context, + ): ?int { $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $migrationService->setIo($io); - $downRunner->setIo($io); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); + $context->downRunner->setIo($io); - $migrationService->databaseConnection(); + $context->migrationService->databaseConnection(); - $migrationService->before($this->getName() ?? ''); + $context->migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -94,27 +103,27 @@ protected function executeForDatabase( $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $migrations = $migrator->getHistory(); + $migrations = $context->migrator->getHistory(); $migrations = array_keys($migrations); - $migrations = $migrationService->filterMigrations($migrations, $namespaces, $paths); + $migrations = $context->migrationService->filterMigrations($migrations, $namespaces, $paths); if (empty($migrations)) { $io->warning('No applied migrations found.'); - return Command::FAILURE; + return null; } if ($limit !== null) { $migrations = array_slice($migrations, 0, $limit); } } else { - $migrations = $migrator->getHistory($limit); + $migrations = $context->migrator->getHistory($limit); if (empty($migrations)) { $output->writeln("Apply at least one migration first.\n"); $io->warning('No migration has been done before.'); - return Command::FAILURE; + return null; } $migrations = array_keys($migrations); @@ -132,12 +141,12 @@ protected function executeForDatabase( } if ($input->getOption('force-yes') || $io->confirm("Revert the above $migrationWord?")) { - $instances = $migrationService->makeRevertibleMigrations($migrations); + $instances = $context->migrationService->makeRevertibleMigrations($migrations); $migrationWas = ($countMigrations === 1 ? 'migration was' : 'migrations were'); foreach ($instances as $i => $instance) { try { - $downRunner->run($instance, $i + 1); + $context->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 bb65a0d6..196446af 100644 --- a/src/Command/HistoryCommand.php +++ b/src/Command/HistoryCommand.php @@ -10,8 +10,9 @@ 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\Command\Context\MigrationContext; +use Yiisoft\Db\Migration\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; @@ -30,6 +31,8 @@ * ./yii migrate:history --limit=5 # last 5 migrations * ./yii migrate:history --all # whole history * ``` + * + * @extends DatabaseCommand */ #[AsCommand('migrate:history', 'Displays the migration history.')] final class HistoryCommand extends DatabaseCommand @@ -37,9 +40,9 @@ final class HistoryCommand extends DatabaseCommand public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -49,21 +52,33 @@ protected function configure(): void ->addOption('all', 'a', InputOption::VALUE_NONE, 'All migrations.'); } + protected function resolveDatabaseNamesWithMissingOption(InputInterface $input): array + { + return $this->getAvailableDatabaseNames(); + } + + protected function createDefaultContext(): MigrationContext + { + return new MigrationContext($this->migrator, $this->migrationService); + } + + protected function createContext(DatabaseSetup $setup): MigrationContext + { + return new MigrationContext($setup->migrator, $setup->migrationService); + } + protected function executeForDatabase( InputInterface $input, OutputInterface $output, - ?DatabaseContext $context, - ): int { - $migrator = $context->migrator ?? $this->migrator; - $migrationService = $context->migrationService ?? $this->migrationService; - + object $context, + ): ?int { $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $migrationService->setIo($io); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); - $migrationService->databaseConnection(); + $context->migrationService->databaseConnection(); - $migrationService->before($this->getName() ?? ''); + $context->migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -75,12 +90,12 @@ protected function executeForDatabase( return Command::INVALID; } - $migrations = $migrator->getHistory($limit); + $migrations = $context->migrator->getHistory($limit); if (empty($migrations)) { $io->warning('No migration has been done before.'); - return Command::FAILURE; + return null; } $countMigrations = count($migrations); diff --git a/src/Command/MarkCommand.php b/src/Command/MarkCommand.php index e55d0437..1553f4dd 100644 --- a/src/Command/MarkCommand.php +++ b/src/Command/MarkCommand.php @@ -12,8 +12,9 @@ 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\Command\Context\MigrationContext; +use Yiisoft\Db\Migration\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; @@ -42,6 +43,8 @@ * * A pending target is recorded along with all earlier pending migrations. A recorded target removes newer history * entries. Use the full class name when several migrations share a timestamp. + * + * @extends DatabaseCommand */ #[AsCommand('migrate:mark', 'Modifies migration history without executing migrations. WARNING: Use only if you understand the consequences; incorrect history can cause data loss.')] final class MarkCommand extends DatabaseCommand @@ -51,9 +54,9 @@ final class MarkCommand extends DatabaseCommand public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -63,17 +66,25 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } + protected function createDefaultContext(): MigrationContext + { + return new MigrationContext($this->migrator, $this->migrationService); + } + + protected function createContext(DatabaseSetup $setup): MigrationContext + { + return new MigrationContext($setup->migrator, $setup->migrationService); + } + protected function executeForDatabase( InputInterface $input, OutputInterface $output, - ?DatabaseContext $context, + object $context, ): int { - $migrator = $context->migrator ?? $this->migrator; - $service = $context->migrationService ?? $this->migrationService; $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $service->setIo($io); - $service->databaseConnection(); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); + $context->migrationService->databaseConnection(); /** @var string $version */ $version = $input->getArgument('version'); @@ -93,7 +104,7 @@ protected function executeForDatabase( $add = []; $remove = []; $found = false; - $history = array_keys($migrator->getHistory()); + $history = array_keys($context->migrator->getHistory()); $isRecordedClass = $timestamp === null && in_array( $version, array_map(static fn(string $name): string => trim($name, '\\'), $history), @@ -102,7 +113,7 @@ protected function executeForDatabase( // Exact recorded classes do not need source files. Timestamps still select pending targets first. if ($version !== self::BASE_MIGRATION && !$isRecordedClass) { - $pending = $service->getNewMigrations(); + $pending = $context->migrationService->getNewMigrations(); foreach ($pending as $i => $migration) { if ($this->matches($migration, $version, $timestamp)) { $add = array_slice($pending, 0, $i + 1); @@ -133,7 +144,7 @@ protected function executeForDatabase( return Command::SUCCESS; } - $limit = $migrator->getMigrationNameLimit(); + $limit = $context->migrator->getMigrationNameLimit(); foreach ($add as $migration) { if ($limit !== null && strlen($migration) > $limit) { $io->error('The migration name "' . OutputFormatter::escape($migration) . '" is too long.'); @@ -143,7 +154,7 @@ protected function executeForDatabase( $io->note('Only migration history will change. No migrations will be applied or reverted.'); if ($input->getOption('force-yes') || $io->confirm("Set migration history at $label?", false)) { - $migrator->updateHistory($add, $remove); + $context->migrator->updateHistory($add, $remove); $io->success("The migration history is set at $label. No actual migration was performed."); } diff --git a/src/Command/NewCommand.php b/src/Command/NewCommand.php index 22ec668b..17150d3b 100644 --- a/src/Command/NewCommand.php +++ b/src/Command/NewCommand.php @@ -10,8 +10,9 @@ 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\Command\Context\MigrationContext; +use Yiisoft\Db\Migration\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Service\MigrationService; @@ -36,6 +37,8 @@ * ./yii migrate:new --path=@vendor/yiisoft/rbac-db/migrations --path=@vendor/yiisoft/cache-db/migrations * ./yii migrate:new --namespace=Yiisoft\\Rbac\\Db\\Migrations --namespace=Yiisoft\\Cache\\Db\\Migrations * ``` + * + * @extends DatabaseCommand */ #[AsCommand('migrate:new', 'Displays not yet applied migrations.')] final class NewCommand extends DatabaseCommand @@ -43,9 +46,9 @@ final class NewCommand extends DatabaseCommand public function __construct( private readonly MigrationService $migrationService, private readonly Migrator $migrator, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -57,19 +60,37 @@ protected function configure(): void ->addOption('namespace', 'ns', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Namespace of migrations to display.'); } + protected function resolveDatabaseNamesWithMissingOption(InputInterface $input): array|string + { + if (!$this->isMultipleDatabases()) { + return [DatabaseRegistry::DEFAULT_DATABASE]; + } + + return $input->getOption('path') !== [] || $input->getOption('namespace') !== [] + ? 'The --db option is required with --path or --namespace when multiple databases are configured.' + : $this->getAvailableDatabaseNames(); + } + + protected function createDefaultContext(): MigrationContext + { + return new MigrationContext($this->migrator, $this->migrationService); + } + + protected function createContext(DatabaseSetup $setup): MigrationContext + { + return new MigrationContext($setup->migrator, $setup->migrationService); + } + protected function executeForDatabase( InputInterface $input, OutputInterface $output, - ?DatabaseContext $context, - ): int { - $migrator = $context->migrator ?? $this->migrator; - $migrationService = $context->migrationService ?? $this->migrationService; - + object $context, + ): ?int { $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $migrationService->setIo($io); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); - $migrationService->databaseConnection(); + $context->migrationService->databaseConnection(); /** @var string[] $paths */ $paths = $input->getOption('path'); @@ -78,11 +99,11 @@ protected function executeForDatabase( $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $migrationService->setSourcePaths($paths); - $migrationService->setSourceNamespaces($namespaces); + $context->migrationService->setSourcePaths($paths); + $context->migrationService->setSourceNamespaces($namespaces); } - $migrationService->before($this->getName() ?? ''); + $context->migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -94,12 +115,12 @@ protected function executeForDatabase( return Command::INVALID; } - $migrations = $migrationService->getNewMigrations(); + $migrations = $context->migrationService->getNewMigrations(); if (empty($migrations)) { $io->warning('No new migrations found. Your system is up-to-date.'); - return Command::FAILURE; + return null; } $countMigrations = count($migrations); diff --git a/src/Command/RedoCommand.php b/src/Command/RedoCommand.php index 55f1f8f0..6b5a6ee9 100644 --- a/src/Command/RedoCommand.php +++ b/src/Command/RedoCommand.php @@ -11,8 +11,9 @@ 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\Command\Context\RedoContext; +use Yiisoft\Db\Migration\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; use Yiisoft\Db\Migration\Runner\UpdateRunner; @@ -37,6 +38,8 @@ * ./yii migrate:redo --path=@vendor/yiisoft/rbac-db/migrations # redo the last migration from the directory * ./yii migrate:redo --namespace=Yiisoft\\Rbac\\Db\\Migrations # redo the last migration from the namespace * ``` + * + * @extends DatabaseCommand */ #[AsCommand('migrate:redo', 'Redoes the last few migrations.')] final class RedoCommand extends DatabaseCommand @@ -46,9 +49,9 @@ public function __construct( private readonly Migrator $migrator, private readonly DownRunner $downRunner, private readonly UpdateRunner $updateRunner, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -61,25 +64,35 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } + protected function createDefaultContext(): RedoContext + { + return new RedoContext($this->migrator, $this->migrationService, $this->downRunner, $this->updateRunner); + } + + protected function createContext(DatabaseSetup $setup): RedoContext + { + return new RedoContext( + $setup->migrator, + $setup->migrationService, + new DownRunner($setup->migrator), + new UpdateRunner($setup->migrator), + ); + } + 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; - + object $context, + ): ?int { $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $migrationService->setIo($io); - $downRunner->setIo($io); - $updateRunner->setIo($io); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); + $context->downRunner->setIo($io); + $context->updateRunner->setIo($io); - $migrationService->databaseConnection(); + $context->migrationService->databaseConnection(); - $migrationService->before($this->getName() ?? ''); + $context->migrationService->before($this->getName() ?? ''); $limit = !$input->getOption('all') ? (int) $input->getOption('limit') @@ -97,26 +110,26 @@ protected function executeForDatabase( $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $migrations = $migrator->getHistory(); + $migrations = $context->migrator->getHistory(); $migrations = array_keys($migrations); - $migrations = $migrationService->filterMigrations($migrations, $namespaces, $paths); + $migrations = $context->migrationService->filterMigrations($migrations, $namespaces, $paths); if (empty($migrations)) { $io->warning('No applied migrations found.'); - return Command::FAILURE; + return null; } if ($limit !== null) { $migrations = array_slice($migrations, 0, $limit); } } else { - $migrations = $migrator->getHistory($limit); + $migrations = $context->migrator->getHistory($limit); if (empty($migrations)) { $io->warning('No migration has been done before.'); - return Command::FAILURE; + return null; } $migrations = array_keys($migrations); @@ -132,12 +145,12 @@ protected function executeForDatabase( } if ($input->getOption('force-yes') || $io->confirm("Redo the above $migrationWord?")) { - $instances = $migrationService->makeRevertibleMigrations($migrations); + $instances = $context->migrationService->makeRevertibleMigrations($migrations); $migrationWas = ($countMigrations === 1 ? 'migration was' : 'migrations were'); foreach ($instances as $i => $instance) { try { - $downRunner->run($instance, $i + 1); + $context->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.'); @@ -148,7 +161,7 @@ protected function executeForDatabase( foreach (array_reverse($instances) as $i => $instance) { try { - $updateRunner->run($instance, $countMigrations - $i); + $context->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 b63ee251..97a3d214 100644 --- a/src/Command/UpdateCommand.php +++ b/src/Command/UpdateCommand.php @@ -11,8 +11,9 @@ 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\Command\Context\UpdateContext; +use Yiisoft\Db\Migration\DatabaseRegistry; +use Yiisoft\Db\Migration\DatabaseSetup; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\UpdateRunner; use Yiisoft\Db\Migration\Service\MigrationService; @@ -36,6 +37,8 @@ * ./yii migrate:up --path=@vendor/yiisoft/rbac-db/migrations --path=@vendor/yiisoft/cache-db/migrations * ./yii migrate:up --namespace=Yiisoft\\Rbac\\Db\\Migrations --namespace=Yiisoft\\Cache\\Db\\Migrations * ``` + * + * @extends DatabaseCommand */ #[AsCommand('migrate:up', 'Applies new migrations.')] final class UpdateCommand extends DatabaseCommand @@ -44,9 +47,9 @@ public function __construct( private readonly UpdateRunner $updateRunner, private readonly MigrationService $migrationService, private readonly Migrator $migrator, - ?DatabaseSetRegistry $databases = null, + ?DatabaseRegistry $additionalDatabases = null, ) { - parent::__construct($databases); + parent::__construct($additionalDatabases); } protected function configure(): void @@ -58,21 +61,42 @@ protected function configure(): void ->addOption('force-yes', 'y', InputOption::VALUE_NONE, 'Force yes to all questions.'); } + protected function resolveDatabaseNamesWithMissingOption(InputInterface $input): array|string + { + if (!$this->isMultipleDatabases()) { + return [DatabaseRegistry::DEFAULT_DATABASE]; + } + + return $input->getOption('path') !== [] || $input->getOption('namespace') !== [] + ? 'The --db option is required with --path or --namespace when multiple databases are configured.' + : $this->getAvailableDatabaseNames(); + } + + protected function createDefaultContext(): UpdateContext + { + return new UpdateContext($this->migrator, $this->migrationService, $this->updateRunner); + } + + protected function createContext(DatabaseSetup $setup): UpdateContext + { + return new UpdateContext( + $setup->migrator, + $setup->migrationService, + new UpdateRunner($setup->migrator), + ); + } + protected function executeForDatabase( InputInterface $input, OutputInterface $output, - ?DatabaseContext $context, + object $context, ): int { - $migrator = $context->migrator ?? $this->migrator; - $migrationService = $context->migrationService ?? $this->migrationService; - $updateRunner = $context->updateRunner ?? $this->updateRunner; - $io = new SymfonyStyle($input, $output); - $migrator->setIo($io); - $migrationService->setIo($io); - $updateRunner->setIo($io); + $context->migrator->setIo($io); + $context->migrationService->setIo($io); + $context->updateRunner->setIo($io); - $migrationService->databaseConnection(); + $context->migrationService->databaseConnection(); /** @var string[] $paths */ $paths = $input->getOption('path'); @@ -81,11 +105,11 @@ protected function executeForDatabase( $namespaces = $input->getOption('namespace'); if (!empty($paths) || !empty($namespaces)) { - $migrationService->setSourcePaths($paths); - $migrationService->setSourceNamespaces($namespaces); + $context->migrationService->setSourcePaths($paths); + $context->migrationService->setSourceNamespaces($namespaces); } - if ($migrationService->before($this->getName() ?? '') === Command::INVALID) { + if ($context->migrationService->before($this->getName() ?? '') === Command::INVALID) { return Command::INVALID; } @@ -101,7 +125,7 @@ protected function executeForDatabase( } } - $migrations = $migrationService->getNewMigrations(); + $migrations = $context->migrationService->getNewMigrations(); if (empty($migrations)) { $output->writeln("No new migrations found.\n"); @@ -122,7 +146,7 @@ protected function executeForDatabase( } foreach ($migrations as $i => $migration) { - $nameLimit = $migrator->getMigrationNameLimit(); + $nameLimit = $context->migrator->getMigrationNameLimit(); if (strlen($migration) > $nameLimit) { $output->writeln( @@ -137,12 +161,12 @@ protected function executeForDatabase( } if ($input->getOption('force-yes') || $io->confirm("Apply the above $migrationWord?")) { - $instances = $migrationService->makeMigrations($migrations); + $instances = $context->migrationService->makeMigrations($migrations); $migrationWas = ($migrationsCount === 1 ? 'migration was' : 'migrations were'); foreach ($instances as $i => $instance) { try { - $updateRunner->run($instance, $i + 1); + $context->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/DatabaseSet.php b/src/DatabaseConfig.php similarity index 58% rename from src/DatabaseSet.php rename to src/DatabaseConfig.php index 7625032f..1de32eb7 100644 --- a/src/DatabaseSet.php +++ b/src/DatabaseConfig.php @@ -5,11 +5,12 @@ namespace Yiisoft\Db\Migration; use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Db\Migration\Service\Generate\CreateService; /** - * Configuration of a named database and its migrations. + * Configuration of an additional named database and its migrations. */ -final class DatabaseSet +final class DatabaseConfig { /** * @param string[] $sourceNamespaces @@ -21,9 +22,9 @@ public function __construct( public readonly string $newMigrationPath = '', public readonly array $sourceNamespaces = [], public readonly array $sourcePaths = [], - public readonly string $historyTable = '{{%migration}}', - public readonly ?int $migrationNameLimit = 180, - public readonly bool $useTablePrefix = true, + public readonly string $historyTable = Migrator::DEFAULT_HISTORY_TABLE, + public readonly ?int $migrationNameLimit = Migrator::DEFAULT_MIGRATION_NAME_LIMIT, + public readonly bool $useTablePrefix = CreateService::DEFAULT_USE_TABLE_PREFIX, public readonly ?int $maxSqlOutputLength = null, ) {} } diff --git a/src/DatabaseRegistry.php b/src/DatabaseRegistry.php new file mode 100644 index 00000000..ecebba73 --- /dev/null +++ b/src/DatabaseRegistry.php @@ -0,0 +1,112 @@ + */ + private readonly array $configs; + + /** + * @param array $databases Additional named databases, excluding "default". + */ + public function __construct( + private readonly Injector $injector, + private readonly MigrationInformerInterface $informer, + array $databases = [], + ) { + $configs = []; + foreach ($databases as $name => $config) { + if (is_int($name)) { + throw new InvalidArgumentException( + sprintf('Database names must be non-numeric strings, "%s" given.', $name), + ); + } + if (trim($name) === '') { + throw new InvalidArgumentException('Database names must be non-empty strings.'); + } + if ($name === self::DEFAULT_DATABASE) { + throw new InvalidArgumentException( + sprintf( + 'The "%s" database is configured by the existing migration settings and can\'t be redefined.', + self::DEFAULT_DATABASE, + ), + ); + } + if (!$config instanceof DatabaseConfig) { + throw new InvalidArgumentException( + sprintf( + 'Configuration of the "%s" database must be an instance of "%s", "%s" given.', + $name, + DatabaseConfig::class, + get_debug_type($config), + ), + ); + } + $configs[$name] = $config; + } + $this->configs = $configs; + } + + /** + * Returns names of additional databases, excluding "default". + * + * @return string[] + * + * @psalm-return list + */ + public function getNames(): array + { + return array_keys($this->configs); + } + + /** + * Creates a setup of an additional database: its configuration and migration services. Each call creates new + * service instances. + * + * @throws InvalidArgumentException If the additional database isn't configured. + * + * @internal + */ + public function createSetup(string $name): DatabaseSetup + { + $config = $this->configs[$name] + ?? throw new InvalidArgumentException(sprintf('Unknown additional database: %s.', $name)); + $migrator = new Migrator( + $config->db, + $this->informer, + $config->historyTable, + $config->migrationNameLimit, + $config->maxSqlOutputLength, + ); + $service = new MigrationService($config->db, $this->injector, $migrator); + $service->setNewMigrationNamespace($config->newMigrationNamespace); + $service->setNewMigrationPath($config->newMigrationPath); + $service->setSourceNamespaces($config->sourceNamespaces); + $service->setSourcePaths($config->sourcePaths); + + return new DatabaseSetup($config, $migrator, $service); + } +} diff --git a/src/DatabaseSetRegistry.php b/src/DatabaseSetRegistry.php deleted file mode 100644 index e2db8369..00000000 --- a/src/DatabaseSetRegistry.php +++ /dev/null @@ -1,96 +0,0 @@ - */ - private readonly array $databases; - - /** - * @param array $databases Additional named sets, excluding "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.'); - } - if ($name === self::DEFAULT_DATABASE) { - throw new InvalidArgumentException( - 'The "' . self::DEFAULT_DATABASE - . '" database is configured by the existing migration settings and can\'t be redefined.', - ); - } - $sets[$name] = $database; - } - $this->databases = $sets; - } - - /** - * @return list - */ - public function getNames(): array - { - return array_keys([self::DEFAULT_DATABASE => null, ...$this->databases]); - } - - /** - * @internal - */ - public function createContext(string $name): ?DatabaseContext - { - if ($name === self::DEFAULT_DATABASE) { - 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/src/DatabaseSetup.php b/src/DatabaseSetup.php new file mode 100644 index 00000000..428b5b99 --- /dev/null +++ b/src/DatabaseSetup.php @@ -0,0 +1,21 @@ +phpRenderer = new PhpRenderer(); } diff --git a/tests/Common/Command/AbstractDatabaseCommandsTest.php b/tests/Common/Command/AbstractDatabaseCommandsTest.php index 10e20094..f0371774 100644 --- a/tests/Common/Command/AbstractDatabaseCommandsTest.php +++ b/tests/Common/Command/AbstractDatabaseCommandsTest.php @@ -18,8 +18,8 @@ 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\DatabaseConfig; +use Yiisoft\Db\Migration\DatabaseRegistry; use Yiisoft\Db\Migration\Informer\NullMigrationInformer; use Yiisoft\Db\Migration\Migrator; use Yiisoft\Db\Migration\Runner\DownRunner; @@ -39,7 +39,7 @@ abstract class AbstractDatabaseCommandsTest extends TestCase protected string $directory; /** @var array */ private array $connections; - /** @var array */ + /** @var array */ private array $sets; protected function setUp(): void @@ -50,7 +50,7 @@ protected function setUp(): void foreach (['default', 'maps', 'analytics'] as $name) { FileHelper::ensureDirectory($this->directory . '/' . $name); $this->connections[$name] = $this->createConnection($name); - $this->sets[$name] = new DatabaseSet( + $this->sets[$name] = new DatabaseConfig( $this->connections[$name], newMigrationPath: $this->directory . '/' . $name, ); @@ -103,7 +103,7 @@ public function testSelectDatabaseForUpDownAndRedo(): void public function testMarkRequiresDatabaseAndChangesOnlyItsHistory(): void { $class = $this->writeMigration('maps'); - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', historyTable: 'maps_history', @@ -124,7 +124,7 @@ public function testMarkRequiresDatabaseAndChangesOnlyItsHistory(): void public function testMarkRejectsLongNamesBeforeAddingAnyHistory(): void { $class = $this->writeMigration('maps'); - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', migrationNameLimit: 10, @@ -295,7 +295,7 @@ public function testStopsOnExceptionAndRollsBackOnlyTheFailingDatabase(): void public function testStopsOnNonzeroExitCode(): void { $this->writeMigration('default'); - $this->sets['maps'] = new DatabaseSet($this->connections['maps']); + $this->sets['maps'] = new DatabaseConfig($this->connections['maps']); $this->writeMigration('analytics'); self::assertSame(Command::INVALID, $this->command('up')->execute(['--force-yes' => true])); self::assertCount(1, $this->migrator('default')->getHistory()); @@ -370,7 +370,7 @@ public function testListingStopsOnErrorEvenAfterFindingResults(string $name): vo if ($name === 'history') { $this->command('up')->execute(['--db' => 'default', '--force-yes' => true]); } - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->createUnavailableConnection(), newMigrationPath: $this->directory . '/maps', ); @@ -409,7 +409,7 @@ public function testLimitAppliesToEachDatabase(): void public function testSetsCanShareAConnectionWithSeparateHistoryTables(): void { $this->connections['maps'] = $this->connections['default']; - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', historyTable: 'maps_history', @@ -429,7 +429,7 @@ public function testNamedSetUsesItsConnectionForGeneration(): void $this->connections['maps']->createCommand() ->createTable('parent', ['maps_key' => ColumnBuilder::integer()->primaryKey()]) ->execute(); - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', useTablePrefix: false, @@ -450,7 +450,7 @@ public function testNamedSetUsesItsConnectionForGeneration(): void public function testCreateUsesNamedSetMigrationNameLimit(): void { - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', migrationNameLimit: 5, @@ -487,7 +487,7 @@ public function testNamedSetDefaultGenerationSettings(): void public function testNamedSetDiscoversMigrationsInItsNewMigrationNamespace(): void { - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationNamespace: 'Yiisoft\Db\Migration\Tests\Support\MigrationsExtra', ); @@ -499,7 +499,7 @@ public function testNamedSetDiscoversMigrationsInItsNewMigrationNamespace(): voi public function testNamedSetRespectsItsMigrationNameLimit(): void { $this->writeMigration('maps'); - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', migrationNameLimit: 5, @@ -513,7 +513,7 @@ public function testNamedSetsRetainMultipleMigrationSources(): void { $class = $this->writeMigration('maps'); $extraClass = $this->writeMigration('analytics', 'extra_example'); - $this->sets['maps'] = new DatabaseSet( + $this->sets['maps'] = new DatabaseConfig( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', sourceNamespaces: ['Yiisoft\\Db\\Migration\\Tests\\Support\\MigrationsExtra'], @@ -594,7 +594,7 @@ private function command(string $name): CommandTester { $sets = $this->sets; unset($sets['default']); - $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), $sets); + $registry = new DatabaseRegistry(new Injector(), new NullMigrationInformer(), $sets); $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 96e483a4..0e1be578 100644 --- a/tests/Migration/BinTest.php +++ b/tests/Migration/BinTest.php @@ -84,7 +84,7 @@ public function testNamedDatabase(): void PHP); $this->replaceParams("'databases' => [],", <<<'PHP' 'databases' => [ - 'maps' => new \Yiisoft\Db\Migration\DatabaseSet( + 'maps' => new \Yiisoft\Db\Migration\DatabaseConfig( 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()) @@ -130,7 +130,7 @@ public function testRejectsDefaultDatabase(): void $this->replaceParams("'db' => null,", "'db' => \$db,"); $this->replaceParams( "'databases' => [],", - "'databases' => ['default' => new \\Yiisoft\\Db\\Migration\\DatabaseSet(\$db)],", + "'databases' => ['default' => new \\Yiisoft\\Db\\Migration\\DatabaseConfig(\$db)],", ); [$output, $exitCode] = $this->runYiiDbMigration(); diff --git a/tests/Migration/ConfigTest.php b/tests/Migration/ConfigTest.php index 99a00b0a..5310da15 100644 --- a/tests/Migration/ConfigTest.php +++ b/tests/Migration/ConfigTest.php @@ -12,7 +12,7 @@ use Yiisoft\Di\Container; use Yiisoft\Di\ContainerConfig; use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; -use Yiisoft\Db\Migration\DatabaseSetRegistry; +use Yiisoft\Db\Migration\DatabaseRegistry; use Symfony\Component\Console\Tester\CommandTester; use Yiisoft\Db\Migration\Command\CreateCommand; use Yiisoft\Db\Migration\Command\DownCommand; @@ -85,7 +85,7 @@ public function testAdditionalDatabaseConfiguration(): void ], ], ); - $this->assertSame(['default', 'maps'], $container->get(DatabaseSetRegistry::class)->getNames()); + $this->assertSame(['maps'], $container->get(DatabaseRegistry::class)->getNames()); $command = new CommandTester($container->get(DownCommand::class)); $this->assertSame(2, $command->execute([])); diff --git a/tests/Migration/DatabaseRegistryTest.php b/tests/Migration/DatabaseRegistryTest.php new file mode 100644 index 00000000..456658e8 --- /dev/null +++ b/tests/Migration/DatabaseRegistryTest.php @@ -0,0 +1,118 @@ + [0, '0'], + 'numeric string' => ['1', '1'], + ]; + } + + #[DataProvider('numericNames')] + public function testRejectsNumericNames(string|int $name, string $expectedName): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Database names must be non-numeric strings, "' . $expectedName . '" given.'); + new DatabaseRegistry(new Injector(), new NullMigrationInformer(), [ + $name => new DatabaseConfig($this->createMock(ConnectionInterface::class)), + ]); + } + + public static function blankNames(): array + { + return [ + 'empty' => [''], + 'whitespace' => [' '], + ]; + } + + #[DataProvider('blankNames')] + public function testRejectsBlankNames(string $name): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Database names must be non-empty strings.'); + new DatabaseRegistry(new Injector(), new NullMigrationInformer(), [ + $name => new DatabaseConfig($this->createMock(ConnectionInterface::class)), + ]); + } + + public function testRejectsInvalidConfiguration(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Configuration of the "maps" database must be an instance of "' . DatabaseConfig::class + . '", "array" given.', + ); + new DatabaseRegistry(new Injector(), new NullMigrationInformer(), ['maps' => []]); + } + + public function testRejectsDefaultName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The "default" database is configured by the existing migration settings and can\'t be redefined.', + ); + new DatabaseRegistry(new Injector(), new NullMigrationInformer(), [ + 'default' => new DatabaseConfig($this->createMock(ConnectionInterface::class)), + ]); + } + + public function testCannotCreateSetupForDefaultDatabase(): void + { + $registry = new DatabaseRegistry(new Injector(), new NullMigrationInformer()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown additional database: default.'); + $registry->createSetup('default'); + } + + public function testCannotCreateSetupForUnknownDatabase(): void + { + $registry = new DatabaseRegistry(new Injector(), new NullMigrationInformer()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown additional database: missing.'); + $registry->createSetup('missing'); + } + + public function testNamesInConfigurationOrder(): void + { + $config = new DatabaseConfig($this->createMock(ConnectionInterface::class)); + $registry = new DatabaseRegistry(new Injector(), new NullMigrationInformer(), [ + 'maps' => $config, + 'analytics' => $config, + ]); + self::assertSame(['maps', 'analytics'], $registry->getNames()); + } + + public function testCreateSetup(): void + { + $config = new DatabaseConfig( + $this->createMock(ConnectionInterface::class), + historyTable: '{{%maps_migration}}', + migrationNameLimit: 100, + ); + $registry = new DatabaseRegistry(new Injector(), new NullMigrationInformer(), ['maps' => $config]); + + $setup = $registry->createSetup('maps'); + + self::assertSame($config, $setup->config); + self::assertSame(100, $setup->migrator->getMigrationNameLimit()); + self::assertNotSame($setup->migrator, $registry->createSetup('maps')->migrator); + } +} diff --git a/tests/Migration/DatabaseSetRegistryTest.php b/tests/Migration/DatabaseSetRegistryTest.php deleted file mode 100644 index 27acfe51..00000000 --- a/tests/Migration/DatabaseSetRegistryTest.php +++ /dev/null @@ -1,68 +0,0 @@ -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 function testRejectsDefaultName(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'The "default" database is configured by the existing migration settings and can\'t be redefined.', - ); - new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), [ - 'default' => new DatabaseSet($this->createMock(ConnectionInterface::class)), - ]); - } - - public function testDefaultUsesExistingServices(): 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'); - } - - 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()); - } -}