Skip to content

Commit b87b268

Browse files
committed
Fix denormalizing nested arrays as object values
1 parent ce26936 commit b87b268

File tree

4 files changed

+213
-69
lines changed

4 files changed

+213
-69
lines changed

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

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -399,23 +399,7 @@ private function validateAndDenormalize(string $currentClass, string $attribute,
399399
return;
400400
}
401401

402-
if ($type->isCollection() && null !== ($collectionValueType = $type->getCollectionValueType()) && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
403-
$builtinType = Type::BUILTIN_TYPE_OBJECT;
404-
$class = $collectionValueType->getClassName().'[]';
405-
406-
// Fix a collection that contains the only one element
407-
// This is special to xml format only
408-
if ('xml' === $format && !\is_int(key($data))) {
409-
$data = [$data];
410-
}
411-
412-
if (null !== $collectionKeyType = $type->getCollectionKeyType()) {
413-
$context['key_type'] = $collectionKeyType;
414-
}
415-
} else {
416-
$builtinType = $type->getBuiltinType();
417-
$class = $type->getClassName();
418-
}
402+
[$builtinType, $class] = $this->flattenDenormalizationType($type, $context);
419403

420404
$expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
421405

@@ -452,6 +436,29 @@ private function validateAndDenormalize(string $currentClass, string $attribute,
452436
throw new NotNormalizableValueException(sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), \gettype($data)));
453437
}
454438

439+
private function flattenDenormalizationType(Type $type, array &$context): array
440+
{
441+
if (!$type->isCollection() || !($collectionValueType = $type->getCollectionValueType()) || (Type::BUILTIN_TYPE_OBJECT !== $collectionValueType->getBuiltinType() && Type::BUILTIN_TYPE_ARRAY !== $collectionValueType->getBuiltinType())) {
442+
return [$type->getBuiltinType(), $type->getClassName()];
443+
}
444+
445+
if ($collectionValueType->isCollection()) {
446+
[$builtinType, $class] = $this->flattenDenormalizationType($collectionValueType, $context);
447+
} else {
448+
[$builtinType, $class] = [$collectionValueType->getBuiltinType(), $collectionValueType->getClassName()];
449+
}
450+
451+
if (Type::BUILTIN_TYPE_OBJECT === $builtinType) {
452+
$class = $class.'[]';
453+
454+
if (null !== $collectionKeyType = $type->getCollectionKeyType()) {
455+
$context['key_types'][] = $collectionKeyType;
456+
}
457+
}
458+
459+
return [$builtinType, $class];
460+
}
461+
455462
/**
456463
* @internal
457464
*/

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Serializer\Normalizer;
1313

14+
use Symfony\Component\Serializer\Encoder\XmlEncoder;
1415
use Symfony\Component\Serializer\Exception\BadMethodCallException;
1516
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
1617
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
@@ -51,7 +52,18 @@ public function denormalize($data, $class, $format = null, array $context = [])
5152
$serializer = $this->serializer;
5253
$class = substr($class, 0, -2);
5354

54-
$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
55+
if (isset($context['key_types']) && \count($context['key_types'])) {
56+
$builtinType = array_pop($context['key_types'])->getBuiltinType();
57+
} else {
58+
$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
59+
}
60+
61+
// Fix a collection that contains the only one element
62+
// This is special to xml format only
63+
if (XmlEncoder::FORMAT === $format && (null === $builtinType ? !\is_int(key($data)) : !('\is_'.$builtinType)(key($data)))) {
64+
$data = [$data];
65+
}
66+
5567
foreach ($data as $key => $value) {
5668
if (null !== $builtinType && !('is_'.$builtinType)($key)) {
5769
throw new NotNormalizableValueException(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, \gettype($key)));

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

Lines changed: 67 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@
1515
use PHPUnit\Framework\TestCase;
1616
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
1717
use Symfony\Component\PropertyInfo\Type;
18-
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1918
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
2019
use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader;
2120
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
21+
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
2222
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2323
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
24-
use Symfony\Component\Serializer\SerializerAwareInterface;
2524
use Symfony\Component\Serializer\SerializerInterface;
2625

2726
class AbstractObjectNormalizerTest extends TestCase
@@ -78,6 +77,31 @@ public function testDenormalizeWithExtraAttributesAndNoGroupsWithMetadataFactory
7877
);
7978
}
8079

80+
public function testDenormalizeDeepCollection()
81+
{
82+
$denormalizer = $this->getDenormalizerForDummyDeepCollection();
83+
84+
$dummyCollection = $denormalizer->denormalize(
85+
[
86+
'children' => [
87+
[
88+
[
89+
'bar' => 'first',
90+
],
91+
],
92+
],
93+
],
94+
DummyDeepCollection::class
95+
);
96+
97+
$this->assertInstanceOf(DummyDeepCollection::class, $dummyCollection);
98+
$this->assertInternalType('array', $dummyCollection->children);
99+
$this->assertCount(1, $dummyCollection->children);
100+
$this->assertInternalType('array', $dummyCollection->children[0]);
101+
$this->assertCount(1, $dummyCollection->children[0]);
102+
$this->assertInstanceOf(DummyChild::class, $dummyCollection->children[0][0]);
103+
}
104+
81105
public function testDenormalizeCollectionDecodedFromXmlWithOneChild()
82106
{
83107
$denormalizer = $this->getDenormalizerForDummyCollection();
@@ -139,7 +163,41 @@ private function getDenormalizerForDummyCollection()
139163
));
140164

141165
$denormalizer = new AbstractObjectNormalizerCollectionDummy(null, null, $extractor);
142-
$arrayDenormalizer = new ArrayDenormalizerDummy();
166+
$arrayDenormalizer = new ArrayDenormalizer();
167+
$serializer = new SerializerCollectionDummy([$arrayDenormalizer, $denormalizer]);
168+
$arrayDenormalizer->setSerializer($serializer);
169+
$denormalizer->setSerializer($serializer);
170+
171+
return $denormalizer;
172+
}
173+
174+
private function getDenormalizerForDummyDeepCollection()
175+
{
176+
$extractor = $this->getMockBuilder(PhpDocExtractor::class)->getMock();
177+
$extractor->method('getTypes')
178+
->will($this->onConsecutiveCalls(
179+
[
180+
new Type(
181+
'array',
182+
false,
183+
null,
184+
true,
185+
new Type('int'),
186+
new Type(
187+
'array',
188+
false,
189+
null,
190+
true,
191+
new Type('int'),
192+
new Type('object', false, DummyChild::class)
193+
)
194+
),
195+
],
196+
null
197+
));
198+
199+
$denormalizer = new AbstractObjectNormalizerCollectionDummy(null, null, $extractor);
200+
$arrayDenormalizer = new ArrayDenormalizer();
143201
$serializer = new SerializerCollectionDummy([$arrayDenormalizer, $denormalizer]);
144202
$arrayDenormalizer->setSerializer($serializer);
145203
$denormalizer->setSerializer($serializer);
@@ -223,6 +281,12 @@ class DummyCollection
223281
public $children;
224282
}
225283

284+
class DummyDeepCollection
285+
{
286+
/** @var DummyChild[][] */
287+
public $children;
288+
}
289+
226290
class DummyChild
227291
{
228292
public $bar;
@@ -296,49 +360,3 @@ public function deserialize($data, $type, $format, array $context = [])
296360
{
297361
}
298362
}
299-
300-
class ArrayDenormalizerDummy implements DenormalizerInterface, SerializerAwareInterface
301-
{
302-
/**
303-
* @var SerializerInterface|DenormalizerInterface
304-
*/
305-
private $serializer;
306-
307-
/**
308-
* {@inheritdoc}
309-
*
310-
* @throws NotNormalizableValueException
311-
*/
312-
public function denormalize($data, $class, $format = null, array $context = [])
313-
{
314-
$serializer = $this->serializer;
315-
$class = substr($class, 0, -2);
316-
317-
foreach ($data as $key => $value) {
318-
$data[$key] = $serializer->denormalize($value, $class, $format, $context);
319-
}
320-
321-
return $data;
322-
}
323-
324-
/**
325-
* {@inheritdoc}
326-
*/
327-
public function supportsDenormalization($data, $type, $format = null, array $context = [])
328-
{
329-
return '[]' === substr($type, -2)
330-
&& $this->serializer->supportsDenormalization($data, substr($type, 0, -2), $format, $context);
331-
}
332-
333-
/**
334-
* {@inheritdoc}
335-
*/
336-
public function setSerializer(SerializerInterface $serializer)
337-
{
338-
$this->serializer = $serializer;
339-
}
340-
}
341-
342-
abstract class ObjectSerializerDenormalizer implements SerializerInterface, DenormalizerInterface
343-
{
344-
}

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

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
namespace Symfony\Component\Serializer\Tests\Normalizer;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\PropertyInfo\Type;
1516
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
17+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
18+
use Symfony\Component\Serializer\Serializer;
1619
use Symfony\Component\Serializer\SerializerInterface;
1720

1821
class ArrayDenormalizerTest extends TestCase
@@ -38,12 +41,12 @@ public function testDenormalize()
3841
{
3942
$this->serializer->expects($this->at(0))
4043
->method('denormalize')
41-
->with(['foo' => 'one', 'bar' => 'two'])
44+
->with(['foo' => 'one', 'bar' => 'two'], __NAMESPACE__.'\ArrayDummy')
4245
->will($this->returnValue(new ArrayDummy('one', 'two')));
4346

4447
$this->serializer->expects($this->at(1))
4548
->method('denormalize')
46-
->with(['foo' => 'three', 'bar' => 'four'])
49+
->with(['foo' => 'three', 'bar' => 'four'], __NAMESPACE__.'\ArrayDummy')
4750
->will($this->returnValue(new ArrayDummy('three', 'four')));
4851

4952
$result = $this->denormalizer->denormalize(
@@ -63,6 +66,110 @@ public function testDenormalize()
6366
);
6467
}
6568

69+
public function testDenormalizeNested()
70+
{
71+
$m = $this->getMockBuilder(DenormalizerInterface::class)
72+
->getMock();
73+
74+
$denormalizer = new ArrayDenormalizer();
75+
$serializer = new Serializer([$denormalizer, $m]);
76+
$denormalizer->setSerializer($serializer);
77+
78+
$m->method('denormalize')
79+
->with(['foo' => 'one', 'bar' => 'two'], __NAMESPACE__.'\ArrayDummy')
80+
->willReturn(new ArrayDummy('one', 'two'));
81+
82+
$m->method('supportsDenormalization')
83+
->with($this->anything(), __NAMESPACE__.'\ArrayDummy')
84+
->willReturn(true);
85+
86+
$result = $denormalizer->denormalize(
87+
[
88+
[
89+
['foo' => 'one', 'bar' => 'two'],
90+
],
91+
],
92+
__NAMESPACE__.'\ArrayDummy[][]',
93+
null,
94+
['key_types' => [new Type('int'), new Type('int')]]
95+
);
96+
97+
$this->assertEquals(
98+
[[
99+
new ArrayDummy('one', 'two'),
100+
]],
101+
$result
102+
);
103+
}
104+
105+
/**
106+
* @expectedException \Symfony\Component\Serializer\Exception\NotNormalizableValueException
107+
*/
108+
public function testDenormalizeCheckKeyType()
109+
{
110+
$this->denormalizer->denormalize(
111+
[
112+
['foo' => 'one', 'bar' => 'two'],
113+
],
114+
__NAMESPACE__.'\ArrayDummy[]',
115+
null,
116+
['key_type' => new Type('string')]
117+
);
118+
}
119+
120+
/**
121+
* @expectedException \Symfony\Component\Serializer\Exception\NotNormalizableValueException
122+
*/
123+
public function testDenormalizeCheckKeyTypes()
124+
{
125+
$this->denormalizer->denormalize(
126+
[
127+
['foo' => 'one', 'bar' => 'two'],
128+
],
129+
__NAMESPACE__.'\ArrayDummy[]',
130+
null,
131+
['key_types' => [new Type('string')]]
132+
);
133+
}
134+
135+
public function testDenormalizeNestedCheckKeyTypes()
136+
{
137+
$m = $this->getMockBuilder(DenormalizerInterface::class)
138+
->getMock();
139+
140+
$denormalizer = new ArrayDenormalizer();
141+
$serializer = new Serializer([$denormalizer, $m]);
142+
$denormalizer->setSerializer($serializer);
143+
144+
$m->method('denormalize')
145+
->with(['foo' => 'one', 'bar' => 'two'], __NAMESPACE__.'\ArrayDummy')
146+
->willReturn(new ArrayDummy('one', 'two'));
147+
148+
$m->method('supportsDenormalization')
149+
->with($this->anything(), __NAMESPACE__.'\ArrayDummy')
150+
->willReturn(true);
151+
152+
$result = $denormalizer->denormalize(
153+
[
154+
'top' => [
155+
['foo' => 'one', 'bar' => 'two'],
156+
],
157+
],
158+
__NAMESPACE__.'\ArrayDummy[][]',
159+
null,
160+
['key_types' => [new Type('string')], 'key_type' => new Type('int')]
161+
);
162+
163+
$this->assertEquals(
164+
[
165+
'top' => [
166+
new ArrayDummy('one', 'two'),
167+
],
168+
],
169+
$result
170+
);
171+
}
172+
66173
public function testSupportsValidArray()
67174
{
68175
$this->serializer->expects($this->once())

0 commit comments

Comments
 (0)