diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 3118834d80175..882c072404a93 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -1,6 +1,10 @@ CHANGELOG ========= +7.2 +--- +* Add support for union collection value types in `ArrayDenormalizer` + 7.1 --- diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index d1f565cea151f..076f61599c22b 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -444,11 +444,9 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass 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]; } @@ -508,41 +506,33 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass } } - 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; } - } 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) { diff --git a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php index 1bd6c54b374ce..e4149b559a8e3 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ArrayDenormalizer.php @@ -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. @@ -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; @@ -88,20 +116,20 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form } /** - * @param list $typeIdentifiers + * @param list $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); } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php index be4a30d866c4e..d07858ecc6e16 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ArrayDenormalizerTest.php @@ -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; @@ -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() @@ -108,6 +98,74 @@ public function testSupportsNoArray() ) ); } + + public static function getTestArrays(): array + { + return [ + 'array' => [ + [ + ['foo' => 'one', 'bar' => 'two'], + ['foo' => 'three', 'bar' => 'four'], + ], + [ + new ArrayDummy('one', 'two'), + new ArrayDummy('three', 'four'), + ], + __NAMESPACE__.'\ArrayDummy[]', + 'json', + ], + + 'array' => [ + [ + ['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' => [ + [ + ['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 @@ -121,3 +179,13 @@ public function __construct($foo, $bar) $this->bar = $bar; } } + +class UnionDummy +{ + public $baz; + + public function __construct($baz) + { + $this->baz = $baz; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index e6c0cd7205ee0..526871ba5e354 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -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", "null" given.', ], ]; @@ -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); } @@ -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); }