From 9d089bd92ffd87d967b8935b4e07062977d69618 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Fri, 21 Feb 2025 12:25:01 +0100 Subject: [PATCH] [Serializer] Add defaultType to DiscriminatorMap --- .../Serializer/Attribute/DiscriminatorMap.php | 11 ++++++ src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../Mapping/ClassDiscriminatorMapping.php | 6 ++++ .../Factory/ClassMetadataFactoryCompiler.php | 1 + .../Mapping/Loader/AttributeLoader.php | 2 +- .../Mapping/Loader/XmlFileLoader.php | 3 +- .../Mapping/Loader/YamlFileLoader.php | 3 +- .../serializer-mapping-1.0.xsd | 1 + .../Normalizer/AbstractObjectNormalizer.php | 2 +- .../Tests/Attribute/DiscriminatorMapTest.php | 9 ++++- .../Fixtures/Attributes/AbstractDummy.php | 2 +- .../Tests/Fixtures/serialization.xml | 2 +- .../Tests/Fixtures/serialization.yml | 1 + .../ClassMetadataFactoryCompilerTest.php | 24 ++++++++++++- .../Mapping/Loader/AttributeLoaderTest.php | 2 +- .../Mapping/Loader/XmlFileLoaderTest.php | 2 +- .../Mapping/Loader/YamlFileLoaderTest.php | 2 +- .../AbstractObjectNormalizerTest.php | 35 +++++++++++++++++++ 18 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/Symfony/Component/Serializer/Attribute/DiscriminatorMap.php b/src/Symfony/Component/Serializer/Attribute/DiscriminatorMap.php index 48d0842aac681..a61f32675a3f4 100644 --- a/src/Symfony/Component/Serializer/Attribute/DiscriminatorMap.php +++ b/src/Symfony/Component/Serializer/Attribute/DiscriminatorMap.php @@ -22,12 +22,14 @@ class DiscriminatorMap /** * @param string $typeProperty The property holding the type discriminator * @param array $mapping The mapping between types and classes (i.e. ['admin_user' => AdminUser::class]) + * @param ?string $defaultType The fallback value if nothing specified by $typeProperty * * @throws InvalidArgumentException */ public function __construct( private readonly string $typeProperty, private readonly array $mapping, + private readonly ?string $defaultType = null, ) { if (!$typeProperty) { throw new InvalidArgumentException(\sprintf('Parameter "typeProperty" given to "%s" cannot be empty.', static::class)); @@ -36,6 +38,10 @@ public function __construct( if (!$mapping) { throw new InvalidArgumentException(\sprintf('Parameter "mapping" given to "%s" cannot be empty.', static::class)); } + + if (null !== $this->defaultType && !\array_key_exists($this->defaultType, $this->mapping)) { + throw new InvalidArgumentException(\sprintf('Default type "%s" given to "%s" must be present in "mapping" types.', $this->defaultType, static::class)); + } } public function getTypeProperty(): string @@ -47,6 +53,11 @@ public function getMapping(): array { return $this->mapping; } + + public function getDefaultType(): ?string + { + return $this->defaultType; + } } if (!class_exists(\Symfony\Component\Serializer\Annotation\DiscriminatorMap::class, false)) { diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 2286d5848a3a7..1b5c95cd39443 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Deprecate the `CompiledClassMetadataFactory` and `CompiledClassMetadataCacheWarmer` classes * Register `NormalizerInterface` and `DenormalizerInterface` aliases for named serializers * Add `NumberNormalizer` to normalize `BcMath\Number` and `GMP` as `string` + * Add `defaultType` to `DiscriminatorMap` 7.2 --- diff --git a/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorMapping.php b/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorMapping.php index 260575a411599..985ea1ce55029 100644 --- a/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorMapping.php +++ b/src/Symfony/Component/Serializer/Mapping/ClassDiscriminatorMapping.php @@ -22,6 +22,7 @@ class ClassDiscriminatorMapping public function __construct( private readonly string $typeProperty, private array $typesMapping = [], + private readonly ?string $defaultType = null, ) { uasort($this->typesMapping, static function (string $a, string $b): int { if (is_a($a, $b, true)) { @@ -61,4 +62,9 @@ public function getTypesMapping(): array { return $this->typesMapping; } + + public function getDefaultType(): ?string + { + return $this->defaultType; + } } diff --git a/src/Symfony/Component/Serializer/Mapping/Factory/ClassMetadataFactoryCompiler.php b/src/Symfony/Component/Serializer/Mapping/Factory/ClassMetadataFactoryCompiler.php index 1e9202b7d81fb..7ec3e0acec54f 100644 --- a/src/Symfony/Component/Serializer/Mapping/Factory/ClassMetadataFactoryCompiler.php +++ b/src/Symfony/Component/Serializer/Mapping/Factory/ClassMetadataFactoryCompiler.php @@ -55,6 +55,7 @@ private function generateDeclaredClassMetadata(array $classMetadatas): string $classDiscriminatorMapping = $classMetadata->getClassDiscriminatorMapping() ? [ $classMetadata->getClassDiscriminatorMapping()->getTypeProperty(), $classMetadata->getClassDiscriminatorMapping()->getTypesMapping(), + $classMetadata->getClassDiscriminatorMapping()->getDefaultType(), ] : null; $compiled .= \sprintf("\n'%s' => %s,", $classMetadata->getName(), VarExporter::export([ diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php index 13c59d1c35ab6..bf8ab356e8f17 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AttributeLoader.php @@ -59,7 +59,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool foreach ($this->loadAttributes($reflectionClass) as $attribute) { match (true) { - $attribute instanceof DiscriminatorMap => $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping($attribute->getTypeProperty(), $attribute->getMapping())), + $attribute instanceof DiscriminatorMap => $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping($attribute->getTypeProperty(), $attribute->getMapping(), $attribute->getDefaultType())), $attribute instanceof Groups => $classGroups = $attribute->getGroups(), $attribute instanceof Context => $classContextAttribute = $attribute, default => null, diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php index 44ba89df18072..ac6fee2db4177 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php @@ -107,7 +107,8 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( (string) $xml->{'discriminator-map'}->attributes()->{'type-property'}, - $mapping + $mapping, + $xml->{'discriminator-map'}->attributes()->{'default-type'} ?? null )); } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php index ca71cbcbae0b4..898ae9f1921b0 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php @@ -133,7 +133,8 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( $yaml['discriminator_map']['type_property'], - $yaml['discriminator_map']['mapping'] + $yaml['discriminator_map']['mapping'], + $yaml['discriminator_map']['default_type'] ?? null )); } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd b/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd index f5f6cca9f0f54..06c6ddfb16f77 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd +++ b/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd @@ -47,6 +47,7 @@ + diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 3570493e712c6..c346aafa8f450 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -1179,7 +1179,7 @@ private function getMappedClass(array $data, string $class, array $context): str return $class; } - if (null === $type = $data[$mapping->getTypeProperty()] ?? null) { + if (null === $type = $data[$mapping->getTypeProperty()] ?? $mapping->getDefaultType()) { throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class), null, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), false); } diff --git a/src/Symfony/Component/Serializer/Tests/Attribute/DiscriminatorMapTest.php b/src/Symfony/Component/Serializer/Tests/Attribute/DiscriminatorMapTest.php index 497bc62016315..39da4019b6a1b 100644 --- a/src/Symfony/Component/Serializer/Tests/Attribute/DiscriminatorMapTest.php +++ b/src/Symfony/Component/Serializer/Tests/Attribute/DiscriminatorMapTest.php @@ -40,9 +40,16 @@ public function testExceptionWithEmptyTypeProperty() new DiscriminatorMap(typeProperty: '', mapping: ['foo' => 'FooClass']); } - public function testExceptionWitEmptyMappingProperty() + public function testExceptionWithEmptyMappingProperty() { $this->expectException(InvalidArgumentException::class); new DiscriminatorMap(typeProperty: 'type', mapping: []); } + + public function testExceptionWithMissingDefaultTypeInMapping() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Default type "bar" given to "%s" must be present in "mapping" types.', DiscriminatorMap::class)); + new DiscriminatorMap(typeProperty: 'type', mapping: ['foo' => 'FooClass'], defaultType: 'bar'); + } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/AbstractDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/AbstractDummy.php index a8c15fccb74a6..f85874c1be9f2 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/AbstractDummy.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/AbstractDummy.php @@ -17,7 +17,7 @@ 'first' => AbstractDummyFirstChild::class, 'second' => AbstractDummySecondChild::class, 'third' => AbstractDummyThirdChild::class, -])] +], defaultType: 'third')] abstract class AbstractDummy { public $foo; diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml index 512736db4bbe4..a03c546ed1d55 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml @@ -35,7 +35,7 @@ - + diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml index 4371016e34be3..ef612924372e3 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml @@ -32,6 +32,7 @@ mapping: first: 'Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild' second: 'Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild' + default_type: first attributes: foo: ~ 'Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummy': diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php index 40dcb50152c66..aec9bcc916fba 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php @@ -15,6 +15,10 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryCompiler; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyThirdChild; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathDummy; @@ -40,6 +44,7 @@ public function testItDumpMetadata() $classMetatadataFactory = new ClassMetadataFactory(new AttributeLoader()); $dummyMetadata = $classMetatadataFactory->getMetadataFor(Dummy::class); + $abstractDummyMetadata = $classMetatadataFactory->getMetadataFor(AbstractDummy::class); $maxDepthDummyMetadata = $classMetatadataFactory->getMetadataFor(MaxDepthDummy::class); $serializedNameDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedNameDummy::class); $serializedPathDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedPathDummy::class); @@ -47,6 +52,7 @@ public function testItDumpMetadata() $code = (new ClassMetadataFactoryCompiler())->compile([ $dummyMetadata, + $abstractDummyMetadata, $maxDepthDummyMetadata, $serializedNameDummyMetadata, $serializedPathDummyMetadata, @@ -56,7 +62,7 @@ public function testItDumpMetadata() file_put_contents($this->dumpPath, $code); $compiledMetadata = require $this->dumpPath; - $this->assertCount(5, $compiledMetadata); + $this->assertCount(6, $compiledMetadata); $this->assertArrayHasKey(Dummy::class, $compiledMetadata); $this->assertEquals([ @@ -69,6 +75,22 @@ public function testItDumpMetadata() null, ], $compiledMetadata[Dummy::class]); + $this->assertArrayHasKey(AbstractDummy::class, $compiledMetadata); + $this->assertEquals([ + [ + 'foo' => [[], null, null, null], + ], + [ + 'type', + [ + 'first' => AbstractDummyFirstChild::class, + 'second' => AbstractDummySecondChild::class, + 'third' => AbstractDummyThirdChild::class, + ], + 'third', + ], + ], $compiledMetadata[AbstractDummy::class]); + $this->assertArrayHasKey(MaxDepthDummy::class, $compiledMetadata); $this->assertEquals([ [ diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php index 2af244a6f704c..16d64f25d5b52 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -85,7 +85,7 @@ public function testLoadDiscriminatorMap() 'first' => AbstractDummyFirstChild::class, 'second' => AbstractDummySecondChild::class, 'third' => AbstractDummyThirdChild::class, - ])); + ], 'third')); $expected->addAttributeMetadata(new AttributeMetadata('foo')); $expected->getReflectionClass(); diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php index c0298129efa59..45b5aeb10571c 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php @@ -109,7 +109,7 @@ public function testLoadDiscriminatorMap() $expected = new ClassMetadata(AbstractDummy::class, new ClassDiscriminatorMapping('type', [ 'first' => AbstractDummyFirstChild::class, 'second' => AbstractDummySecondChild::class, - ])); + ], 'second')); $expected->addAttributeMetadata(new AttributeMetadata('foo')); diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php index 48e95aecd9245..4997f16488cd5 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php @@ -126,7 +126,7 @@ public function testLoadDiscriminatorMap() $expected = new ClassMetadata(AbstractDummy::class, new ClassDiscriminatorMapping('type', [ 'first' => AbstractDummyFirstChild::class, 'second' => AbstractDummySecondChild::class, - ])); + ], 'first')); $expected->addAttributeMetadata(new AttributeMetadata('foo')); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php index 270b65f33ef66..8e70bcc31fa42 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -560,6 +560,41 @@ public function hasMetadataFor($value): bool $this->assertInstanceOf(DummySecondChildQuux::class, $normalizedData->quux); } + public function testDenormalizeWithDiscriminatorMapUsesCorrectClassnameWithDefaultType() + { + $factory = new ClassMetadataFactory(new AttributeLoader()); + + $loaderMock = new class implements ClassMetadataFactoryInterface { + public function getMetadataFor($value): ClassMetadataInterface + { + if (AbstractDummy::class === $value) { + return new ClassMetadata( + AbstractDummy::class, + new ClassDiscriminatorMapping('type', [ + 'first' => AbstractDummyFirstChild::class, + 'second' => AbstractDummySecondChild::class, + ], 'second') + ); + } + + throw new InvalidArgumentException(sprintf('"%s" is not handled.', $value)); + } + + public function hasMetadataFor($value): bool + { + return AbstractDummy::class === $value; + } + }; + + $discriminatorResolver = new ClassDiscriminatorFromClassMetadata($loaderMock); + $normalizer = new AbstractObjectNormalizerDummy($factory, null, new PhpDocExtractor(), $discriminatorResolver); + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + $normalizedData = $normalizer->denormalize(['foo' => 'foo', 'baz' => 'baz', 'quux' => ['value' => 'quux']], AbstractDummy::class); + + $this->assertInstanceOf(DummySecondChildQuux::class, $normalizedData->quux); + } + public function testDenormalizeWithDiscriminatorMapAndObjectToPopulateUsesCorrectClassname() { $factory = new ClassMetadataFactory(new AttributeLoader());