Skip to content

Commit 187e14c

Browse files
committed
fix(doctrine): post with mapped relation
1 parent 696d315 commit 187e14c

File tree

6 files changed

+215
-39
lines changed

6 files changed

+215
-39
lines changed

src/Doctrine/Common/State/PersistProcessor.php

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -94,49 +94,18 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
9494
}
9595
}
9696

97-
$classMetadata = $manager->getClassMetadata($class);
98-
foreach ($reflectionProperties as $propertyName => $reflectionProperty) {
99-
if ($classMetadata->isIdentifier($propertyName)) {
100-
continue;
101-
}
102-
103-
$value = $reflectionProperty->getValue($newData);
104-
105-
if (!\is_object($value)) {
106-
continue;
107-
}
108-
109-
if (
110-
!($relManager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($value)))
111-
|| $relManager->contains($value)
112-
) {
113-
continue;
114-
}
115-
116-
if (\PHP_VERSION_ID > 80400) {
117-
$r = new \ReflectionClass($value);
118-
if ($r->isUninitializedLazyObject($value)) {
119-
$r->initializeLazyObject($value);
120-
}
121-
}
122-
123-
$metadata = $manager->getClassMetadata($class);
124-
$identifiers = $metadata->getIdentifierValues($value);
125-
126-
// Do not get reference for partial objects or objects with null identifiers
127-
if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn ($v) => null !== $v))) {
128-
continue;
129-
}
130-
131-
\assert(method_exists($relManager, 'getReference'));
132-
133-
$reflectionProperty->setValue($newData, $relManager->getReference($class, $identifiers));
134-
}
97+
$this->handleLazyObjectRelations($newData, $manager, $reflectionProperties);
13598
}
13699

137100
$data = $newData;
138101
}
139102

103+
// Handle lazy objects in relations for all operations (POST, PATCH, etc.)
104+
// This is needed when using object mapper with entities that have relations
105+
if (!$manager->contains($data)) {
106+
$this->handleLazyObjectRelations($data, $manager);
107+
}
108+
140109
if (!$manager->contains($data) || $this->isDeferredExplicit($manager, $data)) {
141110
$manager->persist($data);
142111
}
@@ -180,4 +149,55 @@ private function getReflectionProperties(mixed $data): array
180149

181150
return $ret;
182151
}
152+
153+
/**
154+
* Handle lazy object relations by replacing them with Doctrine references.
155+
* This is needed when using object mapper with entities that have relations.
156+
*
157+
* @param array<string, \ReflectionProperty>|null $reflectionProperties
158+
*/
159+
private function handleLazyObjectRelations(object $data, DoctrineObjectManager $manager, ?array $reflectionProperties = null): void
160+
{
161+
$reflectionProperties ??= $this->getReflectionProperties($data);
162+
$class = $this->getObjectClass($data);
163+
$classMetadata = $manager->getClassMetadata($class);
164+
165+
foreach ($reflectionProperties as $propertyName => $reflectionProperty) {
166+
if ($classMetadata->isIdentifier($propertyName)) {
167+
continue;
168+
}
169+
170+
$value = $reflectionProperty->getValue($data);
171+
172+
if (!\is_object($value)) {
173+
continue;
174+
}
175+
176+
if (
177+
!($relManager = $this->managerRegistry->getManagerForClass($relClass = $this->getObjectClass($value)))
178+
|| $relManager->contains($value)
179+
) {
180+
continue;
181+
}
182+
183+
if (\PHP_VERSION_ID > 80400) {
184+
$r = new \ReflectionClass($value);
185+
if ($r->isUninitializedLazyObject($value)) {
186+
$r->initializeLazyObject($value);
187+
}
188+
}
189+
190+
$metadata = $relManager->getClassMetadata($relClass);
191+
$identifiers = $metadata->getIdentifierValues($value);
192+
193+
// Do not get reference for partial objects or objects with null identifiers
194+
if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn ($v) => null !== $v))) {
195+
continue;
196+
}
197+
198+
\assert(method_exists($relManager, 'getReference'));
199+
200+
$reflectionProperty->setValue($data, $relManager->getReference($relClass, $identifiers));
201+
}
202+
}
183203
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689;
6+
7+
use ApiPlatform\Doctrine\Orm\State\Options;
8+
use ApiPlatform\Metadata\ApiResource;
9+
use ApiPlatform\Metadata\Get;
10+
use ApiPlatform\Metadata\Post;
11+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689\Issue7689Category;
12+
use Symfony\Component\ObjectMapper\Attribute\Map;
13+
14+
#[ApiResource(
15+
operations: [
16+
new Get(),
17+
new Post(),
18+
],
19+
shortName: 'Issue7689Category',
20+
stateOptions: new Options(entityClass: Issue7689Category::class)
21+
)]
22+
#[Map(target: Issue7689Category::class)]
23+
class Issue7689CategoryDto
24+
{
25+
public ?int $id = null;
26+
27+
public string $name;
28+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689;
6+
7+
use ApiPlatform\Doctrine\Orm\State\Options as OrmOptions;
8+
use ApiPlatform\Metadata\ApiResource;
9+
use ApiPlatform\Metadata\Get;
10+
use ApiPlatform\Metadata\Post;
11+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689\Issue7689Product;
12+
use Symfony\Component\ObjectMapper\Attribute\Map;
13+
14+
#[ApiResource(
15+
operations: [
16+
new Get(),
17+
new Post(),
18+
],
19+
shortName: 'Issue7689Product',
20+
stateOptions: new OrmOptions(entityClass: Issue7689Product::class)
21+
)]
22+
#[Map(target: Issue7689Product::class)]
23+
class Issue7689ProductDto
24+
{
25+
public ?int $id = null;
26+
27+
public string $name;
28+
29+
public ?Issue7689CategoryDto $category = null;
30+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689;
6+
7+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689\Issue7689CategoryDto;
8+
use Doctrine\ORM\Mapping as ORM;
9+
use Symfony\Component\ObjectMapper\Attribute\Map;
10+
11+
#[ORM\Entity]
12+
#[Map(target: Issue7689CategoryDto::class)]
13+
class Issue7689Category
14+
{
15+
#[ORM\Id]
16+
#[ORM\GeneratedValue]
17+
#[ORM\Column(type: 'integer')]
18+
private ?int $id = null;
19+
20+
#[ORM\Column(type: 'string', length: 255)]
21+
public string $name;
22+
23+
public function getId(): ?int
24+
{
25+
return $this->id;
26+
}
27+
28+
public function setId(?int $id): void
29+
{
30+
$this->id = $id;
31+
}
32+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689;
6+
7+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689\Issue7689ProductDto;
8+
use Doctrine\ORM\Mapping as ORM;
9+
use Symfony\Component\ObjectMapper\Attribute\Map;
10+
11+
#[ORM\Entity]
12+
#[Map(target: Issue7689ProductDto::class)]
13+
class Issue7689Product
14+
{
15+
#[ORM\Id]
16+
#[ORM\GeneratedValue]
17+
#[ORM\Column(type: 'integer')]
18+
private ?int $id = null;
19+
20+
#[ORM\Column(type: 'string', length: 255)]
21+
public string $name;
22+
23+
#[ORM\ManyToOne(targetEntity: Issue7689Category::class)]
24+
public ?Issue7689Category $category = null;
25+
26+
public function getId(): ?int
27+
{
28+
return $this->id;
29+
}
30+
31+
public function setId(?int $id): void
32+
{
33+
$this->id = $id;
34+
}
35+
}

tests/Functional/Doctrine/StateOptionTest.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515

1616
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
1717
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6039\UserApi;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689\Issue7689CategoryDto;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689\Issue7689ProductDto;
1820
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6039\Issue6039EntityUser;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689\Issue7689Category;
22+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689\Issue7689Product;
1923
use ApiPlatform\Tests\RecreateSchemaTrait;
2024
use ApiPlatform\Tests\SetupClassResourcesTrait;
2125

@@ -31,7 +35,7 @@ final class StateOptionTest extends ApiTestCase
3135
*/
3236
public static function getResources(): array
3337
{
34-
return [UserApi::class];
38+
return [UserApi::class, Issue7689ProductDto::class, Issue7689CategoryDto::class];
3539
}
3640

3741
public function testDtoWithEntityClassOptionCollection(): void
@@ -54,4 +58,31 @@ public function testDtoWithEntityClassOptionCollection(): void
5458
$this->assertResponseStatusCodeSame(200);
5559
$this->assertArrayNotHasKey('bar', $response->toArray()['hydra:member'][0]);
5660
}
61+
62+
public function testPostWithEntityClassOption(): void
63+
{
64+
if ($this->isMongoDB()) {
65+
$this->markTestSkipped('This test is not for MongoDB.');
66+
}
67+
68+
$this->recreateSchema([Issue7689Product::class, Issue7689Category::class]);
69+
$manager = static::getContainer()->get('doctrine')->getManager();
70+
71+
$c = new Issue7689Category();
72+
$c->name = 'category';
73+
$manager->persist($c);
74+
$manager->flush();
75+
$iri = '/issue7689_categories/'.$c->getId();
76+
77+
$response = static::createClient()->request('POST', '/issue7689_products', ['json' => [
78+
'name' => 'product',
79+
'category' => $iri,
80+
]]);
81+
$this->assertResponseStatusCodeSame(201);
82+
83+
$this->assertCount(1, $manager->getRepository(Issue7689Product::class)->findAll());
84+
$product = $manager->getRepository(Issue7689Product::class)->findOneBy(['name' => 'product']);
85+
$this->assertNotNull($product->category);
86+
$this->assertEquals(1, $product->category->getId());
87+
}
5788
}

0 commit comments

Comments
 (0)