From 97c00fff680227362b1dd156fba8f0df62dd6637 Mon Sep 17 00:00:00 2001 From: Andrew Longosz Date: Tue, 15 Sep 2026 12:15:20 +0200 Subject: [PATCH 1/3] Resolved Ibexa DB platform when generating test database schema DatabaseSchemaHook generated DDL with $connection->getDatabasePlatform(), which returns vanilla Doctrine SQLitePlatform. That platform drops the table-level PRIMARY KEY clause whenever a table has an autoincrement column, so composite keys degraded to a single column and fixture import failed on ibexa_content_field(id, version). DBAL 4 removed doctrine-bundle's platform_service, so the connection no longer carries Ibexa's platform subclasses. Resolve them explicitly via DbPlatformFactoryInterface, the same way CoreInstaller and LegacySchemaImporter already do. Affects 6.0 only. 4.6 and 5.0 are on DBAL 3 and still wire platform_service. Co-Authored-By: Claude Opus 5 --- .../Bootstrapper/DatabaseSchemaHook.php | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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(); + } } From cc7ca4c85d1eb94ab26dbc7ae19e8c7fcb866b3c Mon Sep 17 00:00:00 2001 From: Andrew Longosz Date: Tue, 15 Sep 2026 14:15:00 +0200 Subject: [PATCH 2/3] [Tests] Added coverage for DatabaseSchemaHook platform resolution Builds a schema with a composite primary key over an autoincrement column, the shape ibexa_content_field has, and asserts the emitted DDL keeps PRIMARY KEY (id, version). Fails against the previous code, which took the platform off the connection. This is the guard for the SchemaApplier work on 4.6: if that call site goes back to $connection->getDatabasePlatform() when the chain merges up, this test fails instead of the bug returning silently. Co-Authored-By: Claude Opus 5 --- .../Bootstrapper/DatabaseSchemaHookTest.php | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/contracts/Bootstrapper/DatabaseSchemaHookTest.php 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); + } +} From 8c2dd1f77d43d1f7403b4ba38142ac04a4daed5f Mon Sep 17 00:00:00 2001 From: Andrew Longosz Date: Tue, 15 Sep 2026 14:15:00 +0200 Subject: [PATCH 3/3] [CS] Kept createStub calls on $this and bumped ibexa/code-style php_unit_test_case_static_method_calls rewrites $this->createStub() to self::, which contradicts PHPStorm's EA inspection. createStub is a non-static method in PHPUnit 9, so $this-> is the accurate form here. The fixer matches on method name across PHPUnit versions and assumes PHPUnit 10+, where createStub did become static, hence the disagreement. Overriding the call type for that one method keeps the rest of the suite on self::. Revisit when moving to PHPUnit 10+, where the override stops being correct. Bumped ibexa/code-style to ~2.3.0 while at it. Co-Authored-By: Claude Opus 5 --- .php-cs-fixer.php | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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",