diff --git a/Mapping/Loader/AccessorCollisionResolverTrait.php b/Mapping/Loader/AccessorCollisionResolverTrait.php index 8bd7d97cc4b..4b72fd722a9 100644 --- a/Mapping/Loader/AccessorCollisionResolverTrait.php +++ b/Mapping/Loader/AccessorCollisionResolverTrait.php @@ -34,7 +34,7 @@ private function getAttributeNameFromAccessor(\ReflectionClass $class, \Reflecti }; // ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel - if (null === $i || ctype_lower($methodName[$i] ?? 'a') || $method->isStatic()) { + if (null === $i || ctype_lower($methodName[$i] ?? 'a') || (!$andMutator && $method->isStatic())) { return null; } diff --git a/Normalizer/AbstractNormalizer.php b/Normalizer/AbstractNormalizer.php index 677696b8e3a..6c9e785fd91 100644 --- a/Normalizer/AbstractNormalizer.php +++ b/Normalizer/AbstractNormalizer.php @@ -321,7 +321,6 @@ protected function instantiateObject(array &$data, string $class, array &$contex $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes); if ($constructor) { - $context['has_constructor'] = true; if (true !== $constructor->isPublic()) { return $reflectionClass->newInstanceWithoutConstructor(); } @@ -452,8 +451,6 @@ protected function instantiateObject(array &$data, string $class, array &$contex } } - unset($context['has_constructor']); - if (!$reflectionClass->isInstantiable()) { throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Failed to create object because the class "%s" is not instantiable.', $class), $data, ['unknown'], $context['deserialization_path'] ?? null); } diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 2ffd84bca28..39b0c99de9d 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -44,6 +44,7 @@ use Symfony\Component\TypeInfo\Type\UnionType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; /** * Base class for a normalizer dealing with objects. @@ -973,11 +974,68 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara // BC layer for PropertyTypeExtractorInterface::getTypes(). // Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). if (\is_array($type)) { + if (($parameterType = $parameter->getType()) instanceof \ReflectionNamedType) { + $matches = false; + + if ($parameterType->isBuiltin()) { + foreach ($type as $legacyType) { + if ($parameterType->getName() === $legacyType->getBuiltinType()) { + $matches = true; + break; + } + } + + if (!$matches) { + $type = [new LegacyType($parameterType->getName(), $parameter->allowsNull())]; + } + } else { + foreach ($type as $legacyType) { + if (LegacyType::BUILTIN_TYPE_OBJECT === $legacyType->getBuiltinType() && $parameterType->getName() === $legacyType->getClassName()) { + $matches = true; + break; + } + } + + if (!$matches) { + $type = [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, $parameter->allowsNull(), $parameterType->getName())]; + } + } + } + $parameterData = $this->validateAndDenormalizeLegacy($type, $class->getName(), $parameterName, $parameterData, $format, $context); - } else { - $parameterData = $this->validateAndDenormalize($type, $class->getName(), $parameterName, $parameterData, $format, $context); + $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + + return $this->applyFilterBool($parameter, $parameterData, $context); + } + + $parameterType = $parameter->getType(); + static $parameterTypeResolver; + + if (null !== $parameterType && $parameterTypeResolver ??= class_exists(ReflectionTypeResolver::class) ? new ReflectionTypeResolver() : false) { + $resolvedParameterType = $parameterTypeResolver->resolve($parameterType); + if ($resolvedParameterType->isSatisfiedBy(static fn (Type $t) => match (true) { + $t instanceof BuiltinType => !$type->isIdentifiedBy($t->getTypeIdentifier()), + $t instanceof ObjectType => !$type->isIdentifiedBy($t->getClassName()), + default => false, + })) { + $type = $resolvedParameterType; + } + } elseif ($parameterType instanceof \ReflectionNamedType) { + if ($parameterType->isBuiltin()) { + $typeIdentifier = TypeIdentifier::tryFrom($parameterType->getName()); + + if (null !== $typeIdentifier && !$type->isIdentifiedBy($typeIdentifier)) { + $type = Type::builtin($typeIdentifier); + } + } elseif (!$type->isIdentifiedBy($parameterType->getName())) { + $type = Type::object($parameterType->getName()); + } + if ($parameter->allowsNull()) { + $type = Type::nullable($type); + } } + $parameterData = $this->validateAndDenormalize($type, $class->getName(), $parameterName, $parameterData, $format, $context); $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); return $this->applyFilterBool($parameter, $parameterData, $context); diff --git a/Normalizer/BackedEnumNormalizer.php b/Normalizer/BackedEnumNormalizer.php index ff5a782c49a..7e335c3c940 100644 --- a/Normalizer/BackedEnumNormalizer.php +++ b/Normalizer/BackedEnumNormalizer.php @@ -69,10 +69,6 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a try { return $type::from($data); } catch (\ValueError|\TypeError $e) { - if (isset($context['has_constructor'])) { - throw new InvalidArgumentException('The data must belong to a backed enumeration of type '.$type, 0, $e); - } - if ($allowInvalidValues && !isset($context['not_normalizable_value_exceptions'])) { return null; } diff --git a/Normalizer/ConstraintViolationListNormalizer.php b/Normalizer/ConstraintViolationListNormalizer.php index 92e036384dc..d5e243be3d0 100644 --- a/Normalizer/ConstraintViolationListNormalizer.php +++ b/Normalizer/ConstraintViolationListNormalizer.php @@ -60,7 +60,11 @@ public function normalize(mixed $data, ?string $format = null, array $context = $violations = []; $messages = []; foreach ($data as $violation) { - $propertyPath = $this->nameConverter ? $this->nameConverter->normalize($violation->getPropertyPath(), null, $format, $context) : $violation->getPropertyPath(); + $propertyPath = $violation->getPropertyPath(); + + if (null !== $this->nameConverter) { + $propertyPath = $this->normalizePropertyPath($propertyPath, \is_object($violation->getRoot()) ? \get_class($violation->getRoot()) : null, $format, $context); + } $violationEntry = [ 'propertyPath' => $propertyPath, @@ -106,6 +110,48 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $result + ['violations' => $violations]; } + private function normalizePropertyPath(string $propertyPath, ?string $class, ?string $format, array $context): string + { + if (!str_contains($propertyPath, '.')) { + return $this->nameConverter->normalize($propertyPath, $class, $format, $context); + } + + $result = []; + $currentClass = $class; + + foreach (explode('.', $propertyPath) as $segment) { + $subscript = ''; + $propertyName = $segment; + if (false !== $bracketPos = strpos($segment, '[')) { + $propertyName = substr($segment, 0, $bracketPos); + $subscript = substr($segment, $bracketPos); + } + + $result[] = $this->nameConverter->normalize($propertyName, $currentClass, $format, $context).$subscript; + + $currentClass = $this->getPropertyClassFromReflection($currentClass, $propertyName); + } + + return implode('.', $result); + } + + private function getPropertyClassFromReflection(?string $class, string $property): ?string + { + if (null === $class) { + return null; + } + + try { + $type = (new \ReflectionProperty($class, $property))->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + return $type->getName(); + } + } catch (\ReflectionException) { + } + + return null; + } + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof ConstraintViolationListInterface; diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 5889aa32086..d4fc4f22e13 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -137,16 +137,18 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string } if ($context['_read_attributes'] ?? true) { - if (!isset(self::$isReadableCache[$class.$attribute])) { - self::$isReadableCache[$class.$attribute] = $this->propertyInfoExtractor->isReadable($class, $attribute) || $this->hasAttributeAccessorMethod($class, $attribute) || (\is_object($classOrObject) && $this->propertyAccessor->isReadable($classOrObject, $attribute)); - } + $context = array_intersect_key($context, ['enable_getter_setter_extraction' => true, 'enable_magic_methods_extraction' => true]); + $cacheKey = $class.$attribute.hash('xxh128', serialize($context)); - return self::$isReadableCache[$class.$attribute]; + return self::$isReadableCache[$cacheKey] ??= $this->propertyInfoExtractor->isReadable($class, $attribute, $context) || $this->hasAttributeAccessorMethod($class, $attribute) || (\is_object($classOrObject) && $this->propertyAccessor->isReadable($classOrObject, $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); + $context = array_intersect_key($context, ['enable_getter_setter_extraction' => true, 'enable_magic_methods_extraction' => true, 'enable_constructor_extraction' => true, 'enable_adder_remover_extraction' => true]); + $cacheKey = $class.$attribute.hash('xxh128', serialize($context)); + + return self::$isWritableCache[$cacheKey] ??= str_contains($attribute, '.') + || $this->propertyInfoExtractor->isWritable($class, $attribute, $context) + || !\in_array($this->writeInfoExtractor->getWriteInfo($class, $attribute, $context)?->getType(), [null, PropertyWriteInfo::TYPE_NONE, PropertyWriteInfo::TYPE_PROPERTY], true); } private function hasAttributeAccessorMethod(string $class, string $attribute): bool diff --git a/Tests/Dummy/DummyClassTwo.php b/Tests/Dummy/DummyClassTwo.php index 8bb5311e1f7..6fe0436df6a 100644 --- a/Tests/Dummy/DummyClassTwo.php +++ b/Tests/Dummy/DummyClassTwo.php @@ -11,6 +11,10 @@ namespace Symfony\Component\Serializer\Tests\Dummy; +use Symfony\Component\Serializer\Attribute\SerializedName; + class DummyClassTwo { + #[SerializedName('nested')] + public DummyClassOne $child; } diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index bd93229f5b6..f2f5d2bb493 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresMethod; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -19,6 +20,7 @@ use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyDocBlockExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\DiscriminatorMap; @@ -65,6 +67,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyWithStringObject; use Symfony\Component\Serializer\Tests\Normalizer\Features\ObjectDummyWithContextAttribute; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\ReflectionTypeResolver; class AbstractObjectNormalizerTest extends TestCase { @@ -964,6 +967,26 @@ classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), $this->assertEquals(new DummyWithEnumUnion(EnumB::B), $serializer->denormalize($normalized, DummyWithEnumUnion::class)); } + #[RequiresMethod(ReflectionTypeResolver::class, 'resolve')] + public function testDenormalizeUsesConstructorUnionTypeWhenExtractorIsLessPrecise() + { + $extractor = new class implements PropertyTypeExtractorInterface { + public function getType(string $class, string $property, array $context = []): ?Type + { + return Type::string(); + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + return null; + } + }; + + $serializer = new Serializer([new ObjectNormalizer(propertyTypeExtractor: $extractor)]); + + $this->assertEquals(new DummyWithIntOrString(1), $serializer->denormalize(['value' => 1], DummyWithIntOrString::class)); + } + public function testDenormalizeWithNumberAsSerializedNameAndNoArrayReindex() { $normalizer = new AbstractObjectNormalizerWithMetadata(); @@ -1931,6 +1954,14 @@ public function __construct( } } +class DummyWithIntOrString +{ + public function __construct( + public readonly int|string $value, + ) { + } +} + #[DiscriminatorMap('type', ['foo' => ScalarCollectionDocBlockDummy::class])] class ScalarCollectionDocBlockDummy { diff --git a/Tests/Normalizer/BackedEnumNormalizerTest.php b/Tests/Normalizer/BackedEnumNormalizerTest.php index 2f76f735ed2..411b7de8818 100644 --- a/Tests/Normalizer/BackedEnumNormalizerTest.php +++ b/Tests/Normalizer/BackedEnumNormalizerTest.php @@ -152,4 +152,22 @@ public function testDenormalizeInvalidValueWithAllowInvalidAndCollectErrorsThrow $this->normalizer->denormalize('invalid-value', StringBackedEnumDummy::class, null, $context); } + + public function testDenormalizeInvalidValueInConstructorContextThrowsPathAwareNotNormalizableValueException() + { + try { + $this->normalizer->denormalize('invalid-value', StringBackedEnumDummy::class, null, [ + 'has_constructor' => true, + 'deserialization_path' => 'get', + ]); + + self::fail(\sprintf('Failed asserting that exception of type "%s" is thrown.', NotNormalizableValueException::class)); + } catch (NotNormalizableValueException $e) { + $this->assertSame('get', $e->getPath()); + $this->assertSame('string', $e->getCurrentType()); + $this->assertSame(['int', 'string'], $e->getExpectedTypes()); + $this->assertTrue($e->canUseMessageForUser()); + $this->assertSame('The data must belong to a backed enumeration of type '.StringBackedEnumDummy::class, $e->getMessage()); + } + } } diff --git a/Tests/Normalizer/ConstraintViolationListNormalizerTest.php b/Tests/Normalizer/ConstraintViolationListNormalizerTest.php index a4a2bbb3da6..e79e6b4bd44 100644 --- a/Tests/Normalizer/ConstraintViolationListNormalizerTest.php +++ b/Tests/Normalizer/ConstraintViolationListNormalizerTest.php @@ -13,8 +13,14 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer; +use Symfony\Component\Serializer\Tests\Dummy\DummyClassOne; +use Symfony\Component\Serializer\Tests\Dummy\DummyClassTwo; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; @@ -114,6 +120,66 @@ public function testNormalizeWithNameConverter() $this->assertEquals($expected, $normalizer->normalize($list)); } + public function testNormalizeWithMetadataAwareNameConverter() + { + $attributeLoader = new AttributeLoader(); + $attributeLoader->loadClassMetadata(new ClassMetadata(DummyClassOne::class)); + + $nameConverter = new MetadataAwareNameConverter(new ClassMetadataFactory($attributeLoader)); + $normalizer = new ConstraintViolationListNormalizer([], $nameConverter); + + $list = new ConstraintViolationList([ + new ConstraintViolation('too long', 'a', [], new DummyClassOne(), 'code', ''), + ]); + + $expected = [ + 'type' => 'https://symfony.com/errors/validation', + 'title' => 'Validation Failed', + 'detail' => 'identifier: too long', + 'violations' => [ + [ + 'propertyPath' => 'identifier', + 'title' => 'too long', + 'template' => 'a', + 'parameters' => [], + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($list)); + } + + public function testNormalizeWithMetadataAwareNameConverterAndNestedPath() + { + $attributeLoader = new AttributeLoader(); + $attributeLoader->loadClassMetadata(new ClassMetadata(DummyClassTwo::class)); + $attributeLoader->loadClassMetadata(new ClassMetadata(DummyClassOne::class)); + + $nameConverter = new MetadataAwareNameConverter(new ClassMetadataFactory($attributeLoader)); + $normalizer = new ConstraintViolationListNormalizer([], $nameConverter); + + $root = new DummyClassTwo(); + $list = new ConstraintViolationList([ + new ConstraintViolation('too long', 'a', [], $root, 'child.code', ''), + ]); + + $expected = [ + 'type' => 'https://symfony.com/errors/validation', + 'title' => 'Validation Failed', + 'detail' => 'nested.identifier: too long', + 'violations' => [ + [ + 'propertyPath' => 'nested.identifier', + 'title' => 'too long', + 'template' => 'a', + 'parameters' => [], + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($list)); + } + #[DataProvider('payloadFieldsProvider')] public function testNormalizePayloadFields($fields, ?array $expected = null) { diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 52dfc342ecc..920d3172148 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -12,13 +12,20 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\RequiresMethod; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\PropertyAccessorBuilder; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Exception\ExtraAttributesException; @@ -37,6 +44,7 @@ use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; @@ -187,6 +195,29 @@ public function testNormalizeObjectWithLazyProperties() ); } + public function testNormalizeWithDisabledMagicMethodsExtractionInContext() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $propertyInfoExtractor = $this->createMock(PropertyInfoExtractorInterface::class); + $propertyInfoExtractor + ->expects($this->once()) + ->method('isReadable') + ->with(ObjectWithGroupedMagicGetPrivateProperty::class, 'foo', ['enable_magic_methods_extraction' => 0]) + ->willReturn(false); + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor + ->expects($this->once()) + ->method('isReadable') + ->with($this->isInstanceOf(ObjectWithGroupedMagicGetPrivateProperty::class), 'foo') + ->willReturn(false); + $normalizer = new ObjectNormalizer($classMetadataFactory, null, $propertyAccessor, null, null, null, [], $propertyInfoExtractor); + + $this->assertSame([], $normalizer->normalize(new ObjectWithGroupedMagicGetPrivateProperty(), null, [ + 'groups' => ['read'], + 'enable_magic_methods_extraction' => 0, + ])); + } + public function testNormalizeObjectWithUninitializedPrivateProperties() { $obj = new Php74DummyPrivate(); @@ -312,6 +343,134 @@ public function testConstructorWithObjectDenormalizeUsingPropertyInfoExtractor() $this->assertEquals('bar', $obj->bar); } + #[RequiresMethod(Type::class, 'list')] + public function testConstructorParameterTypeIsUsedWhenPropertyTypeExtractorReturnsDifferentType() + { + $propertyInfoExtractor = new class implements PropertyInfoExtractorInterface { + public function getType(string $class, string $property, array $context = []): ?Type + { + if (SerializerConstructorTypeConversionDummy::class === $class && 'attributes' === $property) { + return Type::list(Type::string()); + } + + return null; + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + return null; + } + + public function getProperties(string $class, array $context = []): ?array + { + return null; + } + + public function isReadable(string $class, string $property, array $context = []): ?bool + { + return null; + } + + public function isWritable(string $class, string $property, array $context = []): ?bool + { + return !(SerializerConstructorTypeConversionDummy::class === $class && 'attributes' === $property); + } + + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + return null; + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + return null; + } + }; + + $normalizer = new ObjectNormalizer(null, null, null, $propertyInfoExtractor, null, null, [], $propertyInfoExtractor); + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + + $obj = $normalizer->denormalize( + ['attributes' => 'displayName,userName'], + SerializerConstructorTypeConversionDummy::class, + 'csv', + [DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true] + ); + + $this->assertInstanceOf(SerializerConstructorTypeConversionDummy::class, $obj); + $this->assertFalse($obj->isAttributeAllowed('displayName')); + $this->assertFalse($obj->isAttributeAllowed('userName')); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testConstructorParameterTypeIsUsedWhenLegacyPropertyTypeExtractorReturnsDifferentType() + { + $propertyTypeExtractor = new class implements PropertyTypeExtractorInterface { + public function getTypes(string $class, string $property, array $context = []): ?array + { + if (SerializerConstructorTypeConversionDummy::class === $class && 'attributes' === $property) { + return [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]; + } + + return null; + } + }; + + $propertyInfoExtractor = new class implements PropertyInfoExtractorInterface { + public function getType(string $class, string $property, array $context = []): ?Type + { + return null; + } + + public function getTypes(string $class, string $property, array $context = []): ?array + { + return null; + } + + public function getProperties(string $class, array $context = []): ?array + { + return null; + } + + public function isReadable(string $class, string $property, array $context = []): ?bool + { + return null; + } + + public function isWritable(string $class, string $property, array $context = []): ?bool + { + return !(SerializerConstructorTypeConversionDummy::class === $class && 'attributes' === $property); + } + + public function getShortDescription(string $class, string $property, array $context = []): ?string + { + return null; + } + + public function getLongDescription(string $class, string $property, array $context = []): ?string + { + return null; + } + }; + + $normalizer = new ObjectNormalizer(null, null, null, $propertyTypeExtractor, null, null, [], $propertyInfoExtractor); + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + + $obj = $normalizer->denormalize( + ['attributes' => 'displayName,userName'], + SerializerConstructorTypeConversionDummy::class, + 'csv', + [DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true] + ); + + $this->assertInstanceOf(SerializerConstructorTypeConversionDummy::class, $obj); + $this->assertFalse($obj->isAttributeAllowed('displayName')); + $this->assertFalse($obj->isAttributeAllowed('userName')); + } + public function testConstructorWithObjectTypeHintDenormalize() { $data = [ @@ -727,6 +886,13 @@ public function testNormalizeStatic() $this->assertEquals(['foo' => 'K'], $this->normalizer->normalize(new ObjectWithStaticPropertiesAndMethods())); } + public function testNormalizeStaticWithGroups() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $this->createNormalizer([], $classMetadataFactory); + $this->assertEquals(['baz' => 'L'], $this->normalizer->normalize(new ObjectWithStaticMethodWithGroups(), null, [AbstractNormalizer::GROUPS => ['test']])); + } + public function testNormalizeUpperCaseAttributes() { $this->assertEquals(['Foo' => 'Foo', 'Bar' => 'BarBar'], $this->normalizer->normalize(new ObjectWithUpperCaseAttributeNames())); @@ -1489,6 +1655,15 @@ public static function getBaz() } } +class ObjectWithStaticMethodWithGroups +{ + #[Groups('test')] + public static function getBaz() + { + return 'L'; + } +} + class ObjectTypeHinted { public function setFoo(array $f) @@ -1563,6 +1738,17 @@ public function __isset($name): bool } } +class ObjectWithGroupedMagicGetPrivateProperty +{ + #[Groups(['read'])] + private string $foo = 'foo'; + + public function __get($name) + { + return 'foo'; + } +} + class DummyWithConstructorObject { private $id; @@ -2023,6 +2209,22 @@ public function __construct( } } +class SerializerConstructorTypeConversionDummy +{ + /** @var list */ + private array $attributes; + + public function __construct(string $attributes = '') + { + $this->attributes = $attributes ? explode(',', $attributes) : []; + } + + public function isAttributeAllowed(string $attribute): bool + { + return !\in_array($attribute, $this->attributes, true); + } +} + class ObjectWithMetadata { private int $foo; diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index cae3f9f007e..5b5046d146d 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -178,7 +178,7 @@ public function testNormalizeWithSupportOnData() $normalizer1 = $this->createStub(NormalizerInterface::class); $normalizer1->method('getSupportedTypes')->willReturn(['*' => false]); $normalizer1->method('supportsNormalization') - ->willReturnCallback(fn ($data, $format) => isset($data->test)); + ->willReturnCallback(static fn ($data, $format) => isset($data->test)); $normalizer1->method('normalize')->willReturn('test1'); $normalizer2 = $this->createStub(NormalizerInterface::class); @@ -201,7 +201,7 @@ public function testDenormalizeWithSupportOnData() $denormalizer1 = $this->createStub(DenormalizerInterface::class); $denormalizer1->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer1->method('supportsDenormalization') - ->willReturnCallback(fn ($data, $type, $format) => isset($data['test1'])); + ->willReturnCallback(static fn ($data, $type, $format) => isset($data['test1'])); $denormalizer1->method('denormalize')->willReturn('test1'); $denormalizer2 = $this->createStub(DenormalizerInterface::class); @@ -992,7 +992,7 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet $this->assertInstanceOf(Php74Full::class, $th->getData()); - $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), 'path' => $e->getPath(), @@ -1204,7 +1204,7 @@ public function testCollectDenormalizationErrors2(?ClassMetadataFactory $classMe $this->assertInstanceOf(Php74Full::class, $th->getData()[0]); $this->assertInstanceOf(Php74Full::class, $th->getData()[1]); - $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), 'path' => $e->getPath(), @@ -1259,7 +1259,7 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() $this->assertInstanceOf(Php74Full::class, $th->getData()); - $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), 'path' => $e->getPath(), @@ -1326,7 +1326,7 @@ public function testCollectDenormalizationErrorsWithConstructor(?ClassMetadataFa $this->assertInstanceOf(Php80WithPromotedTypedConstructor::class, $th->getData()); - $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), 'path' => $e->getPath(), @@ -1394,7 +1394,7 @@ public function testCollectDenormalizationErrorsWithInvalidConstructorTypes() $this->assertTrue($object->bool); $this->assertSame(1, $object->int); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { + $exceptionsAsArray = array_map(static function (NotNormalizableValueException $e): array { return [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), @@ -1450,7 +1450,7 @@ public function testCollectDenormalizationErrorsWithUnionConstructorTypes() $this->assertInstanceOf(PartialDenormalizationException::class, $th); } - $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), 'path' => $e->getPath(), @@ -1500,7 +1500,7 @@ public function testCollectDenormalizationErrorsWithEnumConstructor() $this->assertInstanceOf(PartialDenormalizationException::class, $th); } - $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ 'currentType' => $e->getCurrentType(), 'useMessageForUser' => $e->canUseMessageForUser(), 'message' => $e->getMessage(), @@ -1539,7 +1539,7 @@ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruc $this->assertInstanceOf(PartialDenormalizationException::class, $e); } - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { + $exceptionsAsArray = array_map(static function (NotNormalizableValueException $e): array { return [ 'currentType' => $e->getCurrentType(), 'useMessageForUser' => $e->canUseMessageForUser(), @@ -1558,7 +1558,7 @@ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruc $this->assertSame($expected, $exceptionsAsArray); } - public function testNoCollectDenormalizationErrorsWithWrongEnumOnConstructor() + public function testCollectDenormalizationErrorsWithWrongEnumOnConstructor() { $serializer = new Serializer( [ @@ -1572,9 +1572,23 @@ public function testNoCollectDenormalizationErrorsWithWrongEnumOnConstructor() $serializer->deserialize('{"get": "POST"}', DummyObjectWithEnumConstructor::class, 'json', [ DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, ]); - } catch (\Throwable $th) { - $this->assertNotInstanceOf(PartialDenormalizationException::class, $th); - $this->assertInstanceOf(InvalidArgumentException::class, $th); + self::fail(\sprintf('Failed asserting that exception of type "%s" is thrown.', PartialDenormalizationException::class)); + } catch (PartialDenormalizationException $e) { + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $error): array => [ + 'currentType' => $error->getCurrentType(), + 'path' => $error->getPath(), + 'useMessageForUser' => $error->canUseMessageForUser(), + 'message' => $error->getMessage(), + ], $e->getErrors()); + + $this->assertSame([ + [ + 'currentType' => 'string', + 'path' => 'get', + 'useMessageForUser' => true, + 'message' => 'The data must belong to a backed enumeration of type Symfony\Component\Serializer\Tests\Fixtures\StringBackedEnumDummy', + ], + ], $exceptionsAsArray); } } @@ -1705,7 +1719,7 @@ public function testPartialDenormalizationWithMissingConstructorTypes() $this->assertFalse(isset($object->two)); $this->assertSame('three string', $object->three); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { + $exceptionsAsArray = array_map(static function (NotNormalizableValueException $e): array { return [ 'currentType' => $e->getCurrentType(), 'expectedTypes' => $e->getExpectedTypes(), @@ -1786,7 +1800,7 @@ public function testDenormalizationFailsWithMultipleErrorsInDefaultContext() $this->assertIsArray($e->getErrors()); $this->assertCount(2, $e->getErrors(), 'Expected two denormalization errors'); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $ex): array { + $exceptionsAsArray = array_map(static function (NotNormalizableValueException $ex): array { return [ 'currentType' => $ex->getCurrentType(), 'expectedTypes' => $ex->getExpectedTypes(), diff --git a/composer.json b/composer.json index bc84d5e31ba..de63185de86 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "phpdocumentor/reflection-docblock": "^5.2", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", "phpstan/phpdoc-parser": "^1.0|^2.0", "seld/jsonlint": "^1.10", "symfony/cache": "^6.4|^7.0|^8.0", @@ -39,7 +39,7 @@ "symfony/property-access": "^6.4|^7.0|^8.0", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/type-info": "^7.1.8|^8.0", + "symfony/type-info": "^7.2.5|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", "symfony/validator": "^6.4|^7.0|^8.0", "symfony/var-dumper": "^6.4|^7.0|^8.0", @@ -47,11 +47,12 @@ "symfony/yaml": "^6.4|^7.0|^8.0" }, "conflict": { - "phpdocumentor/reflection-docblock": "<5.2|>=6", + "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", "symfony/property-access": "<6.4", "symfony/property-info": "<6.4", + "symfony/type-info": "<7.2.5", "symfony/uid": "<6.4", "symfony/validator": "<6.4", "symfony/yaml": "<6.4"