From 2135c6a32f8fdca62f037ce2a2f72a4d111f981a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 3 Jul 2026 07:17:24 +0200 Subject: [PATCH] fix(serializer): denormalize nullable collections of enums/objects Symfony's TypeInfo represents a nullable collection value type (e.g. an array of a nullable BackedEnum, as produced by Doctrine's DoctrineExtractor for a nullable SIMPLE_ARRAY column with "enumType") as a NullableType wrapping the real ObjectType/BackedEnumType. AbstractItemNormalizer::createAndValidateAttributeValue() checked `$collectionValueType instanceof ObjectType` directly, which is false for a NullableType, so the collection-of-objects branch was skipped and the raw scalar values (e.g. "email") were left untouched instead of being denormalized into enum/object instances. This later caused Doctrine errors such as: ReflectionEnumProperty::getValue(): Argument #1 ($item) must be of type BackedEnum, string given Unwrap the collection value type through WrappingTypeInterface before the instanceof check, mirroring the existing unwrap loop used later in the same method for the item type. Nested CollectionType wrapping is deliberately left untouched so a collection-of-collections isn't misdetected as a collection-of-objects. Fixes #8379 --- src/Serializer/AbstractItemNormalizer.php | 12 ++++- .../Tests/AbstractItemNormalizerTest.php | 52 +++++++++++++++++++ .../Fixtures/ApiResource/NotificationType.php | 20 +++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 50f2984ce28..43a8ac5e079 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -1299,11 +1299,19 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } + // A nullable collection value type (e.g. an array of a nullable BackedEnum/object) is represented + // as a NullableType wrapping the real ObjectType, so it must be unwrapped before the instanceof + // check below; nested CollectionType wrapping is left untouched to keep union/collection detection intact. + $unwrappedCollectionValueType = $collectionValueType; + while ($unwrappedCollectionValueType instanceof WrappingTypeInterface && !$unwrappedCollectionValueType instanceof CollectionType) { + $unwrappedCollectionValueType = $unwrappedCollectionValueType->getWrappedType(); + } + if ( - ($t instanceof CollectionType && $collectionValueType instanceof ObjectType) + ($t instanceof CollectionType && $unwrappedCollectionValueType instanceof ObjectType) || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== $collectionValueType->getClassName()) ) { - $className = $collectionValueType->getClassName(); + $className = $unwrappedCollectionValueType instanceof ObjectType ? $unwrappedCollectionValueType->getClassName() : $collectionValueType->getClassName(); if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 5bdebb37817..e09e9f05a48 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -40,6 +40,7 @@ use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithNullableConstructorArg; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NotificationType; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\RelatedDummy; @@ -1335,6 +1336,57 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeNullableCollectionOfBackedEnums(): void + { + // Nullable collection value types (NullableType wrapping ObjectType/BackedEnumType) only exist + // in the native TypeInfo system. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $data = ['notificationType' => ['email']]; + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['notificationType'])); + + // Mirrors what Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor produces for a nullable + // Doctrine SIMPLE_ARRAY column mapped with "enumType": both the collection and its value type + // end up wrapped in a NullableType. + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType(Type::nullable(Type::list(Type::nullable(Type::enum(NotificationType::class))))) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [NotificationType::class, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->expects($this->once()) + ->method('setValue') + ->with($this->isInstanceOf(Dummy::class), 'notificationType', [NotificationType::Email]); + + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->denormalize(['email'], NotificationType::class.'[]', null, Argument::type('array'))->willReturn([NotificationType::Email]); + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $actual = $normalizer->denormalize($data, Dummy::class); + + $this->assertInstanceOf(Dummy::class, $actual); + } + public function testTypeConfusionGuardPreservesPathAndExpectedType(): void { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); diff --git a/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php b/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php new file mode 100644 index 00000000000..279299ac4d4 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/NotificationType.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +enum NotificationType: string +{ + case Email = 'email'; + case Sms = 'sms'; +}