diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 3a557e3c34640..c872e36ed7f04 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -123,6 +123,10 @@ public function __construct(ClassMetadataFactoryInterface $classMetadataFactory $this->defaultContext[self::EXCLUDE_FROM_CACHE_KEY] = array_merge($this->defaultContext[self::EXCLUDE_FROM_CACHE_KEY] ?? [], [self::CIRCULAR_REFERENCE_LIMIT_COUNTERS]); + if (\PHP_VERSION_ID >= 70400) { + $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] = true; + } + $this->propertyTypeExtractor = $propertyTypeExtractor; if (null === $classDiscriminatorResolver && null !== $classMetadataFactory) { @@ -190,7 +194,12 @@ public function normalize($object, string $format = null, array $context = []) try { $attributeValue = $this->getAttributeValue($object, $attribute, $format, $attributeContext); } catch (UninitializedPropertyException $e) { - if ($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? false) { + if ($this->shouldSkipUninitializedValues($context)) { + continue; + } + throw $e; + } catch (\Error $e) { + if ($this->shouldSkipUninitializedValues($context) && $this->isUninitializedValueError($e)) { continue; } throw $e; @@ -724,4 +733,22 @@ private function getCacheKey(?string $format, array $context) return false; } } + + private function shouldSkipUninitializedValues(array $context): bool + { + return $context[self::SKIP_UNINITIALIZED_VALUES] + ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] + ?? false; + } + + /** + * 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 $e): bool + { + return \PHP_VERSION_ID >= 70400 + && str_starts_with($e->getMessage(), 'Typed property') + && str_ends_with($e->getMessage(), 'must not be accessed before initialization'); + } } diff --git a/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php index 1cdeb94808e7b..1bce3ebeb1562 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php @@ -107,23 +107,9 @@ protected function extractAttributes(object $object, string $format = null, arra } } - $checkPropertyInitialization = \PHP_VERSION_ID >= 70400; - // properties foreach ($reflClass->getProperties() as $reflProperty) { - $isPublic = $reflProperty->isPublic(); - - if ($checkPropertyInitialization) { - if (!$isPublic) { - $reflProperty->setAccessible(true); - } - if (!$reflProperty->isInitialized($object)) { - unset($attributes[$reflProperty->name]); - continue; - } - } - - if (!$isPublic) { + if (!$reflProperty->isPublic()) { continue; } diff --git a/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php index 39e7502754393..e0116d6c9008c 100644 --- a/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/PropertyNormalizer.php @@ -101,20 +101,9 @@ protected function extractAttributes(object $object, string $format = null, arra { $reflectionObject = new \ReflectionObject($object); $attributes = []; - $checkPropertyInitialization = \PHP_VERSION_ID >= 70400; do { foreach ($reflectionObject->getProperties() as $property) { - if ($checkPropertyInitialization) { - if (!$property->isPublic()) { - $property->setAccessible(true); - } - - if (!$property->isInitialized($object)) { - continue; - } - } - if (!$this->isAllowedAttribute($reflectionObject->getName(), $property->name, $format, $context)) { continue; } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php new file mode 100644 index 0000000000000..2057795b63cd0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php @@ -0,0 +1,59 @@ +getObjectCollectionWithExpectedArray(); + $this->assertCollectionNormalizedProperly($collection, $expectedArray); + } + + /** + * The same normalizer instance normalizes two objects of the same class in a row: + * 1. an object with all properties being initialized + * 2. an object having some uninitialized properties. + * + * @requires PHP 7.4 + */ + public function testReversedObjectCollectionNormalization() + { + [$collection, $expectedArray] = array_map('array_reverse', $this->getObjectCollectionWithExpectedArray()); + $this->assertCollectionNormalizedProperly($collection, $expectedArray); + } + + private function assertCollectionNormalizedProperly(array $collection, array $expectedArray): void + { + self::assertCount(\count($expectedArray), $collection); + $normalizer = $this->getNormalizerForCacheableObjectAttributesTest(); + foreach ($collection as $i => $object) { + $result = $normalizer->normalize($object); + self::assertSame($expectedArray[$i], $result); + } + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php index 66c5889db9a22..50b170b397df4 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php @@ -14,25 +14,39 @@ abstract protected function getNormalizerForSkipUninitializedValues(): Normalize /** * @requires PHP 7.4 + * @dataProvider skipUninitializedValuesFlagProvider */ - public function testSkipUninitializedValues() + public function testSkipUninitializedValues(array $context) { - $object = new TypedPropertiesObject(); + $object = new TypedPropertiesObjectWithGetters(); $normalizer = $this->getNormalizerForSkipUninitializedValues(); - $result = $normalizer->normalize($object, null, ['skip_uninitialized_values' => true, 'groups' => ['foo']]); + $result = $normalizer->normalize($object, null, $context); $this->assertSame(['initialized' => 'value'], $result); } + public function skipUninitializedValuesFlagProvider(): iterable + { + yield 'passed manually' => [['skip_uninitialized_values' => true, 'groups' => ['foo']]]; + yield 'using default context value' => [['groups' => ['foo']]]; + } + /** * @requires PHP 7.4 */ public function testWithoutSkipUninitializedValues() { - $object = new TypedPropertiesObject(); + $object = new TypedPropertiesObjectWithGetters(); $normalizer = $this->getNormalizerForSkipUninitializedValues(); - $this->expectException(UninitializedPropertyException::class); - $normalizer->normalize($object, null, ['groups' => ['foo']]); + + try { + $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()); + } } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/TypedPropertiesObjectWithGetters.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/TypedPropertiesObjectWithGetters.php new file mode 100644 index 0000000000000..560179b5dd01e --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/TypedPropertiesObjectWithGetters.php @@ -0,0 +1,42 @@ +unInitialized; + } + + public function setUnInitialized(string $unInitialized): self + { + $this->unInitialized = $unInitialized; + + return $this; + } + + public function getInitialized(): string + { + return $this->initialized; + } + + public function setInitialized(string $initialized): self + { + $this->initialized = $initialized; + + return $this; + } + + public function getInitialized2(): string + { + return $this->initialized2; + } + + public function setInitialized2(string $initialized2): self + { + $this->initialized2 = $initialized2; + + return $this; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php index 54b7234e6165a..a8dc7bc885307 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -31,6 +31,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy; use Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; +use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; @@ -38,10 +39,13 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ObjectToPopulateTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\SkipUninitializedValuesTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\TypedPropertiesObjectWithGetters; use Symfony\Component\Serializer\Tests\Normalizer\Features\TypeEnforcementTestTrait; class GetSetMethodNormalizerTest extends TestCase { + use CacheableObjectAttributesTestTrait; use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; @@ -49,6 +53,7 @@ class GetSetMethodNormalizerTest extends TestCase use IgnoredAttributesTestTrait; use MaxDepthTestTrait; use ObjectToPopulateTestTrait; + use SkipUninitializedValuesTestTrait; use TypeEnforcementTestTrait; /** @@ -440,6 +445,27 @@ public function testHasGetterNormalize() $this->normalizer->normalize($obj, 'any') ); } + + protected function getObjectCollectionWithExpectedArray(): array + { + return [[ + new TypedPropertiesObjectWithGetters(), + (new TypedPropertiesObjectWithGetters())->setUninitialized('value2'), + ], [ + ['initialized' => 'value', 'initialized2' => 'value'], + ['unInitialized' => 'value2', 'initialized' => 'value', 'initialized2' => 'value'], + ]]; + } + + protected function getNormalizerForCacheableObjectAttributesTest(): GetSetMethodNormalizer + { + return new GetSetMethodNormalizer(); + } + + protected function getNormalizerForSkipUninitializedValues(): NormalizerInterface + { + return new GetSetMethodNormalizer(new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()))); + } } class GetSetDummy diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index 50223b2e6135a..121f2d6aa0dcc 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -39,6 +39,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Php74DummyPrivate; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; use Symfony\Component\Serializer\Tests\Normalizer\Features\AttributesTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; @@ -50,6 +51,8 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\ObjectToPopulateTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\SkipNullValuesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\SkipUninitializedValuesTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\TypedPropertiesObject; +use Symfony\Component\Serializer\Tests\Normalizer\Features\TypedPropertiesObjectWithGetters; use Symfony\Component\Serializer\Tests\Normalizer\Features\TypeEnforcementTestTrait; /** @@ -58,6 +61,7 @@ class ObjectNormalizerTest extends TestCase { use AttributesTestTrait; + use CacheableObjectAttributesTestTrait; use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; @@ -558,6 +562,33 @@ protected function getNormalizerForSkipUninitializedValues(): ObjectNormalizer return new ObjectNormalizer($classMetadataFactory); } + protected function getObjectCollectionWithExpectedArray(): array + { + $typedPropsObject = new TypedPropertiesObject(); + $typedPropsObject->unInitialized = 'value2'; + + $collection = [ + new TypedPropertiesObject(), + $typedPropsObject, + new TypedPropertiesObjectWithGetters(), + (new TypedPropertiesObjectWithGetters())->setUninitialized('value2'), + ]; + + $expectedArrays = [ + ['initialized' => 'value', 'initialized2' => 'value'], + ['unInitialized' => 'value2', 'initialized' => 'value', 'initialized2' => 'value'], + ['initialized' => 'value', 'initialized2' => 'value'], + ['unInitialized' => 'value2', 'initialized' => 'value', 'initialized2' => 'value'], + ]; + + return [$collection, $expectedArrays]; + } + + protected function getNormalizerForCacheableObjectAttributesTest(): ObjectNormalizer + { + return new ObjectNormalizer(); + } + // type enforcement protected function getDenormalizerForTypeEnforcement(): ObjectNormalizer diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php index f3d9719b1b3d8..7b48e68ed29f4 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/PropertyNormalizerTest.php @@ -21,8 +21,10 @@ use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; @@ -32,6 +34,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Php74Dummy; use Symfony\Component\Serializer\Tests\Fixtures\PropertyCircularReferenceDummy; use Symfony\Component\Serializer\Tests\Fixtures\PropertySiblingHolder; +use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; @@ -39,10 +42,13 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ObjectToPopulateTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\SkipUninitializedValuesTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\TypedPropertiesObject; use Symfony\Component\Serializer\Tests\Normalizer\Features\TypeEnforcementTestTrait; class PropertyNormalizerTest extends TestCase { + use CacheableObjectAttributesTestTrait; use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; @@ -50,6 +56,7 @@ class PropertyNormalizerTest extends TestCase use IgnoredAttributesTestTrait; use MaxDepthTestTrait; use ObjectToPopulateTestTrait; + use SkipUninitializedValuesTestTrait; use TypeEnforcementTestTrait; /** @@ -401,6 +408,30 @@ public function testMultiDimensionObject() [3, 4, 5], ], $root->intMatrix); } + + protected function getObjectCollectionWithExpectedArray(): array + { + $typedPropsObject = new TypedPropertiesObject(); + $typedPropsObject->unInitialized = 'value2'; + + return [[ + new TypedPropertiesObject(), + $typedPropsObject, + ], [ + ['initialized' => 'value', 'initialized2' => 'value'], + ['unInitialized' => 'value2', 'initialized' => 'value', 'initialized2' => 'value'], + ]]; + } + + protected function getNormalizerForCacheableObjectAttributesTest(): AbstractObjectNormalizer + { + return new PropertyNormalizer(); + } + + protected function getNormalizerForSkipUninitializedValues(): NormalizerInterface + { + return new PropertyNormalizer(new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()))); + } } class PropertyDummy