Skip to content

[Serializer] Add support for union collection value types in ArrayDenormalizer #52018

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 2 commits into
base: 7.3
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
4 changes: 4 additions & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
CHANGELOG
=========

7.2
---
* Add support for union collection value types in `ArrayDenormalizer`

7.1
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -444,11 +444,9 @@
return null;
}

$collectionValueType = $type->isCollection() ? $type->getCollectionValueTypes()[0] ?? null : null;

// Fix a collection that contains the only one element
// This is special to xml format only
if ('xml' === $format && null !== $collectionValueType && (!\is_array($data) || !\is_int(key($data)))) {
if ('xml' === $format && \count($type->getCollectionValueTypes()) > 0 && (!\is_array($data) || !\is_int(key($data)))) {
$data = [$data];
}

Expand Down Expand Up @@ -508,41 +506,33 @@
}
}

if (null !== $collectionValueType && LegacyType::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) {
$builtinType = LegacyType::BUILTIN_TYPE_OBJECT;
$class = $collectionValueType->getClassName().'[]';

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

$context['value_type'] = $collectionValueType;
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
// get inner type for any nested array
[$innerType] = $collectionValueType;
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();

// note that it will break for any other builtinType
$dimensions = '[]';
while (\count($innerType->getCollectionValueTypes()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) {
$dimensions .= '[]';
$innerType = $type;
if ($type->isCollection() && \count($type->getCollectionValueTypes()) > 0) {
while (1 === \count($innerType->getCollectionValueTypes()) && LegacyType::BUILTIN_TYPE_ARRAY === $innerType->getCollectionValueTypes()[0]->getBuiltinType()) {
[$innerType] = $innerType->getCollectionValueTypes();
}

if (null !== $innerType->getClassName()) {
// the builtinType is the inner one and the class is the class followed by []...[]
$builtinType = $innerType->getBuiltinType();
$class = $innerType->getClassName().$dimensions;
} else {
// default fallback (keep it as array)
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();
$dimensions = '';
$arrayType = $type;
do {
$dimensions .= '[]';
[$arrayType] = $arrayType->getCollectionValueTypes();
} while (\count($arrayType->getCollectionValueTypes()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $arrayType->getBuiltinType());

if (\count($innerType->getCollectionValueTypes()) > 1 || \in_array($innerType->getCollectionValueTypes()[0]->getBuiltinType(), [LegacyType::BUILTIN_TYPE_OBJECT, LegacyType::BUILTIN_TYPE_ARRAY], true)) {
$builtinType = LegacyType::BUILTIN_TYPE_OBJECT;
$class = $arrayType->getClassName().$dimensions;
$context['value_type'] = $type;
$expectedTypes['array<'.implode('|', array_map(fn (Type $t) => $t->getClassName() ?? $t->getBuiltinType(), $innerType->getCollectionValueTypes())).'>'] = true;

Check failure on line 529 in src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php:529:72: InvalidArgument: Parameter 1 of closure passed to function array_map expects Symfony\Component\TypeInfo\Type, but Symfony\Component\PropertyInfo\Type provided (see https://psalm.dev/004)

Check failure on line 529 in src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php:529:92: UndefinedMethod: Method Symfony\Component\TypeInfo\Type::getClassName does not exist (see https://psalm.dev/022)

Check failure on line 529 in src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php:529:114: UndefinedMethod: Method Symfony\Component\TypeInfo\Type::getBuiltinType does not exist (see https://psalm.dev/022)

Check failure on line 529 in src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidArgument

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php:529:72: InvalidArgument: Parameter 1 of closure passed to function array_map expects Symfony\Component\TypeInfo\Type, but Symfony\Component\PropertyInfo\Type provided (see https://psalm.dev/004)

Check failure on line 529 in src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php:529:92: UndefinedMethod: Method Symfony\Component\TypeInfo\Type::getClassName does not exist (see https://psalm.dev/022)

Check failure on line 529 in src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

View workflow job for this annotation

GitHub Actions / Psalm

UndefinedMethod

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php:529:114: UndefinedMethod: Method Symfony\Component\TypeInfo\Type::getBuiltinType does not exist (see https://psalm.dev/022)
}
} else {
$builtinType = $type->getBuiltinType();
$class = $type->getClassName();
}

$expectedTypes[LegacyType::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
if (!str_ends_with($class, '[]')) {
$expectedTypes[LegacyType::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true;
}

if (LegacyType::BUILTIN_TYPE_OBJECT === $builtinType && null !== $class) {
if (!$this->serializer instanceof DenormalizerInterface) {
Expand Down
62 changes: 45 additions & 17 deletions src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

use Symfony\Component\PropertyInfo\Type as LegacyType;
use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\ExtraAttributesException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\UnionType;

/**
* Denormalizes arrays of objects.
Expand Down Expand Up @@ -48,30 +48,58 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
}
if (!\is_array($data)) {
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be "%s", "%s" given.', $type, get_debug_type($data)), $data, ['array'], $context['deserialization_path'] ?? null);
$valueType = $context['value_type'] ?? null;
$expected = $valueType ? 'array<'.implode('|', array_map(fn (LegacyType $type) => $type->getClassName() ?? $type->getBuiltinType(), $valueType->getCollectionValueTypes())).'>' : $type;

throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be "%s", "%s" given.', $expected, get_debug_type($data)), $data, ['array'], $context['deserialization_path'] ?? null);
}
if (!str_ends_with($type, '[]')) {
throw new InvalidArgumentException('Unsupported class: '.$type);
}

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

$typeIdentifiers = [];
if (null !== $keyType = ($context['key_type'] ?? null)) {
if ($keyType instanceof Type) {
$typeIdentifiers = array_map(fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]);
} else {
$typeIdentifiers = array_map(fn (LegacyType $t): string => $t->getBuiltinType(), \is_array($keyType) ? $keyType : [$keyType]);
}
if ($valueType instanceof LegacyType && \count($keyTypes = $valueType->getCollectionKeyTypes()) > 0) {
$builtinTypes = array_map(static fn (LegacyType $keyType) => $keyType->getBuiltinType(), $keyTypes);
} else {
$builtinTypes = array_map(static fn (LegacyType $keyType) => $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]";

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

if ($valueType instanceof LegacyType) {
foreach ($valueType->getCollectionValueTypes() as $subtype) {
try {
$subContext['value_type'] = $subtype;

if ($subtype->isNullable() && null === $value) {
$data[$key] = null;

continue 2;
}

$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext);
if (LegacyType::BUILTIN_TYPE_ARRAY === $subtype->getBuiltinType()) {
$class = $type;
} else {
$class = $subtype->getClassName() ?? $subtype->getBuiltinType();
}

$data[$key] = $this->denormalizer->denormalize($value, $class, $format, $subContext);

continue 2;
} catch (NotNormalizableValueException|InvalidArgumentException|ExtraAttributesException|MissingConstructorArgumentsException $e) {
}
}

throw $e;
} else {
$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext);
}
}

return $data;
Expand All @@ -88,20 +116,20 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
}

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

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

throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, implode('", "', $typeIdentifiers), get_debug_type($key)), $key, $typeIdentifiers, $path, true);
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

Expand All @@ -28,38 +30,26 @@ protected function setUp(): void
$this->denormalizer->setDenormalizer($this->serializer);
}

public function testDenormalize()
/**
* @dataProvider getTestArrays
*/
public function testDenormalize(array $input, array $expected, string $type, string $format, array $context = [])
{
$series = [
[[['foo' => 'one', 'bar' => 'two']], new ArrayDummy('one', 'two')],
[[['foo' => 'three', 'bar' => 'four']], new ArrayDummy('three', 'four')],
];

$this->serializer->expects($this->exactly(2))
$this->serializer->expects($this->atLeastOnce())
->method('denormalize')
->willReturnCallback(function ($data) use (&$series) {
[$expectedArgs, $return] = array_shift($series);
$this->assertSame($expectedArgs, [$data]);

return $return;
})
;

$result = $this->denormalizer->denormalize(
[
['foo' => 'one', 'bar' => 'two'],
['foo' => 'three', 'bar' => 'four'],
],
__NAMESPACE__.'\ArrayDummy[]'
);

$this->assertEquals(
[
new ArrayDummy('one', 'two'),
new ArrayDummy('three', 'four'),
],
$result
);
->willReturnCallback(function ($data, $type, $format, $context) use ($input) {
$key = (int) trim($context['deserialization_path'], '[]');
$expected = $input[$key];
$this->assertSame($expected, $data);

try {
return class_exists($type) ? new $type(...$data) : $data;
} catch (\Throwable $e) {
throw new NotNormalizableValueException($e->getMessage(), $e->getCode(), $e);
}
});

$this->assertEquals($expected, $this->denormalizer->denormalize($input, $type, $format, $context));
}

public function testSupportsValidArray()
Expand Down Expand Up @@ -108,6 +98,74 @@ public function testSupportsNoArray()
)
);
}

public static function getTestArrays(): array
{
return [
'array<ArrayDummy>' => [
[
['foo' => 'one', 'bar' => 'two'],
['foo' => 'three', 'bar' => 'four'],
],
[
new ArrayDummy('one', 'two'),
new ArrayDummy('three', 'four'),
],
__NAMESPACE__.'\ArrayDummy[]',
'json',
],

'array<ArrayDummy|UnionDummy|null>' => [
[
['foo' => 'one', 'bar' => 'two'],
['baz' => 'three'],
null,
],
[
new ArrayDummy('one', 'two'),
new UnionDummy('three'),
null,
],
'mixed[]',
'json',
[
'value_type' => new Type(
Type::BUILTIN_TYPE_ARRAY,
collection: true,
collectionValueType: [
new Type(Type::BUILTIN_TYPE_OBJECT, true, ArrayDummy::class),
new Type(Type::BUILTIN_TYPE_OBJECT, class: UnionDummy::class),
]
),
],
],

'array<ArrayDummy|string>' => [
[
['foo' => 'one', 'bar' => 'two'],
['foo' => 'three', 'bar' => 'four'],
'string',
],
[
new ArrayDummy('one', 'two'),
new ArrayDummy('three', 'four'),
'string',
],
'mixed[]',
'json',
[
'value_type' => new Type(
Type::BUILTIN_TYPE_ARRAY,
collection: true,
collectionValueType: [
new Type(Type::BUILTIN_TYPE_OBJECT, class: ArrayDummy::class),
new Type(Type::BUILTIN_TYPE_STRING),
]
),
],
],
];
}
}

class ArrayDummy
Expand All @@ -121,3 +179,13 @@ public function __construct($foo, $bar)
$this->bar = $bar;
}
}

class UnionDummy
{
public $baz;

public function __construct($baz)
{
$this->baz = $baz;
}
}
8 changes: 4 additions & 4 deletions src/Symfony/Component/Serializer/Tests/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,7 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet
'expectedTypes' => ['array'],
'path' => 'anotherCollection',
'useMessageForUser' => false,
'message' => 'Data expected to be "Symfony\Component\Serializer\Tests\Fixtures\Php74Full[]", "null" given.',
'message' => 'Data expected to be "array<Symfony\Component\Serializer\Tests\Fixtures\Php74Full>", "null" given.',
],
];

Expand Down Expand Up @@ -1214,7 +1214,7 @@ public function testCollectDenormalizationErrors2(?ClassMetadataFactory $classMe
'useMessageForUser' => false,
'message' => 'The type of the "string" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "string" ("null" given).',
],
];
];

$this->assertSame($expected, $exceptionsAsArray);
}
Expand Down Expand Up @@ -1464,8 +1464,8 @@ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruc

try {
$serializer->deserialize('{"get": "POST"}', DummyObjectWithEnumProperty::class, 'json', [
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
]);
DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true,
]);
} catch (\Throwable $e) {
$this->assertInstanceOf(PartialDenormalizationException::class, $e);
}
Expand Down
Loading