From 165016a19ba17b5fe726260b9078d55ce232f000 Mon Sep 17 00:00:00 2001 From: Aleksey Tupichenkov Date: Sun, 14 Sep 2025 19:51:58 +0300 Subject: [PATCH 1/2] feat: added schema cli commands --- .github/workflows/ci.yaml | 12 ++- README.md | 56 +++++++++- composer.json | 5 +- phpunit.xml.dist | 2 +- .../AbstractDoctrineSchemaCommand.php | 60 +++++++++++ .../AbstractNestingDoctrineSchemaCommand.php | 101 ++++++++++++++++++ .../Doctrine/DoctrineSchemaDropCommand.php | 32 ++++++ .../DoctrineSchemaFixturesLoadCommand.php | 55 ++++++++++ ...DoctrineSchemaMigrationsMigrateCommand.php | 56 ++++++++++ src/Doctrine/SchemaConnection.php | 2 +- .../DoctrineSchemaDropCommandTest.php | 44 ++++++++ .../DoctrineSchemaFixturesLoadCommandTest.php | 75 +++++++++++++ ...rineSchemaMigrationsMigrateCommandTest.php | 82 ++++++++++++++ tests/Doctrine/SchemaConnectionTest.php | 1 - tests/bootstrap.php | 7 ++ 15 files changed, 583 insertions(+), 7 deletions(-) create mode 100644 src/Command/Doctrine/AbstractDoctrineSchemaCommand.php create mode 100644 src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php create mode 100644 src/Command/Doctrine/DoctrineSchemaDropCommand.php create mode 100644 src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php create mode 100644 src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php create mode 100644 tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php create mode 100644 tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php create mode 100644 tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php create mode 100644 tests/bootstrap.php diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3f2bb56..23f6f0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,14 +21,22 @@ jobs: symfony-versions: - '6.4.*' - '7.0.*' + doctrine-migrations: + - "3.6.*" + - "3.9.*" + doctrine-fixtures-bundle: + - "3.5.*" + - "4.0.*" include: - description: 'Log Code Coverage' php: '8.3' symfony-versions: '^7.0' doctrine-orm-versions: '^3.0' + doctrine-migrations: '^3.6' + doctrine-fixtures-bundle: '^3.5' coverage: xdebug - name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} Doctrine ${{ matrix.doctrine-orm-versions }} ${{ matrix.description }} + name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} Doctrine ${{ matrix.doctrine-orm-versions }} Doctrine migrations ${{ matrix.doctrine-migrations }} Doctrine fixtures bundle ${{ matrix.doctrine-fixtures-bundle }} ${{ matrix.description }} steps: - name: Checkout uses: actions/checkout@v4 @@ -63,6 +71,8 @@ jobs: run: | composer require symfony/framework-bundle:${{ matrix.symfony-versions }} --no-update --no-scripts composer require doctrine/orm:${{ matrix.doctrine-orm-versions }} --no-update --no-scripts + composer require --dev doctrine/migrations:${{ matrix.doctrine-migrations }} --no-update --no-scripts + composer require --dev doctrine/doctrine-fixtures-bundle:${{ matrix.doctrine-fixtures-bundle }} --no-update --no-scripts composer require --dev symfony/yaml:${{ matrix.symfony-versions }} --no-update --no-scripts - name: Install dependencies diff --git a/README.md b/README.md index baedcc0..8656e70 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ class Kernel extends BaseKernel public function boot(): void { parent::boot(); - + SchemaConnection::setSchemaResolver( $this->getContainer()->get(BaggageSchemaResolver::class), ); @@ -86,7 +86,59 @@ schema_context: * When Doctrine connects to PostgreSQL, it sets the search_path to the specified schema. * If the schema does not exist or DB is not PostgreSQL, an exception is thrown. -## Testing +## Optional Commands + +The bundle provides three optional commands for schema management that can be registered in your services configuration: + +### Schema Drop Command +Drops a PostgreSQL schema and all its objects: + +```yaml +# config/services.yaml +services: + SharedServices\Command\Doctrine\DoctrineSchemaDropCommand: ~ +``` + +Usage: +```bash +php bin/console doctrine:schema:delete +``` + +### Schema Migrations Command +Runs Doctrine migrations within a specific schema. Creates the schema if it doesn't exist: + +```yaml +# config/services.yaml +services: + SharedServices\Command\Doctrine\DoctrineSchemaMigrationsMigrateCommand: + arguments: + - '@doctrine_migrations.migrate_command' +``` + +Usage: +```bash +php bin/console doctrine:schema:migrations:migrate [options] +``` + +### Schema Fixtures Load Command +Loads Doctrine fixtures within a specific schema: + +```yaml +# config/services.yaml +services: + SharedServices\Command\Doctrine\DoctrineSchemaFixturesLoadCommand: + arguments: + - '@doctrine.fixtures_load_command' +``` + +Usage: +```bash +php bin/console doctrine:schema:fixtures:load [options] +``` + +**Note:** These commands are optional and should only be registered if you're using the corresponding Doctrine features (migrations and/or fixtures) in your project. + +## Testing To run tests: ```bash vendor/bin/phpunit diff --git a/composer.json b/composer.json index 5768661..339a790 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,12 @@ "macpaw/schema-context-bundle": "^1.1" }, "require-dev": { + "doctrine/migrations": "^3.6", + "doctrine/doctrine-fixtures-bundle": "^3.5 || ^4.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.0", - "squizlabs/php_codesniffer": "3.7.*" + "squizlabs/php_codesniffer": "3.7.*", + "dg/bypass-finals": "^1.9" }, "config": { "allow-plugins": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6344d8d..2ad4f0b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ diff --git a/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php b/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php new file mode 100644 index 0000000..c0d93e0 --- /dev/null +++ b/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php @@ -0,0 +1,60 @@ +addArgument( + 'schema', + InputArgument::REQUIRED, + 'The schema name.', + ); + + parent::configure(); + } + + protected function getSchemaFromInput(InputInterface $input): string + { + $schema = $input->getArgument('schema'); + + if (!is_string($schema) || $schema === '') { + throw new Error('Schema name must be a non-empty string'); + } + + return $schema; + } + + protected function isSchemaExist(string $schema): bool + { + $exists = $this->connection->fetchOne( + 'SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ?)', + [$schema], + ); + + return (bool) $exists; + } + + protected function switchToSchema(string $schema): void + { + $quotedSchema = $this->connection->quoteIdentifier($schema); + + $this->connection->executeStatement("SET search_path TO {$quotedSchema}"); + } +} diff --git a/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php b/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php new file mode 100644 index 0000000..4ab7c9c --- /dev/null +++ b/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php @@ -0,0 +1,101 @@ +parentCommand->getDefinition()->getArguments() as $argument) { + $this->addArgument( + $argument->getName(), + $argument->isRequired() ? InputArgument::REQUIRED : InputArgument::OPTIONAL, + $argument->getDescription(), + $argument->getDefault(), + ); + } + + foreach ($this->parentCommand->getDefinition()->getOptions() as $option) { + $this->addOption( + $option->getName(), + $option->getShortcut(), + $option->isValueRequired() ? InputOption::VALUE_REQUIRED : InputOption::VALUE_OPTIONAL, + $option->getDescription(), + $option->getDefault(), + ); + } + + parent::configure(); + } + + protected function runCommand(string $commandName, InputInterface $input, OutputInterface $output): int + { + $application = $this->getApplication(); + + if ($application === null) { + throw new Error('Application is not available'); + } + + $command = $application->find($commandName); + + $arguments = []; + foreach ($input->getArguments() as $name => $value) { + if ($value === null) { + continue; + } + + if ($name === 'schema' || $name === 'command') { + continue; + } + + if ($this->getDefinition()->getArguments()[$name]->getDefault() === $value) { + continue; + } + + $arguments[$name] = $value; + } + + $options = []; + foreach ($input->getOptions() as $name => $value) { + if ($value === null) { + continue; + } + + if ($this->getDefinition()->getOptions()[$name]->getDefault() === $value) { + continue; + } + + $options['--' . $name] = $value; + } + + $commandInput = new ArrayInput([ + ...$arguments, + ...$options, + ]); + + if ($input->getOption('no-interaction') === true) { + $commandInput->setInteractive(false); + } + + return $command->run($commandInput, $output); + } +} diff --git a/src/Command/Doctrine/DoctrineSchemaDropCommand.php b/src/Command/Doctrine/DoctrineSchemaDropCommand.php new file mode 100644 index 0000000..7fe8ab4 --- /dev/null +++ b/src/Command/Doctrine/DoctrineSchemaDropCommand.php @@ -0,0 +1,32 @@ +getSchemaFromInput($input); + + $output->writeln("Drop schema '{$schema}'..."); + + $quotedSchema = $this->connection->quoteIdentifier($schema); + $this->connection->executeStatement(sprintf('DROP SCHEMA IF EXISTS %s CASCADE', $quotedSchema)); + + return Command::SUCCESS; + } +} diff --git a/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php b/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php new file mode 100644 index 0000000..b450f59 --- /dev/null +++ b/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php @@ -0,0 +1,55 @@ +getSchemaFromInput($input); + + if (!$this->isSchemaExist($schema)) { + $output->writeln("Schema '{$schema}' doesn't exist"); + + return Command::FAILURE; + } + + $this->switchToSchema($schema); + + $output->writeln("Load fixtures for '{$schema}'..."); + + $returnCode = $this->runCommand('doctrine:fixtures:load', $input, $output); + + if ($returnCode !== Command::SUCCESS) { + $output->writeln("Fixtures load failed with return code: {$returnCode}"); + + return Command::FAILURE; + } + } catch (Throwable $e) { + $output->writeln("Error executing fixtures load: {$e->getMessage()}"); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php b/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php new file mode 100644 index 0000000..547b1b6 --- /dev/null +++ b/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php @@ -0,0 +1,56 @@ +getSchemaFromInput($input); + + if ($this->isSchemaExist($schema)) { + $output->writeln("Schema '{$schema}' already exists."); + } else { + $output->writeln("Creating schema '{$schema}'..."); + $this->connection->executeStatement('CREATE SCHEMA ' . $this->connection->quoteIdentifier($schema)); + } + + $this->switchToSchema($schema); + + $output->writeln("Running migrations for '{$schema}'..."); + + $returnCode = $this->runCommand('doctrine:migrations:migrate', $input, $output); + + if ($returnCode !== Command::SUCCESS) { + $output->writeln("Migrations failed with return code: {$returnCode}"); + + return Command::FAILURE; + } + } catch (Throwable $e) { + $output->writeln("Error executing migrations: {$e->getMessage()}"); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Doctrine/SchemaConnection.php b/src/Doctrine/SchemaConnection.php index 3061389..56315e0 100644 --- a/src/Doctrine/SchemaConnection.php +++ b/src/Doctrine/SchemaConnection.php @@ -22,7 +22,7 @@ public function connect(): bool { $connection = parent::connect(); - if (!self::$schemaResolver) { + if (self::$schemaResolver === null) { return $connection; } diff --git a/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php b/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php new file mode 100644 index 0000000..4d0e409 --- /dev/null +++ b/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php @@ -0,0 +1,44 @@ +connection = $this->createMock(Connection::class); + $this->command = new DoctrineSchemaDropCommand($this->connection); + } + + public function testSuccess(): void + { + $input = new ArrayInput(['schema' => 'test_schema']); + $output = new BufferedOutput(); + + $this->connection->expects($this->once()) + ->method('quoteIdentifier') + ->with('test_schema') + ->willReturn('"test_schema"'); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with('DROP SCHEMA IF EXISTS "test_schema" CASCADE'); + + $result = $this->command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $result); + } +} diff --git a/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php b/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php new file mode 100644 index 0000000..696ec42 --- /dev/null +++ b/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php @@ -0,0 +1,75 @@ +connection = $this->createMock(Connection::class); + $this->application = $this->createMock(Application::class); + $this->parentCommand = $this->createMock(LoadDataFixturesDoctrineCommand::class); + $this->parentCommand + ->method('getDefinition') + ->willReturn(new InputDefinition([ + new InputOption('no-interaction'), + ])); + + $this->command = new DoctrineSchemaFixturesLoadCommand($this->parentCommand, $this->connection); + $this->command->setApplication($this->application); + } + + public function testSuccess(): void + { + $input = new ArrayInput(['schema' => 'test_schema']); + $output = new BufferedOutput(); + + $this->connection->expects($this->once()) + ->method('fetchOne') + ->with('SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ?)', ['test_schema']) + ->willReturn(1); + + $this->connection->expects($this->once()) + ->method('quoteIdentifier') + ->with('test_schema') + ->willReturn('"test_schema"'); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with('SET search_path TO "test_schema"'); + + $fixturesCommand = $this->createMock(Command::class); + $this->application->expects($this->once()) + ->method('find') + ->with('doctrine:fixtures:load') + ->willReturn($fixturesCommand); + + $fixturesCommand->expects($this->once()) + ->method('run') + ->willReturn(Command::SUCCESS); + + $result = $this->command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $result); + $this->assertStringContainsString("Load fixtures for 'test_schema'...", $output->fetch()); + } +} diff --git a/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php b/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php new file mode 100644 index 0000000..61acefc --- /dev/null +++ b/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php @@ -0,0 +1,82 @@ +connection = $this->createMock(Connection::class); + $this->parentCommand = $this->createMock(MigrateCommand::class); + $this->parentCommand + ->method('getDefinition') + ->willReturn(new InputDefinition([ + new InputOption('no-interaction'), + ])); + + $this->command = new DoctrineSchemaMigrationsMigrateCommand($this->parentCommand, $this->connection); + $this->application = $this->createMock(Application::class); + $this->command->setApplication($this->application); + } + + public function testSuccess(): void + { + $input = new ArrayInput(['schema' => 'new_schema']); + $output = new BufferedOutput(); + + $this->connection->expects($this->once()) + ->method('fetchOne') + ->with('SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = ?)', ['new_schema']) + ->willReturn(0); + + $this->connection + ->method('quoteIdentifier') + ->with('new_schema') + ->willReturn('"new_schema"'); + + $this->connection->expects($this->exactly(2)) + ->method('executeStatement') + ->willReturnCallback(function ($sql) { + static $callCount = 0; + $callCount++; + if ($callCount === 1) { + $this->assertEquals('CREATE SCHEMA "new_schema"', $sql); + } elseif ($callCount === 2) { + $this->assertEquals('SET search_path TO "new_schema"', $sql); + } + }); + + $migrationsCommand = $this->createMock(Command::class); + $this->application->expects($this->once()) + ->method('find') + ->with('doctrine:migrations:migrate') + ->willReturn($migrationsCommand); + + $migrationsCommand->expects($this->once()) + ->method('run') + ->willReturn(Command::SUCCESS); + + $result = $this->command->run($input, $output); + + $this->assertEquals(Command::SUCCESS, $result); + } +} diff --git a/tests/Doctrine/SchemaConnectionTest.php b/tests/Doctrine/SchemaConnectionTest.php index 0caa41a..741128c 100644 --- a/tests/Doctrine/SchemaConnectionTest.php +++ b/tests/Doctrine/SchemaConnectionTest.php @@ -6,7 +6,6 @@ use Doctrine\Common\EventManager; use Doctrine\DBAL\Configuration; -use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver; use Doctrine\DBAL\Driver\Connection as DriverConnection; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..e69c88a --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,7 @@ + Date: Mon, 15 Sep 2025 11:31:41 +0300 Subject: [PATCH 2/2] fix: fix config declaration order --- src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php b/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php index 4ab7c9c..4d63051 100644 --- a/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php +++ b/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php @@ -25,6 +25,8 @@ public function __construct( protected function configure(): void { + parent::configure(); + foreach ($this->parentCommand->getDefinition()->getArguments() as $argument) { $this->addArgument( $argument->getName(), @@ -43,8 +45,6 @@ protected function configure(): void $option->getDefault(), ); } - - parent::configure(); } protected function runCommand(string $commandName, InputInterface $input, OutputInterface $output): int