Skip to content

[Serializer] symfony#36594 attributes cache breaks normalization #43469

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Symfony\Component\Serializer\Tests\Normalizer\Features;

use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;

/**
* This test ensures that attributes caching implemented in AbstractObjectNormalizer
* does not break normalization of multiple objects having different set of initialized/unInitialized properties.
*
* The attributes cache MUST NOT depend on a specific object state, so that cached attributes could be reused
* while normalizing any number of instances of the same class in any order.
*/
trait CacheableObjectAttributesTestTrait
{
/**
* Returns a collection of objects to be normalized and compared with the expected array.
* It is a specific object normalizer test class responsibility to prepare testing data.
*/
abstract protected function getObjectCollectionWithExpectedArray(): array;

abstract protected function getNormalizerForCacheableObjectAttributesTest(): AbstractObjectNormalizer;

/**
* The same normalizer instance normalizes two objects of the same class in a row:
* 1. an object having some uninitialized properties
* 2. an object with all properties being initialized.
*
* @requires PHP 7.4
*/
public function testObjectCollectionNormalization()
{
[$collection, $expectedArray] = $this->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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace Symfony\Component\Serializer\Tests\Normalizer\Features;

class TypedPropertiesObjectWithGetters extends TypedPropertiesObject
{
public function getUnInitialized(): string
{
return $this->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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,29 @@
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;
use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait;
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;
use GroupsTestTrait;
use IgnoredAttributesTestTrait;
use MaxDepthTestTrait;
use ObjectToPopulateTestTrait;
use SkipUninitializedValuesTestTrait;
use TypeEnforcementTestTrait;

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand All @@ -58,6 +61,7 @@
class ObjectNormalizerTest extends TestCase
{
use AttributesTestTrait;
use CacheableObjectAttributesTestTrait;
use CallbacksTestTrait;
use CircularReferenceTestTrait;
use ConstructorArgumentsTestTrait;
Expand Down Expand Up @@ -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
Expand Down
Loading