From e91e5171928ce1e8c1e141a908491e419f4aec3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 26 Oct 2026 10:17:30 +0100 Subject: [PATCH 1/5] Fix configuring a metadata class that extends the bundle's Metadata The README says to override metadata_class with your own class to add mapped fields or behaviour, and Entity\Metadata is deliberately not final for that. But ConvertToEntityListener turned the bundle's Metadata mapped superclass into an entity no matter which class was configured. Doctrine then saw the application's entity as a subclass of another entity without inheritance mapping, and the application didn't boot: Entity class 'App\Entity\ClientMetadata' is a subclass of the root entity class 'Setono\ClientBundle\Entity\Metadata', but no inheritance mapping type was declared. The listener now gets the configured metadata class and only turns that class into an entity. With the default configuration this is the bundle's Metadata, as before. With a custom class, the bundle's Metadata stays a mapped superclass, so the application's entity inherits its fields. --- CLAUDE.md | 2 +- README.md | 2 + config/services.xml | 2 + .../Doctrine/ConvertToEntityListener.php | 20 +++--- .../Functional/App/Entity/CustomMetadata.php | 14 +++++ tests/Functional/App/Kernel.php | 25 ++++++++ .../config/doctrine/CustomMetadata.orm.xml | 7 +++ tests/Functional/CustomMetadataClassTest.php | 61 +++++++++++++++++++ tests/Functional/FunctionalTestCase.php | 7 ++- .../SetonoClientExtensionTest.php | 14 +++++ .../Doctrine/ConvertToEntityListenerTest.php | 31 +++++++++- 11 files changed, 173 insertions(+), 12 deletions(-) create mode 100644 tests/Functional/App/Entity/CustomMetadata.php create mode 100644 tests/Functional/App/config/doctrine/CustomMetadata.orm.xml create mode 100644 tests/Functional/CustomMetadataClassTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 8ee652d..63dfffd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Three concerns are each built as a **Symfony service-decoration chain** in `conf ### Entity mapping -`Entity/Metadata` (table `setono_client__metadata`, string id `clientId`, nullable `json` `metadata`) is mapped in `config/doctrine-mapping/Metadata.orm.xml` as a **mapped-superclass**. `EventListener/Doctrine/ConvertToEntityListener` listens on Doctrine's `loadClassMetadata` and flips `isMappedSuperclass = false` so the bundle's own `Metadata` becomes a concrete entity when the app provides no replacement. Apps can swap in a custom class via the `metadata_class` config option; `SetonoClientExtension::load()` enforces that it implements `Entity/MetadataInterface` and they are responsible for mapping it. +`Entity/Metadata` (table `setono_client__metadata`, string id `clientId`, nullable `json` `metadata`) is mapped in `config/doctrine-mapping/Metadata.orm.xml` as a **mapped-superclass**. `EventListener/Doctrine/ConvertToEntityListener` listens on Doctrine's `loadClassMetadata` and flips `isMappedSuperclass = false` on the configured `metadata_class` only, so with the default config the bundle's own `Metadata` becomes a concrete entity. Apps can swap in a custom class via the `metadata_class` config option; `SetonoClientExtension::load()` enforces that it implements `Entity/MetadataInterface` and they are responsible for mapping it. A custom entity that extends `Entity/Metadata` inherits its fields, because the bundle's class then stays a mapped superclass (`tests/Functional/CustomMetadataClassTest.php` covers this). ### Configuration diff --git a/README.md b/README.md index fb188f2..3701da6 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,8 @@ setono_client: # Entity used to persist metadata. Override it with your own class to add mapped fields # or behaviour; it must implement Setono\ClientBundle\Entity\MetadataInterface. + # If your class extends Setono\ClientBundle\Entity\Metadata, map it as an entity: it inherits + # the client id and metadata fields, because the bundle's class then stays a mapped superclass. metadata_class: Setono\ClientBundle\Entity\Metadata ``` diff --git a/config/services.xml b/config/services.xml index 6756651..53c4dda 100644 --- a/config/services.xml +++ b/config/services.xml @@ -51,6 +51,8 @@ + %setono_client.metadata_class% + diff --git a/src/EventListener/Doctrine/ConvertToEntityListener.php b/src/EventListener/Doctrine/ConvertToEntityListener.php index 2708c82..0ea6cdb 100644 --- a/src/EventListener/Doctrine/ConvertToEntityListener.php +++ b/src/EventListener/Doctrine/ConvertToEntityListener.php @@ -6,25 +6,29 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\Event\LoadClassMetadataEventArgs; -use Setono\ClientBundle\Entity\Metadata; +use Setono\ClientBundle\Entity\MetadataInterface; /** - * If the user of the plugin doesn't add their own Metadata, - * this listener will make sure that Doctrine sees our Metadata as an entity + * The bundle maps its Metadata class as a mapped superclass, so an application can extend it with its own entity. + * This listener makes sure that Doctrine sees the configured metadata class as an entity, i.e. the bundle's + * Metadata class when the application doesn't configure its own */ final class ConvertToEntityListener { + /** + * @param class-string $metadataClass + */ + public function __construct(private readonly string $metadataClass) + { + } + /** * @param LoadClassMetadataEventArgs<\Doctrine\Persistence\Mapping\ClassMetadata, \Doctrine\Persistence\ObjectManager> $eventArgs */ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void { - if (!is_a($eventArgs->getClassMetadata()->getName(), Metadata::class, true)) { - return; - } - $metadata = $eventArgs->getClassMetadata(); - if (!$metadata instanceof ClassMetadata) { + if ($metadata->getName() !== $this->metadataClass || !$metadata instanceof ClassMetadata) { return; } diff --git a/tests/Functional/App/Entity/CustomMetadata.php b/tests/Functional/App/Entity/CustomMetadata.php new file mode 100644 index 0000000..98313e2 --- /dev/null +++ b/tests/Functional/App/Entity/CustomMetadata.php @@ -0,0 +1,14 @@ +load(__NAMESPACE__ . '\\Controller\\', __DIR__ . '/Controller')->tag('controller.service_arguments') ->load(__NAMESPACE__ . '\\EventSubscriber\\', __DIR__ . '/EventSubscriber') ; + + if (self::ENVIRONMENT_CUSTOM_METADATA === $this->environment) { + $container->extension('setono_client', [ + 'metadata_class' => CustomMetadata::class, + ]); + + $container->extension('doctrine', [ + 'orm' => [ + 'mappings' => [ + 'App' => [ + 'type' => 'xml', + 'is_bundle' => false, + 'dir' => __DIR__ . '/config/doctrine', + 'prefix' => __NAMESPACE__ . '\\Entity', + ], + ], + ], + ]); + } } protected function configureRoutes(RoutingConfigurator $routes): void diff --git a/tests/Functional/App/config/doctrine/CustomMetadata.orm.xml b/tests/Functional/App/config/doctrine/CustomMetadata.orm.xml new file mode 100644 index 0000000..eb9baff --- /dev/null +++ b/tests/Functional/App/config/doctrine/CustomMetadata.orm.xml @@ -0,0 +1,7 @@ + + + + diff --git a/tests/Functional/CustomMetadataClassTest.php b/tests/Functional/CustomMetadataClassTest.php new file mode 100644 index 0000000..ca3d110 --- /dev/null +++ b/tests/Functional/CustomMetadataClassTest.php @@ -0,0 +1,61 @@ +browser->request('GET', '/metadata/set?key=foo&value=bar'); + + self::assertResponseIsSuccessful(); + self::assertSame('app_client_metadata', self::getEntityManager()->getClassMetadata(CustomMetadata::class)->getTableName()); + self::assertSame(['foo' => 'bar'], self::fetchStoredMetadata($this->getClientId())); + } + + /** + * @test + */ + public function it_loads_the_metadata_from_the_configured_entity(): void + { + $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); + + $this->browser->request('GET', '/metadata'); + + self::assertSame('{"foo":"bar"}', $this->browser->getResponse()->getContent()); + } + + /** + * @test + */ + public function it_keeps_the_bundles_metadata_a_mapped_superclass(): void + { + self::assertTrue(self::getEntityManager()->getClassMetadata(Metadata::class)->isMappedSuperclass); + } + + /** + * @test + */ + public function its_mapping_is_valid_and_matches_the_database_schema(): void + { + $validator = new SchemaValidator(self::getEntityManager()); + + self::assertSame([], $validator->validateMapping()); + self::assertTrue($validator->schemaInSyncWithMetadata()); + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index e652d16..936e684 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -26,7 +26,7 @@ abstract class FunctionalTestCase extends WebTestCase protected function setUp(): void { // Fixed, so APP_ENV and APP_DEBUG in the environment, e.g. from the Symfony CLI, don't change the application - $this->browser = static::createClient(['environment' => 'test', 'debug' => true]); + $this->browser = static::createClient(['environment' => static::getEnvironment(), 'debug' => true]); // An exception fails the test instead of becoming an error response $this->browser->catchExceptions(false); @@ -37,6 +37,11 @@ protected static function getKernelClass(): string return Kernel::class; } + protected static function getEnvironment(): string + { + return 'test'; + } + /** * Returns the cookie stored in the browser */ diff --git a/tests/Unit/DependencyInjection/SetonoClientExtensionTest.php b/tests/Unit/DependencyInjection/SetonoClientExtensionTest.php index 7a4ad74..ff9003b 100644 --- a/tests/Unit/DependencyInjection/SetonoClientExtensionTest.php +++ b/tests/Unit/DependencyInjection/SetonoClientExtensionTest.php @@ -78,6 +78,20 @@ public function after_loading_the_services_hooking_into_symfony_and_doctrine_are ]); } + /** + * @test + */ + public function after_loading_the_doctrine_listener_gets_the_configured_metadata_class(): void + { + $this->load(); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument( + 'setono_client.event_listener_doctrine.convert_to_entity', + 0, + '%setono_client.metadata_class%', + ); + } + /** * @test */ diff --git a/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php b/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php index 9b196ef..2197052 100644 --- a/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php +++ b/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php @@ -11,6 +11,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Setono\ClientBundle\Entity\Metadata; use Setono\ClientBundle\EventListener\Doctrine\ConvertToEntityListener; +use Setono\ClientBundle\Tests\Unit\DependencyInjection\Fixture\CustomMetadata; final class ConvertToEntityListenerTest extends TestCase { @@ -24,11 +25,37 @@ public function it_turns_the_metadata_mapped_superclass_into_an_entity(): void $classMetadata = new ClassMetadata(Metadata::class); $classMetadata->isMappedSuperclass = true; - (new ConvertToEntityListener())->loadClassMetadata($this->createEventArgs($classMetadata)); + (new ConvertToEntityListener(Metadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); self::assertFalse($classMetadata->isMappedSuperclass); } + /** + * @test + */ + public function it_turns_a_configured_metadata_class_into_an_entity(): void + { + $classMetadata = new ClassMetadata(CustomMetadata::class); + $classMetadata->isMappedSuperclass = true; + + (new ConvertToEntityListener(CustomMetadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); + + self::assertFalse($classMetadata->isMappedSuperclass); + } + + /** + * @test + */ + public function it_leaves_the_metadata_mapped_superclass_untouched_when_another_class_is_configured(): void + { + $classMetadata = new ClassMetadata(Metadata::class); + $classMetadata->isMappedSuperclass = true; + + (new ConvertToEntityListener(CustomMetadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); + + self::assertTrue($classMetadata->isMappedSuperclass); + } + /** * @test */ @@ -37,7 +64,7 @@ public function it_leaves_other_classes_untouched(): void $classMetadata = new ClassMetadata(\stdClass::class); $classMetadata->isMappedSuperclass = true; - (new ConvertToEntityListener())->loadClassMetadata($this->createEventArgs($classMetadata)); + (new ConvertToEntityListener(Metadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); self::assertTrue($classMetadata->isMappedSuperclass); } From 122606c714fb31ffcfe2cc5d7d100cf2771af7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 26 Oct 2026 11:36:51 +0100 Subject: [PATCH 2/5] Remove the Doctrine listener when the metadata class extends the bundle's class Instead of passing the configured class to the listener, the extension decides at build time: when the configured class extends the bundle's Metadata, it removes the listener, so the bundle's class stays a mapped superclass. Otherwise the listener turns the bundle's class into an entity, as before, also when a custom class only implements the interface. The listener no longer touches application classes. The extension also stores the declared name of the configured class, so e.g. a leading backslash doesn't keep Doctrine from finding it. --- CLAUDE.md | 2 +- config/services.xml | 2 -- .../SetonoClientExtension.php | 12 ++++++- .../Doctrine/ConvertToEntityListener.php | 15 +++------ .../Fixture/InterfaceOnlyMetadata.php | 31 ++++++++++++++++++ .../SetonoClientExtensionTest.php | 32 +++++++++++++++---- .../Doctrine/ConvertToEntityListenerTest.php | 31 ++---------------- 7 files changed, 74 insertions(+), 51 deletions(-) create mode 100644 tests/Unit/DependencyInjection/Fixture/InterfaceOnlyMetadata.php diff --git a/CLAUDE.md b/CLAUDE.md index 63dfffd..5014f09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ Three concerns are each built as a **Symfony service-decoration chain** in `conf ### Entity mapping -`Entity/Metadata` (table `setono_client__metadata`, string id `clientId`, nullable `json` `metadata`) is mapped in `config/doctrine-mapping/Metadata.orm.xml` as a **mapped-superclass**. `EventListener/Doctrine/ConvertToEntityListener` listens on Doctrine's `loadClassMetadata` and flips `isMappedSuperclass = false` on the configured `metadata_class` only, so with the default config the bundle's own `Metadata` becomes a concrete entity. Apps can swap in a custom class via the `metadata_class` config option; `SetonoClientExtension::load()` enforces that it implements `Entity/MetadataInterface` and they are responsible for mapping it. A custom entity that extends `Entity/Metadata` inherits its fields, because the bundle's class then stays a mapped superclass (`tests/Functional/CustomMetadataClassTest.php` covers this). +`Entity/Metadata` (table `setono_client__metadata`, string id `clientId`, nullable `json` `metadata`) is mapped in `config/doctrine-mapping/Metadata.orm.xml` as a **mapped-superclass**. `EventListener/Doctrine/ConvertToEntityListener` listens on Doctrine's `loadClassMetadata` and flips `isMappedSuperclass = false` on the bundle's own `Metadata`, so it becomes a concrete entity. Apps can swap in a custom class via the `metadata_class` config option; `SetonoClientExtension::load()` enforces that it implements `Entity/MetadataInterface`, stores its declared name (e.g. without a leading backslash) in the parameter, and the app is responsible for mapping it. When the custom class extends `Entity/Metadata`, the extension removes the listener: the bundle's class stays a mapped superclass and the app's entity inherits its fields (`tests/Functional/CustomMetadataClassTest.php` runs `MetadataTest` that way). A custom class that only implements the interface leaves the bundle's `Metadata` an entity, as before. ### Configuration diff --git a/config/services.xml b/config/services.xml index 53c4dda..6756651 100644 --- a/config/services.xml +++ b/config/services.xml @@ -51,8 +51,6 @@ - %setono_client.metadata_class% - diff --git a/src/DependencyInjection/SetonoClientExtension.php b/src/DependencyInjection/SetonoClientExtension.php index 4af2c3c..1b60ad5 100644 --- a/src/DependencyInjection/SetonoClientExtension.php +++ b/src/DependencyInjection/SetonoClientExtension.php @@ -4,6 +4,7 @@ namespace Setono\ClientBundle\DependencyInjection; +use Setono\ClientBundle\Entity\Metadata; use Setono\ClientBundle\Entity\MetadataInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -27,10 +28,19 @@ public function load(array $configs, ContainerBuilder $container): void )); } + // Doctrine only knows the class by its declared name, e.g. without a leading backslash + $metadataClass = (new \ReflectionClass($config['metadata_class']))->getName(); + $container->setParameter('setono_client.cookie.name', $config['cookie']['name']); $container->setParameter('setono_client.cookie.expiration', $config['cookie']['expiration']); - $container->setParameter('setono_client.metadata_class', $config['metadata_class']); + $container->setParameter('setono_client.metadata_class', $metadataClass); $loader->load('services.xml'); + + // An application entity that extends the bundle's Metadata inherits its fields, but only while the bundle's class + // stays a mapped superclass. Doctrine would otherwise see two entities without an inheritance mapping + if (is_subclass_of($metadataClass, Metadata::class)) { + $container->removeDefinition('setono_client.event_listener_doctrine.convert_to_entity'); + } } } diff --git a/src/EventListener/Doctrine/ConvertToEntityListener.php b/src/EventListener/Doctrine/ConvertToEntityListener.php index 0ea6cdb..ab3a4ca 100644 --- a/src/EventListener/Doctrine/ConvertToEntityListener.php +++ b/src/EventListener/Doctrine/ConvertToEntityListener.php @@ -6,29 +6,22 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\Persistence\Event\LoadClassMetadataEventArgs; -use Setono\ClientBundle\Entity\MetadataInterface; +use Setono\ClientBundle\Entity\Metadata; /** * The bundle maps its Metadata class as a mapped superclass, so an application can extend it with its own entity. - * This listener makes sure that Doctrine sees the configured metadata class as an entity, i.e. the bundle's - * Metadata class when the application doesn't configure its own + * This listener makes Doctrine see the bundle's Metadata class as an entity. SetonoClientExtension only registers it + * when the configured metadata class doesn't extend the bundle's class */ final class ConvertToEntityListener { - /** - * @param class-string $metadataClass - */ - public function __construct(private readonly string $metadataClass) - { - } - /** * @param LoadClassMetadataEventArgs<\Doctrine\Persistence\Mapping\ClassMetadata, \Doctrine\Persistence\ObjectManager> $eventArgs */ public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void { $metadata = $eventArgs->getClassMetadata(); - if ($metadata->getName() !== $this->metadataClass || !$metadata instanceof ClassMetadata) { + if (Metadata::class !== $metadata->getName() || !$metadata instanceof ClassMetadata) { return; } diff --git a/tests/Unit/DependencyInjection/Fixture/InterfaceOnlyMetadata.php b/tests/Unit/DependencyInjection/Fixture/InterfaceOnlyMetadata.php new file mode 100644 index 0000000..be3c321 --- /dev/null +++ b/tests/Unit/DependencyInjection/Fixture/InterfaceOnlyMetadata.php @@ -0,0 +1,31 @@ +load(); + $this->load(['metadata_class' => CustomMetadata::class]); + + $this->assertContainerBuilderNotHasService('setono_client.event_listener_doctrine.convert_to_entity'); + } + + /** + * @test + */ + public function after_loading_with_a_metadata_class_that_only_implements_the_interface_the_doctrine_listener_is_kept(): void + { + $this->load(['metadata_class' => InterfaceOnlyMetadata::class]); - $this->assertContainerBuilderHasServiceDefinitionWithArgument( - 'setono_client.event_listener_doctrine.convert_to_entity', - 0, - '%setono_client.metadata_class%', - ); + $this->assertContainerBuilderHasService('setono_client.event_listener_doctrine.convert_to_entity', ConvertToEntityListener::class); + } + + /** + * @test + */ + public function after_loading_the_metadata_class_parameter_holds_the_declared_class_name(): void + { + $this->load(['metadata_class' => '\\' . CustomMetadata::class]); + + $this->assertContainerBuilderHasParameter('setono_client.metadata_class', CustomMetadata::class); } /** diff --git a/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php b/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php index 2197052..9b196ef 100644 --- a/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php +++ b/tests/Unit/EventListener/Doctrine/ConvertToEntityListenerTest.php @@ -11,7 +11,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Setono\ClientBundle\Entity\Metadata; use Setono\ClientBundle\EventListener\Doctrine\ConvertToEntityListener; -use Setono\ClientBundle\Tests\Unit\DependencyInjection\Fixture\CustomMetadata; final class ConvertToEntityListenerTest extends TestCase { @@ -25,37 +24,11 @@ public function it_turns_the_metadata_mapped_superclass_into_an_entity(): void $classMetadata = new ClassMetadata(Metadata::class); $classMetadata->isMappedSuperclass = true; - (new ConvertToEntityListener(Metadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); + (new ConvertToEntityListener())->loadClassMetadata($this->createEventArgs($classMetadata)); self::assertFalse($classMetadata->isMappedSuperclass); } - /** - * @test - */ - public function it_turns_a_configured_metadata_class_into_an_entity(): void - { - $classMetadata = new ClassMetadata(CustomMetadata::class); - $classMetadata->isMappedSuperclass = true; - - (new ConvertToEntityListener(CustomMetadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); - - self::assertFalse($classMetadata->isMappedSuperclass); - } - - /** - * @test - */ - public function it_leaves_the_metadata_mapped_superclass_untouched_when_another_class_is_configured(): void - { - $classMetadata = new ClassMetadata(Metadata::class); - $classMetadata->isMappedSuperclass = true; - - (new ConvertToEntityListener(CustomMetadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); - - self::assertTrue($classMetadata->isMappedSuperclass); - } - /** * @test */ @@ -64,7 +37,7 @@ public function it_leaves_other_classes_untouched(): void $classMetadata = new ClassMetadata(\stdClass::class); $classMetadata->isMappedSuperclass = true; - (new ConvertToEntityListener(Metadata::class))->loadClassMetadata($this->createEventArgs($classMetadata)); + (new ConvertToEntityListener())->loadClassMetadata($this->createEventArgs($classMetadata)); self::assertTrue($classMetadata->isMappedSuperclass); } From 16ede20a6273fd59e1eda1ba88570c2486324acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 26 Oct 2026 11:36:51 +0100 Subject: [PATCH 3/5] Run the metadata tests with the custom metadata class too --- tests/Functional/CustomMetadataClassTest.php | 29 +++----------------- tests/Functional/DoctrineMappingTest.php | 12 -------- tests/Functional/MetadataTest.php | 20 +++++++++++++- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/tests/Functional/CustomMetadataClassTest.php b/tests/Functional/CustomMetadataClassTest.php index ca3d110..cd1ba8a 100644 --- a/tests/Functional/CustomMetadataClassTest.php +++ b/tests/Functional/CustomMetadataClassTest.php @@ -4,12 +4,14 @@ namespace Setono\ClientBundle\Tests\Functional; -use Doctrine\ORM\Tools\SchemaValidator; use Setono\ClientBundle\Entity\Metadata; use Setono\ClientBundle\Tests\Functional\App\Entity\CustomMetadata; use Setono\ClientBundle\Tests\Functional\App\Kernel; -final class CustomMetadataClassTest extends FunctionalTestCase +/** + * Runs the metadata tests with an application entity that extends the bundle's Metadata class + */ +final class CustomMetadataClassTest extends MetadataTest { protected static function getEnvironment(): string { @@ -28,18 +30,6 @@ public function it_stores_the_metadata_in_the_configured_entity(): void self::assertSame(['foo' => 'bar'], self::fetchStoredMetadata($this->getClientId())); } - /** - * @test - */ - public function it_loads_the_metadata_from_the_configured_entity(): void - { - $this->browser->request('GET', '/metadata/set?key=foo&value=bar'); - - $this->browser->request('GET', '/metadata'); - - self::assertSame('{"foo":"bar"}', $this->browser->getResponse()->getContent()); - } - /** * @test */ @@ -47,15 +37,4 @@ public function it_keeps_the_bundles_metadata_a_mapped_superclass(): void { self::assertTrue(self::getEntityManager()->getClassMetadata(Metadata::class)->isMappedSuperclass); } - - /** - * @test - */ - public function its_mapping_is_valid_and_matches_the_database_schema(): void - { - $validator = new SchemaValidator(self::getEntityManager()); - - self::assertSame([], $validator->validateMapping()); - self::assertTrue($validator->schemaInSyncWithMetadata()); - } } diff --git a/tests/Functional/DoctrineMappingTest.php b/tests/Functional/DoctrineMappingTest.php index ffade33..5d468df 100644 --- a/tests/Functional/DoctrineMappingTest.php +++ b/tests/Functional/DoctrineMappingTest.php @@ -4,7 +4,6 @@ namespace Setono\ClientBundle\Tests\Functional; -use Doctrine\ORM\Tools\SchemaValidator; use Setono\ClientBundle\Entity\Metadata; final class DoctrineMappingTest extends FunctionalTestCase @@ -23,15 +22,4 @@ public function it_maps_the_metadata_as_an_entity(): void self::assertSame('json', $classMetadata->getTypeOfField('metadata')); self::assertTrue($classMetadata->isNullable('metadata')); } - - /** - * @test - */ - public function its_mapping_is_valid_and_matches_the_database_schema(): void - { - $validator = new SchemaValidator(self::getEntityManager()); - - self::assertSame([], $validator->validateMapping()); - self::assertTrue($validator->schemaInSyncWithMetadata()); - } } diff --git a/tests/Functional/MetadataTest.php b/tests/Functional/MetadataTest.php index 09fae06..d574052 100644 --- a/tests/Functional/MetadataTest.php +++ b/tests/Functional/MetadataTest.php @@ -4,10 +4,14 @@ namespace Setono\ClientBundle\Tests\Functional; +use Doctrine\ORM\Tools\SchemaValidator; use Setono\Client\Metadata; use Symfony\Bridge\Doctrine\Middleware\Debug\DebugDataHolder; -final class MetadataTest extends FunctionalTestCase +/** + * Not final, because CustomMetadataClassTest runs these tests with an application's own metadata entity + */ +class MetadataTest extends FunctionalTestCase { /** * @test @@ -160,6 +164,20 @@ public function it_keeps_the_row_of_a_concurrent_request_that_stored_it_first(): self::assertSame(['set_by' => 'concurrent request'], self::fetchStoredMetadata($this->getClientId())); } + /** + * The schema check fails if the mapping makes Doctrine see a difference to the schema it created itself, which would + * make every doctrine:migrations:diff generate a migration + * + * @test + */ + public function its_mapping_is_valid_and_matches_the_database_schema(): void + { + $validator = new SchemaValidator(self::getEntityManager()); + + self::assertSame([], $validator->validateMapping()); + self::assertTrue($validator->schemaInSyncWithMetadata()); + } + /** * @return array */ From 8cfd68a273f29d7ef6c26a4e6acd682ec3d5a9ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 26 Oct 2026 11:36:51 +0100 Subject: [PATCH 4/5] Tell how to keep the stored metadata when extending Metadata --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3701da6..c49542e 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,8 @@ setono_client: # or behaviour; it must implement Setono\ClientBundle\Entity\MetadataInterface. # If your class extends Setono\ClientBundle\Entity\Metadata, map it as an entity: it inherits # the client id and metadata fields, because the bundle's class then stays a mapped superclass. + # It doesn't inherit the table name, so map it to table="setono_client__metadata" to keep the + # metadata you have already stored. Otherwise your next migration drops that table. metadata_class: Setono\ClientBundle\Entity\Metadata ``` From b532e84e2d0bb314201f6ae252c92cf19dbfd90a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Mon, 26 Oct 2026 12:06:38 +0100 Subject: [PATCH 5/5] Give the custom metadata environment its own schema and DAMA connection --- CLAUDE.md | 2 +- tests/Functional/App/Kernel.php | 8 ++++++++ tests/bootstrap.php | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5014f09..5c869c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ PHP 8.1+ is required (CI runs 8.1 and 8.2). Use the `8.1`/`8.2` shell switchers - Tests: `composer phpunit` (or `vendor/bin/phpunit`) runs both suites; pick one with `--testsuite unit` or `--testsuite functional` - `tests/Unit`: isolated classes with Prophecy doubles. - - `tests/Functional`: the bundle inside a real Symfony app (`tests/Functional/App/Kernel.php`: FrameworkBundle + DoctrineBundle + DAMA DoctrineTestBundle + this bundle), driven over HTTP with `KernelBrowser` against a SQLite database. `tests/bootstrap.php` creates the schema once per PHPUnit process, and DAMA's PHPUnit extension (registered in `phpunit.xml.dist`) runs every test in a transaction that it rolls back. So tests must not run DDL: MySQL commits a transaction on DDL. To check which queries a request ran, read DoctrineBundle's debug data holder (see `MetadataTest::getExecutedQueries()`). Extend `FunctionalTestCase`, which rethrows exceptions instead of rendering error pages and has helpers to read the stored metadata. Every request gets a new container, like PHP-FPM. The kernel always runs in the `test` environment with debug on, whatever `APP_ENV`/`APP_DEBUG` say (the Symfony CLI's `php` sets `APP_ENV=dev`). Its cache, log and database live in `sys_get_temp_dir()/setono-client-bundle-/`, so nothing is generated in the repo, and neither two checkouts nor parallel Infection processes share a container or a database. The bootstrap also removes the cached container, because Infection swaps in mutated code without changing file times. Set `SETONO_CLIENT_BUNDLE_DATABASE_URL` (not `DATABASE_URL`, on purpose) to run against another database: the bootstrap drops the bundle's tables in it, and all Infection processes share it, so run Infection with `--threads=1` then. + - `tests/Functional`: the bundle inside a real Symfony app (`tests/Functional/App/Kernel.php`: FrameworkBundle + DoctrineBundle + DAMA DoctrineTestBundle + this bundle), driven over HTTP with `KernelBrowser` against a SQLite database. `tests/bootstrap.php` creates the schema once per PHPUnit process, and DAMA's PHPUnit extension (registered in `phpunit.xml.dist`) runs every test in a transaction that it rolls back. So tests must not run DDL: MySQL commits a transaction on DDL. To check which queries a request ran, read DoctrineBundle's debug data holder (see `MetadataTest::getExecutedQueries()`). Extend `FunctionalTestCase`, which rethrows exceptions instead of rendering error pages and has helpers to read the stored metadata. Every request gets a new container, like PHP-FPM. The kernel always runs in the `test` environment with debug on, whatever `APP_ENV`/`APP_DEBUG` say (the Symfony CLI's `php` sets `APP_ENV=dev`). A test class can pick another environment by overriding `getEnvironment()`, like `CustomMetadataClassTest`. Every environment needs its schema created in `tests/bootstrap.php`, and one that maps the entities differently also needs its own DAMA connection key and no static metadata cache (see the `custom_metadata` block in the kernel). Its cache, log and database live in `sys_get_temp_dir()/setono-client-bundle-/`, so nothing is generated in the repo, and neither two checkouts nor parallel Infection processes share a container or a database. The bootstrap also removes the cached container, because Infection swaps in mutated code without changing file times. Set `SETONO_CLIENT_BUNDLE_DATABASE_URL` (not `DATABASE_URL`, on purpose) to run against another database: the bootstrap drops the bundle's tables in it, and all Infection processes share it, so run Infection with `--threads=1` then. - Single test: `vendor/bin/phpunit --filter after_loading_the_correct_parameter_has_been_set` or by path `vendor/bin/phpunit tests/Unit/DependencyInjection/SetonoClientExtensionTest.php` - Static analysis: `composer analyse` (PHPStan at `level: max`, config in `phpstan.neon.dist`; auto-loads the doctrine/symfony/phpunit/strict-rules extensions via `phpstan/extension-installer`) - Check style: `composer check-style` (ECS, Sylius Labs ruleset) diff --git a/tests/Functional/App/Kernel.php b/tests/Functional/App/Kernel.php index 917db8d..99d39a1 100644 --- a/tests/Functional/App/Kernel.php +++ b/tests/Functional/App/Kernel.php @@ -125,6 +125,14 @@ protected function configureContainer(ContainerConfigurator $container): void ], ], ]); + + // DAMA DoctrineTestBundle shares its static connection and its static metadata cache between kernels by the + // names of the connection and the cache, which are the same in both environments. But this environment has + // its own database and maps the bundle's Metadata as a mapped superclass + $container->extension('dama_doctrine_test', [ + 'connection_keys' => ['default' => self::ENVIRONMENT_CUSTOM_METADATA], + 'enable_static_meta_data_cache' => false, + ]); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 343a11f..1568b00 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -17,7 +17,7 @@ (new Filesystem())->remove(Kernel::getVarDir() . '/cache'); // DAMA DoctrineTestBundle rolls back what each test writes, so the schema is created once, before it starts its transactions -foreach (['test'] as $environment) { +foreach (['test', Kernel::ENVIRONMENT_CUSTOM_METADATA] as $environment) { $kernel = new Kernel($environment, true); $kernel->boot();