From 6c5373ca25b57502b0c9cb43f47a33126581622a Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 15 Oct 2026 11:09:01 +0300 Subject: [PATCH 1/2] Add PostgreSQL multi-database command tests and restore reusable mutation workflow --- .github/workflows/mutation.yml | 39 +- .../Command/AbstractDatabaseCommandsTest.php | 596 ++++++++++++++++++ tests/Driver/Pgsql/DatabaseCommandsTest.php | 80 +++ tests/Driver/Sqlite/DatabaseCommandsTest.php | 587 +---------------- 4 files changed, 690 insertions(+), 612 deletions(-) create mode 100644 tests/Common/Command/AbstractDatabaseCommandsTest.php create mode 100644 tests/Driver/Pgsql/DatabaseCommandsTest.php diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 0d5abca6..a1415330 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -24,41 +24,4 @@ concurrency: jobs: tests: - 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" + uses: yiisoft/actions/.github/workflows/db-mutation.yml@master diff --git a/tests/Common/Command/AbstractDatabaseCommandsTest.php b/tests/Common/Command/AbstractDatabaseCommandsTest.php new file mode 100644 index 00000000..cc1901f1 --- /dev/null +++ b/tests/Common/Command/AbstractDatabaseCommandsTest.php @@ -0,0 +1,596 @@ + */ + 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] = $this->createConnection($name); + $this->sets[$name] = new DatabaseSet( + $this->connections[$name], + newMigrationPath: $this->directory . '/' . $name, + ); + } + } + + protected function tearDown(): void + { + FileHelper::removeDirectory($this->directory); + } + + public function testApplyAllInConfigurationOrderWithIndependentHistory(): void + { + $classes = []; + foreach ($this->sets as $name => $set) { + $classes[$name] = $this->writeMigration($name); + } + // Even when "default" was not inserted first, it must run first. + $this->sets = ['analytics' => $this->sets['analytics'], 'default' => $this->sets['default'], 'maps' => $this->sets['maps']]; + $command = $this->command('up'); + self::assertSame(Command::SUCCESS, $command->execute(['--force-yes' => true])); + foreach ($classes as $name => $class) { + self::assertSame([$class], array_keys($this->migrator($name)->getHistory())); + self::assertNotNull($this->connections[$name]->getSchema()->getTableSchema('example')); + } + $output = $command->getDisplay(); + self::assertLessThan(strpos($output, 'Database: analytics'), strpos($output, 'Database: default')); + self::assertLessThan(strpos($output, 'Database: maps'), strpos($output, 'Database: analytics')); + // Already applied migrations must not run again. + self::assertSame(Command::SUCCESS, $command->execute(['--force-yes' => true])); + } + + public function testSelectDatabaseForUpDownAndRedo(): void + { + foreach (['default', 'maps', 'analytics'] as $name) { + $this->writeMigration($name); + } + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertNull($this->connections['default']->getSchema()->getTableSchema('migration')); + self::assertNull($this->connections['analytics']->getSchema()->getTableSchema('migration')); + self::assertCount(1, $this->migrator('maps')->getHistory()); + self::assertSame(Command::SUCCESS, $this->command('redo')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertCount(1, $this->migrator('maps')->getHistory()); + self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertSame(Command::SUCCESS, $this->command('down')->execute(['--db' => 'maps', '--force-yes' => true])); + self::assertSame([], $this->migrator('maps')->getHistory()); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + } + + public function testMarkRequiresDatabaseAndChangesOnlyItsHistory(): void + { + $class = $this->writeMigration('maps'); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + historyTable: 'maps_history', + ); + $command = $this->command('mark'); + self::assertSame(Command::INVALID, $command->execute(['version' => $class, '-y' => true])); + self::assertStringContainsString('--db option is required', $command->getDisplay()); + self::assertSame([], $this->connections['maps']->getSchema()->getTableNames()); + + self::assertSame(Command::SUCCESS, $command->execute(['version' => $class, '--db' => 'maps', '-y' => true])); + self::assertSame([$class], array_keys($this->migrator('maps')->getHistory())); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + self::assertNull($this->connections['maps']->getSchema()->getTableSchema('migration', true)); + self::assertSame([], $this->connections['default']->getSchema()->getTableNames()); + self::assertSame([], $this->connections['analytics']->getSchema()->getTableNames()); + } + + public function testMarkRejectsLongNamesBeforeAddingAnyHistory(): void + { + $class = $this->writeMigration('maps'); + $this->sets['maps'] = new DatabaseSet( + $this->connections['maps'], + newMigrationPath: $this->directory . '/maps', + migrationNameLimit: 10, + ); + $command = $this->command('mark'); + self::assertSame(Command::INVALID, $command->execute(['version' => $class, '--db' => 'maps', '-y' => true])); + self::assertStringContainsString('too long', $command->getDisplay()); + self::assertSame([], $this->migrator('maps')->getHistory()); + } + + public function testResetHistoryDoesNotRevertSchema(): void + { + $this->writeMigration('maps'); + self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--db' => 'maps', '-y' => true])); + self::assertSame(Command::SUCCESS, $this->command('mark')->execute([ + '--db' => 'maps', + 'version' => MarkCommand::BASE_MIGRATION, + '-y' => true, + ])); + self::assertSame([], $this->migrator('maps')->getHistory()); + self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); + } + + public static function commands(): array + { + return [['up'], ['down'], ['redo'], ['new'], ['history'], ['create']]; + } + + #[DataProvider('commands')] + public function testUnknownDatabaseIsRejectedBeforeAnyWork(string $name): void + { + $command = $this->command($name); + $input = ['--db' => 'unknown']; + if ($name === 'create') { + $input['name'] = 'Example'; + } + self::assertSame(Command::INVALID, $command->execute($input)); + self::assertStringContainsString('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( + $this->createUnavailableConnection(), + 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()); + } + + /** + * Creates a connection to an empty database that is isolated from the databases of other names. + */ + abstract protected function createConnection(string $name): ConnectionInterface; + + /** + * Creates a connection that fails on the first query. + */ + abstract protected function createUnavailableConnection(): ConnectionInterface; + + private function writeMigration(string $database, string $table = 'example', bool $fail = false): string + { + $class = 'M260930000000' . ucfirst($database) . uniqid(); + $failure = $fail ? "throw new \\RuntimeException('Migration failed.');" : ''; + file_put_contents($this->directory . '/' . $database . '/' . $class . '.php', <<createTable('$table', ['id' => ColumnBuilder::primaryKey()]); + $failure + } + public function down(MigrationBuilder \$b): void + { + \$b->dropTable('$table'); + } + } + PHP); + return $class; + } + + private function migrator(string $name): Migrator + { + return new Migrator($this->connections[$name], new NullMigrationInformer(), $this->sets[$name]->historyTable); + } + + private function command(string $name): CommandTester + { + $sets = $this->sets; + unset($sets['default']); + $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), $sets); + $migrator = $this->migrator('default'); + $service = new MigrationService($this->connections['default'], new Injector(), $migrator); + $service->setNewMigrationPath($this->sets['default']->newMigrationPath); + $command = match ($name) { + 'up' => new UpdateCommand(new UpdateRunner($migrator), $service, $migrator, $registry), + 'down' => new DownCommand(new DownRunner($migrator), $service, $migrator, $registry), + 'redo' => new RedoCommand($service, $migrator, new DownRunner($migrator), new UpdateRunner($migrator), $registry), + 'new' => new NewCommand($service, $migrator, $registry), + 'history' => new HistoryCommand($service, $migrator, $registry), + 'mark' => new MarkCommand($service, $migrator, $registry), + 'create' => new CreateCommand(new CreateService($this->connections['default']), $service, $migrator, $registry), + }; + return new CommandTester($command); + } +} diff --git a/tests/Driver/Pgsql/DatabaseCommandsTest.php b/tests/Driver/Pgsql/DatabaseCommandsTest.php new file mode 100644 index 00000000..d91c126b --- /dev/null +++ b/tests/Driver/Pgsql/DatabaseCommandsTest.php @@ -0,0 +1,80 @@ +createdConnections as $connection) { + $this->clearDatabase($connection); + $connection->close(); + } + $this->createdConnections = []; + } + + protected function createConnection(string $name): ConnectionInterface + { + $database = $name === 'default' ? self::DATABASE : self::DATABASE . '_' . $name; + if ($database !== self::DATABASE) { + $this->ensureDatabase($database); + } + + $connection = $this->connect($database); + $this->clearDatabase($connection); + $this->createdConnections[] = $connection; + + return $connection; + } + + protected function createUnavailableConnection(): ConnectionInterface + { + return $this->connect(self::DATABASE . '_missing'); + } + + private function connect(string $database): ConnectionInterface + { + return new Connection( + new Driver('pgsql:host=127.0.0.1;port=5432;dbname=' . $database, 'root', 'root'), + new SchemaCache(new MemorySimpleCache()), + ); + } + + private function ensureDatabase(string $database): void + { + $connection = $this->connect(self::DATABASE); + $exists = $connection + ->createCommand('SELECT 1 FROM pg_database WHERE datname = :name', [':name' => $database]) + ->queryScalar(); + if ($exists === false) { + $connection + ->createCommand('CREATE DATABASE ' . $connection->getQuoter()->quoteTableName($database)) + ->execute(); + } + $connection->close(); + } + + private function clearDatabase(ConnectionInterface $connection): void + { + $connection->createCommand('DROP SCHEMA IF EXISTS "public" CASCADE')->execute(); + $connection->createCommand('CREATE SCHEMA "public"')->execute(); + } +} diff --git a/tests/Driver/Sqlite/DatabaseCommandsTest.php b/tests/Driver/Sqlite/DatabaseCommandsTest.php index 2307eb73..769237f4 100644 --- a/tests/Driver/Sqlite/DatabaseCommandsTest.php +++ b/tests/Driver/Sqlite/DatabaseCommandsTest.php @@ -4,589 +4,28 @@ namespace Yiisoft\Db\Migration\Tests\Driver\Sqlite; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; -use RuntimeException; -use Yiisoft\Db\Exception\Exception as DbException; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Tester\CommandTester; -use Yiisoft\Db\Migration\Command\CreateCommand; -use Yiisoft\Db\Migration\Command\DownCommand; -use Yiisoft\Db\Migration\Command\HistoryCommand; -use Yiisoft\Db\Migration\Command\MarkCommand; -use Yiisoft\Db\Migration\Command\NewCommand; -use Yiisoft\Db\Migration\Command\RedoCommand; -use Yiisoft\Db\Migration\Command\UpdateCommand; -use Yiisoft\Db\Migration\DatabaseSet; -use Yiisoft\Db\Migration\DatabaseSetRegistry; -use Yiisoft\Db\Migration\Informer\NullMigrationInformer; -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\Db\Cache\SchemaCache; -use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; +use Yiisoft\Db\Connection\ConnectionInterface; +use Yiisoft\Db\Migration\Tests\Common\Command\AbstractDatabaseCommandsTest; use Yiisoft\Db\Sqlite\Connection; use Yiisoft\Db\Sqlite\Driver; -use Yiisoft\Files\FileHelper; -use Yiisoft\Injector\Injector; -use Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty; -use Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty2; - -use function dirname; +use Yiisoft\Test\Support\SimpleCache\MemorySimpleCache; -final class DatabaseCommandsTest extends TestCase +/** + * @group sqlite + */ +final class DatabaseCommandsTest extends AbstractDatabaseCommandsTest { - private string $directory; - /** @var array */ - 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)); - } - - public function testMarkRequiresDatabaseAndChangesOnlyItsHistory(): void - { - $class = $this->writeMigration('maps'); - $this->sets['maps'] = new DatabaseSet( - $this->connections['maps'], - newMigrationPath: $this->directory . '/maps', - historyTable: 'maps_history', - ); - $command = $this->command('mark'); - self::assertSame(Command::INVALID, $command->execute(['version' => $class, '-y' => true])); - self::assertStringContainsString('--db option is required', $command->getDisplay()); - self::assertSame([], $this->connections['maps']->getSchema()->getTableNames()); - - self::assertSame(Command::SUCCESS, $command->execute(['version' => $class, '--db' => 'maps', '-y' => true])); - self::assertSame([$class], array_keys($this->migrator('maps')->getHistory())); - self::assertNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); - self::assertNull($this->connections['maps']->getSchema()->getTableSchema('migration', true)); - self::assertSame([], $this->connections['default']->getSchema()->getTableNames()); - self::assertSame([], $this->connections['analytics']->getSchema()->getTableNames()); - } - - public function testMarkRejectsLongNamesBeforeAddingAnyHistory(): void - { - $class = $this->writeMigration('maps'); - $this->sets['maps'] = new DatabaseSet( - $this->connections['maps'], - newMigrationPath: $this->directory . '/maps', - migrationNameLimit: 10, - ); - $command = $this->command('mark'); - self::assertSame(Command::INVALID, $command->execute(['version' => $class, '--db' => 'maps', '-y' => true])); - self::assertStringContainsString('too long', $command->getDisplay()); - self::assertSame([], $this->migrator('maps')->getHistory()); - } - - public function testResetHistoryDoesNotRevertSchema(): void - { - $this->writeMigration('maps'); - self::assertSame(Command::SUCCESS, $this->command('up')->execute(['--db' => 'maps', '-y' => true])); - self::assertSame(Command::SUCCESS, $this->command('mark')->execute([ - '--db' => 'maps', - 'version' => MarkCommand::BASE_MIGRATION, - '-y' => true, - ])); - self::assertSame([], $this->migrator('maps')->getHistory()); - self::assertNotNull($this->connections['maps']->getSchema()->getTableSchema('example', true)); - } - - public static function commands(): array - { - return [['up'], ['down'], ['redo'], ['new'], ['history'], ['create']]; - } - - #[DataProvider('commands')] - public function testUnknownDatabaseIsRejectedBeforeAnyWork(string $name): void - { - $command = $this->command($name); - $input = ['--db' => 'unknown']; - if ($name === 'create') { - $input['name'] = 'Example'; - } - self::assertSame(Command::INVALID, $command->execute($input)); - self::assertStringContainsString('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 + protected function createConnection(string $name): ConnectionInterface { - $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()); - } + return new Connection(new Driver('sqlite::memory:'), new SchemaCache(new MemorySimpleCache())); } - #[DataProvider('listingCommands')] - public function testListingStopsOnErrorEvenAfterFindingResults(string $name): void + protected function createUnavailableConnection(): ConnectionInterface { - $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', + return new Connection( + new Driver('sqlite:' . $this->directory . '/missing/maps.sqlite'), + new SchemaCache(new MemorySimpleCache()), ); - $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): CommandTester - { - $sets = $this->sets; - unset($sets['default']); - $registry = new DatabaseSetRegistry(new Injector(), new NullMigrationInformer(), $sets); - $migrator = $this->migrator('default'); - $service = new MigrationService($this->connections['default'], new Injector(), $migrator); - $service->setNewMigrationPath($this->sets['default']->newMigrationPath); - $command = match ($name) { - 'up' => new UpdateCommand(new UpdateRunner($migrator), $service, $migrator, $registry), - 'down' => new DownCommand(new DownRunner($migrator), $service, $migrator, $registry), - 'redo' => new RedoCommand($service, $migrator, new DownRunner($migrator), new UpdateRunner($migrator), $registry), - 'new' => new NewCommand($service, $migrator, $registry), - 'history' => new HistoryCommand($service, $migrator, $registry), - 'mark' => new MarkCommand($service, $migrator, $registry), - 'create' => new CreateCommand(new CreateService($this->connections['default']), $service, $migrator, $registry), - }; - return new CommandTester($command); } } From 278be75a163cac56341660e251982c164a82174d Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 15 Oct 2026 16:05:11 +0300 Subject: [PATCH 2/2] improve --- .../Command/AbstractDatabaseCommandsTest.php | 28 ++++- tests/Driver/Mssql/DatabaseCommandsTest.php | 107 ++++++++++++++++++ tests/Driver/Mysql/DatabaseCommandsTest.php | 90 +++++++++++++++ tests/Driver/Oracle/DatabaseCommandsTest.php | 99 ++++++++++++++++ 4 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 tests/Driver/Mssql/DatabaseCommandsTest.php create mode 100644 tests/Driver/Mysql/DatabaseCommandsTest.php create mode 100644 tests/Driver/Oracle/DatabaseCommandsTest.php diff --git a/tests/Common/Command/AbstractDatabaseCommandsTest.php b/tests/Common/Command/AbstractDatabaseCommandsTest.php index cc1901f1..10e20094 100644 --- a/tests/Common/Command/AbstractDatabaseCommandsTest.php +++ b/tests/Common/Command/AbstractDatabaseCommandsTest.php @@ -26,6 +26,7 @@ use Yiisoft\Db\Migration\Runner\UpdateRunner; use Yiisoft\Db\Migration\Service\Generate\CreateService; use Yiisoft\Db\Migration\Service\MigrationService; +use Yiisoft\Db\Schema\Column\ColumnBuilder; use Yiisoft\Files\FileHelper; use Yiisoft\Injector\Injector; use Yiisoft\Db\Migration\Tests\Support\MigrationsExtra\M231108183919Empty; @@ -273,6 +274,9 @@ public function testCreateDefaultsToDefaultAndCanSelectAnotherSet(): void public function testStopsOnExceptionAndRollsBackOnlyTheFailingDatabase(): void { $defaultClass = $this->writeMigration('default'); + $this->connections['maps']->createCommand() + ->createTable('example', ['value' => ColumnBuilder::integer()]) + ->execute(); $this->writeMigration('maps', fail: true); $this->writeMigration('analytics'); try { @@ -284,7 +288,7 @@ public function testStopsOnExceptionAndRollsBackOnlyTheFailingDatabase(): void 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::assertSame(0, $this->countRows($this->connections['maps'], 'example')); self::assertNull($this->connections['analytics']->getSchema()->getTableSchema('migration')); } @@ -419,8 +423,12 @@ public function testSetsCanShareAConnectionWithSeparateHistoryTables(): void 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->connections['default']->createCommand() + ->createTable('parent', ['default_key' => ColumnBuilder::integer()->primaryKey()]) + ->execute(); + $this->connections['maps']->createCommand() + ->createTable('parent', ['maps_key' => ColumnBuilder::integer()->primaryKey()]) + ->execute(); $this->sets['maps'] = new DatabaseSet( $this->connections['maps'], newMigrationPath: $this->directory . '/maps', @@ -546,7 +554,9 @@ abstract protected function createUnavailableConnection(): ConnectionInterface; private function writeMigration(string $database, string $table = 'example', bool $fail = false): string { $class = 'M260930000000' . ucfirst($database) . uniqid(); - $failure = $fail ? "throw new \\RuntimeException('Migration failed.');" : ''; + $up = $fail + ? "\$b->insert('$table', ['value' => 1]); throw new \\RuntimeException('Migration failed.');" + : "\$b->createTable('$table', ['id' => ColumnBuilder::primaryKey()]);"; file_put_contents($this->directory . '/' . $database . '/' . $class . '.php', <<createTable('$table', ['id' => ColumnBuilder::primaryKey()]); - $failure + $up } public function down(MigrationBuilder \$b): void { @@ -569,6 +578,13 @@ public function down(MigrationBuilder \$b): void return $class; } + private function countRows(ConnectionInterface $connection, string $table): int + { + return (int) $connection + ->createCommand('SELECT COUNT(*) FROM ' . $connection->getQuoter()->quoteTableName($table)) + ->queryScalar(); + } + private function migrator(string $name): Migrator { return new Migrator($this->connections[$name], new NullMigrationInformer(), $this->sets[$name]->historyTable); diff --git a/tests/Driver/Mssql/DatabaseCommandsTest.php b/tests/Driver/Mssql/DatabaseCommandsTest.php new file mode 100644 index 00000000..6ef91431 --- /dev/null +++ b/tests/Driver/Mssql/DatabaseCommandsTest.php @@ -0,0 +1,107 @@ +createdConnections as $connection) { + $this->clearDatabase($connection); + $connection->close(); + } + $this->createdConnections = []; + } + + protected function createConnection(string $name): ConnectionInterface + { + $database = $name === 'default' ? self::DATABASE : self::DATABASE . '_' . $name; + if ($database !== self::DATABASE) { + $this->ensureDatabase($database); + } + + $connection = $this->connect($database); + $this->clearDatabase($connection); + $this->createdConnections[] = $connection; + + return $connection; + } + + protected function createUnavailableConnection(): ConnectionInterface + { + return $this->connect(self::DATABASE . '_missing'); + } + + private function connect(string $database): ConnectionInterface + { + return new Connection( + new Driver( + 'sqlsrv:Server=127.0.0.1,1433;Database=' . $database . ';Encrypt=no', + 'SA', + 'YourStrong!Passw0rd', + ), + new SchemaCache(new MemorySimpleCache()), + ); + } + + private function ensureDatabase(string $database): void + { + $connection = $this->connect(self::DATABASE); + $id = $connection + ->createCommand('SELECT DB_ID(:name)', [':name' => $database]) + ->queryScalar(); + if ($id === null) { + $connection + ->createCommand('CREATE DATABASE ' . $connection->getQuoter()->quoteTableName($database)) + ->execute(); + } + $connection->close(); + } + + private function clearDatabase(ConnectionInterface $connection): void + { + $quoter = $connection->getQuoter(); + + $foreignKeys = $connection + ->createCommand( + 'SELECT OBJECT_SCHEMA_NAME(parent_object_id) AS table_schema,' + . ' OBJECT_NAME(parent_object_id) AS table_name, name FROM sys.foreign_keys', + ) + ->queryAll(); + foreach ($foreignKeys as $foreignKey) { + $connection + ->createCommand( + 'ALTER TABLE ' . $quoter->quoteTableName($foreignKey['table_schema'] . '.' . $foreignKey['table_name']) + . ' DROP CONSTRAINT ' . $quoter->quoteColumnName($foreignKey['name']), + ) + ->execute(); + } + + $tables = $connection + ->createCommand('SELECT SCHEMA_NAME(schema_id) AS table_schema, name FROM sys.tables') + ->queryAll(); + foreach ($tables as $table) { + $connection + ->createCommand('DROP TABLE ' . $quoter->quoteTableName($table['table_schema'] . '.' . $table['name'])) + ->execute(); + } + } +} diff --git a/tests/Driver/Mysql/DatabaseCommandsTest.php b/tests/Driver/Mysql/DatabaseCommandsTest.php new file mode 100644 index 00000000..57af785e --- /dev/null +++ b/tests/Driver/Mysql/DatabaseCommandsTest.php @@ -0,0 +1,90 @@ +createdConnections as $connection) { + $this->clearDatabase($connection); + $connection->close(); + } + $this->createdConnections = []; + } + + protected function createConnection(string $name): ConnectionInterface + { + $database = $name === 'default' ? self::DATABASE : self::DATABASE . '_' . $name; + if ($database !== self::DATABASE) { + $this->ensureDatabase($database); + } + + $connection = $this->connect($database); + $this->clearDatabase($connection); + $this->createdConnections[] = $connection; + + return $connection; + } + + protected function createUnavailableConnection(): ConnectionInterface + { + return $this->connect(self::DATABASE . '_missing'); + } + + private function connect(string $database): ConnectionInterface + { + return new Connection( + new Driver('mysql:host=127.0.0.1;dbname=' . $database . ';port=3306;charset=utf8', 'root', ''), + new SchemaCache(new MemorySimpleCache()), + ); + } + + private function ensureDatabase(string $database): void + { + $connection = $this->connect(self::DATABASE); + $connection + ->createCommand('CREATE DATABASE IF NOT EXISTS ' . $connection->getQuoter()->quoteTableName($database)) + ->execute(); + $connection->close(); + } + + private function clearDatabase(ConnectionInterface $connection): void + { + $tables = $connection + ->createCommand( + "SELECT table_name FROM information_schema.tables" + . " WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", + ) + ->queryColumn(); + if ($tables === []) { + return; + } + + $connection->createCommand('SET FOREIGN_KEY_CHECKS = 0')->execute(); + foreach ($tables as $table) { + $connection + ->createCommand('DROP TABLE ' . $connection->getQuoter()->quoteTableName($table)) + ->execute(); + } + $connection->createCommand('SET FOREIGN_KEY_CHECKS = 1')->execute(); + } +} diff --git a/tests/Driver/Oracle/DatabaseCommandsTest.php b/tests/Driver/Oracle/DatabaseCommandsTest.php new file mode 100644 index 00000000..f42d8ae7 --- /dev/null +++ b/tests/Driver/Oracle/DatabaseCommandsTest.php @@ -0,0 +1,99 @@ +createdConnections as $connection) { + $this->clearDatabase($connection); + $connection->close(); + } + $this->createdConnections = []; + } + + protected function createConnection(string $name): ConnectionInterface + { + $user = self::USER_PREFIX . strtoupper($name); + $this->ensureUser($user); + + $connection = $this->connect($user, self::PASSWORD); + $this->clearDatabase($connection); + $this->createdConnections[] = $connection; + + return $connection; + } + + protected function createUnavailableConnection(): ConnectionInterface + { + return $this->connect(self::USER_PREFIX . 'MISSING', self::PASSWORD); + } + + private function connect(string $user, string $password): ConnectionInterface + { + return new Connection( + new Driver('oci:dbname=localhost:1521/XE;charset=AL32UTF8', $user, $password), + new SchemaCache(new MemorySimpleCache()), + ); + } + + private function ensureUser(string $user): void + { + $connection = $this->connect('system', 'root'); + $exists = $connection + ->createCommand('SELECT 1 FROM all_users WHERE username = :name', [':name' => $user]) + ->queryScalar(); + if ($exists === false) { + $quotedUser = $connection->getQuoter()->quoteTableName($user); + $connection + ->createCommand('CREATE USER ' . $quotedUser . ' IDENTIFIED BY ' . self::PASSWORD) + ->execute(); + $connection + ->createCommand( + 'GRANT CREATE SESSION, CREATE TABLE, CREATE SEQUENCE, CREATE TRIGGER, UNLIMITED TABLESPACE TO ' + . $quotedUser, + ) + ->execute(); + } + $connection->close(); + } + + private function clearDatabase(ConnectionInterface $connection): void + { + $tables = $connection + ->createCommand("SELECT table_name FROM user_tables WHERE dropped = 'NO'") + ->queryColumn(); + foreach ($tables as $table) { + $connection + ->createCommand( + 'DROP TABLE ' . $connection->getQuoter()->quoteTableName($table) . ' CASCADE CONSTRAINTS PURGE', + ) + ->execute(); + } + $connection->createCommand('PURGE RECYCLEBIN')->execute(); + } +}