Skip to content

Commit 0294a59

Browse files
committed
fix(state): create PUT has relation with lazy mapper
1 parent 70611b3 commit 0294a59

File tree

11 files changed

+145
-75
lines changed

11 files changed

+145
-75
lines changed

src/Doctrine/Common/State/PersistProcessor.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
7878
$reflectionProperty->setValue($newData, $newValue);
7979
}
8080
}
81+
// We create a new entity through PUT
8182
} else {
8283
foreach (array_reverse($links) as $link) {
8384
if ($link->getExpandedValue() || !$link->getFromClass()) {
@@ -92,6 +93,45 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
9293
$reflectionProperty->setValue($newData, $this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null));
9394
}
9495
}
96+
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+
}
95135
}
96136

97137
$data = $newData;

src/State/ObjectMapper/ClearObjectMapInterface.php

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/State/ObjectMapper/ObjectMapper.php

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,18 @@
1717
use Symfony\Component\ObjectMapper\ObjectMapperAwareInterface;
1818
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
1919

20-
final class ObjectMapper implements ObjectMapperInterface, ClearObjectMapInterface, ObjectMapperAwareInterface
20+
final class ObjectMapper implements ObjectMapperInterface
2121
{
22-
private ?\SplObjectStorage $objectMap = null;
23-
2422
public function __construct(private ObjectMapperInterface $decorated)
2523
{
26-
if (null === $this->objectMap) {
27-
$this->objectMap = new \SplObjectStorage();
28-
}
29-
3024
if ($this->decorated instanceof ObjectMapperAwareInterface) {
3125
$this->decorated = $this->decorated->withObjectMapper($this);
3226
}
3327
}
3428

3529
public function map(object $source, object|string|null $target = null): object
3630
{
37-
if (!\is_object($target) && isset($this->objectMap[$source])) {
38-
$target = $this->objectMap[$source];
39-
}
40-
$mapped = $this->decorated->map($source, $target);
41-
$this->objectMap[$mapped] = $source;
42-
43-
return $mapped;
44-
}
45-
46-
public function clearObjectMap(): void
47-
{
48-
foreach ($this->objectMap as $k) {
49-
$this->objectMap->detach($k);
50-
}
31+
return $this->decorated->map($source, $target);
5132
}
5233

5334
public function withObjectMapper(ObjectMapperInterface $objectMapper): static

src/State/Processor/ObjectMapperProcessor.php

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
namespace ApiPlatform\State\Processor;
1515

1616
use ApiPlatform\Metadata\Operation;
17-
use ApiPlatform\State\ObjectMapper\ClearObjectMapInterface;
1817
use ApiPlatform\State\ProcessorInterface;
1918
use Symfony\Component\ObjectMapper\Attribute\Map;
19+
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
2020
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
2121

2222
/**
@@ -30,27 +30,46 @@ final class ObjectMapperProcessor implements ProcessorInterface
3030
public function __construct(
3131
private readonly ?ObjectMapperInterface $objectMapper,
3232
private readonly ProcessorInterface $decorated,
33+
private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null,
3334
) {
35+
// TODO: 4.3 add this deprecation
36+
// if (!$objectMapperMetadata) {
37+
// trigger_deprecation('api-platform/state', '4.3', 'Not injecting "%s" in "%s" will not be possible anymore in 5.0.', ObjectMapperMetadataFactoryInterface::class, __CLASS__);
38+
// }
3439
}
3540

3641
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null
3742
{
43+
$class = $operation->getInput()['class'] ?? $operation->getClass();
44+
3845
if (
3946
!$this->objectMapper
4047
|| !$operation->canWrite()
4148
|| null === $data
42-
|| !is_a($data, $operation->getClass(), true)
43-
|| !(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class)
49+
|| !is_a($data, $class, true)
4450
) {
4551
return $this->decorated->process($data, $operation, $uriVariables, $context);
4652
}
4753

48-
$data = $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context), $operation->getClass());
49-
50-
if ($this->objectMapper instanceof ClearObjectMapInterface) {
51-
$this->objectMapper->clearObjectMap();
54+
if ($this->objectMapperMetadata) {
55+
if (!$this->objectMapperMetadata->create($data)) {
56+
return $this->decorated->process($data, $operation, $uriVariables, $context);
57+
}
58+
} elseif (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class)) {
59+
return $this->decorated->process($data, $operation, $uriVariables, $context);
5260
}
5361

54-
return $data;
62+
// return the Resource representation of the persisted entity
63+
return $this->objectMapper->map(
64+
// persist the entity
65+
$this->decorated->process(
66+
// maps the Resource to an Entity
67+
$this->objectMapper->map($data),
68+
$operation,
69+
$uriVariables,
70+
$context,
71+
),
72+
$operation->getClass()
73+
);
5574
}
5675
}

src/State/Provider/ObjectMapperProvider.php

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\State\Pagination\PaginatorInterface;
2222
use ApiPlatform\State\ProviderInterface;
2323
use Symfony\Component\ObjectMapper\Attribute\Map;
24+
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
2425
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
2526

2627
/**
@@ -36,7 +37,12 @@ final class ObjectMapperProvider implements ProviderInterface
3637
public function __construct(
3738
private readonly ?ObjectMapperInterface $objectMapper,
3839
private readonly ProviderInterface $decorated,
40+
private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null,
3941
) {
42+
// TODO: 4.3 add this deprecation
43+
// if (!$objectMapperMetadata) {
44+
// trigger_deprecation('api-platform/state', '4.3', 'Not injecting "%s" in "%s" will not be possible anymore in 5.0.', ObjectMapperMetadataFactoryInterface::class, __CLASS__);
45+
// }
4046
}
4147

4248
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
@@ -57,9 +63,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5763
$entityClass = $options->getDocumentClass();
5864
}
5965

60-
$entityClass ??= $data::class;
61-
62-
if (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class) && !(new \ReflectionClass($entityClass))->getAttributes(Map::class)) {
66+
// Look for Mapping metadata
67+
if ($this->objectMapperMetadata) {
68+
if (!$this->canBeMapped($operation->getClass()) || !$entityClass || !$this->canBeMapped($entityClass)) {
69+
return $data;
70+
}
71+
} elseif (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class) && !(new \ReflectionClass($entityClass))->getAttributes(Map::class)) {
6372
return $data;
6473
}
6574

@@ -74,4 +83,20 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
7483

7584
return $data;
7685
}
86+
87+
private function canBeMapped(string $class): bool
88+
{
89+
try {
90+
$r = new \ReflectionClass($class);
91+
if (!$r->isInstantiable() || !$this->objectMapperMetadata->create($r->newInstanceWithoutConstructor(), null, ['_api_check_can_be_mapped' => true])) {
92+
return false;
93+
}
94+
} catch (\ReflectionException $e) {
95+
dd($e);
96+
97+
return false;
98+
}
99+
100+
return true;
101+
}
77102
}

src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
service('api_platform.resource_class_resolver'),
2424
service('api_platform.property_accessor'),
2525
service('api_platform.object_mapper')->nullOnInvalid(),
26+
service('api_platform.object_mapper.metadata_factory')->nullOnInvalid(),
2627
])
2728
->tag('doctrine.event_listener', ['event' => 'preUpdate'])
2829
->tag('doctrine.event_listener', ['event' => 'onFlush'])

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@
3535
->args([
3636
service('api_platform.object_mapper')->nullOnInvalid(),
3737
service('api_platform.state_provider.object_mapper.inner'),
38+
service('api_platform.object_mapper.metadata_factory'),
3839
]);
3940

4041
$services->set('api_platform.state_processor.object_mapper', 'ApiPlatform\State\Processor\ObjectMapperProcessor')
4142
->decorate('api_platform.state_processor.locator', null, 0)
4243
->args([
4344
service('api_platform.object_mapper')->nullOnInvalid(),
4445
service('api_platform.state_processor.object_mapper.inner'),
46+
service('api_platform.object_mapper.metadata_factory'),
4547
]);
4648
};

src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use Doctrine\ORM\Mapping\AssociationMapping;
2828
use Doctrine\ORM\PersistentCollection;
2929
use Symfony\Component\ObjectMapper\Attribute\Map;
30+
use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface;
3031
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
3132
use Symfony\Component\PropertyAccess\PropertyAccess;
3233
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
@@ -48,7 +49,8 @@ public function __construct(private readonly PurgerInterface $purger,
4849
private readonly IriConverterInterface $iriConverter,
4950
private readonly ResourceClassResolverInterface $resourceClassResolver,
5051
?PropertyAccessorInterface $propertyAccessor = null,
51-
private readonly ?ObjectMapperInterface $objectMapper = null)
52+
private readonly ?ObjectMapperInterface $objectMapper = null,
53+
private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null)
5254
{
5355
$this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
5456
}
@@ -153,13 +155,21 @@ private function gatherRelationTags(EntityManagerInterface $em, object $entity):
153155
if (
154156
\is_array($associationMapping)
155157
&& \array_key_exists('targetEntity', $associationMapping)
156-
&& !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])
157-
&& (
158-
!$this->objectMapper
159-
|| !(new \ReflectionClass($associationMapping['targetEntity']))->getAttributes(Map::class)
160-
)
158+
&& ($targetEntity = $associationMapping['targetEntity'])
159+
&& !$this->resourceClassResolver->isResourceClass($targetEntity)
161160
) {
162-
return;
161+
if (!$this->objectMapper) {
162+
return;
163+
}
164+
165+
$targetRefl = new \ReflectionClass($targetEntity);
166+
if ($this->objectMapperMetadata) {
167+
if (!$this->objectMapperMetadata->create($targetRefl->newInstanceWithoutConstructor())) {
168+
return;
169+
}
170+
} elseif (!$targetRefl->getAttributes(Map::class)) {
171+
return;
172+
}
163173
}
164174

165175
$this->addTagsFor($this->propertyAccessor->getValue($entity, $property));
@@ -210,17 +220,30 @@ private function getResourcesForEntity(object $entity): array
210220
return [];
211221
}
212222

213-
$mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class);
223+
if ($this->objectMapperMetadata) {
224+
$mappings = $this->objectMapperMetadata->create($entity);
214225

215-
if (!$mapAttributes) {
216-
return [];
217-
}
226+
if (!$mappings) {
227+
return [];
228+
}
229+
230+
$resources = array_map(
231+
fn ($mapping) => $this->objectMapper->map($entity, $mapping->target),
232+
$mappings
233+
);
234+
} else {
235+
$mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class);
218236

219-
// loop over all mappings to fetch all resources mapped to this entity
220-
$resources = array_map(
221-
fn ($mapAttribute) => $this->objectMapper->map($entity, $mapAttribute->newInstance()->target),
222-
$mapAttributes
223-
);
237+
if (!$mapAttributes) {
238+
return [];
239+
}
240+
241+
// loop over all mappings to fetch all resources mapped to this entity
242+
$resources = array_map(
243+
fn ($mapAttribute) => $this->objectMapper->map($entity, $mapAttribute->newInstance()->target),
244+
$mapAttributes
245+
);
246+
}
224247
} else {
225248
$resources[] = $entity;
226249
}

tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@
3232
#[Map(target: MappedResourceWithRelationRelatedEntity::class)]
3333
class MappedResourceWithRelationRelated
3434
{
35-
#[Map(if: false)]
3635
public string $id;
37-
3836
public string $name;
3937
}

tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
class MappedResourceWithRelationEntity
2323
{
2424
#[ORM\Id, ORM\Column]
25+
#[Map(transform: 'to_string')]
2526
private ?int $id = null;
2627

2728
#[ORM\ManyToOne(targetEntity: MappedResourceWithRelationRelatedEntity::class)]

0 commit comments

Comments
 (0)