Skip to content

Commit

Permalink
bug #50933 [Serializer] Fix deserializing nested arrays of objects wi…
Browse files Browse the repository at this point in the history
…th mixed keys (HypeMC)

This PR was merged into the 5.4 branch.

Discussion
----------

[Serializer] Fix deserializing nested arrays of objects with mixed keys

| Q             | A
| ------------- | ---
| Branch?       | 5.4
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | Fix #50675
| License       | MIT
| Doc PR        | -

Currently an error is thrown when trying to deserialize the following nested array of objects:

```php
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;

$serializer = new Serializer([
    new ObjectNormalizer(null, null, null, new PhpDocExtractor()),
    new ArrayDenormalizer(),
], ['json' => new JsonEncoder()]);

class Outer
{
    /**
     * `@var` array<int|string, Inner>
     */
    public array $inners;
}

class Inner
{
    public string $name;
}

$serializer->deserialize('{"inners": {"1": {"name": "One"}, "two": {"name": "Two"}}}', Outer::class, 'json');
```

```
Fatal error: Uncaught Symfony\Component\Serializer\Exception\NotNormalizableValueException:
The type of the key "two" must be "int" ("string" given).
```

The same happens when using generics, e.g. `ArrayCollection<Inner>`.

Commits
-------

db0e89383c [Serializer] Fix deserializing nested arrays of objects with mixed keys
  • Loading branch information
nicolas-grekas committed Jul 27, 2023
2 parents dc70d86 + 14c28a2 commit 3589ce0
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 16 deletions.
2 changes: 1 addition & 1 deletion Normalizer/AbstractObjectNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ private function validateAndDenormalize(array $types, string $currentClass, stri
$class = $collectionValueType->getClassName().'[]';

if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) {
[$context['key_type']] = $collectionKeyType;
$context['key_type'] = \count($collectionKeyType) > 1 ? $collectionKeyType : $collectionKeyType[0];
}

$context['value_type'] = $collectionValueType;
Expand Down
27 changes: 23 additions & 4 deletions Normalizer/ArrayDenormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ public function denormalize($data, string $type, string $format = null, array $c

$type = substr($type, 0, -2);

$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
$builtinTypes = array_map(static function (Type $keyType) {
return $keyType->getBuiltinType();
}, \is_array($keyType = $context['key_type'] ?? []) ? $keyType : [$keyType]);

foreach ($data as $key => $value) {
$subContext = $context;
$subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$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);
}
$this->validateKeyType($builtinTypes, $key, $subContext['deserialization_path']);

$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext);
}
Expand Down Expand Up @@ -102,4 +103,22 @@ public function hasCacheableSupportsMethod(): bool
{
return $this->denormalizer instanceof CacheableSupportsMethodInterface && $this->denormalizer->hasCacheableSupportsMethod();
}

/**
* @param mixed $key
*/
private function validateKeyType(array $builtinTypes, $key, string $path): void
{
if (!$builtinTypes) {
return;
}

foreach ($builtinTypes as $builtinType) {
if (('is_'.$builtinType)($key)) {
return;
}
}

throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, implode('", "', $builtinTypes), get_debug_type($key)), $key, $builtinTypes, $path, true);
}
}
73 changes: 64 additions & 9 deletions Tests/DeserializeNestedArrayOfObjectsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ public static function provider()
*/
public function testPropertyPhpDoc($class)
{
// GIVEN
$json = <<<EOF
{
"animals": [
Expand All @@ -47,13 +46,62 @@ public function testPropertyPhpDoc($class)
new ObjectNormalizer(null, null, null, new PhpDocExtractor()),
new ArrayDenormalizer(),
], ['json' => new JsonEncoder()]);
// WHEN
/** @var Zoo $zoo */

/** @var Zoo|ZooImmutable $zoo */
$zoo = $serializer->deserialize($json, $class, 'json');
// THEN

self::assertCount(1, $zoo->getAnimals());
self::assertInstanceOf(Animal::class, $zoo->getAnimals()[0]);
}

public function testPropertyPhpDocWithKeyTypes()
{
$json = <<<EOF
{
"animalsInt": [
{"name": "Bug"}
],
"animalsString": {
"animal1": {"name": "Bug"}
},
"animalsUnion": {
"animal2": {"name": "Bug"},
"2": {"name": "Dog"}
},
"animalsGenerics": {
"animal3": {"name": "Bug"},
"3": {"name": "Dog"}
}
}
EOF;
$serializer = new Serializer([
new ObjectNormalizer(null, null, null, new PhpDocExtractor()),
new ArrayDenormalizer(),
], ['json' => new JsonEncoder()]);

/** @var ZooWithKeyTypes $zoo */
$zoo = $serializer->deserialize($json, ZooWithKeyTypes::class, 'json');

self::assertCount(1, $zoo->animalsInt);
self::assertArrayHasKey(0, $zoo->animalsInt);
self::assertInstanceOf(Animal::class, $zoo->animalsInt[0]);

self::assertCount(1, $zoo->animalsString);
self::assertArrayHasKey('animal1', $zoo->animalsString);
self::assertInstanceOf(Animal::class, $zoo->animalsString['animal1']);

self::assertCount(2, $zoo->animalsUnion);
self::assertArrayHasKey('animal2', $zoo->animalsUnion);
self::assertInstanceOf(Animal::class, $zoo->animalsUnion['animal2']);
self::assertArrayHasKey(2, $zoo->animalsUnion);
self::assertInstanceOf(Animal::class, $zoo->animalsUnion[2]);

self::assertCount(2, $zoo->animalsGenerics);
self::assertArrayHasKey('animal3', $zoo->animalsGenerics);
self::assertInstanceOf(Animal::class, $zoo->animalsGenerics['animal3']);
self::assertArrayHasKey(3, $zoo->animalsGenerics);
self::assertInstanceOf(Animal::class, $zoo->animalsGenerics[3]);
}
}

class Zoo
Expand Down Expand Up @@ -100,16 +148,23 @@ public function getAnimals(): array
}
}

class ZooWithKeyTypes
{
/** @var array<int, Animal> */
public $animalsInt = [];
/** @var array<string, Animal> */
public $animalsString = [];
/** @var array<int|string, Animal> */
public $animalsUnion = [];
/** @var \stdClass<Animal> */
public $animalsGenerics = [];
}

class Animal
{
/** @var string */
private $name;

public function __construct()
{
echo '';
}

public function getName(): ?string
{
return $this->name;
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"symfony/http-kernel": "^4.4|^5.0|^6.0",
"symfony/mime": "^4.4|^5.0|^6.0",
"symfony/property-access": "^5.4|^6.0",
"symfony/property-info": "^5.3.13|^6.0",
"symfony/property-info": "^5.4.24|^6.2.11",
"symfony/uid": "^5.3|^6.0",
"symfony/validator": "^4.4|^5.0|^6.0",
"symfony/var-dumper": "^4.4|^5.0|^6.0",
Expand All @@ -47,7 +47,7 @@
"phpdocumentor/type-resolver": "<1.4.0",
"symfony/dependency-injection": "<4.4",
"symfony/property-access": "<5.4",
"symfony/property-info": "<5.3.13",
"symfony/property-info": "<5.4.24|>=6,<6.2.11",
"symfony/uid": "<5.3",
"symfony/yaml": "<4.4"
},
Expand Down

0 comments on commit 3589ce0

Please sign in to comment.