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'; +}