10000 Add support for union collection value types in `ArrayDenormalizer` · symfony/symfony@3e542ae · GitHub
[go: up one dir, main page]

Skip to content

Commit 3e542ae

Browse files
committed
Add support for union collection value types in ArrayDenormalizer
1 parent ba7f78a commit 3e542ae

File tree

6 files changed

+159
-77
lines changed

6 files changed

+159
-77
lines changed

src/Symfony/Component/Serializer/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ CHANGELOG
1111
* JsonDecode: Add `json_decode_detailed_errors` option
1212
* Make `ProblemNormalizer` give details about Messenger's `ValidationFailedException`
1313
* Add `XmlEncoder::CDATA_WRAPPING` context option
14+
* Add support for union collection value types in `ArrayDenormalizer`
1415

1516
6.3
1617
---

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

+21-40
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,6 @@ public function __construct(
139139
}
140140

141141
/**
142-
* @param array $context
143-
*
144142
* @return bool
145143
*/
146144
public function supportsNormalization(mixed $data, string $format = null /* , array $context = [] */)
@@ -290,24 +288,17 @@ abstract protected function extractAttributes(object $object, string $format = n
290288

291289
/**
292290
* Gets the attribute value.
293-
*
294-
* @return mixed
295291
*/
296292
abstract protected function getAttributeValue(object $object, string $attribute, string $format = null, array $context = []);
297293

298294
/**
299-
* @param array $context
300-
*
301295
* @return bool
302296
*/
303297
public function supportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
304298
{
305299
return class_exists($type) || (interface_exists($type, false) && null !== $this->classDiscriminatorResolver?->getMappingForClass($type));
306300
}
307301

308-
/**
309-
* @return mixed
310-
*/
311302
public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
312303
{
313304
if (!isset($context['cache_key'])) {
@@ -439,11 +430,9 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
439430
return null;
440431
}
441432

442-
$collectionValueType = $type->isCollection() ? $type->getCollectionValueTypes()[0] ?? null : null;
443-
444433
// Fix a collection that contains the only one element
445434
// This is special to xml format only
446-
if ('xml' === $format && null !== $collectionValueType && (!\is_array($data) || !\is_int(key($data)))) {
435+
if ('xml' === $format && \count($type->getCollectionValueTypes()) > 0 && (!\is_array($data) || !\is_int(key($data)))) {
447436
$data = [$data];
448437
}
449438

@@ -499,41 +488,33 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
499488
}
500489
}
501490

502-
if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
503-
$builtinType = Type::BUILTIN_TYPE_OBJECT;
504-
$class = $collectionValueType->getClassName().'[]';
491+
$builtinType = $type->getBuiltinType();
492+
$class = $type->getClassName();
505493

506-
if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) {
507-
$context['key_type'] = \count($collectionKeyType) > 1 ? $collectionKeyType : $collectionKeyType[0];
508-
}
509-
510-
$context['value_type'] = $collectionValueType;
511-
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
512-
// get inner type for any nested array
513-
[$innerType] = $collectionValueType;
514-
515-
// note that it will break for any other builtinType
516-
$dimensions = '[]';
517-
while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) {
518-
$dimensions .= '[]';
494+
$innerType = $type;
495+
if ($type->isCollection() && \count($type->getCollectionValueTypes()) > 0) {
496+
while (1 === \count($innerType->getCollectionValueTypes()) && Type::BUILTIN_TYPE_ARRAY === $innerType->getCollectionValueTypes()[0]->getBuiltinType()) {
519497
[$innerType] = $innerType->getCollectionValueTypes();
520498
}
521499

522-
if (null !== $innerType->getClassName()) {
523-
// the builtinType is the inner one and the class is the class followed by []...[]
524-
$builtinType = $innerType->getBuiltinType();
525-
$class = $innerType->getClassName().$dimensions;
526-
} else {
527-
// default fallback (keep it as array)
528-
$builtinType = $type->getBuiltinType();
529-
$class = $type->getClassName();
500+
$dimensions = '';
501+
$arrayType = $type;
502+
do {
503+
$dimensions .= '[]';
504+
[$arrayType] = $arrayType->getCollectionValueTypes();
505+
} while (\count($arrayType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $arrayType->getBuiltinType());
506+
507+
if (\count($innerType->getCollectionValueTypes()) > 1 || \in_array($innerType->getCollectionValueTypes()[0]->getBuiltinType(), [Type::BUILTIN_TYPE_OBJECT, Type::BUILTIN_TYPE_ARRAY], true)) {
508+
$builtinType = Type::BUILTIN_TYPE_OBJECT;
509+
$class = $arrayType->getClassName().$dimensions;
510+
$context['value_type'] = $type;
511+
$expectedTypes['array<'.implode('|', array_map(fn (Type $t) => $t->getClassName() ?? $t->getBuiltinType(), $innerType->getCollectionValueTypes())).'>'] = true;
530512
}
531-
} else {
532-
$builtinType = $type->getBuiltinType();
533-
$class = $type->getClassName();
534513
}
535514

536-
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
515+
if (!str_ends_with($class, '[]')) {
516+
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
517+
}
537518

538519
if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
539520
if (!$this->serializer instanceof DenormalizerInterface) {

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

+40-8
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
use Symfony\Component\PropertyInfo\Type;
1515
use Symfony\Component\Serializer\Exception\BadMethodCallException;
16+
use Symfony\Component\Serializer\Exception\ExtraAttributesException;
1617
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
18+
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
1719
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1820

1921
/**
@@ -50,25 +52,58 @@ public function denormalize(mixed $data, string $type, string $format = null, ar
5052
throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
5153
}
5254
if (!\is_array($data)) {
53-
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be "%s", "%s" given.', $type, get_debug_type($data)), $data, [Type::BUILTIN_TYPE_ARRAY], $context['deserialization_path'] ?? null);
55+
$valueType = $context['value_type'] ?? null;
56+
$expected = $valueType ? 'array<'.implode('|', array_map(fn (Type $type) => $type->getClassName() ?? $type->getBuiltinType(), $valueType->getCollectionValueTypes())).'>' : $type;
57+
58+
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be "%s", "%s" given.', $expected, get_debug_type($data)), $data, [Type::BUILTIN_TYPE_ARRAY], $context['deserialization_path'] ?? null);
5459
}
5560
if (!str_ends_with($type, '[]')) {
5661
throw new InvalidArgumentException('Unsupported class: '.$type);
5762
}
5863

5964
$type = substr($type, 0, -2);
65+
$valueType = $context['value_type'] ?? null;
6066

61-
$builtinTypes = array_map(static function (Type $keyType) {
62-
return $keyType->getBuiltinType();
63-
}, \is_array($keyType = $context['key_type'] ?? []) ? $keyType : [$keyType]);
67+
if ($valueType instanceof Type && \count($keyTypes = $valueType->getCollectionKeyTypes()) > 0) {
68+
$builtinTypes = array_map(static fn (Type $keyType) => $keyType->getBuiltinType(), $keyTypes);
69+
} else {
70+
$builtinTypes = array_map(static fn (Type $keyType) => $keyType->getBuiltinType(), \is_array($keyType = $context['key_type'] ?? []) ? $keyType : [$keyType]);
71+
}
6472

6573
foreach ($data as $key => $value) {
6674
$subContext = $context;
6775
$subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]";
6876

6977
$this->validateKeyType($builtinTypes, $key, $subContext['deserialization_path']);
7078

71-
$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext);
79+
if ($valueType instanceof Type) {
80+
foreach ($valueType->getCollectionValueTypes() as $subtype) {
81+
try {
82+
$subContext['value_type'] = $subtype;
83+
84+
if ($subtype->isNullable() && null === $value) {
85+
$data[$key] = null;
86+
87+
continue 2;
88+
}
89+
90+
if (Type::BUILTIN_TYPE_ARRAY === $subtype->getBuiltinType()) {
91+
$class = $type;
92+
} else {
93+
$class = $subtype->getClassName() ?? $subtype->getBuiltinType();
94+
}
95+
96+
$data[$key] = $this->denormalizer->denormalize($value, $class, $format, $subContext);
97+
98+
continue 2;
99+
} catch (NotNormalizableValueException|InvalidArgumentException|ExtraAttributesException|MissingConstructorArgumentsException $e) {
100+
}
101+
}
102+
103+
throw $e;
104+
} else {
105+
$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext);
106+
}
72107
}
73108

74109
return $data;
@@ -94,9 +129,6 @@ public function hasCacheableSupportsMethod(): bool
94129
return $this->denormalizer instanceof CacheableSupportsMethodInterface && $this->denormalizer->hasCacheableSupportsMethod();
95130
}
96131

97-
/**
98-
* @param mixed $key
99-
*/
100132
private function validateKeyType(array $builtinTypes, $key, string $path): void
101133
{
102134
if (!$builtinTypes) {

src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderWithDoctrineAnnotationsTest.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class AnnotationLoaderWithDoctrineAnnotationsTest extends AnnotationLoaderTestCa
2424

2525
protected function setUp(): void
2626
{
27-
$this->expectDeprecation('Since symfony/validator 6.4: Passing a "Doctrine\Common\Annotations\AnnotationReader" instance as argument 1 to "Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader::__construct()" is deprecated, pass null or omit the parameter instead.');
27+
$this->expectDeprecation('Since symfony/serializer 6.4: Passing a "Doctrine\Common\Annotations\AnnotationReader" instance as argument 1 to "Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader::__construct()" is deprecated, pass null or omit the parameter instead.');
2828

2929
parent::setUp();
3030
}

src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php

+95-27
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
use PHPUnit\Framework\MockObject\MockObject;
1515
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\PropertyInfo\Type;
17+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1618
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
1719
use Symfony\Component\Serializer\Tests\Fixtures\UpcomingDenormalizerInterface as DenormalizerInterface;
1820

@@ -28,38 +30,94 @@ protected function setUp(): void
2830
$this->denormalizer->setDenormalizer($this->serializer);
2931
}
3032

31-
public function testDenormalize()
33+
public static function getTestArrays(): array
3234
{
33-
$series = [
34-
[[['foo' => 'one', 'bar' => 'two']], new ArrayDummy('one', 'two')],
35-
[[['foo' => 'three', 'bar' => 'four']], new ArrayDummy('three', 'four')],
36-
];
37-
38-
$this->serializer->expects($this->exactly(2))
39-
->method('denormalize')
40-
->willReturnCallback(function ($data) use (&$series) {
41-
[$expectedArgs, $return] = array_shift($series);
42-
$this->assertSame($expectedArgs, [$data]);
43-
44-
return $return;
45-
})
46-
;
35+
return [
36+
'array<ArrayDummy>' => [
37+
[
38+
['foo' => 'one', 'bar' => 'two'],
39+
['foo' => 'three', 'bar' => 'four'],
40+
],
41+
[
42+
new ArrayDummy('one', 'two'),
43+
new ArrayDummy('three', 'four'),
44+
],
45+
__NAMESPACE__.'\ArrayDummy[]',
46+
'json',
47+
],
4748

48-
$result = $this->denormalizer->denormalize(
49-
[
50-
['foo' => 'one', 'bar' => 'two'],
51-
['foo' => 'three', 'bar' => 'four'],
49+
'array<ArrayDummy|UnionDummy|null>' => [
50+
[
51+
['foo' => 'one', 'bar' => 'two'],
52+
['baz' => 'three'],
53+
null,
54+
],
55+
[
56+
new ArrayDummy('one', 'two'),
57+
new UnionDummy('three'),
58+
null,
59+
],
60+
'mixed[]',
61+
'json',
62+
[
63+
'value_type' => new Type(
64+
Type::BUILTIN_TYPE_ARRAY,
65+
collection: true,
66+
collectionValueType: [
67+
new Type(Type::BUILTIN_TYPE_OBJECT, true, ArrayDummy::class),
68+
new Type(Type::BUILTIN_TYPE_OBJECT, class: UnionDummy::class),
69+
]
70+
),
71+
],
5272
],
53-
__NAMESPACE__.'\ArrayDummy[]'
54-
);
5573

56-
$this->assertEquals(
57-
[
58-
new ArrayDummy('one', 'two'),
59-
new ArrayDummy('three', 'four'),
74+
'array<ArrayDummy|string>' => [
75+
[
76+
['foo' => 'one', 'bar' => 'two'],
77+
['foo' => 'three', 'bar' => 'four'],
78+
'string',
79+
],
80+
[
81+
new ArrayDummy('one', 'two'),
82+
new ArrayDummy('three', 'four'),
83+
'string',
84+
],
85+
'mixed[]',
86+
'json',
87+
[
88+
'value_type' => new Type(
89+
Type::BUILTIN_TYPE_ARRAY,
90+
collection: true,
91+
collectionValueType: [
92+
new Type(Type::BUILTIN_TYPE_OBJECT, class: ArrayDummy::class),
93+
new Type(Type::BUILTIN_TYPE_STRING),
94+
]
95+
),
96+
],
6097
],
61-
$result
62-
);
98+
];
99+
}
100+
101+
/**
102+
* @dataProvider getTestArrays
103+
*/
104+
public function testDenormalizeMixed(array $input, array $expected, string $type, string $format, array $context = [])
105+
{
106+
$this->serializer->expects($this->atLeastOnce())
107+
->method('denormalize')
108+
->willReturnCallback(function ($data, $type, $format, $context) use ($input) {
109+
$key = (int) trim($context['deserialization_path'], '[]');
110+
$expected = $input[$key];
111+
$this->assertSame($expected, $data);
112+
113+
try {
114+
return class_exists($type) ? new $type(...$data) : $data;
115+
} catch (\Throwable $e) {
116+
throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
117+
}
118+
});
119+
120+
$this->assertEquals($expected, $this->denormalizer->denormalize($input, $type, $format, $context));
63121
}
64122

65123
public function testSupportsValidArray()
@@ -121,3 +179,13 @@ public function __construct($foo, $bar)
121179
$this->bar = $bar;
122180
}
123181
}
182+
183+
class UnionDummy
184+
{
185+
public $baz;
186+
187+
public function __construct($baz)
188+
{
189+
$this->baz = $baz;
190+
}
191+
}

src/Symfony/Component/Serializer/Tests/SerializerTest.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -1084,7 +1084,7 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet
10841084
'expectedTypes' => ['array'],
10851085
'path' => 'anotherCollection',
10861086
'useMessageForUser' => false,
1087-
'message' => 'Data expected to be "Symfony\Component\Serializer\Tests\Fixtures\Php74Full[]", "null" given.',
1087+
'message' => 'Data expected to be "array<Symfony\Component\Serializer\Tests\Fixtures\Php74Full>", "null" given.',
10881088
],
10891089
];
10901090

0 commit comments

Comments
 (0)
0