Skip to content

[Serializer] Add COLLECT_EXTRA_ATTRIBUTES_ERRORS and full deserialization path #46654

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

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from
Open
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
Add COLLECT_EXTRA_ATTRIBUTES_ERRORS and full deserialization path
  • Loading branch information
NorthBlue333 committed Oct 23, 2022
commit 2e6a44fbfcaa38a825d6045f53765e922ede6faa
2 changes: 2 additions & 0 deletions UPGRADE-6.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ Serializer
* Deprecate calling `AttributeMetadata::setSerializedName()`, `ClassMetadata::setClassDiscriminatorMapping()` without arguments
* Change the signature of `AttributeMetadataInterface::setSerializedName()` to `setSerializedName(?string)`
* Change the signature of `ClassMetadataInterface::setClassDiscriminatorMapping()` to `setClassDiscriminatorMapping(?ClassDiscriminatorMapping)`
* Change the signature of `PartialDenormalizationException::__construct($data, array $errors)` to `__construct(mixed $data, array $errors, array $extraAttributesErrors = [])`
* Deprecate `PartialDenormalizationException::getErrors()`, call `getNotNormalizableValueErrors()` instead

Translation
-----------
Expand Down
1 change: 1 addition & 0 deletions src/Symfony/Component/PropertyAccess/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CHANGELOG
* Added method `isNullSafe()` to `PropertyPathInterface`, implementing the interface without implementing this method
is deprecated
* Add support for the null-coalesce operator in property paths
* Add `PropertyPath::append()`

6.0
---
Expand Down
29 changes: 29 additions & 0 deletions src/Symfony/Component/PropertyAccess/PropertyPath.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,33 @@ public function isNullSafe(int $index): bool

return $this->isNullSafe[$index];
}

/**
* Utility method for dealing with property paths.
* For more extensive functionality, use instances of this class.
*
* Appends a path to a given property path.
*
* If the base path is empty, the appended path will be returned unchanged.
* If the base path is not empty, and the appended path starts with a
* squared opening bracket ("["), the concatenation of the two paths is
* returned. Otherwise, the concatenation of the two paths is returned,
* separated by a dot (".").
*/
public static function append(string $basePath, string $subPath): string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update the PropertyPathInterface by adding a new @method phpdoc attribute?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is a static method, I don't think it makes sense to add in in the PropertyPathInterface.

{
if ('' === $subPath) {
return $basePath;
}

if ('[' === $subPath[0]) {
return $basePath.$subPath;
}

if ('' === $basePath) {
return $subPath;
}

return $basePath.'.'.$subPath;
}
}
20 changes: 20 additions & 0 deletions src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,24 @@ public function testIsIndexDoesNotAcceptNegativeIndices()

$propertyPath->isIndex(-1);
}

/**
* @dataProvider provideAppendPaths
*/
public function testAppend(string $basePath, string $subPath, string $expectedPath, string $message)
{
$this->assertSame($expectedPath, PropertyPath::append($basePath, $subPath), $message);
}

public function provideAppendPaths()
{
return [
['foo', '', 'foo', 'It returns the basePath if subPath is empty'],
['', 'bar', 'bar', 'It returns the subPath if basePath is empty'],
['foo', 'bar', 'foo.bar', 'It append the subPath to the basePath'],
['foo', '[bar]', 'foo[bar]', 'It does not include the dot separator if subPath uses the array notation'],
['0', 'bar', '0.bar', 'Leading zeros are kept.'],
['0', 1, '0.1', 'Numeric subpaths do not cause PHP 7.4 errors.'],
];
}
}
2 changes: 2 additions & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ CHANGELOG
* Change the signature of `ClassMetadataInterface::setClassDiscriminatorMapping()` to `setClassDiscriminatorMapping(?ClassDiscriminatorMapping)`
* Add option YamlEncoder::YAML_INDENTATION to YamlEncoder constructor options to configure additional indentation for each level of nesting. This allows configuring indentation in the service configuration.
* Add `SerializedPath` annotation to flatten nested attributes
* Add `COLLECT_EXTRA_ATTRIBUTES_ERRORS` option to `Serializer` to collect errors from nested denormalizations
* Deprecate `PartialDenormalizationException::getErrors()`, call `getNotNormalizableValueErrors()` instead

6.1
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Serializer\Context;

use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Serializer;

Expand All @@ -36,4 +37,9 @@ public function withCollectDenormalizationErrors(?bool $collectDenormalizationEr
{
return $this->with(DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS, $collectDenormalizationErrors);
}

public function withCollectExtraAttributesErrors(?bool $collectExtraAttributesErrors): static
{
return $this->with(DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS, $collectExtraAttributesErrors);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,51 @@
*/
class PartialDenormalizationException extends UnexpectedValueException
{
private $data;
private $errors;
private ?ExtraAttributesException $extraAttributesError = null;

public function __construct($data, array $errors)
public function __construct(
private mixed $data,
/**
* @var NotNormalizableValueException[]
*/
private array $notNormalizableErrors,
array $extraAttributesErrors = []
)
{
$this->data = $data;
$this->errors = $errors;
$this->notNormalizableErrors = $notNormalizableErrors;
$extraAttributes = [];
foreach ($extraAttributesErrors as $error) {
$extraAttributes = \array_merge($extraAttributes, $error->getExtraAttributes());
}
if ($extraAttributes) {
$this->extraAttributesError = new ExtraAttributesException($extraAttributes);
}
}

public function getData()
{
return $this->data;
}

/**
* @deprecated since Symfony 6.2, use getNotNormalizableValueErrors() instead.
*/
public function getErrors(): array
{
return $this->errors;
return $this->getNotNormalizableValueErrors();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the method should throw a runtime deprecation
can you remind me why we deprecated the method?
deprecating always comes with a cost so it needs a good enough reason

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well this was because it might be used. Should I completely remove it / throw an exception instead of deprecating it?

}

/**
* @return NotNormalizableValueException[]
*/
public function getNotNormalizableValueErrors(): array
{
return $this->notNormalizableErrors;
}

public function getExtraAttributesError(): ?ExtraAttributesException
{
return $this->extraAttributesError;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\PropertyAccess\PropertyPath;
use Symfony\Component\Serializer\Exception\CircularReferenceException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\LogicException;
Expand Down Expand Up @@ -505,7 +506,7 @@ protected function getAttributeNormalizationContext(object $object, string $attr
*/
protected function getAttributeDenormalizationContext(string $class, string $attribute, array $context): array
{
$context['deserialization_path'] = ($context['deserialization_path'] ?? false) ? $context['deserialization_path'].'.'.$attribute : $attribute;
$context['deserialization_path'] = PropertyPath::append($context['deserialization_path'] ?? '', $attribute);

if (null === $metadata = $this->getAttributeMetadata($class, $attribute)) {
return $context;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyPath;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Encoder\CsvEncoder;
Expand Down Expand Up @@ -228,12 +229,12 @@ protected function instantiateObject(array &$data, string $class, array &$contex
{
if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) {
if (!isset($data[$mapping->getTypeProperty()])) {
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class), null, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), false);
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class), null, ['string'], PropertyPath::append($context['deserialization_path'] ?? '', $mapping->getTypeProperty()), false);
}

$type = $data[$mapping->getTypeProperty()];
if (null === ($mappedClass = $mapping->getClassForType($type))) {
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), true);
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], PropertyPath::append($context['deserialization_path'] ?? '', $mapping->getTypeProperty()), true);
}

if ($mappedClass !== $class) {
Expand Down Expand Up @@ -398,8 +399,12 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
}
}

if ($extraAttributes) {
throw new ExtraAttributesException($extraAttributes);
if (!$extraAttributes) {
$extraAttributeException = new ExtraAttributesException(array_map(fn (string $extraAttribute) => PropertyPath::append($context['deserialization_path'] ?? '', $extraAttribute), $extraAttributes));
if (!isset($context['extra_attributes_exceptions'])) {
throw $extraAttributeException;
}
$context['extra_attributes_exceptions'][] = $extraAttributeException;
}

return $object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Serializer\Normalizer;

use Symfony\Component\PropertyAccess\PropertyPath;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
Expand Down Expand Up @@ -47,7 +48,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
foreach ($data as $key => $value) {
$subContext = $context;
$subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]";
$subContext['deserialization_path'] = PropertyPath::append($context['deserialization_path'] ?? '', "[$key]");

if (null !== $builtinType && !('is_'.$builtinType)($key)) {
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key)), $key, [$builtinType], $subContext['deserialization_path'] ?? null, true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,16 @@
*/
interface DenormalizerInterface
{
/**
* Whether to collect all denormalization errors or to stop at first error.
*/
public const COLLECT_DENORMALIZATION_ERRORS = 'collect_denormalization_errors';

/**
* Whether to collect all extra attributes errors or to stop at first nested error.
*/
public const COLLECT_EXTRA_ATTRIBUTES_ERRORS = 'collect_extra_attributes_errors';

/**
* Denormalizes data back into an object of the given class.
*
Expand Down
26 changes: 17 additions & 9 deletions src/Symfony/Component/Serializer/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,19 +212,27 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
throw new NotNormalizableValueException(sprintf('Could not denormalize object of type "%s", no supporting normalizer found.', $type));
}

if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS])) {
unset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]);
$notNormalizableExceptions = [];
if ($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS] ?? false) {
$context['not_normalizable_value_exceptions'] = [];
$errors = &$context['not_normalizable_value_exceptions'];
$denormalized = $normalizer->denormalize($data, $type, $format, $context);
if ($errors) {
throw new PartialDenormalizationException($denormalized, $errors);
}
$notNormalizableExceptions = &$context['not_normalizable_value_exceptions'];
}
unset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]);

$extraAttributesExceptions = [];
if ($context[DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS] ?? false) {
$context['extra_attributes_exceptions'] = [];
$extraAttributesExceptions = &$context['extra_attributes_exceptions'];
}
unset($context[DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS]);

$denormalized = $normalizer->denormalize($data, $type, $format, $context);

return $denormalized;
if ($notNormalizableExceptions || $extraAttributesExceptions) {
throw new PartialDenormalizationException($denormalized, $notNormalizableExceptions, $extraAttributesExceptions);
}

return $normalizer->denormalize($data, $type, $format, $context);
return $denormalized;
}

public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Context\SerializerContextBuilder;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Serializer;

Expand All @@ -38,6 +39,7 @@ public function testWithers(array $values)
$context = $this->contextBuilder
->withEmptyArrayAsObject($values[Serializer::EMPTY_ARRAY_AS_OBJECT])
->withCollectDenormalizationErrors($values[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS])
->withCollectExtraAttributesErrors($values[DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS])
->toArray();

$this->assertSame($values, $context);
Expand All @@ -51,11 +53,13 @@ public function withersDataProvider(): iterable
yield 'With values' => [[
Serializer::EMPTY_ARRAY_AS_OBJECT => true,
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => false,
DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS => false,
]];

yield 'With null values' => [[
Serializer::EMPTY_ARRAY_AS_OBJECT => null,
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => null,
DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS => null,
]];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ final class Php74Full
public TestFoo $nestedObject;
/** @var Php74Full[] */
public $anotherCollection;
public TestFoo $nestedObject2;
}


Expand Down
Loading