From 240c03130bafdde5fd2dd085843779f17f5e687b Mon Sep 17 00:00:00 2001 From: HypeMC Date: Sat, 6 May 2023 19:22:54 +0200 Subject: [PATCH] [Serializer] Fix SerializedPath not working with constructor arguments --- .../Normalizer/AbstractObjectNormalizer.php | 68 +++---- .../SerializedPathInConstructorDummy.php | 25 +++ .../SerializedPathInConstructorDummy.php | 22 +++ .../Tests/Fixtures/serialization.xml | 4 + .../Tests/Fixtures/serialization.yml | 4 + .../ClassMetadataFactoryCompilerTest.php | 13 +- .../Loader/AnnotationLoaderTestCase.php | 9 + .../Mapping/Loader/XmlFileLoaderTest.php | 9 + .../Mapping/Loader/YamlFileLoaderTest.php | 9 + .../AbstractObjectNormalizerTest.php | 170 +++++++++++++++++- 10 files changed, 298 insertions(+), 35 deletions(-) create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/SerializedPathInConstructorDummy.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index afd3acabc46a2..69dc534e417fb 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -128,8 +128,8 @@ public function __construct(ClassMetadataFactoryInterface $classMetadataFactory $this->propertyTypeExtractor = $propertyTypeExtractor; - if (null === $classDiscriminatorResolver && null !== $classMetadataFactory) { - $classDiscriminatorResolver = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); + if ($classMetadataFactory) { + $classDiscriminatorResolver ??= new ClassDiscriminatorFromClassMetadata($classMetadataFactory); } $this->classDiscriminatorResolver = $classDiscriminatorResolver; $this->objectClassResolver = $objectClassResolver; @@ -217,7 +217,7 @@ public function normalize(mixed $object, string $format = null, array $context = } $preserveEmptyObjects = $context[self::PRESERVE_EMPTY_OBJECTS] ?? $this->defaultContext[self::PRESERVE_EMPTY_OBJECTS] ?? false; - if ($preserveEmptyObjects && !\count($data)) { + if ($preserveEmptyObjects && !$data) { return new \ArrayObject(); } @@ -226,19 +226,8 @@ public function normalize(mixed $object, string $format = null, array $context = protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, string $format = null) { - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) { - if (!isset($data[$mapping->getTypeProperty()])) { - 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); - } - - $type = $data[$mapping->getTypeProperty()]; - if (null === ($mappedClass = $mapping->getClassForType($type))) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), true); - } - - if ($mappedClass !== $class) { - return $this->instantiateObject($data, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); - } + if ($class !== $mappedClass = $this->getMappedClass($data, $class, $context)) { + return $this->instantiateObject($data, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); } return parent::instantiateObject($data, $class, $context, $reflectionClass, $allowedAttributes, $format); @@ -270,7 +259,7 @@ protected function getAttributes(object $object, ?string $format, array $context $attributes = $this->extractAttributes($object, $format, $context); - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForMappedObject($object)) { + if ($mapping = $this->classDiscriminatorResolver?->getMappingForMappedObject($object)) { array_unshift($attributes, $mapping->getTypeProperty()); } @@ -319,11 +308,9 @@ public function denormalize(mixed $data, string $type, string $format = null, ar $normalizedData = $this->prepareForDenormalization($data); $extraAttributes = []; - $reflectionClass = new \ReflectionClass($type); - $object = $this->instantiateObject($normalizedData, $type, $context, $reflectionClass, $allowedAttributes, $format); - $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : $object::class; + $mappedClass = $this->getMappedClass($normalizedData, $type, $context); - $nestedAttributes = $this->getNestedAttributes($resolvedClass); + $nestedAttributes = $this->getNestedAttributes($mappedClass); $nestedData = []; $propertyAccessor = PropertyAccess::createPropertyAccessor(); foreach ($nestedAttributes as $property => $serializedPath) { @@ -336,6 +323,9 @@ public function denormalize(mixed $data, string $type, string $format = null, ar $normalizedData = array_merge($normalizedData, $nestedData); + $object = $this->instantiateObject($normalizedData, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); + $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : $object::class; + foreach ($normalizedData as $attribute => $value) { if ($this->nameConverter) { $notConverted = $attribute; @@ -675,11 +665,8 @@ private function updateData(array $data, string $attribute, mixed $attributeValu */ private function isMaxDepthReached(array $attributesMetadata, string $class, string $attribute, array &$context): bool { - $enableMaxDepth = $context[self::ENABLE_MAX_DEPTH] ?? $this->defaultContext[self::ENABLE_MAX_DEPTH] ?? false; - if ( - !$enableMaxDepth || - !isset($attributesMetadata[$attribute]) || - null === $maxDepth = $attributesMetadata[$attribute]->getMaxDepth() + if (!($enableMaxDepth = $context[self::ENABLE_MAX_DEPTH] ?? $this->defaultContext[self::ENABLE_MAX_DEPTH] ?? false) + || null === $maxDepth = $attributesMetadata[$attribute]?->getMaxDepth() ) { return false; } @@ -755,7 +742,7 @@ private function isUninitializedValueError(\Error $e): bool */ private function getNestedAttributes(string $class): array { - if (!$this->classMetadataFactory || !$this->classMetadataFactory->hasMetadataFor($class)) { + if (!$this->classMetadataFactory?->hasMetadataFor($class)) { return []; } @@ -781,15 +768,30 @@ private function getNestedAttributes(string $class): array private function removeNestedValue(array $path, array $data): array { $element = array_shift($path); - if ([] === $path) { + if (!$path || !$data[$element] = $this->removeNestedValue($path, $data[$element])) { unset($data[$element]); - } else { - $data[$element] = $this->removeNestedValue($path, $data[$element]); - if ([] === $data[$element]) { - unset($data[$element]); - } } return $data; } + + /** + * @return class-string + */ + private function getMappedClass(array $data, string $class, array $context): string + { + if (!$mapping = $this->classDiscriminatorResolver?->getMappingForClass($class)) { + return $class; + } + + if (null === $type = $data[$mapping->getTypeProperty()] ?? null) { + 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); + } + + if (null === $mappedClass = $mapping->getClassForType($type)) { + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), true); + } + + return $mappedClass; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/SerializedPathInConstructorDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/SerializedPathInConstructorDummy.php new file mode 100644 index 0000000000000..a6d5109086899 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/SerializedPathInConstructorDummy.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\SerializedPath; + +class SerializedPathInConstructorDummy +{ + public function __construct( + /** + * @SerializedPath("[one][two]") + */ + public $three, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php new file mode 100644 index 0000000000000..90aee115417d4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\SerializedPath; + +class SerializedPathInConstructorDummy +{ + public function __construct( + #[SerializedPath('[one][two]')] public $three, + ) { + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml index 4890f56bfd0f9..5ca6ac412fc07 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml @@ -30,6 +30,10 @@ + + + + diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml index 7519b979efa96..e052d65a88779 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml @@ -22,6 +22,10 @@ serialized_path: '[one][two]' seven: serialized_path: '[three][four]' +'Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedPathInConstructorDummy': + attributes: + three: + serialized_path: '[one][two]' 'Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummy': discriminator_map: type_property: type diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php index 5ce1931ba0cab..903612c684c0d 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php @@ -19,6 +19,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\MaxDepthDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedPathDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedPathInConstructorDummy; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; final class ClassMetadataFactoryCompilerTest extends TestCase @@ -46,18 +47,20 @@ public function testItDumpMetadata() $maxDepthDummyMetadata = $classMetatadataFactory->getMetadataFor(MaxDepthDummy::class); $serializedNameDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedNameDummy::class); $serializedPathDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedPathDummy::class); + $serializedPathInConstructorDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedPathInConstructorDummy::class); $code = (new ClassMetadataFactoryCompiler())->compile([ $dummyMetadata, $maxDepthDummyMetadata, $serializedNameDummyMetadata, $serializedPathDummyMetadata, + $serializedPathInConstructorDummyMetadata, ]); file_put_contents($this->dumpPath, $code); $compiledMetadata = require $this->dumpPath; - $this->assertCount(4, $compiledMetadata); + $this->assertCount(5, $compiledMetadata); $this->assertArrayHasKey(Dummy::class, $compiledMetadata); $this->assertEquals([ @@ -99,5 +102,13 @@ public function testItDumpMetadata() ], null, ], $compiledMetadata[SerializedPathDummy::class]); + + $this->assertArrayHasKey(SerializedPathInConstructorDummy::class, $compiledMetadata); + $this->assertEquals([ + [ + 'three' => [[], null, null, '[one][two]'], + ], + null, + ], $compiledMetadata[SerializedPathInConstructorDummy::class]); } } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php index de7accd844b32..2dbd03703a2ce 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php @@ -103,6 +103,15 @@ public function testLoadSerializedPath() $this->assertEquals(new PropertyPath('[three][four]'), $attributesMetadata['seven']->getSerializedPath()); } + public function testLoadSerializedPathInConstructor() + { + $classMetadata = new ClassMetadata($this->getNamespace().'\SerializedPathInConstructorDummy'); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(new PropertyPath('[one][two]'), $attributesMetadata['three']->getSerializedPath()); + } + public function testLoadClassMetadataAndMerge() { $classMetadata = new ClassMetadata($this->getNamespace().'\GroupDummy'); diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php index b1e9ed7222636..202534f56fca5 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php @@ -94,6 +94,15 @@ public function testSerializedPath() $this->assertEquals('[three][four]', $attributesMetadata['seven']->getSerializedPath()); } + public function testSerializedPathInConstructor() + { + $classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedPathInConstructorDummy'); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals('[one][two]', $attributesMetadata['three']->getSerializedPath()); + } + public function testLoadDiscriminatorMap() { $classMetadata = new ClassMetadata(AbstractDummy::class); diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php index bbe0a99aeab89..dcfd2b4afac51 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php @@ -108,6 +108,15 @@ public function testSerializedPath() $this->assertEquals(new PropertyPath('[three][four]'), $attributesMetadata['seven']->getSerializedPath()); } + public function testSerializedPathInConstructor() + { + $classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedPathInConstructorDummy'); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(new PropertyPath('[one][two]'), $attributesMetadata['three']->getSerializedPath()); + } + public function testLoadDiscriminatorMap() { $classMetadata = new ClassMetadata(AbstractDummy::class); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php index 0c342b64d8000..d876193c8a741 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -18,6 +18,7 @@ use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Annotation\SerializedPath; use Symfony\Component\Serializer\Exception\ExtraAttributesException; @@ -171,6 +172,53 @@ public function testDenormalizeWithNestedAttributesDuplicateKeys() $normalizer->denormalize($data, DuplicateKeyNestedDummy::class, 'any'); } + public function testDenormalizeWithNestedAttributesInConstructor() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + $test = $normalizer->denormalize($data, NestedDummyWithConstructor::class, 'any'); + $this->assertSame('foo', $test->foo); + $this->assertSame('quux', $test->quux); + $this->assertSame('notfoo', $test->notfoo); + $this->assertSame('baz', $test->baz); + } + + public function testDenormalizeWithNestedAttributesInConstructorAndDiscriminatorMap() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + + $test1 = $normalizer->denormalize($data + ['type' => 'first'], AbstractNestedDummyWithConstructorAndDiscriminator::class, 'any'); + $this->assertInstanceOf(FirstNestedDummyWithConstructorAndDiscriminator::class, $test1); + $this->assertSame('foo', $test1->foo); + $this->assertSame('notfoo', $test1->notfoo); + $this->assertSame('baz', $test1->baz); + + $test2 = $normalizer->denormalize($data + ['type' => 'second'], AbstractNestedDummyWithConstructorAndDiscriminator::class, 'any'); + $this->assertInstanceOf(SecondNestedDummyWithConstructorAndDiscriminator::class, $test2); + $this->assertSame('quux', $test2->quux); + $this->assertSame('notfoo', $test2->notfoo); + $this->assertSame('baz', $test2->baz); + } + public function testNormalizeWithNestedAttributesMixingArrayTypes() { $this->expectException(LogicException::class); @@ -236,6 +284,52 @@ public function testNormalizeWithNestedAttributesWithoutMetadata() $this->assertSame($data, $test); } + public function testNormalizeWithNestedAttributesInConstructor() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $test = $normalizer->normalize(new NestedDummyWithConstructor('foo', 'quux', 'notfoo', 'baz'), 'any'); + $this->assertSame([ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ], $test); + } + + public function testNormalizeWithNestedAttributesInConstructorAndDiscriminatorMap() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $test1 = $normalizer->normalize(new FirstNestedDummyWithConstructorAndDiscriminator('foo', 'notfoo', 'baz'), 'any'); + $this->assertSame([ + 'type' => 'first', + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ], $test1); + + $test2 = $normalizer->normalize(new SecondNestedDummyWithConstructorAndDiscriminator('quux', 'notfoo', 'baz'), 'any'); + $this->assertSame([ + 'type' => 'second', + 'one' => [ + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ], $test2); + } + public function testDenormalizeCollectionDecodedFromXmlWithOneChild() { $denormalizer = $this->getDenormalizerForDummyCollection(); @@ -661,6 +755,78 @@ class NestedDummy public $baz; } +class NestedDummyWithConstructor +{ + public function __construct( + /** + * @SerializedPath("[one][two][three]") + */ + public $foo, + + /** + * @SerializedPath("[one][four]") + */ + public $quux, + + /** + * @SerializedPath("[foo]") + */ + public $notfoo, + + public $baz, + ) { + } +} + +/** + * @DiscriminatorMap(typeProperty="type", mapping={ + * "first" = FirstNestedDummyWithConstructorAndDiscriminator::class, + * "second" = SecondNestedDummyWithConstructorAndDiscriminator::class, + * }) + */ +abstract class AbstractNestedDummyWithConstructorAndDiscriminator +{ + public function __construct( + /** + * @SerializedPath("[foo]") + */ + public $notfoo, + + public $baz, + ) { + } +} + +class FirstNestedDummyWithConstructorAndDiscriminator extends AbstractNestedDummyWithConstructorAndDiscriminator +{ + public function __construct( + /** + * @SerializedPath("[one][two][three]") + */ + public $foo, + + $notfoo, + $baz, + ) { + parent::__construct($notfoo, $baz); + } +} + +class SecondNestedDummyWithConstructorAndDiscriminator extends AbstractNestedDummyWithConstructorAndDiscriminator +{ + public function __construct( + /** + * @SerializedPath("[one][four]") + */ + public $quux, + + $notfoo, + $baz, + ) { + parent::__construct($notfoo, $baz); + } +} + class DuplicateKeyNestedDummy { /** @@ -711,7 +877,9 @@ protected function getAttributeValue(object $object, string $attribute, string $ protected function setAttributeValue(object $object, string $attribute, $value, string $format = null, array $context = []) { - $object->$attribute = $value; + if (property_exists($object, $attribute)) { + $object->$attribute = $value; + } } }