Skip to content

Commit 134bb5c

Browse files
authored
fix(jsonld): replace already-populated nested relation from embedded @id on patch (#8274)
Fixes #8055
1 parent 1ffe0ad commit 134bb5c

6 files changed

Lines changed: 359 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v4.3.11
4+
5+
### Notes
6+
7+
* JSON-LD `PATCH`: an embedded `@id` on a nested writable relation now replaces the currently-linked relation when it points to a different resource. A dangling embedded `@id` now returns a 400 instead of being silently ignored (it previously mutated the existing relation in place). See #8274.
8+
39
## v4.3.10
410

511
### Bug fixes

src/JsonLd/Serializer/ItemNormalizer.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a
218218
throw $e;
219219
}
220220
}
221+
} elseif (isset($data['@id']) && ($context['deep_object_to_populate'] ?? false)) {
222+
// the object to populate is the relation currently linked to the parent, an explicit @id must replace it instead of mutating it in place
223+
$context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null);
221224
}
222225

223226
return parent::denormalize($data, $type, $format, $context);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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\Entity\NestedRelationPatch;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\Patch;
20+
use ApiPlatform\Metadata\Post;
21+
use Doctrine\ORM\Mapping as ORM;
22+
use Symfony\Component\Serializer\Attribute\Groups;
23+
24+
#[ORM\Entity]
25+
#[ORM\Table(name: 'nested_data_pool')]
26+
#[ApiResource(
27+
operations: [
28+
new Get(),
29+
new Post(),
30+
new Patch(inputFormats: ['jsonld' => ['application/ld+json'], 'json' => ['application/merge-patch+json']]),
31+
],
32+
normalizationContext: ['groups' => ['datapool:read']],
33+
denormalizationContext: ['groups' => ['datapool:write']],
34+
)]
35+
class NestedDataPool
36+
{
37+
#[ORM\Id]
38+
#[ORM\GeneratedValue]
39+
#[ORM\Column]
40+
#[Groups(['datapool:read'])]
41+
private ?int $id = null;
42+
43+
#[ORM\ManyToOne(cascade: ['persist'])]
44+
#[ApiProperty(writableLink: true, readableLink: true)]
45+
#[Groups(['datapool:read', 'datapool:write'])]
46+
private ?NestedDataPoolStartup $dataPoolStartup = null;
47+
48+
public function getId(): ?int
49+
{
50+
return $this->id;
51+
}
52+
53+
public function getDataPoolStartup(): ?NestedDataPoolStartup
54+
{
55+
return $this->dataPoolStartup;
56+
}
57+
58+
public function setDataPoolStartup(?NestedDataPoolStartup $dataPoolStartup): void
59+
{
60+
$this->dataPoolStartup = $dataPoolStartup;
61+
}
62+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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\Entity\NestedRelationPatch;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use Doctrine\ORM\Mapping as ORM;
19+
use Symfony\Component\Serializer\Attribute\Groups;
20+
21+
#[ORM\Entity]
22+
#[ORM\Table(name: 'nested_data_pool_startup')]
23+
#[ApiResource]
24+
class NestedDataPoolStartup
25+
{
26+
#[ORM\Id]
27+
#[ORM\GeneratedValue]
28+
#[ORM\Column]
29+
#[Groups(['datapool:read'])]
30+
private ?int $id = null;
31+
32+
#[ORM\ManyToOne(cascade: ['persist'])]
33+
#[ApiProperty(writableLink: true, readableLink: true)]
34+
#[Groups(['datapool:read', 'datapool:write'])]
35+
private ?NestedStartup $startup = null;
36+
37+
public function getId(): ?int
38+
{
39+
return $this->id;
40+
}
41+
42+
public function getStartup(): ?NestedStartup
43+
{
44+
return $this->startup;
45+
}
46+
47+
public function setStartup(?NestedStartup $startup): void
48+
{
49+
$this->startup = $startup;
50+
}
51+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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\Entity\NestedRelationPatch;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Symfony\Component\Serializer\Attribute\Groups;
19+
20+
#[ORM\Entity]
21+
#[ORM\Table(name: 'nested_startup')]
22+
#[ApiResource]
23+
class NestedStartup
24+
{
25+
#[ORM\Id]
26+
#[ORM\GeneratedValue]
27+
#[ORM\Column]
28+
#[Groups(['datapool:read', 'datapool:write'])]
29+
private ?int $id = null;
30+
31+
#[ORM\Column(length: 255, nullable: true)]
32+
#[Groups(['datapool:read', 'datapool:write'])]
33+
private ?string $name = null;
34+
35+
public function getId(): ?int
36+
{
37+
return $this->id;
38+
}
39+
40+
public function getName(): ?string
41+
{
42+
return $this->name;
43+
}
44+
45+
public function setName(?string $name): void
46+
{
47+
$this->name = $name;
48+
}
49+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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\Entity\NestedRelationPatch\NestedDataPool;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NestedRelationPatch\NestedDataPoolStartup;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NestedRelationPatch\NestedStartup;
20+
use ApiPlatform\Tests\RecreateSchemaTrait;
21+
use ApiPlatform\Tests\SetupClassResourcesTrait;
22+
23+
final class NestedRelationUpdateTest extends ApiTestCase
24+
{
25+
use RecreateSchemaTrait;
26+
use SetupClassResourcesTrait;
27+
28+
protected static ?bool $alwaysBootKernel = false;
29+
30+
public static function getResources(): array
31+
{
32+
return [NestedDataPool::class, NestedDataPoolStartup::class, NestedStartup::class];
33+
}
34+
35+
public function testPatchReplacesAlreadyPopulatedNestedRelationFromEmbeddedIri(): void
36+
{
37+
if ($this->isMongoDB()) {
38+
$this->markTestSkipped();
39+
}
40+
41+
$this->recreateSchema(self::getResources());
42+
43+
$manager = static::getContainer()->get('doctrine')->getManager();
44+
45+
$startupOne = new NestedStartup();
46+
$startupOne->setName('one');
47+
$manager->persist($startupOne);
48+
49+
$startupTwo = new NestedStartup();
50+
$startupTwo->setName('two');
51+
$manager->persist($startupTwo);
52+
53+
$dataPoolStartup = new NestedDataPoolStartup();
54+
$dataPoolStartup->setStartup($startupOne);
55+
$manager->persist($dataPoolStartup);
56+
57+
$dataPool = new NestedDataPool();
58+
$dataPool->setDataPoolStartup($dataPoolStartup);
59+
$manager->persist($dataPool);
60+
61+
$manager->flush();
62+
63+
$dataPoolId = $dataPool->getId();
64+
$startupTwoIri = '/nested_startups/'.$startupTwo->getId();
65+
$manager->clear();
66+
67+
self::createClient()->request('PATCH', '/nested_data_pools/'.$dataPoolId, [
68+
'json' => [
69+
'dataPoolStartup' => [
70+
'startup' => [
71+
'@id' => $startupTwoIri,
72+
'id' => $startupTwo->getId(),
73+
],
74+
],
75+
],
76+
'headers' => [
77+
'content-type' => 'application/ld+json',
78+
],
79+
]);
80+
81+
static::assertResponseIsSuccessful();
82+
83+
$manager->clear();
84+
$updated = $manager->getRepository(NestedDataPool::class)->find($dataPoolId);
85+
static::assertSame($startupTwo->getId(), $updated->getDataPoolStartup()->getStartup()->getId());
86+
}
87+
88+
public function testPatchKeepsAndMutatesNestedRelationWhenEmbeddedIriMatches(): void
89+
{
90+
if ($this->isMongoDB()) {
91+
$this->markTestSkipped();
92+
}
93+
94+
$this->recreateSchema(self::getResources());
95+
96+
$manager = static::getContainer()->get('doctrine')->getManager();
97+
98+
$startup = new NestedStartup();
99+
$startup->setName('one');
100+
$manager->persist($startup);
101+
102+
$dataPoolStartup = new NestedDataPoolStartup();
103+
$dataPoolStartup->setStartup($startup);
104+
$manager->persist($dataPoolStartup);
105+
106+
$dataPool = new NestedDataPool();
107+
$dataPool->setDataPoolStartup($dataPoolStartup);
108+
$manager->persist($dataPool);
109+
110+
$manager->flush();
111+
112+
$dataPoolId = $dataPool->getId();
113+
$startupId = $startup->getId();
114+
$startupIri = '/nested_startups/'.$startupId;
115+
$manager->clear();
116+
117+
self::createClient()->request('PATCH', '/nested_data_pools/'.$dataPoolId, [
118+
'json' => [
119+
'dataPoolStartup' => [
120+
'startup' => [
121+
'@id' => $startupIri,
122+
'name' => 'renamed',
123+
],
124+
],
125+
],
126+
'headers' => [
127+
'content-type' => 'application/ld+json',
128+
],
129+
]);
130+
131+
static::assertResponseIsSuccessful();
132+
133+
$manager->clear();
134+
$updated = $manager->getRepository(NestedDataPool::class)->find($dataPoolId);
135+
static::assertSame($startupId, $updated->getDataPoolStartup()->getStartup()->getId());
136+
static::assertSame('renamed', $updated->getDataPoolStartup()->getStartup()->getName());
137+
}
138+
139+
public function testPatchWithDanglingNestedIriFailsAndKeepsRelationUntouched(): void
140+
{
141+
if ($this->isMongoDB()) {
142+
$this->markTestSkipped();
143+
}
144+
145+
$this->recreateSchema(self::getResources());
146+
147+
$manager = static::getContainer()->get('doctrine')->getManager();
148+
149+
$startup = new NestedStartup();
150+
$startup->setName('one');
151+
$manager->persist($startup);
152+
153+
$dataPoolStartup = new NestedDataPoolStartup();
154+
$dataPoolStartup->setStartup($startup);
155+
$manager->persist($dataPoolStartup);
156+
157+
$dataPool = new NestedDataPool();
158+
$dataPool->setDataPoolStartup($dataPoolStartup);
159+
$manager->persist($dataPool);
160+
161+
$manager->flush();
162+
163+
$dataPoolId = $dataPool->getId();
164+
$startupId = $startup->getId();
165+
$manager->clear();
166+
167+
self::createClient()->request('PATCH', '/nested_data_pools/'.$dataPoolId, [
168+
'json' => [
169+
'dataPoolStartup' => [
170+
'startup' => [
171+
'@id' => '/nested_startups/99999',
172+
'name' => 'should not be applied',
173+
],
174+
],
175+
],
176+
'headers' => [
177+
'content-type' => 'application/ld+json',
178+
],
179+
]);
180+
181+
static::assertResponseStatusCodeSame(400);
182+
183+
$manager->clear();
184+
$untouched = $manager->getRepository(NestedDataPool::class)->find($dataPoolId);
185+
static::assertSame($startupId, $untouched->getDataPoolStartup()->getStartup()->getId());
186+
static::assertSame('one', $untouched->getDataPoolStartup()->getStartup()->getName());
187+
}
188+
}

0 commit comments

Comments
 (0)