diff --git a/DataCollector/SerializerDataCollector.php b/DataCollector/SerializerDataCollector.php index e87c51ca1c3..15d7f47d9c1 100644 --- a/DataCollector/SerializerDataCollector.php +++ b/DataCollector/SerializerDataCollector.php @@ -69,7 +69,7 @@ public function getTotalTime(): float $totalTime = 0; foreach ($this->data as $handled) { - $totalTime += array_sum(array_map(fn (array $el): float => $el['time'], $handled)); + $totalTime += array_sum(array_map(static fn (array $el): float => $el['time'], $handled)); } return $totalTime; diff --git a/DependencyInjection/SerializerPass.php b/DependencyInjection/SerializerPass.php index 3f2a4ec729d..017a270be12 100644 --- a/DependencyInjection/SerializerPass.php +++ b/DependencyInjection/SerializerPass.php @@ -84,12 +84,12 @@ public function process(ContainerBuilder $container): void private function createNamedSerializerTags(ContainerBuilder $container, string $tagName, string $configName, array $namedSerializers): void { $serializerNames = array_keys($namedSerializers); - $withBuiltIn = array_filter($serializerNames, fn (string $name) => $namedSerializers[$name][$configName] ?? false); + $withBuiltIn = array_filter($serializerNames, static fn (string $name) => $namedSerializers[$name][$configName] ?? false); foreach ($container->findTaggedServiceIds($tagName) as $serviceId => $tags) { $definition = $container->getDefinition($serviceId); - if (array_any($tags, $closure = fn (array $tag) => (bool) $tag)) { + if (array_any($tags, $closure = static fn (array $tag) => (bool) $tag)) { $tags = array_filter($tags, $closure); } diff --git a/Mapping/AttributeMetadataInterface.php b/Mapping/AttributeMetadataInterface.php index 9d430602c50..fa6aab1dca7 100644 --- a/Mapping/AttributeMetadataInterface.php +++ b/Mapping/AttributeMetadataInterface.php @@ -18,7 +18,7 @@ * * Primarily, the metadata stores serialization groups. * - * @internal + * @psalm-inheritors AttributeMetadata * * @author Kévin Dunglas */ diff --git a/Mapping/ClassMetadataInterface.php b/Mapping/ClassMetadataInterface.php index 3f380d2ff19..8abd6adae92 100644 --- a/Mapping/ClassMetadataInterface.php +++ b/Mapping/ClassMetadataInterface.php @@ -18,7 +18,7 @@ * * There may only exist one metadata for each attribute according to its name. * - * @internal + * @psalm-inheritors ClassMetadata * * @author Kévin Dunglas */ diff --git a/NameConverter/CamelCaseToSnakeCaseNameConverter.php b/NameConverter/CamelCaseToSnakeCaseNameConverter.php index 033ec94b798..4f8325551ae 100644 --- a/NameConverter/CamelCaseToSnakeCaseNameConverter.php +++ b/NameConverter/CamelCaseToSnakeCaseNameConverter.php @@ -65,7 +65,7 @@ public function denormalize(string $propertyName/* , ?string $class = null, ?str throw new UnexpectedPropertyException($propertyName); } - $camelCasedName = preg_replace_callback('/(^|_|\.)+(.)/', fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), $propertyName); + $camelCasedName = preg_replace_callback('/(^|_|\.)+(.)/', static fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), $propertyName); if ($this->lowerCamelCase) { $camelCasedName = lcfirst($camelCasedName); diff --git a/NameConverter/SnakeCaseToCamelCaseNameConverter.php b/NameConverter/SnakeCaseToCamelCaseNameConverter.php index ab8a0635172..99afcb8993d 100644 --- a/NameConverter/SnakeCaseToCamelCaseNameConverter.php +++ b/NameConverter/SnakeCaseToCamelCaseNameConverter.php @@ -47,7 +47,7 @@ public function normalize(string $propertyName, ?string $class = null, ?string $ $camelCasedName = preg_replace_callback( '/(^|_|\.)+(.)/', - fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), + static fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), $propertyName ); diff --git a/Normalizer/AbstractNormalizer.php b/Normalizer/AbstractNormalizer.php index 6c9e785fd91..760d6f646bd 100644 --- a/Normalizer/AbstractNormalizer.php +++ b/Normalizer/AbstractNormalizer.php @@ -242,9 +242,14 @@ protected function getAllowedAttributes(string|object $classOrObject, array $con } } - if (!$ignoreUsed && [] === $groups && $allowExtraAttributes) { - // Backward Compatibility with the code using this method written before the introduction of @Ignore - return false; + if (!$ignoreUsed && $allowExtraAttributes) { + if ([] === $groups) { + return false; + } + + if ([] === $allowedAttributes && \in_array('*', $groups, true)) { + return false; + } } return $allowedAttributes; @@ -329,6 +334,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex $missingConstructorArguments = []; $params = []; $unsetKeys = []; + $collectedErrorCountBeforeConstructor = \count($context['not_normalizable_value_exceptions'] ?? []); foreach ($constructorParameters as $constructorParameter) { $paramName = $constructorParameter->name; @@ -447,6 +453,22 @@ protected function instantiateObject(array &$data, string $class, array &$contex throw $e; } + // Only report the TypeError when no constructor-argument error was collected for + // this instantiation. When the loop above already recorded missing or invalid + // arguments, the TypeError is a downstream consequence of those errors and would + // just duplicate the report. + if (\count($context['not_normalizable_value_exceptions']) === $collectedErrorCountBeforeConstructor) { + $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( + \sprintf('Failed to create an instance of "%s" from constructor: %s', $class, $e->getMessage()), + null, + ['unknown'], + $context['deserialization_path'] ?? null, + false, + 0, + $e, + ); + } + return $reflectionClass->newInstanceWithoutConstructor(); } } diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 69a12ade799..112ea3a6c10 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -11,8 +11,6 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; -use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; @@ -199,8 +197,8 @@ public function normalize(mixed $data, ?string $format = null, array $context = $attributeValue = $attribute === $this->classDiscriminatorResolver?->getMappingForMappedObject($data)?->getTypeProperty() ? $this->classDiscriminatorResolver?->getTypeForMappedObject($data) : $this->getAttributeValue($data, $attribute, $format, $attributeContext); - } catch (UninitializedPropertyException|\Error $e) { - if (($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true) && $this->isUninitializedValueError($e)) { + } catch (UninitializedPropertyException $e) { + if ($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true) { continue; } throw $e; @@ -292,6 +290,8 @@ abstract protected function extractAttributes(object $object, ?string $format = /** * Gets the attribute value. + * + * @throws UninitializedPropertyException When the attribute exists but is not initialized on the object */ abstract protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed; @@ -380,8 +380,8 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a ? $discriminatorMapping : $this->getAttributeValue($object, $attribute, $format, $attributeContext); } catch (NoSuchPropertyException) { - } catch (UninitializedPropertyException|\Error $e) { - if (!(($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true) && $this->isUninitializedValueError($e))) { + } catch (UninitializedPropertyException $e) { + if (!($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true)) { throw $e; } } @@ -409,16 +409,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a try { $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); - } catch (PropertyAccessInvalidArgumentException $e) { - $exception = NotNormalizableValueException::createForUnexpectedDataType( - \sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $resolvedClass), - $data, - $e instanceof InvalidTypeException ? [$e->expectedType] : ['unknown'], - $attributeContext['deserialization_path'] ?? null, - false, - $e->getCode(), - $e - ); + } catch (NotNormalizableValueException $exception) { if (isset($context['not_normalizable_value_exceptions'])) { $context['not_normalizable_value_exceptions'][] = $exception; continue; @@ -434,6 +425,9 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a return $object; } + /** + * @throws NotNormalizableValueException When the value cannot be assigned to the attribute (e.g. type mismatch) + */ abstract protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void; /** @@ -1233,17 +1227,6 @@ private function getCacheKey(?string $format, array $context): bool|string } } - /** - * This error may occur when specific object normalizer implementation gets attribute value - * by accessing a public uninitialized property or by calling a method accessing such property. - */ - private function isUninitializedValueError(\Error|UninitializedPropertyException $e): bool - { - return $e instanceof UninitializedPropertyException - || str_starts_with($e->getMessage(), 'Typed property') - && str_ends_with($e->getMessage(), 'must not be accessed before initialization'); - } - /** * Returns all attributes with a SerializedPath attribute and the respective path. */ diff --git a/Normalizer/ArrayDenormalizer.php b/Normalizer/ArrayDenormalizer.php index 23e364c7ada..726b96a12e6 100644 --- a/Normalizer/ArrayDenormalizer.php +++ b/Normalizer/ArrayDenormalizer.php @@ -59,15 +59,15 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a if ($keyType instanceof Type) { // BC layer for type-info < 7.2 if (method_exists(Type::class, 'getBaseType')) { - $typeIdentifiers = array_map(fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]); + $typeIdentifiers = array_map(static fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]); } else { /** @var list|BuiltinType> */ $keyTypes = $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]; - $typeIdentifiers = array_map(fn (BuiltinType $t): string => $t->getTypeIdentifier()->value, $keyTypes); + $typeIdentifiers = array_map(static fn (BuiltinType $t): string => $t->getTypeIdentifier()->value, $keyTypes); } } else { - $typeIdentifiers = array_map(fn (LegacyType $t): string => $t->getBuiltinType(), \is_array($keyType) ? $keyType : [$keyType]); + $typeIdentifiers = array_map(static fn (LegacyType $t): string => $t->getBuiltinType(), \is_array($keyType) ? $keyType : [$keyType]); } } diff --git a/Normalizer/BackedEnumNormalizer.php b/Normalizer/BackedEnumNormalizer.php index 7e335c3c940..5c4e4a69d1b 100644 --- a/Normalizer/BackedEnumNormalizer.php +++ b/Normalizer/BackedEnumNormalizer.php @@ -58,8 +58,8 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a $allowInvalidValues = $context[self::ALLOW_INVALID_VALUES] ?? false; - if (null === $data || (!\is_int($data) && !\is_string($data))) { - if ($allowInvalidValues && !isset($context['not_normalizable_value_exceptions'])) { + if (!\is_int($data) && !\is_string($data)) { + if ($allowInvalidValues) { return null; } @@ -69,7 +69,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a try { return $type::from($data); } catch (\ValueError|\TypeError $e) { - if ($allowInvalidValues && !isset($context['not_normalizable_value_exceptions'])) { + if ($allowInvalidValues) { return null; } diff --git a/Normalizer/GetSetMethodNormalizer.php b/Normalizer/GetSetMethodNormalizer.php index 71181a7fccd..58d3a865ca8 100644 --- a/Normalizer/GetSetMethodNormalizer.php +++ b/Normalizer/GetSetMethodNormalizer.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; use Symfony\Component\Serializer\Annotation\Ignore as LegacyIgnore; use Symfony\Component\Serializer\Attribute\Ignore; @@ -130,24 +131,31 @@ protected function extractAttributes(object $object, ?string $format = null, arr protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { - $getter = 'get'.$attribute; - if (method_exists($object, $getter) && \is_callable([$object, $getter])) { - return $object->$getter(); - } + try { + $getter = 'get'.$attribute; + if (method_exists($object, $getter) && \is_callable([$object, $getter])) { + return $object->$getter(); + } - $isser = 'is'.$attribute; - if (method_exists($object, $isser) && \is_callable([$object, $isser])) { - return $object->$isser(); - } + $isser = 'is'.$attribute; + if (method_exists($object, $isser) && \is_callable([$object, $isser])) { + return $object->$isser(); + } - $haser = 'has'.$attribute; - if (method_exists($object, $haser) && \is_callable([$object, $haser])) { - return $object->$haser(); - } + $haser = 'has'.$attribute; + if (method_exists($object, $haser) && \is_callable([$object, $haser])) { + return $object->$haser(); + } - $caner = 'can'.$attribute; - if (method_exists($object, $caner) && \is_callable([$object, $caner])) { - return $object->$caner(); + $caner = 'can'.$attribute; + if (method_exists($object, $caner) && \is_callable([$object, $caner])) { + return $object->$caner(); + } + } catch (\Error $e) { + if (str_starts_with($e->getMessage(), 'Typed property') && str_ends_with($e->getMessage(), 'must not be accessed before initialization')) { + throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $object::class, $attribute), 0, $e); + } + throw $e; } return null; diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index 12491d6c58b..54aae89a7b6 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; +use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -20,6 +22,7 @@ use Symfony\Component\PropertyInfo\PropertyWriteInfo; use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Loader\AccessorCollisionResolverTrait; @@ -121,6 +124,8 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v $this->propertyAccessor->setValue($object, $attribute, $value); } catch (NoSuchPropertyException) { // Properties not found are ignored + } catch (PropertyAccessInvalidArgumentException $e) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Failed to denormalize attribute "%s" value for class "%s": %s', $attribute, $object::class, $e->getMessage()), $value, $e instanceof InvalidTypeException ? [$e->expectedType] : ['unknown'], $context['deserialization_path'] ?? null, false, $e->getCode(), $e); } } diff --git a/Normalizer/ProblemNormalizer.php b/Normalizer/ProblemNormalizer.php index 8f255e6cee6..12500e0456a 100644 --- a/Normalizer/ProblemNormalizer.php +++ b/Normalizer/ProblemNormalizer.php @@ -65,13 +65,13 @@ public function normalize(mixed $object, ?string $format = null, array $context $exception = $exception->getPrevious(); if ($exception instanceof PartialDenormalizationException) { - $trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p); + $trans = $this->translator ? $this->translator->trans(...) : static fn ($m, $p) => strtr($m, $p); $template = 'This value should be of type {{ type }}.'; $error = [ self::TYPE => 'https://symfony.com/errors/validation', self::TITLE => 'Validation Failed', 'violations' => array_map( - fn ($e) => [ + static fn ($e) => [ 'propertyPath' => $e->getPath(), 'title' => $trans($template, [ '{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']), @@ -84,7 +84,7 @@ public function normalize(mixed $object, ?string $format = null, array $context $exception->getErrors() ), ]; - $error['detail'] = implode("\n", array_map(fn ($e) => $e['propertyPath'].': '.$e['title'], $error['violations'])); + $error['detail'] = implode("\n", array_map(static fn ($e) => $e['propertyPath'].': '.$e['title'], $error['violations'])); } elseif (($exception instanceof ValidationFailedException || $exception instanceof MessageValidationFailedException) && $this->serializer instanceof NormalizerInterface && $this->serializer->supportsNormalization($exception->getViolations(), $format, $context) diff --git a/Normalizer/PropertyNormalizer.php b/Normalizer/PropertyNormalizer.php index d200d972c47..248fa395d16 100644 --- a/Normalizer/PropertyNormalizer.php +++ b/Normalizer/PropertyNormalizer.php @@ -14,6 +14,7 @@ use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -157,6 +158,10 @@ protected function getAttributeValue(object $object, string $attribute, ?string } if ($reflectionProperty->hasType()) { + if (!$reflectionProperty->isInitialized($object)) { + throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $object::class, $reflectionProperty->name)); + } + return $reflectionProperty->getValue($object); } @@ -186,17 +191,21 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v return; } - if (!$reflectionProperty->isReadOnly()) { - $reflectionProperty->setValue($object, $value); + try { + if (!$reflectionProperty->isReadOnly()) { + $reflectionProperty->setValue($object, $value); - return; - } + return; + } - if (!$reflectionProperty->isInitialized($object)) { - $declaringClass = $reflectionProperty->getDeclaringClass(); - $declaringClass->getProperty($reflectionProperty->getName())->setValue($object, $value); + if (!$reflectionProperty->isInitialized($object)) { + $declaringClass = $reflectionProperty->getDeclaringClass(); + $declaringClass->getProperty($reflectionProperty->getName())->setValue($object, $value); - return; + return; + } + } catch (\TypeError $e) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Failed to denormalize attribute "%s" value for class "%s": %s', $attribute, $object::class, $e->getMessage()), $value, ['unknown'], $context['deserialization_path'] ?? null, false, $e->getCode(), $e); } if ($reflectionProperty->getValue($object) !== $value) { diff --git a/Tests/Attribute/ContextTest.php b/Tests/Attribute/ContextTest.php index c54a023a71a..18726f5e3df 100644 --- a/Tests/Attribute/ContextTest.php +++ b/Tests/Attribute/ContextTest.php @@ -75,7 +75,7 @@ public function testValidInputs(callable $factory, string $expectedDump) public static function provideValidInputs(): iterable { yield 'named arguments: with context option' => [ - fn () => new Context(context: ['foo' => 'bar']), + static fn () => new Context(context: ['foo' => 'bar']), << [ - fn () => new Context(normalizationContext: ['foo' => 'bar']), + static fn () => new Context(normalizationContext: ['foo' => 'bar']), << [ - fn () => new Context(denormalizationContext: ['foo' => 'bar']), + static fn () => new Context(denormalizationContext: ['foo' => 'bar']), << [ - fn () => new Context(context: ['foo' => 'bar'], groups: 'a'), + static fn () => new Context(context: ['foo' => 'bar'], groups: 'a'), << [ - fn () => new Context(context: ['foo' => 'bar'], groups: ['a', 'b']), + static fn () => new Context(context: ['foo' => 'bar'], groups: ['a', 'b']), << [$data]; yield 'array iterator' => [new \ArrayIterator($data)]; yield 'iterator aggregate' => [new \IteratorIterator(new \ArrayIterator($data))]; - yield 'generator' => [(fn (): \Generator => yield from $data)()]; + yield 'generator' => [(static fn (): \Generator => yield from $data)()]; } #[IgnoreDeprecations] diff --git a/Tests/Fixtures/DummyWithObjectConstructor.php b/Tests/Fixtures/DummyWithObjectConstructor.php new file mode 100644 index 00000000000..8e7b2879beb --- /dev/null +++ b/Tests/Fixtures/DummyWithObjectConstructor.php @@ -0,0 +1,19 @@ + + * + * 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; + +class DummyWithObjectConstructor +{ + public function __construct(public DummyFirstChildQuux $nested) + { + } +} diff --git a/Tests/Normalizer/AbstractNormalizerTest.php b/Tests/Normalizer/AbstractNormalizerTest.php index 8a6d0bf54d4..9a51cf47818 100644 --- a/Tests/Normalizer/AbstractNormalizerTest.php +++ b/Tests/Normalizer/AbstractNormalizerTest.php @@ -84,6 +84,16 @@ public function testGetAllowedAttributesAsString() $this->assertEquals(['a1', 'a2', 'a3', 'a4'], $result); } + public function testGetAllowedAttributesWithWildcardGroupAndNoMetadata() + { + $classMetadata = new ClassMetadata('c'); + + $this->classMetadata->method('getMetadataFor')->willReturn($classMetadata); + + $result = $this->normalizer->getAllowedAttributes('c', [AbstractNormalizer::GROUPS => ['*']], true); + $this->assertFalse($result); + } + public function testGetAllowedAttributesAsObjects() { $classMetadata = new ClassMetadata('c'); diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index 651f52923fa..848c75cb097 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -68,7 +68,6 @@ 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 { @@ -994,7 +993,7 @@ public function getTypes(string $class, string $property, array $context = []): } }; - $serializer = new Serializer([new ObjectNormalizer(propertyTypeExtractor: $extractor)]); + $serializer = new Serializer([new ObjectNormalizer(null, null, null, $extractor)]); $this->assertEquals(new DummyWithIntOrString(1), $serializer->denormalize(['value' => 1], DummyWithIntOrString::class)); } @@ -1022,7 +1021,7 @@ public function getSupportedTypes(?string $format): array $serializer = new Serializer([ $entityDenormalizer, - new ObjectNormalizer(propertyTypeExtractor: $extractor), + new ObjectNormalizer(null, null, null, $extractor), ]); $result = $serializer->denormalize(['entity' => 42], DummyWithMixedConstructorParamAndEntityGetter::class); @@ -1057,7 +1056,7 @@ public function getSupportedTypes(?string $format): array $serializer = new Serializer([ $entityDenormalizer, - new ObjectNormalizer(propertyTypeExtractor: $extractor), + new ObjectNormalizer(null, null, null, $extractor), ]); $result = $serializer->denormalize(['entity' => 42], DummyWithMixedConstructorParamAndEntityGetter::class); @@ -1101,7 +1100,7 @@ public function getSupportedTypes(?string $format): array $serializer = new Serializer([ $entityDenormalizer, - new ObjectNormalizer(propertyTypeExtractor: $extractor), + new ObjectNormalizer(null, null, null, $extractor), ]); $result = $serializer->denormalize(['entity' => 42], DummyWithMixedConstructorParamAndEntityGetter::class); @@ -1436,7 +1435,7 @@ public function testNormalizationWithMaxDepthOnStdclassObjectDoesNotThrowWarning $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory); - $normalized = $normalizer->normalize($object, context: [ + $normalized = $normalizer->normalize($object, null, [ AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true, ]); @@ -1596,7 +1595,7 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string public function testTemplateTypeWhenAnObjectIsPassedToDenormalize() { - $normalizer = new class(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new PhpStanExtractor(), new ReflectionExtractor()])) extends AbstractObjectNormalizerDummy { + $normalizer = new class(new ClassMetadataFactory(new AttributeLoader()), null, new PropertyInfoExtractor([], [new PhpStanExtractor(), new ReflectionExtractor()])) extends AbstractObjectNormalizerDummy { protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool { return true; @@ -1619,7 +1618,7 @@ public function testDenormalizeTemplateType() $this->markTestSkipped('The PropertyInfo component before Symfony 7.1 does not support template types.'); } - $normalizer = new class(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new PhpStanExtractor(), new ReflectionExtractor()])) extends AbstractObjectNormalizerDummy { + $normalizer = new class(new ClassMetadataFactory(new AttributeLoader()), null, new PropertyInfoExtractor([], [new PhpStanExtractor(), new ReflectionExtractor()])) extends AbstractObjectNormalizerDummy { protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool { return true; diff --git a/Tests/Normalizer/BackedEnumNormalizerTest.php b/Tests/Normalizer/BackedEnumNormalizerTest.php index 411b7de8818..3af8928fe4b 100644 --- a/Tests/Normalizer/BackedEnumNormalizerTest.php +++ b/Tests/Normalizer/BackedEnumNormalizerTest.php @@ -127,30 +127,14 @@ public function testItUsesTryFromIfContextIsPassed() $this->assertSame(StringBackedEnumDummy::GET, $this->normalizer->denormalize('GET', StringBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); } - public function testDenormalizeNullWithAllowInvalidAndCollectErrorsThrows() + public function testDenormalizeInvalidValueWithAllowInvalidAndCollectErrorsReturnsNull() { - $this->expectException(NotNormalizableValueException::class); - $this->expectExceptionMessage('The data is neither an integer nor a string'); - - $context = [ - BackedEnumNormalizer::ALLOW_INVALID_VALUES => true, - 'not_normalizable_value_exceptions' => [], // Indicate that we want to collect errors - ]; - - $this->normalizer->denormalize(null, StringBackedEnumDummy::class, null, $context); - } - - public function testDenormalizeInvalidValueWithAllowInvalidAndCollectErrorsThrows() - { - $this->expectException(NotNormalizableValueException::class); - $this->expectExceptionMessage('The data must belong to a backed enumeration of type'); - $context = [ BackedEnumNormalizer::ALLOW_INVALID_VALUES => true, 'not_normalizable_value_exceptions' => [], ]; - $this->normalizer->denormalize('invalid-value', StringBackedEnumDummy::class, null, $context); + $this->assertNull($this->normalizer->denormalize('invalid-value', StringBackedEnumDummy::class, null, $context)); } public function testDenormalizeInvalidValueInConstructorContextThrowsPathAwareNotNormalizableValueException() diff --git a/Tests/Normalizer/Features/CallbacksTestTrait.php b/Tests/Normalizer/Features/CallbacksTestTrait.php index dbfdec22ec7..f297b81a796 100644 --- a/Tests/Normalizer/Features/CallbacksTestTrait.php +++ b/Tests/Normalizer/Features/CallbacksTestTrait.php @@ -118,7 +118,7 @@ public static function provideNormalizeCallbacks() return [ 'Change a string' => [ [ - 'bar' => function ($bar) { + 'bar' => static function ($bar) { static::assertEquals('baz', $bar); return 'baz'; @@ -129,7 +129,7 @@ public static function provideNormalizeCallbacks() ], 'Null an item' => [ [ - 'bar' => function ($value, $object, $attributeName, $format, $context) { + 'bar' => static function ($value, $object, $attributeName, $format, $context) { static::assertSame('baz', $value); static::assertInstanceOf(CallbacksObject::class, $object); static::assertSame('bar', $attributeName); @@ -142,7 +142,7 @@ public static function provideNormalizeCallbacks() ], 'Format a date' => [ [ - 'bar' => function ($bar) { + 'bar' => static function ($bar) { static::assertInstanceOf(\DateTimeImmutable::class, $bar); return $bar->format('d-m-Y H:i:s'); @@ -153,7 +153,7 @@ public static function provideNormalizeCallbacks() ], 'Collect a property' => [ [ - 'bar' => function (array $bars) { + 'bar' => static function (array $bars) { $result = ''; foreach ($bars as $bar) { $result .= $bar->bar; @@ -167,7 +167,7 @@ public static function provideNormalizeCallbacks() ], 'Count a property' => [ [ - 'bar' => fn (array $bars) => \count($bars), + 'bar' => static fn (array $bars) => \count($bars), ], [new CallbacksObject(), new CallbacksObject()], ['bar' => 2, 'foo' => null], @@ -180,7 +180,7 @@ public static function provideDenormalizeCallbacks(): array return [ 'Change a string' => [ [ - 'bar' => function ($bar) { + 'bar' => static function ($bar) { static::assertEquals('bar', $bar); return $bar; @@ -191,7 +191,7 @@ public static function provideDenormalizeCallbacks(): array ], 'Null an item' => [ [ - 'bar' => function ($value, $object, $attributeName, $format, $context) { + 'bar' => static function ($value, $object, $attributeName, $format, $context) { static::assertSame('baz', $value); static::assertTrue(is_a($object, CallbacksObject::class, true)); static::assertSame('bar', $attributeName); @@ -204,7 +204,7 @@ public static function provideDenormalizeCallbacks(): array ], 'Format a date' => [ [ - 'bar' => function ($bar) { + 'bar' => static function ($bar) { static::assertIsString($bar); return \DateTimeImmutable::createFromFormat('d-m-Y H:i:s', $bar); @@ -215,7 +215,7 @@ public static function provideDenormalizeCallbacks(): array ], 'Collect a property' => [ [ - 'bar' => function (array $bars) { + 'bar' => static function (array $bars) { $result = ''; foreach ($bars as $bar) { $result .= $bar->bar; @@ -229,7 +229,7 @@ public static function provideDenormalizeCallbacks(): array ], 'Count a property' => [ [ - 'bar' => fn (array $bars) => \count($bars), + 'bar' => static fn (array $bars) => \count($bars), ], [new CallbacksObject(), new CallbacksObject()], new CallbacksObject(2), @@ -242,7 +242,7 @@ public static function providerDenormalizeCallbacksWithTypedProperty(): array return [ 'Change a typed string' => [ [ - 'foo' => function ($foo) { + 'foo' => static function ($foo) { static::assertEquals('foo', $foo); return $foo; @@ -253,7 +253,7 @@ public static function providerDenormalizeCallbacksWithTypedProperty(): array ], 'Null an typed item' => [ [ - 'foo' => function ($value, $object, $attributeName, $format, $context) { + 'foo' => static function ($value, $object, $attributeName, $format, $context) { static::assertSame('fool', $value); static::assertTrue(is_a($object, CallbacksObject::class, true)); static::assertSame('foo', $attributeName); diff --git a/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php b/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php index feba73777d0..15da6a5019f 100644 --- a/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php +++ b/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php @@ -57,9 +57,7 @@ public function testWithoutSkipUninitializedValues() $normalizer->normalize($object, null, ['skip_uninitialized_values' => false, 'groups' => ['foo']]); $this->fail('Normalizing an object with uninitialized property should have failed'); } catch (UninitializedPropertyException $e) { - self::assertSame('The property "Symfony\Component\Serializer\Tests\Normalizer\Features\TypedPropertiesObject::$unInitialized" is not readable because it is typed "string". You should initialize it or declare a default value instead.', $e->getMessage()); - } catch (\Error $e) { - self::assertSame('Typed property Symfony\Component\Serializer\Tests\Normalizer\Features\TypedPropertiesObject::$unInitialized must not be accessed before initialization', $e->getMessage()); + self::assertStringContainsString('unInitialized', $e->getMessage()); } } } diff --git a/Tests/Normalizer/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index af87f58c13f..313014149b9 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -530,6 +530,18 @@ protected function getNormalizerForSkipUninitializedValues(): GetSetMethodNormal return new GetSetMethodNormalizer(new ClassMetadataFactory(new AttributeLoader())); } + public function testUnrelatedErrorFromGetterIsNotSwallowed() + { + $normalizer = new GetSetMethodNormalizer(); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('intentional getter failure'); + + $normalizer->normalize(new GetSetDummyWithThrowingGetter(), null, [ + 'skip_uninitialized_values' => true, + ]); + } + public function testNormalizeWithDiscriminator() { $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); @@ -813,6 +825,14 @@ public function otherMethod() } } +class GetSetDummyWithThrowingGetter +{ + public function getValue(): string + { + throw new \TypeError('intentional getter failure'); + } +} + class GetConstructorArgsWithDefaultValueDummy { protected $foo; diff --git a/Tests/Normalizer/JsonSerializableNormalizerTest.php b/Tests/Normalizer/JsonSerializableNormalizerTest.php index 39dce311e20..c4d5dd0d946 100644 --- a/Tests/Normalizer/JsonSerializableNormalizerTest.php +++ b/Tests/Normalizer/JsonSerializableNormalizerTest.php @@ -66,7 +66,7 @@ public function testCircularNormalize() $serializer ->expects($this->once()) ->method('normalize') - ->willReturnCallback(function ($data, $format, $context) use ($normalizer) { + ->willReturnCallback(static function ($data, $format, $context) use ($normalizer) { $normalizer->normalize($data['qux'], $format, $context); return 'string_object'; diff --git a/Tests/Normalizer/NumberNormalizerTest.php b/Tests/Normalizer/NumberNormalizerTest.php index fb49ea22bab..fff50ca886e 100644 --- a/Tests/Normalizer/NumberNormalizerTest.php +++ b/Tests/Normalizer/NumberNormalizerTest.php @@ -52,7 +52,7 @@ public static function supportsNormalizationProvider(): iterable yield 'null' => [null, false]; } - #[RequiresPhp('>=8.4')] + #[RequiresPhp('>=8.4.0')] #[RequiresPhpExtension('bcmath')] #[DataProvider('normalizeGoodBcMathNumberValueProvider')] public function testNormalizeBcMathNumber(Number $data, string $expected) @@ -100,7 +100,7 @@ public static function normalizeBadValueProvider(): iterable yield 'null' => [null]; } - #[RequiresPhp('>=8.4')] + #[RequiresPhp('>=8.4.0')] #[RequiresPhpExtension('bcmath')] public function testSupportsBcMathNumberDenormalization() { @@ -118,7 +118,7 @@ public function testDoesNotSupportOtherValuesDenormalization() $this->assertFalse($this->normalizer->supportsDenormalization(null, \stdClass::class)); } - #[RequiresPhp('>=8.4')] + #[RequiresPhp('>=8.4.0')] #[RequiresPhpExtension('bcmath')] #[DataProvider('denormalizeGoodBcMathNumberValueProvider')] public function testDenormalizeBcMathNumber(string|int $data, string $type, Number $expected) @@ -150,7 +150,7 @@ public static function denormalizeGoodGmpValueProvider(): iterable } } - #[RequiresPhp('>=8.4')] + #[RequiresPhp('>=8.4.0')] #[RequiresPhpExtension('bcmath')] #[DataProvider('denormalizeBadBcMathNumberValueProvider')] public function testDenormalizeBadBcMathNumberValueThrows(mixed $data, string $type, string $expectedException, string $expectedExceptionMessage) diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 130390c9dbb..c9c1b842bde 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -31,6 +31,7 @@ use Symfony\Component\Serializer\Exception\ExtraAttributesException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; @@ -42,6 +43,7 @@ use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -585,6 +587,33 @@ public function testConstructorWithNotMatchingUnionTypes() ]); } + public function testTypeMismatchOnTypedPropertyIsCollectedAsDenormalizationError() + { + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $serializer = new Serializer([new ObjectNormalizer(null, null, null, $extractor)]); + + try { + $serializer->denormalize( + ['name' => ['oops']], + ObjectTypedDummy::class, + null, + [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true, + ], + ); + + $this->fail(\sprintf('Expected a "%s".', PartialDenormalizationException::class)); + } catch (PartialDenormalizationException $e) { + $this->assertCount(1, $e->getErrors()); + $error = $e->getErrors()[0]; + $this->assertInstanceOf(NotNormalizableValueException::class, $error); + $this->assertSame('name', $error->getPath()); + $this->assertSame('array', $error->getCurrentType()); + $this->assertSame([class_exists(InvalidTypeException::class) ? 'string' : 'unknown'], $error->getExpectedTypes()); + } + } + // attributes protected function getNormalizerForAttributes(): ObjectNormalizer @@ -958,7 +987,7 @@ public function testNormalizeNotSerializableContext() 'go' => null, ]; - $this->assertEquals($expected, $this->normalizer->normalize($objectDummy, null, ['not_serializable' => function () { + $this->assertEquals($expected, $this->normalizer->normalize($objectDummy, null, ['not_serializable' => static function () { }])); } @@ -1088,7 +1117,7 @@ public function testDefaultObjectClassResolver() public function testObjectClassResolver() { - $classResolver = fn ($object) => ObjectDummy::class; + $classResolver = static fn ($object) => ObjectDummy::class; $normalizer = new ObjectNormalizer(null, null, null, null, null, $classResolver); @@ -1237,7 +1266,8 @@ protected function getNormalizerForAccessors($accessorPrefixes = null): ObjectNo return new ObjectNormalizer( $classMetadataFactory, - propertyAccessor: $propertyAccessorBuilder->getPropertyAccessor(), + null, + $propertyAccessorBuilder->getPropertyAccessor(), ); } @@ -2321,3 +2351,8 @@ public function __construct( ) { } } + +class ObjectTypedDummy +{ + public string $name; +} diff --git a/Tests/Normalizer/PropertyNormalizerTest.php b/Tests/Normalizer/PropertyNormalizerTest.php index 34aa28e1d70..d91a1594331 100644 --- a/Tests/Normalizer/PropertyNormalizerTest.php +++ b/Tests/Normalizer/PropertyNormalizerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; @@ -18,6 +19,8 @@ use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; @@ -193,7 +196,7 @@ public function testDenormalizeWithReadOnlyClass() $this->assertSame('childProp', $object->childProp); } - #[RequiresPhp('>=8.4')] + #[RequiresPhp('>=8.4.0')] public function testDenormalizeWithAsymmetricPropertyVisibility() { /** @var SpecialBookDummy $object */ @@ -599,6 +602,71 @@ public function testDiscriminatorWithAllowExtraAttributesFalse() $this->assertInstanceOf(PropertyDiscriminatedDummyOne::class, $obj); } + + #[DataProvider('provideTypedPropertyDummies')] + public function testTypeMismatchOnTypedPropertyIsCollectedAsDenormalizationError(string $class) + { + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $serializer = new Serializer([new PropertyNormalizer(null, null, $extractor)]); + + try { + $serializer->denormalize( + ['name' => ['oops']], + $class, + null, + [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true, + ], + ); + + $this->fail(\sprintf('Expected a "%s".', PartialDenormalizationException::class)); + } catch (PartialDenormalizationException $e) { + $this->assertCount(1, $e->getErrors()); + $error = $e->getErrors()[0]; + $this->assertInstanceOf(NotNormalizableValueException::class, $error); + $this->assertSame('name', $error->getPath()); + $this->assertSame('array', $error->getCurrentType()); + $this->assertSame(['unknown'], $error->getExpectedTypes()); + $this->assertInstanceOf(\TypeError::class, $error->getPrevious()); + } + } + + public static function provideTypedPropertyDummies(): iterable + { + yield 'readonly typed property' => [PropertyReadonlyTypedDummy::class]; + yield 'plain typed property' => [PropertyTypedDummy::class]; + } + + public function testTypeMismatchOnTypedPropertyIsRethrownAsNotNormalizableValueException() + { + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $serializer = new Serializer([new PropertyNormalizer(null, null, $extractor)]); + + try { + $serializer->denormalize( + ['name' => ['oops']], + PropertyTypedDummy::class, + null, + [AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true], + ); + + $this->fail(\sprintf('Expected a "%s".', NotNormalizableValueException::class)); + } catch (NotNormalizableValueException $e) { + $this->assertSame('name', $e->getPath()); + $this->assertInstanceOf(\TypeError::class, $e->getPrevious()); + } + } +} + +class PropertyReadonlyTypedDummy +{ + public readonly string $name; +} + +class PropertyTypedDummy +{ + public string $name; } class PropertyDummy diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index 5b5046d146d..dda445894dc 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -18,7 +18,6 @@ use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; -use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder; @@ -1394,15 +1393,13 @@ public function testCollectDenormalizationErrorsWithInvalidConstructorTypes() $this->assertTrue($object->bool); $this->assertSame(1, $object->int); - $exceptionsAsArray = array_map(static function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'expectedTypes' => $e->getExpectedTypes(), - 'path' => $e->getPath(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ @@ -1539,13 +1536,11 @@ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruc $this->assertInstanceOf(PartialDenormalizationException::class, $e); } - $exceptionsAsArray = array_map(static function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $e->getErrors()); + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $e->getErrors()); $expected = [ [ @@ -1592,6 +1587,48 @@ public function testCollectDenormalizationErrorsWithWrongEnumOnConstructor() } } + public function testCollectDenormalizationErrorsCapturesConstructorTypeError() + { + $classStringDenormalizer = new class implements DenormalizerInterface { + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): object + { + return new $data(); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return \is_string($data) && class_exists($data); + } + + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + }; + + $serializer = new Serializer([$classStringDenormalizer, new ObjectNormalizer()]); + + $target = Fixtures\DummyWithObjectConstructor::class; + $otherClass = Php74Full::class; + + try { + $serializer->denormalize( + ['nested' => $otherClass], + $target, + context: [DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true], + ); + self::fail(\sprintf('Failed asserting that exception of type "%s" is thrown.', PartialDenormalizationException::class)); + } catch (PartialDenormalizationException $e) { + $capturedFromTypeError = array_values(array_filter( + $e->getErrors(), + static fn (NotNormalizableValueException $error): bool => $error->getPrevious() instanceof \TypeError, + )); + self::assertCount(1, $capturedFromTypeError, 'Constructor TypeError must be captured exactly once as a NotNormalizableValueException.'); + self::assertFalse($capturedFromTypeError[0]->canUseMessageForUser()); + self::assertSame(['unknown'], $capturedFromTypeError[0]->getExpectedTypes()); + } + } + public function testGroupsOnClassSerialization() { $obj = new GroupClassDummy(); @@ -1719,15 +1756,13 @@ public function testPartialDenormalizationWithMissingConstructorTypes() $this->assertFalse(isset($object->two)); $this->assertSame('three string', $object->three); - $exceptionsAsArray = array_map(static function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'expectedTypes' => $e->getExpectedTypes(), - 'path' => $e->getPath(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ @@ -1800,15 +1835,13 @@ public function testDenormalizationFailsWithMultipleErrorsInDefaultContext() $this->assertIsArray($e->getErrors()); $this->assertCount(2, $e->getErrors(), 'Expected two denormalization errors'); - $exceptionsAsArray = array_map(static function (NotNormalizableValueException $ex): array { - return [ - 'currentType' => $ex->getCurrentType(), - 'expectedTypes' => $ex->getExpectedTypes(), - 'path' => $ex->getPath(), - 'useMessageForUser' => $ex->canUseMessageForUser(), - 'message' => $ex->getMessage(), - ]; - }, $e->getErrors()); + $exceptionsAsArray = array_map(static fn (NotNormalizableValueException $ex): array => [ + 'currentType' => $ex->getCurrentType(), + 'expectedTypes' => $ex->getExpectedTypes(), + 'path' => $ex->getPath(), + 'useMessageForUser' => $ex->canUseMessageForUser(), + 'message' => $ex->getMessage(), + ], $e->getErrors()); $expected = [ [ @@ -1831,7 +1864,7 @@ public function testDenormalizationFailsWithMultipleErrorsInDefaultContext() } } - #[RequiresPhp('>=8.4')] + #[RequiresPhp('>=8.4.0')] public function testDeserializeObjectWithAsymmetricPropertyVisibility() { $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); @@ -1841,38 +1874,6 @@ public function testDeserializeObjectWithAsymmetricPropertyVisibility() $this->assertSame('one', $object->item); $this->assertSame('final', $object->type); // Value set in the constructor; must not be changed during deserialization } - - public function testPartialDenormalizationWithInvalidEnumAndAllowInvalid() - { - $factory = new ClassMetadataFactory(new AttributeLoader()); - $extractor = new PropertyInfoExtractor( - [new SerializerExtractor($factory)], - [new ReflectionExtractor()] - ); - $serializer = new Serializer( - [ - new ArrayDenormalizer(), - new BackedEnumNormalizer(), - new ObjectNormalizer($factory, null, null, $extractor), - ], - ); - - $context = [ - 'collect_denormalization_errors' => true, - 'allow_invalid_values' => true, - ]; - - try { - $serializer->denormalize(['id' => 123, 'status' => null], SerializerTestRequestDto::class, null, $context); - $this->fail('PartialDenormalizationException was not thrown.'); - } catch (PartialDenormalizationException $exception) { - $this->assertCount(1, $exception->getErrors()); - $error = $exception->getErrors()[0]; - - $this->assertSame('status', $error->getPath()); - $this->assertSame(['int', 'string'], $error->getExpectedTypes()); - } - } } class Model diff --git a/composer.json b/composer.json index de63185de86..0a5004279f4 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4.37|^7.4.9|^8.0.9", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.2.5|^8.0", @@ -50,7 +50,7 @@ "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", - "symfony/property-access": "<6.4", + "symfony/property-access": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", "symfony/property-info": "<6.4", "symfony/type-info": "<7.2.5", "symfony/uid": "<6.4", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 02430dc3321..ed354c950db 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - + trigger_deprecation Doctrine\Deprecations\Deprecation::trigger Doctrine\Deprecations\Deprecation::triggerIfCalledFromOutside