Skip to content

Commit 70b8d87

Browse files
committed
fix(state): object-mapper reuse related entity
1 parent d3b4b7b commit 70b8d87

File tree

9 files changed

+312
-6
lines changed

9 files changed

+312
-6
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\State\ObjectMapper;
15+
16+
/**
17+
* @internal
18+
*/
19+
interface ClearObjectMapInterface
20+
{
21+
/**
22+
* Clear object map to free memory.
23+
*/
24+
public function clearObjectMap(): void;
25+
}
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 <[email protected]>
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\State\ObjectMapper;
15+
16+
use Symfony\Component\ObjectMapper\ObjectMapperAwareInterface;
17+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
18+
19+
final class ObjectMapper implements ObjectMapperInterface, ClearObjectMapInterface
20+
{
21+
private ?\SplObjectStorage $objectMap = null;
22+
23+
public function __construct(private ObjectMapperInterface $decorated)
24+
{
25+
if (null === $this->objectMap) {
26+
$this->objectMap = new \SplObjectStorage();
27+
}
28+
29+
if ($this->decorated instanceof ObjectMapperAwareInterface) {
30+
$this->decorated = $this->decorated->withObjectMapper($this);
31+
}
32+
}
33+
34+
public function map(object $source, object|string|null $target = null): object
35+
{
36+
if (isset($this->objectMap[$source])) {
37+
$target = $this->objectMap[$source];
38+
}
39+
$mapped = $this->decorated->map($source, $target);
40+
$this->objectMap[$mapped] = $source;
41+
42+
return $mapped;
43+
}
44+
45+
public function clearObjectMap(): void
46+
{
47+
foreach ($this->objectMap as $k) {
48+
$this->objectMap->detach($k);
49+
}
50+
}
51+
}

src/State/Processor/ObjectMapperProcessor.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\State\Processor;
1515

1616
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\State\ObjectMapper\ClearObjectMapInterface;
1718
use ApiPlatform\State\ProcessorInterface;
1819
use Symfony\Component\ObjectMapper\Attribute\Map;
1920
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
@@ -42,6 +43,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
4243
return $this->decorated->process($data, $operation, $uriVariables, $context);
4344
}
4445

45-
return $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context));
46+
$data = $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context));
47+
48+
if ($this->objectMapper instanceof ClearObjectMapInterface) {
49+
$this->objectMapper->clearObjectMap();
50+
}
51+
52+
return $data;
4653
}
4754
}

src/Symfony/Bundle/Resources/config/state/object_mapper.xml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@
44
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
55
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
66
<services>
7-
<service id="api_platform.state_provider.object_mapper" class="ApiPlatform\State\Provider\ObjectMapperProvider" decorates="api_platform.state_provider.read">
8-
<argument type="service" id="object_mapper" on-invalid="null" />
7+
<service id="api_platform.object_mapper" class="Symfony\Component\ObjectMapper\ObjectMapper">
8+
<argument type="service" id="object_mapper.metadata_factory"/>
9+
<argument type="service" id="property_accessor" on-invalid="null" />
10+
<argument type="tagged_locator" tag="object_mapper.transform_callable"/>
11+
<argument type="tagged_locator" tag="object_mapper.condition_callable"/>
12+
</service>
13+
14+
<service id="api_platform.object_mapper.relation" class="ApiPlatform\State\ObjectMapper\ObjectMapper" decorates="api_platform.object_mapper" decoration-priority="-255">
15+
<argument type="service" id="api_platform.object_mapper.relation.inner" />
16+
</service>
17+
18+
<service id="api_platform.state_provider.object_mapper" class="ApiPlatform\State\Provider\ObjectMapperProvider" decorates="api_platform.state_provider.locator">
19+
<argument type="service" id="api_platform.object_mapper" on-invalid="null" />
920
<argument type="service" id="api_platform.state_provider.object_mapper.inner" />
1021
</service>
1122

1223
<service id="api_platform.state_processor.object_mapper" class="ApiPlatform\State\Processor\ObjectMapperProcessor" decorates="api_platform.state_processor.locator">
13-
<argument type="service" id="object_mapper" on-invalid="null" />
24+
<argument type="service" id="api_platform.object_mapper" on-invalid="null" />
1425
<argument type="service" id="api_platform.state_processor.object_mapper.inner" />
1526
</service>
1627
</services>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\JsonLd\ContextBuilder;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Metadata\Get;
20+
use ApiPlatform\Metadata\Put;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationEntity;
22+
use Symfony\Component\ObjectMapper\Attribute\Map;
23+
24+
#[ApiResource(
25+
stateOptions: new Options(entityClass: MappedResourceWithRelationEntity::class),
26+
normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false],
27+
extraProperties: [
28+
'standard_put' => true,
29+
],
30+
operations: [
31+
new Get(),
32+
new Put(allowCreate: true),
33+
]
34+
)]
35+
#[Map(target: MappedResourceWithRelationEntity::class)]
36+
class MappedResourceWithRelation
37+
{
38+
public ?string $id = null;
39+
#[Map(if: false)]
40+
public ?string $relationName = null;
41+
#[Map(target: 'related')]
42+
public ?MappedResourceWithRelationRelated $relation = null;
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\JsonLd\ContextBuilder;
18+
use ApiPlatform\Metadata\NotExposed;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity;
20+
use Symfony\Component\ObjectMapper\Attribute\Map;
21+
22+
#[NotExposed(
23+
stateOptions: new Options(entityClass: MappedResourceWithRelationRelatedEntity::class),
24+
normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false],
25+
)]
26+
#[Map(target: MappedResourceWithRelationRelatedEntity::class)]
27+
class MappedResourceWithRelationRelated
28+
{
29+
#[Map(if: false)]
30+
public string $id;
31+
32+
public string $name;
33+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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;
15+
16+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelation;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Symfony\Component\ObjectMapper\Attribute\Map;
19+
20+
#[ORM\Entity]
21+
#[Map(target: MappedResourceWithRelation::class)]
22+
class MappedResourceWithRelationEntity
23+
{
24+
#[ORM\Id, ORM\Column]
25+
private ?int $id = null;
26+
27+
#[ORM\ManyToOne(targetEntity: MappedResourceWithRelationRelatedEntity::class)]
28+
#[Map(target: 'relation')]
29+
#[Map(target: 'relationName', transform: [self::class, 'transformRelation'])]
30+
private ?MappedResourceWithRelationRelatedEntity $related = null;
31+
32+
public static function transformRelation($value, $source)
33+
{
34+
return $source->getRelated()->name;
35+
}
36+
37+
public function getId(): ?int
38+
{
39+
return $this->id;
40+
}
41+
42+
public function setId(?int $id = null)
43+
{
44+
$this->id = $id;
45+
46+
return $this;
47+
}
48+
49+
public function getRelated(): ?MappedResourceWithRelationRelatedEntity
50+
{
51+
return $this->related;
52+
}
53+
54+
public function setRelated(?MappedResourceWithRelationRelatedEntity $related): self
55+
{
56+
$this->related = $related;
57+
58+
return $this;
59+
}
60+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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;
15+
16+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelationRelated;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Symfony\Component\ObjectMapper\Attribute\Map;
19+
20+
#[ORM\Entity]
21+
#[Map(target: MappedResourceWithRelationRelated::class)]
22+
class MappedResourceWithRelationRelatedEntity
23+
{
24+
#[ORM\Id, ORM\Column, ORM\GeneratedValue]
25+
private ?int $id = null;
26+
27+
#[ORM\Column]
28+
public string $name;
29+
30+
public function getId(): ?int
31+
{
32+
return $this->id;
33+
}
34+
}

tests/Functional/MappingTest.php

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
1717
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource;
1818
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelation;
20+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelationRelated;
1921
use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument;
2022
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity;
23+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationEntity;
24+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity;
2125
use ApiPlatform\Tests\RecreateSchemaTrait;
2226
use ApiPlatform\Tests\SetupClassResourcesTrait;
2327
use Doctrine\ODM\MongoDB\DocumentManager;
@@ -33,12 +37,12 @@ final class MappingTest extends ApiTestCase
3337
*/
3438
public static function getResources(): array
3539
{
36-
return [MappedResource::class, MappedResourceOdm::class];
40+
return [MappedResource::class, MappedResourceOdm::class, MappedResourceWithRelation::class, MappedResourceWithRelationRelated::class];
3741
}
3842

3943
public function testShouldMapBetweenResourceAndEntity(): void
4044
{
41-
if (!$this->getContainer()->has('object_mapper')) {
45+
if (!$this->getContainer()->has('api_platform.object_mapper')) {
4246
$this->markTestSkipped('ObjectMapper not installed');
4347
}
4448

@@ -68,6 +72,44 @@ public function testShouldMapBetweenResourceAndEntity(): void
6872
$this->assertJsonContains(['username' => 'ba zar']);
6973
}
7074

75+
public function testMapPutAllowCreate(): void
76+
{
77+
if (!$this->getContainer()->has('api_platform.object_mapper')) {
78+
$this->markTestSkipped('ObjectMapper not installed');
79+
}
80+
81+
if ($this->isMongoDB()) {
82+
$this->markTestSkipped('MongoDB is not tested');
83+
}
84+
85+
$this->recreateSchema([MappedResourceWithRelationEntity::class, MappedResourceWithRelationRelatedEntity::class]);
86+
$manager = $this->getManager();
87+
88+
$e = new MappedResourceWithRelationRelatedEntity();
89+
$e->name = 'test';
90+
$manager->persist($e);
91+
$manager->flush();
92+
93+
self::createClient()->request('PUT', '/mapped_resource_with_relations/4', [
94+
'json' => [
95+
'@id' => '/mapped_resource_with_relations/4',
96+
'relation' => '/mapped_resource_with_relation_relateds/'.$e->getId(),
97+
],
98+
'headers' => [
99+
'content-type' => 'application/ld+json',
100+
],
101+
]);
102+
103+
$this->assertJsonContains([
104+
'@context' => '/contexts/MappedResourceWithRelation',
105+
'@id' => '/mapped_resource_with_relations/4',
106+
'@type' => 'MappedResourceWithRelation',
107+
'id' => '4',
108+
'relationName' => 'test',
109+
'relation' => '/mapped_resource_with_relation_relateds/1',
110+
]);
111+
}
112+
71113
private function loadFixtures(): void
72114
{
73115
$manager = $this->getManager();

0 commit comments

Comments
 (0)