From 16f5c2ae71ae52ad97c8d52cc4442201cd8d19b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Sat, 26 Sep 2026 17:23:38 +0200 Subject: [PATCH 1/4] IBX-11939: Pointed the bundle_installer test suite at tests/bundle/RepositoryInstaller The suite still pointed at tests/bundle/Installer, which hasn't existed since the IBX-538 rename, so none of the RepositoryInstaller bundle tests ran. Same path the 5.0 branch already uses. --- phpunit.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 3babacfd15..2423c77dd4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,7 +24,7 @@ tests/bundle/Debug - tests/bundle/Installer + tests/bundle/RepositoryInstaller tests/bundle/IO From d365379d438120a40ccf2d4b89a68824c2a903b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Sat, 26 Sep 2026 18:07:06 +0200 Subject: [PATCH 2/4] IBX-11939: Fixed the stale installer type in IbexaInstallerExtensionTest IBX-538 renamed CoreInstaller's installer type from "clean" to "ibexa-oss", but the moved test kept asserting the old one. It went unnoticed because the suite never ran these tests. --- .../DependencyInjection/IbexaInstallerExtensionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bundle/RepositoryInstaller/DependencyInjection/IbexaInstallerExtensionTest.php b/tests/bundle/RepositoryInstaller/DependencyInjection/IbexaInstallerExtensionTest.php index 19ff4e77d1..56b4533d56 100644 --- a/tests/bundle/RepositoryInstaller/DependencyInjection/IbexaInstallerExtensionTest.php +++ b/tests/bundle/RepositoryInstaller/DependencyInjection/IbexaInstallerExtensionTest.php @@ -33,7 +33,7 @@ public function testLoadLoadsTaggedCoreInstaller(): void $this->assertContainerBuilderHasServiceDefinitionWithTag( CoreInstaller::class, InstallerTagPass::INSTALLER_TAG, - ['type' => 'clean'] + ['type' => 'ibexa-oss'] ); } From ccc940718935cf7c5ed749aa350f199775c6c773 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Sat, 26 Sep 2026 16:46:34 +0200 Subject: [PATCH 3/4] IBX-11939: Added a shared SchemaBuilderEvent subscriber for plain ORM entities OrmEntitiesSchemaSubscriber contributes the tables (and sequences) of a given list of Doctrine ORM entities to the SchemaBuilderEvent schema, derived from their mappings via SchemaTool. ibexa/taxonomy and ibexa/connector-payum each carried an identical copy of this logic, differing only in the entity list. Each package now registers its own instance of this class instead, listing just its own entities. --- .../OrmEntitiesSchemaSubscriber.php | 179 +++++++++++++++++ .../Subscriber/Fixtures/Entity/Category.php | 16 ++ .../Event/Subscriber/Fixtures/Entity/Item.php | 18 ++ .../Subscriber/Fixtures/orm/Category.orm.xml | 14 ++ .../Subscriber/Fixtures/orm/Item.orm.xml | 18 ++ .../OrmEntitiesSchemaSubscriberTest.php | 189 ++++++++++++++++++ 6 files changed, 434 insertions(+) create mode 100644 src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php create mode 100644 tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/Entity/Category.php create mode 100644 tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/Entity/Item.php create mode 100644 tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/orm/Category.orm.xml create mode 100644 tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/orm/Item.orm.xml create mode 100644 tests/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriberTest.php diff --git a/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php b/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php new file mode 100644 index 0000000000..bb5b01d6a6 --- /dev/null +++ b/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php @@ -0,0 +1,179 @@ +.schema_builder.orm_entities: + * class: Ibexa\Bundle\RepositoryInstaller\Event\Subscriber\OrmEntitiesSchemaSubscriber + * arguments: + * $entityManager: '@ibexa.doctrine.orm.entity_manager' + * $entityClasses: + * - Ibexa\\Persistence\Entity\SomeEntity + * tags: [kernel.event_subscriber] + * + * Tables already present in the schema are left as they are. + */ +final class OrmEntitiesSchemaSubscriber implements EventSubscriberInterface +{ + private EntityManagerInterface $entityManager; + + /** @var list */ + private array $entityClasses; + + /** + * @param list $entityClasses + */ + public function __construct(EntityManagerInterface $entityManager, array $entityClasses) + { + $this->entityManager = $entityManager; + $this->entityClasses = $entityClasses; + } + + public static function getSubscribedEvents(): array + { + return [ + SchemaBuilderEvents::BUILD_SCHEMA => ['onBuildSchema', 250], + ]; + } + + public function onBuildSchema(SchemaBuilderEvent $event): void + { + $metadataFactory = $this->entityManager->getMetadataFactory(); + $classMetadata = array_map( + static function (string $class) use ($metadataFactory) { + return $metadataFactory->getMetadataFor($class); + }, + $this->entityClasses, + ); + + $schemaTool = new SchemaTool($this->entityManager); + $ormSchema = $schemaTool->getSchemaFromMetadata($classMetadata); + + // SchemaTool::getSchemaFromMetadata() fires Doctrine's ToolEvents::postGenerateSchema on + // the entity manager's (shared) event manager -- any *other* listener on that same event + // manager (e.g. Symfony Messenger's Doctrine transport auto-setup) reacts too and adds its + // own table, regardless of which classes were actually requested here. Only transplant the + // tables that genuinely belong to the requested entities. + $ownTableNames = array_map( + static function ($metadata) { + return $metadata->getTableName(); + }, + $classMetadata, + ); + + $targetSchema = $event->getSchema(); + foreach ($ormSchema->getTables() as $ormTable) { + if (!in_array($ormTable->getName(), $ownTableNames, true)) { + continue; + } + + if ($targetSchema->hasTable($ormTable->getName())) { + continue; + } + + $this->copyTable($ormTable, $targetSchema->createTable($ormTable->getName())); + } + + $ownSequenceNames = array_filter(array_map( + static function ($metadata) { + return isset($metadata->sequenceGeneratorDefinition['sequenceName']) + ? $metadata->sequenceGeneratorDefinition['sequenceName'] + : null; + }, + $classMetadata, + )); + + foreach ($ormSchema->getSequences() as $sequence) { + if (!in_array($sequence->getName(), $ownSequenceNames, true)) { + continue; + } + + if ($targetSchema->hasSequence($sequence->getName())) { + continue; + } + + $targetSchema->createSequence( + $sequence->getName(), + $sequence->getAllocationSize(), + $sequence->getInitialValue(), + ); + } + } + + /** + * Doctrine\DBAL\Schema\Schema has no public API to transplant an existing Table between two + * Schema instances (Schema::_addTable() is protected), so each aspect is copied individually + * through Table's own public API -- the same approach ibexa/doctrine-schema's own + * SchemaImporter uses when building a table from a parsed Yaml array, just reading from an + * already-built Table here instead. + */ + private function copyTable(Table $source, Table $target): void + { + foreach ($source->getColumns() as $column) { + $options = [ + 'length' => $column->getLength(), + 'precision' => $column->getPrecision(), + 'scale' => $column->getScale(), + 'unsigned' => $column->getUnsigned(), + 'fixed' => $column->getFixed(), + 'notnull' => $column->getNotnull(), + 'default' => $column->getDefault(), + 'autoincrement' => $column->getAutoincrement(), + 'columnDefinition' => $column->getColumnDefinition(), + 'comment' => $column->getComment(), + ]; + if ($column->getPlatformOptions() !== []) { + $options['platformOptions'] = $column->getPlatformOptions(); + } + $target->addColumn($column->getName(), Type::getTypeRegistry()->lookupName($column->getType()), $options); + } + + $primaryKey = $source->getPrimaryKey(); + if ($primaryKey !== null) { + $target->setPrimaryKey($primaryKey->getUnquotedColumns()); + } + + foreach ($source->getIndexes() as $index) { + if ($index->isPrimary()) { + continue; + } + + if ($index->isUnique()) { + $target->addUniqueIndex($index->getUnquotedColumns(), $index->getName(), $index->getOptions()); + } else { + $target->addIndex($index->getUnquotedColumns(), $index->getName(), $index->getFlags(), $index->getOptions()); + } + } + + foreach ($source->getForeignKeys() as $foreignKey) { + $target->addForeignKeyConstraint( + $foreignKey->getForeignTableName(), + $foreignKey->getUnquotedLocalColumns(), + $foreignKey->getUnquotedForeignColumns(), + $foreignKey->getOptions(), + $foreignKey->getName(), + ); + } + } +} diff --git a/tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/Entity/Category.php b/tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/Entity/Category.php new file mode 100644 index 0000000000..e0ab5099d7 --- /dev/null +++ b/tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/Entity/Category.php @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/orm/Item.orm.xml b/tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/orm/Item.orm.xml new file mode 100644 index 0000000000..2168f1d51b --- /dev/null +++ b/tests/bundle/RepositoryInstaller/Event/Subscriber/Fixtures/orm/Item.orm.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/tests/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriberTest.php b/tests/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriberTest.php new file mode 100644 index 0000000000..55951b01cb --- /dev/null +++ b/tests/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriberTest.php @@ -0,0 +1,189 @@ +setMetadataDriverImpl(new SimplifiedXmlDriver([ + __DIR__ . '/Fixtures/orm' => 'Ibexa\Tests\Bundle\RepositoryInstaller\Event\Subscriber\Fixtures\Entity', + ])); + $configuration->setProxyDir(sys_get_temp_dir()); + $configuration->setProxyNamespace('Ibexa\Tests\Bundle\RepositoryInstaller\Event\Subscriber\Proxies'); + + $this->eventManager = new EventManager(); + $this->entityManager = new EntityManager( + DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]), + $configuration, + $this->eventManager, + ); + } + + /** + * @dataProvider providePlatforms + */ + public function testAddsTablesOfListedEntitiesAsSchemaToolGeneratesThem(AbstractPlatform $platform): void + { + $schema = $this->dispatch(new Schema()); + + self::assertSame( + $this->getCreateTablesSql($this->getSchemaToolSchema(), $platform), + $this->getCreateTablesSql($schema, $platform), + ); + } + + /** + * @return iterable + */ + public static function providePlatforms(): iterable + { + yield 'MySQL' => [new MySQL57Platform()]; + yield 'PostgreSQL' => [new PostgreSQL94Platform()]; + yield 'SQLite' => [new SqlitePlatform()]; + } + + public function testAddsSequencesOfListedEntitiesAsSchemaToolGeneratesThem(): void + { + $schema = $this->dispatch(new Schema()); + + self::assertSame( + $this->getCreateSequencesSql($this->getSchemaToolSchema()), + $this->getCreateSequencesSql($schema), + ); + self::assertNotEmpty($this->getCreateSequencesSql($schema)); + } + + public function testIgnoresTablesAndSequencesOtherListenersAddToTheOrmSchema(): void + { + $this->eventManager->addEventListener(ToolEvents::postGenerateSchema, new class() { + public function postGenerateSchema(GenerateSchemaEventArgs $args): void + { + $args->getSchema()->createTable('test_orm_foreign')->addColumn('id', 'integer'); + $args->getSchema()->createSequence('test_orm_foreign_id_seq'); + } + }); + + $schema = $this->dispatch(new Schema()); + + self::assertTrue($schema->hasTable('test_orm_item')); + self::assertFalse($schema->hasTable('test_orm_foreign')); + self::assertTrue($schema->hasSequence('test_orm_item_id_seq')); + self::assertFalse($schema->hasSequence('test_orm_foreign_id_seq')); + } + + public function testLeavesTablesAlreadyInTheSchemaUntouched(): void + { + $schema = new Schema(); + $schema->createTable('test_orm_category')->addColumn('legacy_id', 'integer'); + + $schema = $this->dispatch($schema); + + $columnNames = array_map( + static function (Column $column): string { + return $column->getName(); + }, + array_values($schema->getTable('test_orm_category')->getColumns()), + ); + + self::assertSame(['legacy_id'], $columnNames); + self::assertTrue($schema->hasTable('test_orm_item')); + } + + private function getSchemaToolSchema(): Schema + { + $metadataFactory = $this->entityManager->getMetadataFactory(); + + return (new SchemaTool($this->entityManager))->getSchemaFromMetadata(array_map( + static function (string $class) use ($metadataFactory) { + return $metadataFactory->getMetadataFor($class); + }, + self::ENTITY_CLASSES, + )); + } + + /** + * @return array> + */ + private function getCreateTablesSql(Schema $schema, AbstractPlatform $platform): array + { + return array_map( + static function (Table $table) use ($platform): array { + return $platform->getCreateTableSQL( + $table, + AbstractPlatform::CREATE_INDEXES | AbstractPlatform::CREATE_FOREIGNKEYS, + ); + }, + $schema->getTables(), + ); + } + + /** + * Sequences are compared on PostgreSQL only, as neither MySQL nor SQLite supports them. + * + * @return array + */ + private function getCreateSequencesSql(Schema $schema): array + { + $platform = new PostgreSQL94Platform(); + + return array_map( + static function (Sequence $sequence) use ($platform): string { + return $platform->getCreateSequenceSQL($sequence); + }, + $schema->getSequences(), + ); + } + + private function dispatch(Schema $schema): Schema + { + $event = new SchemaBuilderEvent($this->createStub(SchemaBuilderInterface::class), $schema); + + (new OrmEntitiesSchemaSubscriber($this->entityManager, self::ENTITY_CLASSES))->onBuildSchema($event); + + return $event->getSchema(); + } +} From 1b52d4a7f7ee7b0361e24a4df3b660df3df6063a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Mon, 28 Sep 2026 09:17:05 +0200 Subject: [PATCH 4/4] IBX-11939: Typed the closures in OrmEntitiesSchemaSubscriber --- .../Event/Subscriber/OrmEntitiesSchemaSubscriber.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php b/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php index bb5b01d6a6..bd420da371 100644 --- a/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php +++ b/src/bundle/RepositoryInstaller/Event/Subscriber/OrmEntitiesSchemaSubscriber.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Type; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Tools\SchemaTool; use Ibexa\Contracts\DoctrineSchema\Event\SchemaBuilderEvent; use Ibexa\Contracts\DoctrineSchema\SchemaBuilderEvents; @@ -61,7 +62,7 @@ public function onBuildSchema(SchemaBuilderEvent $event): void { $metadataFactory = $this->entityManager->getMetadataFactory(); $classMetadata = array_map( - static function (string $class) use ($metadataFactory) { + static function (string $class) use ($metadataFactory): ClassMetadata { return $metadataFactory->getMetadataFor($class); }, $this->entityClasses, @@ -76,7 +77,7 @@ static function (string $class) use ($metadataFactory) { // own table, regardless of which classes were actually requested here. Only transplant the // tables that genuinely belong to the requested entities. $ownTableNames = array_map( - static function ($metadata) { + static function (ClassMetadata $metadata): string { return $metadata->getTableName(); }, $classMetadata, @@ -96,7 +97,7 @@ static function ($metadata) { } $ownSequenceNames = array_filter(array_map( - static function ($metadata) { + static function (ClassMetadata $metadata): ?string { return isset($metadata->sequenceGeneratorDefinition['sequenceName']) ? $metadata->sequenceGeneratorDefinition['sequenceName'] : null;