diff --git a/Normalizer/AbstractNormalizer.php b/Normalizer/AbstractNormalizer.php index 4aba4b0b67c..677696b8e3a 100644 --- a/Normalizer/AbstractNormalizer.php +++ b/Normalizer/AbstractNormalizer.php @@ -396,16 +396,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/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index cbba35ba0f6..9b6ea1c322c 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -88,24 +88,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); + } } } @@ -164,16 +161,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/Serializer.php b/Serializer.php index 7308bfc7c75..17fa398cefe 100644 --- a/Serializer.php +++ b/Serializer.php @@ -272,8 +272,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) { @@ -291,7 +291,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; } @@ -302,13 +302,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; @@ -328,8 +328,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) { @@ -351,7 +351,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; } @@ -362,13 +362,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; 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/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/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index a7cef4af335..6e42ce875e2 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -574,7 +574,7 @@ public function hasMetadataFor($value): bool } /** - * @return iterable + * @return array */ public static function provideInvalidDiscriminatorTypes(): array { @@ -1854,7 +1854,7 @@ public function setSerializer(SerializerInterface $serializer): void class NotSerializable { - public function __sleep(): array + public function __serialize(): array { throw new \Error('not serializable'); } diff --git a/Tests/Normalizer/DateTimeNormalizerTest.php b/Tests/Normalizer/DateTimeNormalizerTest.php index eed5db067ea..2246114d268 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 [ diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 66033f6bc8e..6b6289aa225 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; +use Symfony\Component\PropertyAccess\PropertyAccessorBuilder; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; @@ -963,10 +964,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); @@ -980,6 +997,95 @@ public function testNormalizeWithMethodNamesSimilarToAccessors() 123 => 321, ], $normalized); } + + public function testNormalizeObjectWithPublicPropertyAccessorPrecedence() + { + $normalizer = $this->getNormalizerForAccessors(); + + $object = new ObjectWithPropertyAndAllAccessorMethods( + 'foo', + ); + $normalized = $normalizer->normalize($object); + + // The getter method should take precedence over all other accessor methods + $this->assertSame([ + 'foo' => 'foo', + ], $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 @@ -1322,3 +1428,99 @@ public function isolate() $this->accessorishCalled = true; } } + +class ObjectWithPropertyAndAllAccessorMethods +{ + 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'; + } +} diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index 255a745f039..c31064eb1bb 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -52,6 +52,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; @@ -66,6 +67,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; @@ -1428,6 +1430,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( @@ -1762,6 +1818,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