Skip to content

Commit 7b8a73a

Browse files
committed
Add COLLECT_EXTRA_ATTRIBUTES_ERRORS and full deserialization path
1 parent c884399 commit 7b8a73a

16 files changed

+440
-22
lines changed

UPGRADE-6.2.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ Validator
1212
---------
1313

1414
* Deprecate the `loose` e-mail validation mode, use `html5` instead
15+
* Deprecate `PartialDenormalizationException::getErrors()`, call `getNotNormalizableValueErrors()` instead

src/Symfony/Component/PropertyAccess/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.2
5+
---
6+
7+
* Add `Symfony\Component\PropertyAccess\PropertyPath::append()`
8+
49
6.0
510
---
611

src/Symfony/Component/PropertyAccess/PropertyPath.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,29 @@ public function isIndex(int $index): bool
194194

195195
return $this->isIndex[$index];
196196
}
197+
198+
/**
199+
* Utility method for dealing with property paths.
200+
* For more extensive functionality, use instances of this class.
201+
*
202+
* Appends a path to a given property path.
203+
*
204+
* If the base path is empty, the appended path will be returned unchanged.
205+
* If the base path is not empty, and the appended path starts with a
206+
* squared opening bracket ("["), the concatenation of the two paths is
207+
* returned. Otherwise, the concatenation of the two paths is returned,
208+
* separated by a dot (".").
209+
*/
210+
public static function append(string $basePath, string $subPath): string
211+
{
212+
if ('' !== $subPath) {
213+
if ('[' === $subPath[0]) {
214+
return $basePath.$subPath;
215+
}
216+
217+
return '' !== $basePath ? $basePath.'.'.$subPath : $subPath;
218+
}
219+
220+
return $basePath;
221+
}
197222
}

src/Symfony/Component/PropertyAccess/Tests/PropertyPathTest.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,24 @@ public function testIsIndexDoesNotAcceptNegativeIndices()
170170

171171
$propertyPath->isIndex(-1);
172172
}
173+
174+
/**
175+
* @dataProvider provideAppendPaths
176+
*/
177+
public function testAppend($basePath, $subPath, $expectedPath, $message)
178+
{
179+
$this->assertSame($expectedPath, PropertyPath::append($basePath, $subPath), $message);
180+
}
181+
182+
public function provideAppendPaths()
183+
{
184+
return [
185+
['foo', '', 'foo', 'It returns the basePath if subPath is empty'],
186+
['', 'bar', 'bar', 'It returns the subPath if basePath is empty'],
187+
['foo', 'bar', 'foo.bar', 'It append the subPath to the basePath'],
188+
['foo', '[bar]', 'foo[bar]', 'It does not include the dot separator if subPath uses the array notation'],
189+
['0', 'bar', '0.bar', 'Leading zeros are kept.'],
190+
['0', 1, '0.1', 'Numeric subpaths do not cause PHP 7.4 errors.'],
191+
];
192+
}
173193
}

src/Symfony/Component/Serializer/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
6.2
5+
---
6+
* Add `COLLECT_EXTRA_ATTRIBUTES_ERRORS` option to `Serializer` to collect errors from nested denormalizations
7+
* Deprecate `PartialDenormalizationException::getErrors()`, call `getNotNormalizableValueErrors()` instead
8+
49
6.1
510
---
611

src/Symfony/Component/Serializer/Context/SerializerContextBuilder.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Serializer\Context;
1313

14+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
1415
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
1516
use Symfony\Component\Serializer\Serializer;
1617

@@ -36,4 +37,9 @@ public function withCollectDenormalizationErrors(?bool $collectDenormalizationEr
3637
{
3738
return $this->with(DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS, $collectDenormalizationErrors);
3839
}
40+
41+
public function withCollectExtraAttributesErrors(?bool $collectExtraAttributesErrors): static
42+
{
43+
return $this->with(DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS, $collectExtraAttributesErrors);
44+
}
3945
}

src/Symfony/Component/Serializer/Exception/PartialDenormalizationException.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,45 @@
1717
class PartialDenormalizationException extends UnexpectedValueException
1818
{
1919
private $data;
20-
private $errors;
20+
/**
21+
* @var NotNormalizableValueException[]
22+
*/
23+
private array $notNormalizableErrors;
24+
private ?ExtraAttributesException $extraAttributesError = null;
2125

22-
public function __construct($data, array $errors)
26+
public function __construct($data, array $notNormalizableErrors, array $extraAttributesErrors = [])
2327
{
2428
$this->data = $data;
25-
$this->errors = $errors;
29+
$this->notNormalizableErrors = $notNormalizableErrors;
30+
$extraAttributes = [];
31+
foreach ($extraAttributesErrors as $error) {
32+
\array_push($extraAttributes, ...$error->getExtraAttributes());
33+
}
34+
if (\count($extraAttributes) > 0) {
35+
$this->extraAttributesError = new ExtraAttributesException($extraAttributes);
36+
}
2637
}
2738

2839
public function getData()
2940
{
3041
return $this->data;
3142
}
3243

44+
/**
45+
* @deprecated Use getNotNormalizableValueErrors() instead.
46+
*/
3347
public function getErrors(): array
3448
{
35-
return $this->errors;
49+
return $this->getNotNormalizableValueErrors();
50+
}
51+
52+
public function getNotNormalizableValueErrors(): array
53+
{
54+
return $this->notNormalizableErrors;
55+
}
56+
57+
public function getExtraAttributesError(): ?ExtraAttributesException
58+
{
59+
return $this->extraAttributesError;
3660
}
3761
}

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
2828
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
2929
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
30+
use Symfony\Component\Serializer\Util\PropertyPath;
3031

3132
/**
3233
* Base class for a normalizer dealing with objects.
@@ -242,7 +243,7 @@ private function getAttributeNormalizationContext(object $object, string $attrib
242243
*/
243244
private function getAttributeDenormalizationContext(string $class, string $attribute, array $context): array
244245
{
245-
$context['deserialization_path'] = ($context['deserialization_path'] ?? false) ? $context['deserialization_path'].'.'.$attribute : $attribute;
246+
$context['deserialization_path'] = PropertyPath::append($context['deserialization_path'] ?? '', $attribute);
246247

247248
if (null === $metadata = $this->getAttributeMetadata($class, $attribute)) {
248249
return $context;
@@ -267,12 +268,12 @@ protected function instantiateObject(array &$data, string $class, array &$contex
267268
{
268269
if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) {
269270
if (!isset($data[$mapping->getTypeProperty()])) {
270-
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);
271+
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);
271272
}
272273

273274
$type = $data[$mapping->getTypeProperty()];
274275
if (null === ($mappedClass = $mapping->getClassForType($type))) {
275-
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);
276+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], PropertyPath::append($context['deserialization_path'] ?? '', $mapping->getTypeProperty()), true);
276277
}
277278

278279
if ($mappedClass !== $class) {
@@ -422,7 +423,11 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
422423
}
423424

424425
if (!empty($extraAttributes)) {
425-
throw new ExtraAttributesException($extraAttributes);
426+
$extraAttributeException = new ExtraAttributesException(array_map(fn (string $extraAttribute) => PropertyPath::append($context['deserialization_path'] ?? '', $extraAttribute), $extraAttributes));
427+
if (!isset($context['extra_attributes_exceptions'])) {
428+
throw $extraAttributeException;
429+
}
430+
$context['extra_attributes_exceptions'][] = $extraAttributeException;
426431
}
427432

428433
return $object;
@@ -486,14 +491,14 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
486491
} elseif ('true' === $data || '1' === $data) {
487492
$data = true;
488493
} else {
489-
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null);
494+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? $attribute);
490495
}
491496
break;
492497
case Type::BUILTIN_TYPE_INT:
493498
if (ctype_digit($data) || '-' === $data[0] && ctype_digit(substr($data, 1))) {
494499
$data = (int) $data;
495500
} else {
496-
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null);
501+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? $attribute);
497502
}
498503
break;
499504
case Type::BUILTIN_TYPE_FLOAT:
@@ -504,7 +509,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
504509
'NaN' => \NAN,
505510
'INF' => \INF,
506511
'-INF' => -\INF,
507-
default => throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null),
512+
default => throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? $attribute),
508513
};
509514
}
510515
}

src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Serializer\Exception\BadMethodCallException;
1515
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
1616
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
17+
use Symfony\Component\Serializer\Util\PropertyPath;
1718

1819
/**
1920
* Denormalizes arrays of objects.
@@ -48,7 +49,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
4849
$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
4950
foreach ($data as $key => $value) {
5051
$subContext = $context;
51-
$subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]";
52+
$subContext['deserialization_path'] = PropertyPath::append($context['deserialization_path'] ?? '', "[$key]");
5253

5354
if (null !== $builtinType && !('is_'.$builtinType)($key)) {
5455
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);

src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@
2424
*/
2525
interface DenormalizerInterface
2626
{
27+
/**
28+
* Whether to collect all denormalization errors or to stop at first error.
29+
*/
2730
public const COLLECT_DENORMALIZATION_ERRORS = 'collect_denormalization_errors';
2831

32+
/**
33+
* Whether to collect all extra attributes errors or to stop at first nested error.
34+
*/
35+
public const COLLECT_EXTRA_ATTRIBUTES_ERRORS = 'collect_extra_attributes_errors';
36+
2937
/**
3038
* Denormalizes data back into an object of the given class.
3139
*

src/Symfony/Component/Serializer/Serializer.php

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,19 +223,31 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
223223
throw new NotNormalizableValueException(sprintf('Could not denormalize object of type "%s", no supporting normalizer found.', $type));
224224
}
225225

226+
$notNormalizableExceptions = [];
226227
if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS])) {
228+
if ($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]) {
229+
$context['not_normalizable_value_exceptions'] = [];
230+
$notNormalizableExceptions = &$context['not_normalizable_value_exceptions'];
231+
}
227232
unset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]);
228-
$context['not_normalizable_value_exceptions'] = [];
229-
$errors = &$context['not_normalizable_value_exceptions'];
230-
$denormalized = $normalizer->denormalize($data, $type, $format, $context);
231-
if ($errors) {
232-
throw new PartialDenormalizationException($denormalized, $errors);
233+
}
234+
235+
$extraAttributesExceptions = [];
236+
if (isset($context[DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS])) {
237+
if ($context[DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS]) {
238+
$context['extra_attributes_exceptions'] = [];
239+
$extraAttributesExceptions = &$context['extra_attributes_exceptions'];
233240
}
241+
unset($context[DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS]);
242+
}
243+
244+
$denormalized = $normalizer->denormalize($data, $type, $format, $context);
234245

235-
return $denormalized;
246+
if (\count($notNormalizableExceptions) > 0 || \count($extraAttributesExceptions) > 0) {
247+
throw new PartialDenormalizationException($denormalized, $notNormalizableExceptions ?? [], $extraAttributesExceptions ?? []);
236248
}
237249

238-
return $normalizer->denormalize($data, $type, $format, $context);
250+
return $denormalized;
239251
}
240252

241253
/**

src/Symfony/Component/Serializer/Tests/Context/SerializerContextBuilderTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Serializer\Context\SerializerContextBuilder;
16+
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
1617
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
1718
use Symfony\Component\Serializer\Serializer;
1819

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

4345
$this->assertSame($values, $context);
@@ -51,11 +53,13 @@ public function withersDataProvider(): iterable
5153
yield 'With values' => [[
5254
Serializer::EMPTY_ARRAY_AS_OBJECT => true,
5355
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => false,
56+
DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS => false,
5457
]];
5558

5659
yield 'With null values' => [[
5760
Serializer::EMPTY_ARRAY_AS_OBJECT => null,
5861
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => null,
62+
DenormalizerInterface::COLLECT_EXTRA_ATTRIBUTES_ERRORS => null,
5963
]];
6064
}
6165
}

src/Symfony/Component/Serializer/Tests/Fixtures/Php74Full.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ final class Php74Full
3131
public DummyMessageInterface $dummyMessage;
3232
/** @var TestFoo[] $nestedArray */
3333
public TestFoo $nestedObject;
34+
public TestFoo $nestedObject2;
3435
}
3536

3637

0 commit comments

Comments
 (0)