From 2fcd6cb818a968b0cbaf9a83f92524b40d6a041f Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Mon, 1 Sep 2025 10:43:05 +0200 Subject: [PATCH 1/7] fix tests --- Tests/Normalizer/DateTimeNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Normalizer/DateTimeNormalizerTest.php b/Tests/Normalizer/DateTimeNormalizerTest.php index 6a5ce436398..d8834e352a6 100644 --- a/Tests/Normalizer/DateTimeNormalizerTest.php +++ b/Tests/Normalizer/DateTimeNormalizerTest.php @@ -139,7 +139,7 @@ public static function normalizeUsingTimeZonePassedInContextAndExpectedFormatWit '2018-12-01T18:03:06.067634', new \DateTimeZone('UTC') ), - new \DateTimeZone('Europe/Kyiv'), + new \DateTimeZone(\in_array('Europe/Kyiv', \DateTimeZone::listIdentifiers(), true) ? 'Europe/Kyiv' : 'Europe/Kiev'), ]; yield [ From af46a27774759cc863ed653c6e6aa9ed78ff06bd Mon Sep 17 00:00:00 2001 From: Santiago San Martin Date: Sun, 31 Aug 2025 22:56:21 -0300 Subject: [PATCH 2/7] [Serializer] Fix serializer crash due to asymmetric visibility on `protected(set)` properties --- Normalizer/ObjectNormalizer.php | 13 +++-------- Tests/Fixtures/AsymmetricVisibilityDummy.php | 23 ++++++++++++++++++++ Tests/SerializerTest.php | 14 ++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 Tests/Fixtures/AsymmetricVisibilityDummy.php diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 64a51cfc0d9..0b1fbe2e960 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -179,16 +179,9 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string return self::$isReadableCache[$class.$attribute]; } - if (!isset(self::$isWritableCache[$class.$attribute])) { - if (str_contains($attribute, '.')) { - self::$isWritableCache[$class.$attribute] = true; - } else { - self::$isWritableCache[$class.$attribute] = $this->propertyInfoExtractor->isWritable($class, $attribute) - || (($writeInfo = $this->writeInfoExtractor->getWriteInfo($class, $attribute)) && PropertyWriteInfo::TYPE_NONE !== $writeInfo->getType()); - } - } - - return self::$isWritableCache[$class.$attribute]; + return self::$isWritableCache[$class.$attribute] ??= str_contains($attribute, '.') + || $this->propertyInfoExtractor->isWritable($class, $attribute) + || !\in_array($this->writeInfoExtractor->getWriteInfo($class, $attribute)?->getType(), [null, PropertyWriteInfo::TYPE_NONE, PropertyWriteInfo::TYPE_PROPERTY], true); } private function hasAttributeAccessorMethod(string $class, string $attribute): bool diff --git a/Tests/Fixtures/AsymmetricVisibilityDummy.php b/Tests/Fixtures/AsymmetricVisibilityDummy.php new file mode 100644 index 00000000000..15f4c0cb779 --- /dev/null +++ b/Tests/Fixtures/AsymmetricVisibilityDummy.php @@ -0,0 +1,23 @@ + + * + * 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; + +final class AsymmetricVisibilityDummy +{ + private(set) string $type; + + public function __construct( + public readonly string $item, + ) { + $this->type = 'final'; + } +} diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index d131e519d46..db16e81db0f 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -48,6 +48,7 @@ use Symfony\Component\Serializer\Normalizer\UidNormalizer; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\Tests\Fixtures\AsymmetricVisibilityDummy; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummy; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; @@ -1725,6 +1726,19 @@ public function testDenormalizationFailsWithMultipleErrorsInDefaultContext() $this->assertSame($expected, $exceptionsAsArray); } } + + /** + * @requires PHP 8.4 + */ + public function testDeserializeObjectWithAsymmetricPropertyVisibility() + { + $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); + /** @var AsymmetricVisibilityDummy $object */ + $object = $serializer->deserialize(json_encode(['type' => 'This value must not be changed because the property has a private setter', 'item' => 'one']), AsymmetricVisibilityDummy::class, 'json'); + + $this->assertSame('one', $object->item); + $this->assertSame('final', $object->type); // Value set in the constructor; must not be changed during deserialization + } } class Model From 1479f0d20facf8f6f79d2a2e7e9f93fedb66b67f Mon Sep 17 00:00:00 2001 From: Rafael Kraut <14234815+RafaelKr@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:48:06 +0200 Subject: [PATCH 3/7] [Serializer] Fix normalizing objects with accessors having the same name as a property --- Normalizer/ObjectNormalizer.php | 31 ++++++++++------------- Tests/Normalizer/ObjectNormalizerTest.php | 30 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 0b1fbe2e960..2f79cbe1c37 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -100,24 +100,21 @@ protected function extractAttributes(object $object, ?string $format = null, arr $attributeName = null; // ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel - if (3 < \strlen($name) && !ctype_lower($name[3]) && match ($name[0]) { - 'g' => str_starts_with($name, 'get'), - 'h' => str_starts_with($name, 'has'), - 'c' => str_starts_with($name, 'can'), + if (match ($name[0]) { + 'g' => str_starts_with($name, 'get') && isset($name[$i = 3]), + 'h' => str_starts_with($name, 'has') && isset($name[$i = 3]), + 'c' => str_starts_with($name, 'can') && isset($name[$i = 3]), + 'i' => str_starts_with($name, 'is') && isset($name[$i = 2]), default => false, - }) { - // getters, hassers and canners - $attributeName = substr($name, 3); - - if (!$reflClass->hasProperty($attributeName)) { - $attributeName = lcfirst($attributeName); - } - } elseif ('is' !== $name && str_starts_with($name, 'is') && !ctype_lower($name[2])) { - // issers - $attributeName = substr($name, 2); - - if (!$reflClass->hasProperty($attributeName)) { - $attributeName = lcfirst($attributeName); + } && !ctype_lower($name[$i])) { + if ($reflClass->hasProperty($name)) { + $attributeName = $name; + } else { + $attributeName = substr($name, $i); + + if (!$reflClass->hasProperty($attributeName)) { + $attributeName = lcfirst($attributeName); + } } } diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 6273b50deab..e8db5325c43 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -955,6 +955,20 @@ public function testNormalizeWithMethodNamesSimilarToAccessors() 123 => 321, ], $normalized); } + + public function testNormalizeObjectWithBooleanPropertyAndIsserMethodWithSameName() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory); + + $object = new ObjectWithBooleanPropertyAndIsserWithSameName(); + $normalized = $normalizer->normalize($object); + + $this->assertSame([ + 'foo' => 'foo', + 'isFoo' => true, + ], $normalized); + } } class ProxyObjectDummy extends ObjectDummy @@ -1297,3 +1311,19 @@ public function isolate() $this->accessorishCalled = true; } } + +class ObjectWithBooleanPropertyAndIsserWithSameName +{ + private $foo = 'foo'; + private $isFoo = true; + + public function getFoo() + { + return $this->foo; + } + + public function isFoo() + { + return $this->isFoo; + } +} From ba56f52932310beee562f107fbc2067b49232e05 Mon Sep 17 00:00:00 2001 From: Rafael Kraut <14234815+RafaelKr@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:19:26 +0200 Subject: [PATCH 4/7] [Serializer] Adjust ObjectNormalizerTest for the accessor method changes from #61097 --- Tests/Normalizer/ObjectNormalizerTest.php | 192 ++++++++++++++++++++-- 1 file changed, 182 insertions(+), 10 deletions(-) diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index e8db5325c43..ec26acf7328 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -14,6 +14,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\PropertyAccessorBuilder; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -938,10 +939,26 @@ public function testObjectNormalizerWithAttributeLoaderAndObjectHasStaticPropert $this->assertSame([], $normalizer->normalize($class)); } - public function testNormalizeWithMethodNamesSimilarToAccessors() + // accessors + + protected function getNormalizerForAccessors($accessorPrefixes = null): ObjectNormalizer { + $accessorPrefixes = $accessorPrefixes ?? ReflectionExtractor::$defaultAccessorPrefixes; $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $normalizer = new ObjectNormalizer($classMetadataFactory); + $propertyAccessorBuilder = (new PropertyAccessorBuilder()) + ->setReadInfoExtractor( + new ReflectionExtractor([], $accessorPrefixes, null, false) + ); + + return new ObjectNormalizer( + $classMetadataFactory, + propertyAccessor: $propertyAccessorBuilder->getPropertyAccessor(), + ); + } + + public function testNormalizeWithMethodNamesSimilarToAccessors() + { + $normalizer = $this->getNormalizerForAccessors(); $object = new ObjectWithAccessorishMethods(); $normalized = $normalizer->normalize($object); @@ -956,19 +973,94 @@ public function testNormalizeWithMethodNamesSimilarToAccessors() ], $normalized); } - public function testNormalizeObjectWithBooleanPropertyAndIsserMethodWithSameName() + public function testNormalizeObjectWithPublicPropertyAccessorPrecedence() { - $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); - $normalizer = new ObjectNormalizer($classMetadataFactory); + $normalizer = $this->getNormalizerForAccessors(); - $object = new ObjectWithBooleanPropertyAndIsserWithSameName(); + $object = new ObjectWithPropertyAndAllAccessorMethods( + 'foo', + ); $normalized = $normalizer->normalize($object); + // The getter method should take precedence over all other accessor methods $this->assertSame([ 'foo' => 'foo', - 'isFoo' => true, ], $normalized); } + + public function testNormalizeObjectWithPropertyAndAccessorMethodsWithSameName() + { + $normalizer = $this->getNormalizerForAccessors(); + + $object = new ObjectWithPropertyAndAccessorSameName( + 'foo', + 'getFoo', + 'canFoo', + 'hasFoo', + 'isFoo' + ); + $normalized = $normalizer->normalize($object); + + // Accessor methods with exactly the same name as the property should take precedence + $this->assertSame([ + 'getFoo' => 'getFoo', + 'canFoo' => 'canFoo', + 'hasFoo' => 'hasFoo', + 'isFoo' => 'isFoo', + // The getFoo accessor method is used for foo, thus it's also 'getFoo' instead of 'foo' + 'foo' => 'getFoo', + ], $normalized); + + $denormalized = $this->normalizer->denormalize($normalized, ObjectWithPropertyAndAccessorSameName::class); + + $this->assertSame('getFoo', $denormalized->getFoo()); + + // On the initial object the value was 'foo', but the normalizer prefers the accessor method 'getFoo' + // Thus on the denoramilzed object the value is 'getFoo' + $this->assertSame('foo', $object->foo); + $this->assertSame('getFoo', $denormalized->foo); + + $this->assertSame('hasFoo', $denormalized->hasFoo()); + $this->assertSame('canFoo', $denormalized->canFoo()); + $this->assertSame('isFoo', $denormalized->isFoo()); + } + + /** + * Priority of accessor methods is defined by the PropertyReadInfoExtractorInterface passed to the PropertyAccessor + * component. By default ReflectionExtractor::$defaultAccessorPrefixes are used. + */ + public function testPrecedenceOfAccessorMethods() + { + // by default 'is' comes before 'has' + $defaultAccessorPrefixNormalizer = $this->getNormalizerForAccessors(); + $swappedAccessorPrefixNormalizer = $this->getNormalizerForAccessors(['has', 'is']); + + // Nearly equal class, only accessor order is different + $isserHasserObject = new ObjectWithPropertyIsserAndHasser('foo'); + $hasserIsserObject = new ObjectWithPropertyHasserAndIsser('foo'); + + // default precedence (is, has) + $normalizedDefaultIsserHasser = $defaultAccessorPrefixNormalizer->normalize($isserHasserObject); + $normalizedDefaultHasserIsser = $defaultAccessorPrefixNormalizer->normalize($hasserIsserObject); + + $this->assertSame([ + 'foo' => 'isFoo', + ], $normalizedDefaultIsserHasser); + $this->assertSame([ + 'foo' => 'isFoo', + ], $normalizedDefaultHasserIsser); + + // swapped precedence (has, is) + $normalizedSwappedIsserHasser = $swappedAccessorPrefixNormalizer->normalize($isserHasserObject); + $normalizedSwappedHasserIsser = $swappedAccessorPrefixNormalizer->normalize($hasserIsserObject); + + $this->assertSame([ + 'foo' => 'hasFoo', + ], $normalizedSwappedIsserHasser); + $this->assertSame([ + 'foo' => 'hasFoo', + ], $normalizedSwappedHasserIsser); + } } class ProxyObjectDummy extends ObjectDummy @@ -1312,18 +1404,98 @@ public function isolate() } } -class ObjectWithBooleanPropertyAndIsserWithSameName +class ObjectWithPropertyAndAllAccessorMethods { - private $foo = 'foo'; - private $isFoo = true; + public function __construct( + private $foo, + ) { + } + + public function canFoo() + { + return 'canFoo'; + } public function getFoo() { return $this->foo; } + public function hasFoo() + { + return 'hasFoo'; + } + + public function isFoo() + { + return 'isFoo'; + } +} + +class ObjectWithPropertyAndAccessorSameName +{ + public function __construct( + public $foo, + private $getFoo, + private $canFoo = null, + private $hasFoo = null, + private $isFoo = null, + ) { + } + + public function getFoo() + { + return $this->getFoo; + } + + public function canFoo() + { + return $this->canFoo; + } + + public function hasFoo() + { + return $this->hasFoo; + } + public function isFoo() { return $this->isFoo; } } + +class ObjectWithPropertyHasserAndIsser +{ + public function __construct( + private $foo, + ) { + } + + public function hasFoo() + { + return 'hasFoo'; + } + + public function isFoo() + { + return 'isFoo'; + } +} + +class ObjectWithPropertyIsserAndHasser +{ + public function __construct( + private $foo, + ) { + } + + public function isFoo() + { + return 'isFoo'; + } + + public function hasFoo() + { + return 'hasFoo'; + } +} From 31058f687b33c21e9b25647faa73c5c82f8fe6c2 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 5 Sep 2025 14:17:45 +0200 Subject: [PATCH 5/7] use the empty string instead of null as an array offset --- Serializer.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Serializer.php b/Serializer.php index f1caf7d954e..0aa24cf579a 100644 --- a/Serializer.php +++ b/Serializer.php @@ -281,8 +281,8 @@ private function getNormalizer(mixed $data, ?string $format, array $context): ?N $genericType = '*'; } - if (!isset($this->normalizerCache[$format][$type])) { - $this->normalizerCache[$format][$type] = []; + if (!isset($this->normalizerCache[$format ?? ''][$type])) { + $this->normalizerCache[$format ?? ''][$type] = []; foreach ($this->normalizers as $k => $normalizer) { if (!$normalizer instanceof NormalizerInterface) { @@ -293,9 +293,9 @@ private function getNormalizer(mixed $data, ?string $format, array $context): ?N trigger_deprecation('symfony/serializer', '6.3', '"%s" should implement "NormalizerInterface::getSupportedTypes(?string $format): array".', $normalizer::class); if (!$normalizer instanceof CacheableSupportsMethodInterface || !$normalizer->hasCacheableSupportsMethod()) { - $this->normalizerCache[$format][$type][$k] = false; + $this->normalizerCache[$format ?? ''][$type][$k] = false; } elseif ($normalizer->supportsNormalization($data, $format, $context)) { - $this->normalizerCache[$format][$type][$k] = true; + $this->normalizerCache[$format ?? ''][$type][$k] = true; break; } @@ -313,7 +313,7 @@ private function getNormalizer(mixed $data, ?string $format, array $context): ?N if (null === $isCacheable) { unset($supportedTypes['*'], $supportedTypes['object']); - } elseif ($this->normalizerCache[$format][$type][$k] = $isCacheable && $normalizer->supportsNormalization($data, $format, $context)) { + } elseif ($this->normalizerCache[$format ?? ''][$type][$k] = $isCacheable && $normalizer->supportsNormalization($data, $format, $context)) { break 2; } @@ -324,13 +324,13 @@ private function getNormalizer(mixed $data, ?string $format, array $context): ?N continue; } - if ($this->normalizerCache[$format][$type][$k] ??= $isCacheable && $normalizer->supportsNormalization($data, $format, $context)) { + if ($this->normalizerCache[$format ?? ''][$type][$k] ??= $isCacheable && $normalizer->supportsNormalization($data, $format, $context)) { break; } } } - foreach ($this->normalizerCache[$format][$type] as $k => $cached) { + foreach ($this->normalizerCache[$format ?? ''][$type] as $k => $cached) { $normalizer = $this->normalizers[$k]; if ($cached || $normalizer->supportsNormalization($data, $format, $context)) { return $normalizer; @@ -350,8 +350,8 @@ private function getNormalizer(mixed $data, ?string $format, array $context): ?N */ private function getDenormalizer(mixed $data, string $class, ?string $format, array $context): ?DenormalizerInterface { - if (!isset($this->denormalizerCache[$format][$class])) { - $this->denormalizerCache[$format][$class] = []; + if (!isset($this->denormalizerCache[$format ?? ''][$class])) { + $this->denormalizerCache[$format ?? ''][$class] = []; $genericType = class_exists($class) || interface_exists($class, false) ? 'object' : '*'; foreach ($this->normalizers as $k => $normalizer) { @@ -363,9 +363,9 @@ private function getDenormalizer(mixed $data, string $class, ?string $format, ar trigger_deprecation('symfony/serializer', '6.3', '"%s" should implement "DenormalizerInterface::getSupportedTypes(?string $format): array".', $normalizer::class); if (!$normalizer instanceof CacheableSupportsMethodInterface || !$normalizer->hasCacheableSupportsMethod()) { - $this->denormalizerCache[$format][$class][$k] = false; + $this->denormalizerCache[$format ?? ''][$class][$k] = false; } elseif ($normalizer->supportsDenormalization(null, $class, $format, $context)) { - $this->denormalizerCache[$format][$class][$k] = true; + $this->denormalizerCache[$format ?? ''][$class][$k] = true; break; } @@ -386,7 +386,7 @@ private function getDenormalizer(mixed $data, string $class, ?string $format, ar if (null === $isCacheable) { unset($supportedTypes['*'], $supportedTypes['object']); - } elseif ($this->denormalizerCache[$format][$class][$k] = $isCacheable && $normalizer->supportsDenormalization(null, $class, $format, $context)) { + } elseif ($this->denormalizerCache[$format ?? ''][$class][$k] = $isCacheable && $normalizer->supportsDenormalization(null, $class, $format, $context)) { break 2; } @@ -397,13 +397,13 @@ private function getDenormalizer(mixed $data, string $class, ?string $format, ar continue; } - if ($this->denormalizerCache[$format][$class][$k] ??= $isCacheable && $normalizer->supportsDenormalization(null, $class, $format, $context)) { + if ($this->denormalizerCache[$format ?? ''][$class][$k] ??= $isCacheable && $normalizer->supportsDenormalization(null, $class, $format, $context)) { break; } } } - foreach ($this->denormalizerCache[$format][$class] as $k => $cached) { + foreach ($this->denormalizerCache[$format ?? ''][$class] as $k => $cached) { $normalizer = $this->normalizers[$k]; if ($cached || $normalizer->supportsDenormalization($data, $class, $format, $context)) { return $normalizer; From 27c1f05f9e421b39a29fce72a76a5dd10c20a5db Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 11 Sep 2025 10:16:56 +0200 Subject: [PATCH 6/7] Replace __sleep/wakeup() by __(un)serialize() for throwing and internal usages --- Tests/Normalizer/AbstractObjectNormalizerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index 2a6261eccb5..d86b65c5a40 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -1509,7 +1509,7 @@ public function setSerializer(SerializerInterface $serializer): void class NotSerializable { - public function __sleep(): array + public function __serialize(): array { throw new \Error('not serializable'); } From 48d0477483614d615aa1d5e5d90a45e4c7bfa2c9 Mon Sep 17 00:00:00 2001 From: d-mitrofanov-v Date: Sun, 14 Sep 2025 15:10:50 +0300 Subject: [PATCH 7/7] [Serializer] Fix unknown type in denormalization errors when union type used in constructor --- Normalizer/AbstractNormalizer.php | 14 +++++--- Tests/Fixtures/DummyWithUnion.php | 24 ++++++++++++++ Tests/SerializerTest.php | 55 +++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 Tests/Fixtures/DummyWithUnion.php diff --git a/Normalizer/AbstractNormalizer.php b/Normalizer/AbstractNormalizer.php index f07be74c2e8..b46ea1a2676 100644 --- a/Normalizer/AbstractNormalizer.php +++ b/Normalizer/AbstractNormalizer.php @@ -408,16 +408,22 @@ protected function instantiateObject(array &$data, string $class, array &$contex continue; } - $constructorParameterType = 'unknown'; + $constructorParameterTypes = []; $reflectionType = $constructorParameter->getType(); - if ($reflectionType instanceof \ReflectionNamedType) { - $constructorParameterType = $reflectionType->getName(); + if ($reflectionType instanceof \ReflectionUnionType) { + foreach ($reflectionType->getTypes() as $reflectionType) { + $constructorParameterTypes[] = (string) $reflectionType; + } + } elseif ($reflectionType instanceof \ReflectionType) { + $constructorParameterTypes[] = (string) $reflectionType; + } else { + $constructorParameterTypes[] = 'unknown'; } $exception = NotNormalizableValueException::createForUnexpectedDataType( \sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name), null, - [$constructorParameterType], + $constructorParameterTypes, $attributeContext['deserialization_path'] ?? null, true ); diff --git a/Tests/Fixtures/DummyWithUnion.php b/Tests/Fixtures/DummyWithUnion.php new file mode 100644 index 00000000000..c40bc186724 --- /dev/null +++ b/Tests/Fixtures/DummyWithUnion.php @@ -0,0 +1,24 @@ + + * + * 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; + +/** + * @author Dmitrii + */ +class DummyWithUnion +{ + public function __construct( + public int|float $value, + public string|int $value2, + ) { + } +} diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index db16e81db0f..b1b56e5c07a 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -62,6 +62,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumConstructor; use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumProperty; use Symfony\Component\Serializer\Tests\Fixtures\DummyWithObjectOrNull; +use Symfony\Component\Serializer\Tests\Fixtures\DummyWithUnion; use Symfony\Component\Serializer\Tests\Fixtures\DummyWithVariadicParameter; use Symfony\Component\Serializer\Tests\Fixtures\FalseBuiltInDummy; use Symfony\Component\Serializer\Tests\Fixtures\FooImplementationDummy; @@ -1392,6 +1393,60 @@ public function testCollectDenormalizationErrorsWithInvalidConstructorTypes() $this->assertSame($expected, $exceptionsAsArray); } + public function testCollectDenormalizationErrorsWithUnionConstructorTypes() + { + $json = '{}'; + + $serializer = new Serializer( + [new ObjectNormalizer()], + ['json' => new JsonEncoder()] + ); + + try { + $serializer->deserialize( + $json, + DummyWithUnion::class, + 'json', + [DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true] + ); + + $this->fail(); + } catch (\Throwable $th) { + $this->assertInstanceOf(PartialDenormalizationException::class, $th); + } + + $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); + + $expected = [ + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'int', 'float', + ], + 'path' => 'value', + 'useMessageForUser' => true, + 'message' => 'Failed to create object because the class misses the "value" property.', + ], + [ + 'currentType' => 'null', + 'expectedTypes' => [ + 'string', 'int', + ], + 'path' => 'value2', + 'useMessageForUser' => true, + 'message' => 'Failed to create object because the class misses the "value2" property.', + ], + ]; + + $this->assertSame($expected, $exceptionsAsArray); + } + public function testCollectDenormalizationErrorsWithEnumConstructor() { $serializer = new Serializer(