diff --git a/README.md b/README.md index 8656e70..6b15856 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,19 @@ Drops a PostgreSQL schema and all its objects: ```yaml # config/services.yaml services: - SharedServices\Command\Doctrine\DoctrineSchemaDropCommand: ~ + SharedServices\Command\Doctrine\DoctrineSchemaDropCommand: + arguments: + - '@Doctrine\DBAL\Connection' + - ['public'] # Disallowed schema names for safety ``` Usage: ```bash -php bin/console doctrine:schema:delete +php bin/console doctrine:database:schema:drop ``` +**Security Note:** You can specify disallowed schema names to prevent accidental deletion of critical schemas like `public`. + ### Schema Migrations Command Runs Doctrine migrations within a specific schema. Creates the schema if it doesn't exist: @@ -129,6 +134,8 @@ services: SharedServices\Command\Doctrine\DoctrineSchemaFixturesLoadCommand: arguments: - '@doctrine.fixtures_load_command' + - '@Doctrine\DBAL\Connection' + - ['public'] # Disallowed schema names for safety ``` Usage: @@ -136,6 +143,8 @@ Usage: php bin/console doctrine:schema:fixtures:load [options] ``` +**Security Note:** You can specify disallowed schema names to prevent accidental fixture loading into critical schemas like `public`. + **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 diff --git a/composer.json b/composer.json index 339a790..92b7c43 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "doctrine/orm": "^2.17 || ^3.0", "symfony/doctrine-bridge": "^6.4 || ^7.0", "doctrine/dbal": "^3.4", - "macpaw/schema-context-bundle": "^1.1" + "macpaw/schema-context-bundle": "^2.0" }, "require-dev": { "doctrine/migrations": "^3.6", diff --git a/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php b/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php index c0d93e0..ff142a8 100644 --- a/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php +++ b/src/Command/Doctrine/AbstractDoctrineSchemaCommand.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Error; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -15,6 +16,7 @@ abstract class AbstractDoctrineSchemaCommand extends Command public function __construct( string $commandName, protected readonly Connection $connection, + protected readonly BaggageSchemaResolver $schemaResolver, ) { parent::__construct($commandName); } @@ -53,6 +55,7 @@ protected function isSchemaExist(string $schema): bool protected function switchToSchema(string $schema): void { + $this->schemaResolver->setSchema($schema); $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 index 4d63051..ab9e95c 100644 --- a/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php +++ b/src/Command/Doctrine/AbstractNestingDoctrineSchemaCommand.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Error; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; @@ -19,8 +20,9 @@ public function __construct( string $commandName, private readonly Command $parentCommand, Connection $connection, + BaggageSchemaResolver $schemaResolver, ) { - parent::__construct($commandName, $connection); + parent::__construct($commandName, $connection, $schemaResolver); } protected function configure(): void @@ -76,10 +78,6 @@ protected function runCommand(string $commandName, InputInterface $input, Output $options = []; foreach ($input->getOptions() as $name => $value) { - if ($value === null) { - continue; - } - if ($this->getDefinition()->getOptions()[$name]->getDefault() === $value) { continue; } diff --git a/src/Command/Doctrine/DoctrineSchemaDropCommand.php b/src/Command/Doctrine/DoctrineSchemaDropCommand.php index 7fe8ab4..562b723 100644 --- a/src/Command/Doctrine/DoctrineSchemaDropCommand.php +++ b/src/Command/Doctrine/DoctrineSchemaDropCommand.php @@ -5,15 +5,22 @@ namespace Macpaw\PostgresSchemaBundle\Command\Doctrine; use Doctrine\DBAL\Connection; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class DoctrineSchemaDropCommand extends AbstractDoctrineSchemaCommand { - public function __construct(Connection $connection) - { - parent::__construct('doctrine:schema:delete', $connection); + /** + * @param string[] $disallowedSchemaNames + */ + public function __construct( + Connection $connection, + BaggageSchemaResolver $schemaResolver, + private readonly array $disallowedSchemaNames = [], + ) { + parent::__construct('doctrine:database:schema:drop', $connection, $schemaResolver); } protected function execute( @@ -22,6 +29,14 @@ protected function execute( ): int { $schema = $this->getSchemaFromInput($input); + if (in_array($schema, $this->disallowedSchemaNames, true)) { + $output->writeln( + "Command is disallowed from being called for the '$schema' schema" + ); + + return Command::FAILURE; + } + $output->writeln("Drop schema '{$schema}'..."); $quotedSchema = $this->connection->quoteIdentifier($schema); diff --git a/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php b/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php index b450f59..2a3ef07 100644 --- a/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php +++ b/src/Command/Doctrine/DoctrineSchemaFixturesLoadCommand.php @@ -6,6 +6,7 @@ use Doctrine\Bundle\FixturesBundle\Command\LoadDataFixturesDoctrineCommand; use Doctrine\DBAL\Connection; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -13,11 +14,16 @@ class DoctrineSchemaFixturesLoadCommand extends AbstractNestingDoctrineSchemaCommand { + /** + * @param string[] $disallowedSchemaNames + */ public function __construct( LoadDataFixturesDoctrineCommand $parentCommand, Connection $connection, + BaggageSchemaResolver $schemaResolver, + private readonly array $disallowedSchemaNames = [], ) { - parent::__construct('doctrine:schema:fixtures:load', $parentCommand, $connection); + parent::__construct('doctrine:schema:fixtures:load', $parentCommand, $connection, $schemaResolver); } protected function execute( @@ -27,6 +33,14 @@ protected function execute( try { $schema = $this->getSchemaFromInput($input); + if (in_array($schema, $this->disallowedSchemaNames, true)) { + $output->writeln( + "Command is disallowed from being called for the '$schema' schema" + ); + + return Command::FAILURE; + } + if (!$this->isSchemaExist($schema)) { $output->writeln("Schema '{$schema}' doesn't exist"); diff --git a/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php b/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php index 547b1b6..3d7d9e7 100644 --- a/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php +++ b/src/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommand.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\Migrations\Tools\Console\Command\MigrateCommand; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -16,8 +17,9 @@ class DoctrineSchemaMigrationsMigrateCommand extends AbstractNestingDoctrineSche public function __construct( MigrateCommand $parentCommand, Connection $connection, + BaggageSchemaResolver $schemaResolver, ) { - parent::__construct('doctrine:schema:migrations:migrate', $parentCommand, $connection); + parent::__construct('doctrine:schema:migrations:migrate', $parentCommand, $connection, $schemaResolver); } protected function execute( diff --git a/src/Doctrine/SchemaConnection.php b/src/Doctrine/SchemaConnection.php index 56315e0..ce4e29d 100644 --- a/src/Doctrine/SchemaConnection.php +++ b/src/Doctrine/SchemaConnection.php @@ -12,6 +12,7 @@ class SchemaConnection extends DBALConnection { private static ?BaggageSchemaResolver $schemaResolver = null; + private ?string $currentSchema = null; public static function setSchemaResolver(BaggageSchemaResolver $resolver): void { @@ -32,6 +33,11 @@ public function connect(): bool return $connection; } + if ($this->currentSchema === $schema) { + return $connection; + } + $this->currentSchema = $schema; + $this->ensurePostgreSql(); $this->applySearchPath($schema); @@ -50,6 +56,8 @@ private function ensurePostgreSql(): void private function applySearchPath(string $schema): void { if ($this->_conn !== null) { + $schema = $this->getDatabasePlatform()->quoteIdentifier($schema); + $this->_conn->exec('SET search_path TO ' . $schema); } } diff --git a/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php b/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php index 4d0e409..7965ebc 100644 --- a/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php +++ b/tests/Command/Doctrine/DoctrineSchemaDropCommandTest.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Macpaw\PostgresSchemaBundle\Command\Doctrine\DoctrineSchemaDropCommand; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; @@ -20,7 +21,8 @@ class DoctrineSchemaDropCommandTest extends TestCase protected function setUp(): void { $this->connection = $this->createMock(Connection::class); - $this->command = new DoctrineSchemaDropCommand($this->connection); + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); + $this->command = new DoctrineSchemaDropCommand($this->connection, $resolver, ['public']); } public function testSuccess(): void @@ -41,4 +43,18 @@ public function testSuccess(): void $this->assertEquals(Command::SUCCESS, $result); } + + public function testDisallowedSchemaNameFail(): void + { + $input = new ArrayInput(['schema' => 'public']); + $output = new BufferedOutput(); + + $result = $this->command->run($input, $output); + + $this->assertStringContainsString( + "Command is disallowed from being called for the 'public' schema", + $output->fetch() + ); + $this->assertEquals(Command::FAILURE, $result); + } } diff --git a/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php b/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php index 696ec42..6d96f6d 100644 --- a/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php +++ b/tests/Command/Doctrine/DoctrineSchemaFixturesLoadCommandTest.php @@ -7,6 +7,7 @@ use Doctrine\Bundle\FixturesBundle\Command\LoadDataFixturesDoctrineCommand; use Doctrine\DBAL\Connection; use Macpaw\PostgresSchemaBundle\Command\Doctrine\DoctrineSchemaFixturesLoadCommand; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; @@ -33,8 +34,14 @@ protected function setUp(): void ->willReturn(new InputDefinition([ new InputOption('no-interaction'), ])); + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); - $this->command = new DoctrineSchemaFixturesLoadCommand($this->parentCommand, $this->connection); + $this->command = new DoctrineSchemaFixturesLoadCommand( + $this->parentCommand, + $this->connection, + $resolver, + ['public'] + ); $this->command->setApplication($this->application); } @@ -72,4 +79,18 @@ public function testSuccess(): void $this->assertEquals(Command::SUCCESS, $result); $this->assertStringContainsString("Load fixtures for 'test_schema'...", $output->fetch()); } + + public function testDisallowedSchemaNameFail(): void + { + $input = new ArrayInput(['schema' => 'public']); + $output = new BufferedOutput(); + + $result = $this->command->run($input, $output); + + $this->assertStringContainsString( + "Command is disallowed from being called for the 'public' schema", + $output->fetch() + ); + $this->assertEquals(Command::FAILURE, $result); + } } diff --git a/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php b/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php index 61acefc..0533070 100644 --- a/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php +++ b/tests/Command/Doctrine/DoctrineSchemaMigrationsMigrateCommandTest.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Connection; use Doctrine\Migrations\Tools\Console\Command\MigrateCommand; use Macpaw\PostgresSchemaBundle\Command\Doctrine\DoctrineSchemaMigrationsMigrateCommand; +use Macpaw\SchemaContextBundle\Service\BaggageSchemaResolver; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; @@ -32,8 +33,9 @@ protected function setUp(): void ->willReturn(new InputDefinition([ new InputOption('no-interaction'), ])); + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); - $this->command = new DoctrineSchemaMigrationsMigrateCommand($this->parentCommand, $this->connection); + $this->command = new DoctrineSchemaMigrationsMigrateCommand($this->parentCommand, $this->connection, $resolver); $this->application = $this->createMock(Application::class); $this->command->setApplication($this->application); } diff --git a/tests/Doctrine/SchemaConnectionTest.php b/tests/Doctrine/SchemaConnectionTest.php index 741128c..ba03ebc 100644 --- a/tests/Doctrine/SchemaConnectionTest.php +++ b/tests/Doctrine/SchemaConnectionTest.php @@ -23,7 +23,7 @@ public function testConnectSetsSearchPath(): void $driverConnection->expects($this->once()) ->method('exec') - ->with('SET search_path TO test_schema'); + ->with('SET search_path TO "test_schema"'); $driver = $this->createMock(Driver::class); @@ -38,7 +38,7 @@ public function testConnectSetsSearchPath(): void $connection->method('getDatabasePlatform')->willReturn($platform); $connection->method('fetchOne')->willReturn(true); - $resolver = new BaggageSchemaResolver(); + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); $resolver->setSchema('test_schema'); @@ -49,16 +49,60 @@ public function testConnectSetsSearchPath(): void self::assertTrue($result); } + public function testConnectSetsSearchPathWithSpecialChars(): void + { + $driverConnection = $this->createMock(DriverConnection::class); + + $driverConnection->expects($this->once()) + ->method('exec') + ->with('SET search_path TO "test-schema/foo"'); + + $driver = $this->createMock(Driver::class); + + $driver->method('connect')->willReturn($driverConnection); + + $platform = new PostgreSQLPlatform(); + $connection = $this->getMockBuilder(SchemaConnection::class) + ->setConstructorArgs([[], $driver, new Configuration(), new EventManager()]) + ->onlyMethods(['getDatabasePlatform', 'fetchOne']) + ->getMock(); + + $connection->method('getDatabasePlatform')->willReturn($platform); + $connection->method('fetchOne')->willReturn(true); + + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); + + $resolver->setSchema('test-schema/foo'); + + SchemaConnection::setSchemaResolver($resolver); + + $result = $connection->connect(); + + self::assertTrue($result); + } + public function testConnectSkipsWhenNoSchema(): void { $driverConnection = $this->createMock(DriverConnection::class); + $driverConnection->expects($this->once()) + ->method('exec') + ->with('SET search_path TO "public"'); + $driver = $this->createMock(Driver::class); $driver->method('connect')->willReturn($driverConnection); - $connection = new SchemaConnection([], $driver, new Configuration()); - $resolver = new BaggageSchemaResolver(); + $platform = new PostgreSQLPlatform(); + $connection = $this->getMockBuilder(SchemaConnection::class) + ->setConstructorArgs([[], $driver, new Configuration(), new EventManager()]) + ->onlyMethods(['getDatabasePlatform', 'fetchOne']) + ->getMock(); + + $connection->method('getDatabasePlatform')->willReturn($platform); + $connection->method('fetchOne')->willReturn(true); + + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); SchemaConnection::setSchemaResolver($resolver); @@ -82,7 +126,7 @@ public function testThrowsForUnsupportedPlatform(): void $connection->method('getDatabasePlatform')->willReturn($platform); - $resolver = new BaggageSchemaResolver(); + $resolver = new BaggageSchemaResolver('public', 'development', ['development']); $resolver->setSchema('test_schema');