diff --git a/README.md b/README.md index c325c0c..dd8670c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ There is also a subtle schema-pollution problem: after running migrations, `doct ## What this bundle does - **Context-aware commands**: every `doctrine:migrations:*` command gains `--em` and `--conn` options. Pass one to target a specific context, or omit both to run across all registered contexts in sequence. -- **ORM command integration** *(requires `doctrine/orm`)*: `doctrine:schema:validate` and `doctrine:mapping:info` receive the same fan-out behaviour. +- **ORM command integration** *(requires `doctrine/orm`)*: `doctrine:schema:create`, `doctrine:schema:validate`, and `doctrine:mapping:info` receive the same fan-out behaviour. +- **Database command integration**: `doctrine:database:create` fans out across all registered contexts. Accepts both `--connection` (native option) and `--conn` (context-system alias). - **Schema filter**: automatically hides the migration metadata table from `doctrine:schema:update` and `doctrine:schema:validate`, so those commands never see it as unmanaged. - **`--ctx-isolation`**: an extra flag added to every wrapped command. When set, a failure in one context does not abort the remaining contexts. @@ -170,6 +171,17 @@ By default, a failure in one context stops execution when executed in non-intera php bin/console doctrine:migrations:migrate --no-interaction --ctx-isolation ``` +### Create databases + +```bash +# All contexts +php bin/console doctrine:database:create + +# Specific context – both flags are equivalent +php bin/console doctrine:database:create --connection=shop +php bin/console doctrine:database:create --conn=shop +``` + ### All supported commands Every `doctrine:migrations:*` command supports the context options: @@ -190,10 +202,17 @@ Every `doctrine:migrations:*` command supports the context options: | `doctrine:migrations:dump-schema` | Dump the schema for a mapping | | `doctrine:migrations:sync-metadata-storage` | Sync the metadata storage | +Always available: + +| Command | Description | +|------------------------------|-----------------------------------------------------| +| `doctrine:database:create` | Create the database for each registered context | + When `doctrine/orm` is installed and configured: | Command | Description | |----------------------------|----------------------------------------------| +| `doctrine:schema:create` | Create schema across all entity managers | | `doctrine:schema:validate` | Validate schema across all entity managers | | `doctrine:mapping:info` | Show mapping info across all entity managers | diff --git a/config/services.php b/config/services.php index 0222659..ea1d96a 100644 --- a/config/services.php +++ b/config/services.php @@ -6,6 +6,7 @@ use Doctrine\Migrations\Configuration\Configuration as DoctrineMigrationsConfiguration; use Doctrine\Migrations\DependencyFactory; +use Kraz\DoctrineContextBundle\Command\Doctrine\Database\CreateDatabaseCommand; use Kraz\DoctrineContextBundle\Command\Doctrine\Migrations\CurrentCommand; use Kraz\DoctrineContextBundle\Command\Doctrine\Migrations\DiffCommand; use Kraz\DoctrineContextBundle\Command\Doctrine\Migrations\DumpSchemaCommand; @@ -20,6 +21,7 @@ use Kraz\DoctrineContextBundle\Command\Doctrine\Migrations\UpToDateCommand; use Kraz\DoctrineContextBundle\Command\Doctrine\Migrations\VersionCommand; use Kraz\DoctrineContextBundle\Configuration\Configuration as DoctrineContextConfiguration; +use Symfony\Component\DependencyInjection\ContainerInterface; return static function (ContainerConfigurator $container): void { $container->services() @@ -33,6 +35,14 @@ ->abstract() // Commands + ->set('doctrine.database_create_command.with_context', CreateDatabaseCommand::class) + ->decorate('doctrine.database_create_command', null, 0, ContainerInterface::IGNORE_ON_INVALID_REFERENCE) + ->args([ + service('.inner'), + service('doctrine.doctrine_context.configuration'), + ]) + ->tag('console.command', ['command' => 'doctrine:database:create']) + ->set('doctrine_migrations.current_command.with_context', CurrentCommand::class) ->decorate('doctrine_migrations.current_command') ->args([ diff --git a/config/services_orm.php b/config/services_orm.php index 3b85bcc..dd221b0 100644 --- a/config/services_orm.php +++ b/config/services_orm.php @@ -5,6 +5,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Kraz\DoctrineContextBundle\Command\Doctrine\Mapping\InfoCommand; +use Kraz\DoctrineContextBundle\Command\Doctrine\Schema\CreateSchemaCommand; use Kraz\DoctrineContextBundle\Command\Doctrine\Schema\ValidateSchemaCommand; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -18,6 +19,14 @@ ]) ->tag('console.command', ['command' => 'doctrine:mapping:info']) + ->set('doctrine.schema_create_command.with_context', CreateSchemaCommand::class) + ->decorate('doctrine.schema_create_command', null, 0, ContainerInterface::IGNORE_ON_INVALID_REFERENCE) + ->args([ + service('.inner'), + service('doctrine.doctrine_context.configuration'), + ]) + ->tag('console.command', ['command' => 'doctrine:schema:create']) + ->set('doctrine.schema_validate_command.with_context', ValidateSchemaCommand::class) ->decorate('doctrine.schema_validate_command', null, 0, ContainerInterface::IGNORE_ON_INVALID_REFERENCE) ->args([ diff --git a/src/Command/Doctrine/Database/CreateDatabaseCommand.php b/src/Command/Doctrine/Database/CreateDatabaseCommand.php new file mode 100644 index 0000000..440e65d --- /dev/null +++ b/src/Command/Doctrine/Database/CreateDatabaseCommand.php @@ -0,0 +1,41 @@ +configuration = $configuration; + + parent::__construct($this->command->getName(), $this->command->getCode()); + } + + #[Override] + protected function configure(): void + { + $this->configureAs($this->command); + $this->addOption('conn', null, InputOption::VALUE_OPTIONAL, 'The name of the connection to use (alias for --connection).'); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + return $this->runAs($this->command, $input, $output); + } +} diff --git a/src/Command/Doctrine/DoctrineContextTrait.php b/src/Command/Doctrine/DoctrineContextTrait.php index ffae5a6..e819aa0 100644 --- a/src/Command/Doctrine/DoctrineContextTrait.php +++ b/src/Command/Doctrine/DoctrineContextTrait.php @@ -17,6 +17,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Throwable; use function array_filter; use function array_replace; @@ -77,6 +78,26 @@ private function runAs(Command $command, InputInterface $input, OutputInterface }, $list, $input, $output); } + if ($command->getNativeDefinition()->hasOption('connection')) { + $connectionOption = trim((string) $input->getOption('connection')); + $connOption = trim((string) $input->getOption('conn')); + + if ($connectionOption !== '' && $connOption !== '') { + throw new InvalidArgumentException('You can specify only one of the --connection and --conn options.'); + } + + $targetConnection = $connectionOption ?: ($connOption ?: null); + $list = $this->filterDoctrineContexts(null, null, $targetConnection); + + return $this->walkDoctrineContexts(function (InputInterface $input, OutputInterface $output, string $contextName) use ($command) { + $command->setDefinition($this->getNativeDefinition()); + $command->setApplication($this->getApplication()); + $newInput = $this->createNewInput($command, $input, ['--connection' => $contextName]); + + return $command->run($newInput, $output); + }, $list, $input, $output); + } + throw new InvalidArgumentException(sprintf('Unsupported CLI command "%s"', $command::class)); } @@ -94,7 +115,13 @@ private function walkDoctrineContexts(callable $callback, array $list, InputInte $ui->section(sprintf('%s: %s', $dependencyFactory->hasEntityManager() ? 'Entity Manager' : 'Connection', $contextName)); } - $cmdResult = $callback($input, $output, $contextName, $dependencyFactory); + try { + $cmdResult = $callback($input, $output, $contextName, $dependencyFactory); + } catch (Throwable $e) { + $ui->error($e->getMessage()); + $cmdResult = Command::FAILURE; + } + if (count($all) > 1) { $ui->newLine(); } diff --git a/src/Command/Doctrine/Schema/CreateSchemaCommand.php b/src/Command/Doctrine/Schema/CreateSchemaCommand.php new file mode 100644 index 0000000..0d38bf3 --- /dev/null +++ b/src/Command/Doctrine/Schema/CreateSchemaCommand.php @@ -0,0 +1,42 @@ +configuration = $configuration; + $this->entityManagerProvider = $entityManagerProvider; + + parent::__construct($this->command->getName(), $this->command->getCode()); + } + + #[Override] + protected function configure(): void + { + $this->configureAs($this->command); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + return $this->runAs($this->command, $input, $output); + } +} diff --git a/tests/Functional/DatabaseCreateTest.php b/tests/Functional/DatabaseCreateTest.php new file mode 100644 index 0000000..3ce8651 --- /dev/null +++ b/tests/Functional/DatabaseCreateTest.php @@ -0,0 +1,94 @@ +cleanDatabases(); + + $kernel = self::bootKernel(); + $this->application = new Application($kernel); + $this->application->setAutoExit(false); + } + + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + + $this->cleanDatabases(); + } + + public function testDatabaseCreateSingleContextViaConnectionOption(): void + { + $output = $this->captureOutput('doctrine:database:create --connection=alpha --if-not-exists'); + + // No section header is emitted for a single context, so the connection name does not + // appear in the output — but untargeted connections must not be processed at all. + self::assertStringNotContainsString('default', $output, 'Output should not mention untargeted connections'); + self::assertStringNotContainsString('beta', $output, 'Output should not mention untargeted connections'); + } + + public function testDatabaseCreateSingleContextViaConnOption(): void + { + $output = $this->captureOutput('doctrine:database:create --conn=alpha --if-not-exists'); + + // No section header is emitted for a single context, so the connection name does not + // appear in the output — but untargeted connections must not be processed at all. + self::assertStringNotContainsString('default', $output, 'Output should not mention untargeted connections'); + self::assertStringNotContainsString('beta', $output, 'Output should not mention untargeted connections'); + } + + public function testDatabaseCreateAllContextsFansOutAcrossAllRegisteredContexts(): void + { + // SQLite does not support createDatabase, so every context fails. + // --ctx-isolation keeps the loop going after each failure instead of breaking. + // --no-interaction suppresses the interactive "continue?" prompt that would + // otherwise block waiting on stdin (walkDoctrineContexts asks before --ctx-isolation + // is consulted as the fallback). + $output = $this->captureOutput('doctrine:database:create --if-not-exists --ctx-isolation --no-interaction'); + + self::assertStringContainsString('default', $output, 'Output should mention the default context'); + self::assertStringContainsString('alpha', $output, 'Output should mention the alpha context'); + self::assertStringContainsString('beta', $output, 'Output should mention the beta context'); + } + + public function testDatabaseCreateFailsWhenBothConnectionAndConnAreSpecified(): void + { + // Run through the command directly (bypassing Application) so that Symfony's + // ConsoleEvents::ERROR listener never fires and no output is printed to stdout. + $command = self::getContainer()->get('doctrine.database_create_command.with_context'); + self::assertInstanceOf(CreateDatabaseCommand::class, $command); + /** @psalm-suppress InternalMethod */ + $command->mergeApplicationDefinition(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You can specify only one of the --connection and --conn options.'); + + $input = new ArrayInput(['--connection' => 'alpha', '--conn' => 'alpha'], $command->getDefinition()); + $command->run($input, new BufferedOutput()); + } +} diff --git a/tests/Functional/SchemaCreateTest.php b/tests/Functional/SchemaCreateTest.php new file mode 100644 index 0000000..2bec91d --- /dev/null +++ b/tests/Functional/SchemaCreateTest.php @@ -0,0 +1,123 @@ +markTestSkipped('doctrine/orm is not installed'); + } + + $this->cleanDatabases(); + + $kernel = self::bootKernel(); + $this->application = new Application($kernel); + $this->application->setAutoExit(false); + } + + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + + $this->cleanDatabases(); + } + + public function testSchemaCreateSingleContextCreatesTablesOnlyInTargetDatabase(): void + { + $exitCode = $this->runCommand('doctrine:schema:create --em=alpha'); + + self::assertSame(0, $exitCode); + + $this->assertTableExists('alpha', 'product'); + $this->assertTableNotExists('alpha', 'tag'); + $this->assertTableNotExists('alpha', 'customer'); + + $this->assertTableNotExists('default', 'tag'); + $this->assertTableNotExists('beta', 'customer'); + } + + public function testSchemaCreateAllContextsCreatesTablesInEachDatabase(): void + { + $exitCode = $this->runCommand('doctrine:schema:create'); + + self::assertSame(0, $exitCode); + + $this->assertTableExists('default', 'tag'); + $this->assertTableNotExists('default', 'product'); + $this->assertTableNotExists('default', 'customer'); + + $this->assertTableExists('alpha', 'product'); + $this->assertTableNotExists('alpha', 'tag'); + $this->assertTableNotExists('alpha', 'customer'); + + $this->assertTableExists('beta', 'customer'); + $this->assertTableNotExists('beta', 'tag'); + $this->assertTableNotExists('beta', 'product'); + } + + public function testSchemaCreateDumpSqlDoesNotCreateTables(): void + { + // --dump-sql prints DDL statements instead of executing them. + // If the flag is not forwarded to the inner command the schema is created + // anyway, which the assertion below would catch. + $exitCode = $this->runCommand('doctrine:schema:create --em=alpha --dump-sql'); + + self::assertSame(0, $exitCode, '--dump-sql must be forwarded to the inner command'); + $this->assertTableNotExists('alpha', 'product'); + } + + public function testSchemaCreateDoesNotCreateMigrationTableInTargetDatabase(): void + { + $this->runCommand('doctrine:schema:create --em=alpha'); + + $this->assertTableNotExists('alpha', 'zzz_migrations'); + } + + public function testSchemaValidateSucceedsAfterSchemaCreate(): void + { + $this->runCommand('doctrine:schema:create'); + + $exitCode = $this->runCommand('doctrine:schema:validate'); + + self::assertSame(0, $exitCode, 'Schema validation should succeed after schema:create'); + } + + #[Override] + protected function getConnection(string $name): Connection + { + $registry = self::getContainer()->get('doctrine'); + self::assertInstanceOf(ManagerRegistry::class, $registry); + + $connection = $registry->getConnection($name); + self::assertInstanceOf(Connection::class, $connection); + + return $connection; + } +} diff --git a/tests/Functional/SchemaFilterDbalTest.php b/tests/Functional/SchemaFilterDbalTest.php index 650cb63..013b53a 100644 --- a/tests/Functional/SchemaFilterDbalTest.php +++ b/tests/Functional/SchemaFilterDbalTest.php @@ -5,23 +5,19 @@ namespace Kraz\DoctrineContextBundle\Tests\Functional; use Doctrine\DBAL\Connection; +use Kraz\DoctrineContextBundle\Tests\InspectsSqliteDatabasesTrait; +use Kraz\DoctrineContextBundle\Tests\RunsConsoleCommandsTrait; use Kraz\DoctrineContextBundle\Tests\TestKernelDbal; use Override; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Output\BufferedOutput; -use function array_column; -use function file_exists; -use function in_array; use function sprintf; -use function sys_get_temp_dir; -use function unlink; class SchemaFilterDbalTest extends KernelTestCase { - private Application $application; + use RunsConsoleCommandsTrait; + use InspectsSqliteDatabasesTrait; #[Override] protected static function getKernelClass(): string @@ -104,12 +100,8 @@ public function testAllContextsMigrateIsolatesTablesPerDatabase(): void $this->assertTableNotExists('beta', 'product'); } - private function runCommand(string $command): int - { - return $this->application->run(new StringInput($command), new BufferedOutput()); - } - - private function getConnection(string $name): Connection + #[Override] + protected function getConnection(string $name): Connection { $connection = self::getContainer()->get(sprintf('doctrine.dbal.%s_connection', $name)); self::assertInstanceOf(Connection::class, $connection); @@ -117,54 +109,8 @@ private function getConnection(string $name): Connection return $connection; } - /** - * Queries sqlite_master directly to bypass any active schema filters - * that would hide tables like the migration metadata table. - * - * @return list - */ - private function getTableNames(string $connectionName): array - { - $rows = $this->getConnection($connectionName)->fetchAllAssociative( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", - ); - - return array_column($rows, 'name'); - } - - private function assertTableExists(string $connectionName, string $tableName): void - { - self::assertTrue( - in_array($tableName, $this->getTableNames($connectionName), true), - 'Table "' . $tableName . '" should exist in connection "' . $connectionName . '"', - ); - } - - private function assertTableNotExists(string $connectionName, string $tableName): void - { - self::assertFalse( - in_array($tableName, $this->getTableNames($connectionName), true), - 'Table "' . $tableName . '" should NOT exist in connection "' . $connectionName . '"', - ); - } - - private function assertMigrationRecorded(string $connectionName, string $version): void - { - $count = $this->getConnection($connectionName)->fetchOne( - 'SELECT COUNT(*) FROM zzz_migrations WHERE version = ?', - [$version], - ); - - self::assertGreaterThan(0, (int) $count, 'Migration "' . $version . '" should be recorded in connection "' . $connectionName . '"'); - } - - private function cleanDatabases(): void + protected function databaseFilePrefix(): string { - foreach (['default', 'alpha', 'beta'] as $name) { - $path = sys_get_temp_dir() . '/doctrine_context_test_dbal_' . $name . '.db'; - if (file_exists($path)) { - unlink($path); - } - } + return 'doctrine_context_test_dbal_'; } } diff --git a/tests/Functional/SchemaFilterTest.php b/tests/Functional/SchemaFilterTest.php index d280e61..ea9aa4f 100644 --- a/tests/Functional/SchemaFilterTest.php +++ b/tests/Functional/SchemaFilterTest.php @@ -7,23 +7,19 @@ use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; +use Kraz\DoctrineContextBundle\Tests\InspectsSqliteDatabasesTrait; +use Kraz\DoctrineContextBundle\Tests\RunsConsoleCommandsTrait; use Kraz\DoctrineContextBundle\Tests\TestKernel; use Override; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; -use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Output\BufferedOutput; -use function array_column; -use function file_exists; -use function in_array; use function interface_exists; -use function sys_get_temp_dir; -use function unlink; class SchemaFilterTest extends KernelTestCase { - private Application $application; + use RunsConsoleCommandsTrait; + use InspectsSqliteDatabasesTrait; #[Override] protected static function getKernelClass(): string @@ -159,28 +155,6 @@ public function testSchemaUpdateIsolatesTablesPerDatabase(): void $this->assertTableNotExists('beta', 'product'); } - public function testSchemaValidateSkipSyncPassesOnEmptyDatabase(): void - { - // With an empty database the sync check would fail (tables are missing). - // If --skip-sync is not forwarded the exit code would be non-zero. - $exitCode = $this->runCommand('doctrine:schema:validate --em=alpha --skip-sync'); - - self::assertSame(0, $exitCode, '--skip-sync must be forwarded to the inner command'); - } - - public function testSchemaValidateSkipMappingAndSkipSyncPassOnEmptyDatabase(): void - { - // On an empty database both checks fail independently: - // --skip-sync alone → mapping passes, sync skipped → exit 0 - // --skip-mapping alone → mapping skipped, sync fails (no tables) → exit non-zero - // Passing both flags together must also exit 0. If either flag is not forwarded to - // the inner command then at least one check runs against the empty database and the - // exit code becomes non-zero, catching the regression. - $exitCode = $this->runCommand('doctrine:schema:validate --em=alpha --skip-mapping --skip-sync'); - - self::assertSame(0, $exitCode, '--skip-mapping and --skip-sync must both be forwarded to the inner command'); - } - public function testSchemaValidateSingleContextDoesNotReportMigrationTable(): void { $this->runCommand('doctrine:migrations:migrate --no-interaction'); @@ -199,12 +173,8 @@ public function testSchemaValidateAllContextsDoesNotReportMigrationTable(): void self::assertSame(0, $exitCode, 'Schema validation for all contexts should succeed without reporting the migration table as unmanaged'); } - private function runCommand(string $command): int - { - return $this->application->run(new StringInput($command), new BufferedOutput()); - } - - private function getConnection(string $name): Connection + #[Override] + protected function getConnection(string $name): Connection { $registry = self::getContainer()->get('doctrine'); self::assertInstanceOf(ManagerRegistry::class, $registry); @@ -214,55 +184,4 @@ private function getConnection(string $name): Connection return $connection; } - - /** - * Queries sqlite_master directly to bypass any active schema filters - * that would hide tables like the migration metadata table. - * - * @return list - */ - private function getTableNames(string $connectionName): array - { - $rows = $this->getConnection($connectionName)->fetchAllAssociative( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", - ); - - return array_column($rows, 'name'); - } - - private function assertTableExists(string $connectionName, string $tableName): void - { - self::assertTrue( - in_array($tableName, $this->getTableNames($connectionName), true), - 'Table "' . $tableName . '" should exist in connection "' . $connectionName . '"', - ); - } - - private function assertTableNotExists(string $connectionName, string $tableName): void - { - self::assertFalse( - in_array($tableName, $this->getTableNames($connectionName), true), - 'Table "' . $tableName . '" should NOT exist in connection "' . $connectionName . '"', - ); - } - - private function assertMigrationRecorded(string $connectionName, string $version): void - { - $count = $this->getConnection($connectionName)->fetchOne( - 'SELECT COUNT(*) FROM zzz_migrations WHERE version = ?', - [$version], - ); - - self::assertGreaterThan(0, (int) $count, 'Migration "' . $version . '" should be recorded in connection "' . $connectionName . '"'); - } - - private function cleanDatabases(): void - { - foreach (['default', 'alpha', 'beta'] as $name) { - $path = sys_get_temp_dir() . '/doctrine_context_test_' . $name . '.db'; - if (file_exists($path)) { - unlink($path); - } - } - } } diff --git a/tests/Functional/SchemaValidateTest.php b/tests/Functional/SchemaValidateTest.php new file mode 100644 index 0000000..fe08f90 --- /dev/null +++ b/tests/Functional/SchemaValidateTest.php @@ -0,0 +1,82 @@ +markTestSkipped('doctrine/orm is not installed'); + } + + $this->cleanDatabases(); + + $kernel = self::bootKernel(); + $this->application = new Application($kernel); + $this->application->setAutoExit(false); + } + + #[Override] + protected function tearDown(): void + { + parent::tearDown(); + + $this->cleanDatabases(); + } + + public function testHelpListsStandardApplicationOptions(): void + { + $output = $this->captureOutput('doctrine:schema:validate --help'); + + // Every option from Application::getDefaultInputDefinition() must appear in the + // decorated command's help text, proving the application definition is merged in. + self::assertStringContainsString('--help', $output); + self::assertStringContainsString('--quiet', $output); + self::assertStringContainsString('--verbose', $output); + self::assertStringContainsString('--no-interaction', $output); + self::assertStringContainsString('--ansi', $output); + } + + public function testSchemaValidateSkipSyncPassesOnEmptyDatabase(): void + { + // With an empty database the sync check would fail (tables are missing). + // If --skip-sync is not forwarded the exit code would be non-zero. + $exitCode = $this->runCommand('doctrine:schema:validate --em=alpha --skip-sync'); + + self::assertSame(0, $exitCode, '--skip-sync must be forwarded to the inner command'); + } + + public function testSchemaValidateSkipMappingAndSkipSyncPassOnEmptyDatabase(): void + { + // On an empty database both checks fail independently: + // --skip-sync alone → mapping passes, sync skipped → exit 0 + // --skip-mapping alone → mapping skipped, sync fails (no tables) → exit non-zero + // Passing both flags together must also exit 0. If either flag is not forwarded to + // the inner command then at least one check runs against the empty database and the + // exit code becomes non-zero, catching the regression. + $exitCode = $this->runCommand('doctrine:schema:validate --em=alpha --skip-mapping --skip-sync'); + + self::assertSame(0, $exitCode, '--skip-mapping and --skip-sync must both be forwarded to the inner command'); + } +} diff --git a/tests/InspectsSqliteDatabasesTrait.php b/tests/InspectsSqliteDatabasesTrait.php new file mode 100644 index 0000000..14a5167 --- /dev/null +++ b/tests/InspectsSqliteDatabasesTrait.php @@ -0,0 +1,56 @@ + + */ + private function getTableNames(string $connectionName): array + { + $rows = $this->getConnection($connectionName)->fetchAllAssociative( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ); + + return array_column($rows, 'name'); + } + + private function assertTableExists(string $connectionName, string $tableName): void + { + self::assertTrue( + in_array($tableName, $this->getTableNames($connectionName), true), + 'Table "' . $tableName . '" should exist in connection "' . $connectionName . '"', + ); + } + + private function assertTableNotExists(string $connectionName, string $tableName): void + { + self::assertFalse( + in_array($tableName, $this->getTableNames($connectionName), true), + 'Table "' . $tableName . '" should NOT exist in connection "' . $connectionName . '"', + ); + } + + private function assertMigrationRecorded(string $connectionName, string $version): void + { + $count = $this->getConnection($connectionName)->fetchOne( + 'SELECT COUNT(*) FROM zzz_migrations WHERE version = ?', + [$version], + ); + + self::assertGreaterThan(0, (int) $count, 'Migration "' . $version . '" should be recorded in connection "' . $connectionName . '"'); + } +} diff --git a/tests/RunsConsoleCommandsTrait.php b/tests/RunsConsoleCommandsTrait.php new file mode 100644 index 0000000..dc273da --- /dev/null +++ b/tests/RunsConsoleCommandsTrait.php @@ -0,0 +1,78 @@ +application->run(new StringInput($command), new BufferedOutput()); + } + + /** + * Runs a command and returns its output as a string. + * + * PHPUnit sets SHELL_VERBOSITY=-1 to keep its own output clean, which causes + * Symfony's Application to silence all command output. We temporarily override + * that so the captured output reflects what a real terminal would see. + * + * Symfony's Application::configureIO reads $_ENV first, then $_SERVER, then getenv(). + * PHPUnit sets $_SERVER['SHELL_VERBOSITY']=-1 via phpunit.xml.dist; overriding $_ENV + * is sufficient to prevent the quiet-mode override. + */ + private function captureOutput(string $command): string + { + $previousEnv = $_ENV['SHELL_VERBOSITY'] ?? null; + $previousServer = $_SERVER['SHELL_VERBOSITY'] ?? null; + + $_ENV['SHELL_VERBOSITY'] = 0; + $_SERVER['SHELL_VERBOSITY'] = 0; + + try { + $output = new BufferedOutput(OutputInterface::VERBOSITY_NORMAL); + $this->application->run(new StringInput($command), $output); + + return $output->fetch(); + } finally { + if ($previousEnv !== null) { + $_ENV['SHELL_VERBOSITY'] = $previousEnv; + } else { + unset($_ENV['SHELL_VERBOSITY']); + } + + if ($previousServer !== null) { + $_SERVER['SHELL_VERBOSITY'] = $previousServer; + } else { + unset($_SERVER['SHELL_VERBOSITY']); + } + } + } + + private function cleanDatabases(): void + { + foreach (['default', 'alpha', 'beta'] as $name) { + $path = sys_get_temp_dir() . '/' . $this->databaseFilePrefix() . $name . '.db'; + if (file_exists($path)) { + unlink($path); + } + } + } + + protected function databaseFilePrefix(): string + { + return 'doctrine_context_test_'; + } +}