diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 424f73a..faad7f9 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -25,7 +25,7 @@ 'single_item_single_line' => true, 'inline_constructor_arguments' => false, ], - 'php_unit_test_case_static_method_calls' => ['call_type' => 'self'], + 'php_unit_test_case_static_method_calls' => ['call_type' => 'self', 'methods' => ['createStub' => 'this']], ]) ->buildConfig() ->setFinder( diff --git a/composer.json b/composer.json index 4f230d6..85ce023 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ }, "require-dev": { "phpunit/phpunit": "^9", - "ibexa/code-style": "^2.0", + "ibexa/code-style": "~2.3.0", "ibexa/core": "~6.0.x-dev", "ibexa/doctrine-schema": "~6.0.x-dev", "ibexa/rector": "~6.0.x-dev", diff --git a/src/contracts/Bootstrapper/DatabaseSchemaHook.php b/src/contracts/Bootstrapper/DatabaseSchemaHook.php index 129d047..c239b94 100644 --- a/src/contracts/Bootstrapper/DatabaseSchemaHook.php +++ b/src/contracts/Bootstrapper/DatabaseSchemaHook.php @@ -9,8 +9,10 @@ namespace Ibexa\Contracts\Test\Core\Bootstrapper; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractPlatform; use Ibexa\Bundle\Test\Core\DependencyInjection\CompilerPass\RemoveUnsatisfiableHooksPass; use Ibexa\Contracts\DoctrineSchema\Builder\SchemaBuilderInterface; +use Ibexa\Contracts\DoctrineSchema\DbPlatformFactoryInterface; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -36,12 +38,16 @@ final class DatabaseSchemaHook implements HookInterface private Connection $connection; + private DbPlatformFactoryInterface $dbPlatformFactory; + public function __construct( SchemaBuilderInterface $schemaBuilder, - Connection $connection + Connection $connection, + DbPlatformFactoryInterface $dbPlatformFactory ) { $this->schemaBuilder = $schemaBuilder; $this->connection = $connection; + $this->dbPlatformFactory = $dbPlatformFactory; } public function configureOptions(OptionsResolver $resolver): void @@ -58,10 +64,28 @@ public function __invoke(array $options): void } $schema = $this->schemaBuilder->buildSchema(); - $platform = $this->connection->getDatabasePlatform(); + $platform = $this->getIbexaDatabasePlatform(); foreach ($schema->toSql($platform) as $sql) { $this->connection->executeStatement($sql); } } + + /** + * Ibexa ships its own platform subclasses which adjust the generated DDL. The SQLite one is + * load-bearing here: vanilla SQLitePlatform drops the table-level PRIMARY KEY clause as soon as + * a table has an autoincrement column, so composite primary keys such as + * ibexa_content_field(id, version) silently degrade to a single-column one. + * + * DBAL 4 removed doctrine-bundle's `platform_service`, so the connection no longer carries those + * subclasses and they have to be resolved explicitly, the same way ibexa/core's CoreInstaller + * and LegacySchemaImporter already do. + */ + private function getIbexaDatabasePlatform(): AbstractPlatform + { + $driverName = $this->connection->getParams()['driver'] ?? ''; + + return $this->dbPlatformFactory->createDatabasePlatformFromDriverName($driverName) + ?? $this->connection->getDatabasePlatform(); + } } diff --git a/tests/contracts/Bootstrapper/DatabaseSchemaHookTest.php b/tests/contracts/Bootstrapper/DatabaseSchemaHookTest.php new file mode 100644 index 0000000..9cca5b6 --- /dev/null +++ b/tests/contracts/Bootstrapper/DatabaseSchemaHookTest.php @@ -0,0 +1,208 @@ + */ + private array $executedStatements = []; + + protected function setUp(): void + { + $this->executedStatements = []; + } + + /** + * Vanilla SQLitePlatform drops the table-level PRIMARY KEY clause once a table has an + * autoincrement column, which silently turns a composite key into a single-column one. The + * schema has to be generated with Ibexa's platform, not with whatever the connection carries. + * + * @throws \Exception + * @throws TypesException + */ + public function testGeneratesSchemaWithIbexaDatabasePlatform(): void + { + $hook = $this->createHook(new SqliteDbPlatform()); + + $hook($this->resolve($hook, [])); + + self::assertStringContainsString('PRIMARY KEY (id, version)', $this->getExecutedDdl()); + } + + /** + * @throws \Exception + * @throws TypesException + */ + public function testFallsBackToConnectionPlatformWhenFactoryProvidesNone(): void + { + $hook = $this->createHook(null); + + $hook($this->resolve($hook, [])); + + // the connection's own vanilla platform is used, PK degradation and all + $ddl = $this->getExecutedDdl(); + self::assertStringContainsString('CREATE TABLE ibexa_content_field', $ddl); + self::assertStringContainsString('id INTEGER PRIMARY KEY AUTOINCREMENT', $ddl); + self::assertStringNotContainsString('PRIMARY KEY (id, version)', $ddl); + } + + /** + * A connection configured with driverClass rather than driver has no 'driver' key at all. That + * must degrade to the connection's own platform, not blow up on a missing array key. + * + * @throws \Exception + * @throws TypesException + */ + public function testDegradesToConnectionPlatformWhenDriverParamIsAbsent(): void + { + $hook = $this->createHook(new SqliteDbPlatform(), []); + + $hook($this->resolve($hook, [])); + + $ddl = $this->getExecutedDdl(); + self::assertStringContainsString('id INTEGER PRIMARY KEY AUTOINCREMENT', $ddl); + self::assertStringNotContainsString('PRIMARY KEY (id, version)', $ddl); + } + + /** + * @throws TypesException + */ + public function testLoadSchemaOptionDefaultsToTrue(): void + { + $hook = $this->createHook(new SqliteDbPlatform()); + + $options = $this->resolve($hook, []); + + self::assertTrue($options[DatabaseSchemaHook::OPTION_LOAD_SCHEMA]); + } + + /** + * @throws \Exception + */ + public function testDoesNotBuildSchemaWhenDisabled(): void + { + $schemaBuilder = $this->createMock(SchemaBuilderInterface::class); + $schemaBuilder->expects(self::never())->method('buildSchema'); + + $hook = new DatabaseSchemaHook( + $schemaBuilder, + $this->createConnection(), + $this->createMock(DbPlatformFactoryInterface::class) + ); + + $hook($this->resolve($hook, [DatabaseSchemaHook::OPTION_LOAD_SCHEMA => false])); + + self::assertSame([], $this->executedStatements); + } + + private function getExecutedDdl(): string + { + self::assertNotEmpty($this->executedStatements, 'the hook executed no statements at all'); + + return implode("\n", $this->executedStatements); + } + + /** + * @param array $connectionParams + * + * @throws TypesException + */ + private function createHook( + ?AbstractPlatform $ibexaPlatform, + array $connectionParams = ['driver' => 'pdo_sqlite'] + ): DatabaseSchemaHook { + $schemaBuilder = $this->createStub(SchemaBuilderInterface::class); + $schemaBuilder->method('buildSchema')->willReturn($this->createSchema()); + + // keyed like the real factory, so a driver it does not know about resolves to null + $dbPlatformFactory = $this->createStub(DbPlatformFactoryInterface::class); + $dbPlatformFactory->method('createDatabasePlatformFromDriverName')->willReturnCallback( + static fn (string $driverName): ?AbstractPlatform => $driverName === 'pdo_sqlite' + ? $ibexaPlatform + : null + ); + + return new DatabaseSchemaHook( + $schemaBuilder, + $this->createConnection($connectionParams), + $dbPlatformFactory + ); + } + + /** + * A connection reporting the vanilla SQLite platform, recording every statement it is asked + * to run. + * + * @param array $params + */ + private function createConnection(array $params = ['driver' => 'pdo_sqlite']): Connection + { + $connection = $this->createStub(Connection::class); + $connection->method('getParams')->willReturn($params); + $connection->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); + $connection->method('executeStatement')->willReturnCallback( + function (string $sql): int { + $this->executedStatements[] = $sql; + + return 0; + } + ); + + return $connection; + } + + /** + * Mirrors ibexa_content_field: a composite primary key over an autoincrement column. + * + * @throws TypesException + */ + private function createSchema(): Schema + { + $schema = new Schema(); + $table = $schema->createTable('ibexa_content_field'); + $table->addColumn('id', 'integer', ['autoincrement' => true]); + $table->addColumn('version', 'integer', ['default' => 0]); + $table->addPrimaryKeyConstraint( + PrimaryKeyConstraint::editor()->setUnquotedColumnNames('id', 'version')->create() + ); + + return $schema; + } + + /** + * @param array $options + * + * @return array + */ + private function resolve( + DatabaseSchemaHook $hook, + array $options + ): array { + $resolver = new OptionsResolver(); + $hook->configureOptions($resolver); + + return $resolver->resolve($options); + } +}