Skip to content

Commit d5d8176

Browse files
authored
fix(serializer): allow nullable to-many relations to normalize as null (#8254)
Fixes #7050
1 parent 39edcdd commit d5d8176

5 files changed

Lines changed: 195 additions & 0 deletions

File tree

src/Serializer/AbstractItemNormalizer.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,10 @@ protected function getAttributeValue(object $object, string $attribute, ?string
852852

853853
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
854854

855+
if (null === $attributeValue && $type->isNullable()) {
856+
return null;
857+
}
858+
855859
if (!is_iterable($attributeValue)) {
856860
throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
857861
}
@@ -983,6 +987,10 @@ protected function getAttributeValue(object $object, string $attribute, ?string
983987

984988
$attributeValue = $this->propertyAccessor->getValue($object, $attribute);
985989

990+
if ($nullable && null === $attributeValue) {
991+
return null;
992+
}
993+
986994
if (!is_iterable($attributeValue)) {
987995
throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.');
988996
}

src/Serializer/Tests/AbstractItemNormalizerTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,61 @@ public function testNormalize(): void
168168
]));
169169
}
170170

171+
public function testNormalizeNullableToManyRelationReturnsNull(): void
172+
{
173+
$dummy = new Dummy();
174+
$dummy->setName('foo');
175+
176+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
177+
$propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummies']));
178+
179+
// BC layer for api-platform/metadata < 4.1
180+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
181+
$relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class);
182+
$relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType);
183+
184+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
185+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true));
186+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false));
187+
} else {
188+
$relatedDummiesType = Type::nullable(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()));
189+
190+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
191+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true));
192+
$propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false));
193+
}
194+
195+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
196+
$iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1');
197+
198+
$propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class);
199+
$propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('foo');
200+
$propertyAccessorProphecy->getValue($dummy, 'relatedDummies')->willReturn(null);
201+
202+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
203+
$resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class);
204+
$resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class);
205+
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);
206+
$resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true);
207+
208+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
209+
$serializerProphecy->willImplement(NormalizerInterface::class);
210+
$serializerProphecy->normalize('foo', null, Argument::type('array'))->willReturn('foo');
211+
$serializerProphecy->normalize(null, null, Argument::type('array'))->willReturn(null);
212+
213+
$normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {};
214+
$normalizer->setSerializer($serializerProphecy->reveal());
215+
216+
$expected = [
217+
'name' => 'foo',
218+
'relatedDummies' => null,
219+
];
220+
$this->assertSame($expected, $normalizer->normalize($dummy, null, [
221+
'resources' => [],
222+
'skip_null_values' => false,
223+
]));
224+
}
225+
171226
public function testNormalizeWithSecuredProperty(): void
172227
{
173228
$dummy = new SecuredDummy();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Operation;
19+
20+
#[Get(
21+
shortName: 'NullableToManyChild',
22+
uriTemplate: '/nullable_to_many_children/{id}',
23+
provider: [self::class, 'provide'],
24+
)]
25+
class NullableToManyChild
26+
{
27+
#[ApiProperty(identifier: true)]
28+
public ?int $id = null;
29+
30+
public string $name = '';
31+
32+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
33+
{
34+
$c = new self();
35+
$c->id = (int) ($uriVariables['id'] ?? 1);
36+
$c->name = 'child';
37+
38+
return $c;
39+
}
40+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\Metadata\Operation;
19+
use Doctrine\Common\Collections\Collection;
20+
21+
#[Get(
22+
shortName: 'NullableToManyParent',
23+
uriTemplate: '/nullable_to_many_parents/{id}',
24+
provider: [self::class, 'provide'],
25+
)]
26+
class NullableToManyParent
27+
{
28+
#[ApiProperty(identifier: true)]
29+
public ?int $id = null;
30+
31+
public string $name = '';
32+
33+
/** @var Collection<int, NullableToManyChild>|null */
34+
#[ApiProperty(readableLink: true)]
35+
public ?Collection $children = null;
36+
37+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
38+
{
39+
$p = new self();
40+
$p->id = (int) ($uriVariables['id'] ?? 1);
41+
$p->name = 'parent';
42+
$p->children = null;
43+
44+
return $p;
45+
}
46+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Serializer;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation\NullableToManyChild;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation\NullableToManyParent;
19+
use ApiPlatform\Tests\SetupClassResourcesTrait;
20+
21+
final class NullableToManyRelationTest extends ApiTestCase
22+
{
23+
use SetupClassResourcesTrait;
24+
25+
protected static ?bool $alwaysBootKernel = false;
26+
27+
/**
28+
* @return class-string[]
29+
*/
30+
public static function getResources(): array
31+
{
32+
return [NullableToManyParent::class, NullableToManyChild::class];
33+
}
34+
35+
public function testNullableToManyRelationNormalizesAsNull(): void
36+
{
37+
$response = self::createClient()->request('GET', '/nullable_to_many_parents/1', [
38+
'headers' => ['Accept' => 'application/ld+json'],
39+
]);
40+
41+
$this->assertResponseStatusCodeSame(200);
42+
$body = $response->toArray();
43+
$this->assertArrayHasKey('children', $body);
44+
$this->assertNull($body['children']);
45+
}
46+
}

0 commit comments

Comments
 (0)