From 86583920f0bd68a07a745407382d0bb597fcd4f6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 10 Nov 2025 13:23:36 +0100 Subject: [PATCH] fix(state): create PUT has relation with lazy mapper --- .../Common/State/PersistProcessor.php | 40 ++++++++++++++ .../ObjectMapper/ClearObjectMapInterface.php | 25 --------- src/State/ObjectMapper/ObjectMapper.php | 23 +------- src/State/Processor/ObjectMapperProcessor.php | 35 +++++++++--- src/State/Provider/ObjectMapperProvider.php | 29 +++++++++- .../config/doctrine_orm_http_cache_purger.php | 1 + .../Resources/config/state/object_mapper.php | 2 + .../EventListener/PurgeHttpCacheListener.php | 55 +++++++++++++------ .../MappedResourceWithRelationRelated.php | 2 - .../MappedResourceWithRelationEntity.php | 1 + ...appedResourceWithRelationRelatedEntity.php | 5 ++ 11 files changed, 143 insertions(+), 75 deletions(-) delete mode 100644 src/State/ObjectMapper/ClearObjectMapInterface.php diff --git a/src/Doctrine/Common/State/PersistProcessor.php b/src/Doctrine/Common/State/PersistProcessor.php index 19e575c822c..5899affed4b 100644 --- a/src/Doctrine/Common/State/PersistProcessor.php +++ b/src/Doctrine/Common/State/PersistProcessor.php @@ -78,6 +78,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $reflectionProperty->setValue($newData, $newValue); } } + // We create a new entity through PUT } else { foreach (array_reverse($links) as $link) { if ($link->getExpandedValue() || !$link->getFromClass()) { @@ -92,6 +93,45 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $reflectionProperty->setValue($newData, $this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null)); } } + + $classMetadata = $manager->getClassMetadata($class); + foreach ($reflectionProperties as $propertyName => $reflectionProperty) { + if ($classMetadata->isIdentifier($propertyName)) { + continue; + } + + $value = $reflectionProperty->getValue($newData); + + if (!\is_object($value)) { + continue; + } + + if ( + !($relManager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($value))) + || $relManager->contains($value) + ) { + continue; + } + + if (\PHP_VERSION_ID > 80400) { + $r = new \ReflectionClass($value); + if ($r->isUninitializedLazyObject($value)) { + $r->initializeLazyObject($value); + } + } + + $metadata = $manager->getClassMetadata($class); + $identifiers = $metadata->getIdentifierValues($value); + + // Do not get reference for partial objects or objects with null identifiers + if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn ($v) => null !== $v))) { + continue; + } + + \assert(method_exists($relManager, 'getReference')); + + $reflectionProperty->setValue($newData, $relManager->getReference($class, $identifiers)); + } } $data = $newData; diff --git a/src/State/ObjectMapper/ClearObjectMapInterface.php b/src/State/ObjectMapper/ClearObjectMapInterface.php deleted file mode 100644 index 5c66aa5a82e..00000000000 --- a/src/State/ObjectMapper/ClearObjectMapInterface.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\State\ObjectMapper; - -/** - * @internal - */ -interface ClearObjectMapInterface -{ - /** - * Clear object map to free memory. - */ - public function clearObjectMap(): void; -} diff --git a/src/State/ObjectMapper/ObjectMapper.php b/src/State/ObjectMapper/ObjectMapper.php index 187eae7dd6e..ff4909c96ca 100644 --- a/src/State/ObjectMapper/ObjectMapper.php +++ b/src/State/ObjectMapper/ObjectMapper.php @@ -17,16 +17,10 @@ use Symfony\Component\ObjectMapper\ObjectMapperAwareInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; -final class ObjectMapper implements ObjectMapperInterface, ClearObjectMapInterface, ObjectMapperAwareInterface +final class ObjectMapper implements ObjectMapperInterface { - private ?\SplObjectStorage $objectMap = null; - public function __construct(private ObjectMapperInterface $decorated) { - if (null === $this->objectMap) { - $this->objectMap = new \SplObjectStorage(); - } - if ($this->decorated instanceof ObjectMapperAwareInterface) { $this->decorated = $this->decorated->withObjectMapper($this); } @@ -34,20 +28,7 @@ public function __construct(private ObjectMapperInterface $decorated) public function map(object $source, object|string|null $target = null): object { - if (!\is_object($target) && isset($this->objectMap[$source])) { - $target = $this->objectMap[$source]; - } - $mapped = $this->decorated->map($source, $target); - $this->objectMap[$mapped] = $source; - - return $mapped; - } - - public function clearObjectMap(): void - { - foreach ($this->objectMap as $k) { - $this->objectMap->detach($k); - } + return $this->decorated->map($source, $target); } public function withObjectMapper(ObjectMapperInterface $objectMapper): static diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index 67511e67cea..e0f62ba7693 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -14,9 +14,9 @@ namespace ApiPlatform\State\Processor; use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ObjectMapper\ClearObjectMapInterface; use ApiPlatform\State\ProcessorInterface; use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; /** @@ -30,27 +30,46 @@ final class ObjectMapperProcessor implements ProcessorInterface public function __construct( private readonly ?ObjectMapperInterface $objectMapper, private readonly ProcessorInterface $decorated, + private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, ) { + // TODO: 4.3 add this deprecation + // if (!$objectMapperMetadata) { + // trigger_deprecation('api-platform/state', '4.3', 'Not injecting "%s" in "%s" will not be possible anymore in 5.0.', ObjectMapperMetadataFactoryInterface::class, __CLASS__); + // } } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null { + $class = $operation->getInput()['class'] ?? $operation->getClass(); + if ( !$this->objectMapper || !$operation->canWrite() || null === $data - || !is_a($data, $operation->getClass(), true) - || !(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class) + || !is_a($data, $class, true) ) { return $this->decorated->process($data, $operation, $uriVariables, $context); } - $data = $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context), $operation->getClass()); - - if ($this->objectMapper instanceof ClearObjectMapInterface) { - $this->objectMapper->clearObjectMap(); + if ($this->objectMapperMetadata) { + if (!$this->objectMapperMetadata->create($data)) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + } elseif (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class)) { + return $this->decorated->process($data, $operation, $uriVariables, $context); } - return $data; + // return the Resource representation of the persisted entity + return $this->objectMapper->map( + // persist the entity + $this->decorated->process( + // maps the Resource to an Entity + $this->objectMapper->map($data), + $operation, + $uriVariables, + $context, + ), + $operation->getClass() + ); } } diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index c9170d7a56e..7f8dfae60a9 100644 --- a/src/State/Provider/ObjectMapperProvider.php +++ b/src/State/Provider/ObjectMapperProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\ProviderInterface; use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; /** @@ -36,7 +37,12 @@ final class ObjectMapperProvider implements ProviderInterface public function __construct( private readonly ?ObjectMapperInterface $objectMapper, private readonly ProviderInterface $decorated, + private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null, ) { + // TODO: 4.3 add this deprecation + // if (!$objectMapperMetadata) { + // trigger_deprecation('api-platform/state', '4.3', 'Not injecting "%s" in "%s" will not be possible anymore in 5.0.', ObjectMapperMetadataFactoryInterface::class, __CLASS__); + // } } 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 $entityClass = $options->getDocumentClass(); } - $entityClass ??= $data::class; - - if (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class) && !(new \ReflectionClass($entityClass))->getAttributes(Map::class)) { + // Look for Mapping metadata + if ($this->objectMapperMetadata) { + if (!$this->canBeMapped($operation->getClass()) && (!$entityClass || !$this->canBeMapped($entityClass))) { + return $data; + } + } elseif (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class) && !(new \ReflectionClass($entityClass))->getAttributes(Map::class)) { return $data; } @@ -74,4 +83,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } + + private function canBeMapped(string $class): bool + { + try { + $r = new \ReflectionClass($class); + if (!$r->isInstantiable() || !$this->objectMapperMetadata->create($r->newInstanceWithoutConstructor(), null, ['_api_check_can_be_mapped' => true])) { + return false; + } + } catch (\ReflectionException $e) { + return false; + } + + return true; + } } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php index 98a784b09bc..4a59b4b4e28 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm_http_cache_purger.php @@ -23,6 +23,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.property_accessor'), service('api_platform.object_mapper')->nullOnInvalid(), + service('api_platform.object_mapper.metadata_factory')->nullOnInvalid(), ]) ->tag('doctrine.event_listener', ['event' => 'preUpdate']) ->tag('doctrine.event_listener', ['event' => 'onFlush']) diff --git a/src/Symfony/Bundle/Resources/config/state/object_mapper.php b/src/Symfony/Bundle/Resources/config/state/object_mapper.php index 6d04d07f21f..000c73aae97 100644 --- a/src/Symfony/Bundle/Resources/config/state/object_mapper.php +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.php @@ -35,6 +35,7 @@ ->args([ service('api_platform.object_mapper')->nullOnInvalid(), service('api_platform.state_provider.object_mapper.inner'), + service('api_platform.object_mapper.metadata_factory'), ]); $services->set('api_platform.state_processor.object_mapper', 'ApiPlatform\State\Processor\ObjectMapperProcessor') @@ -42,5 +43,6 @@ ->args([ service('api_platform.object_mapper')->nullOnInvalid(), service('api_platform.state_processor.object_mapper.inner'), + service('api_platform.object_mapper.metadata_factory'), ]); }; diff --git a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php index 2ebe5c7f742..8419b496734 100644 --- a/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php +++ b/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php @@ -27,6 +27,7 @@ use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\PersistentCollection; use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -48,7 +49,8 @@ public function __construct(private readonly PurgerInterface $purger, private readonly IriConverterInterface $iriConverter, private readonly ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, - private readonly ?ObjectMapperInterface $objectMapper = null) + private readonly ?ObjectMapperInterface $objectMapper = null, + private readonly ?ObjectMapperMetadataFactoryInterface $objectMapperMetadata = null) { $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); } @@ -153,13 +155,21 @@ private function gatherRelationTags(EntityManagerInterface $em, object $entity): if ( \is_array($associationMapping) && \array_key_exists('targetEntity', $associationMapping) - && !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity']) - && ( - !$this->objectMapper - || !(new \ReflectionClass($associationMapping['targetEntity']))->getAttributes(Map::class) - ) + && ($targetEntity = $associationMapping['targetEntity']) + && !$this->resourceClassResolver->isResourceClass($targetEntity) ) { - return; + if (!$this->objectMapper) { + return; + } + + $targetRefl = new \ReflectionClass($targetEntity); + if ($this->objectMapperMetadata) { + if (!$this->objectMapperMetadata->create($targetRefl->newInstanceWithoutConstructor())) { + return; + } + } elseif (!$targetRefl->getAttributes(Map::class)) { + return; + } } $this->addTagsFor($this->propertyAccessor->getValue($entity, $property)); @@ -210,17 +220,30 @@ private function getResourcesForEntity(object $entity): array return []; } - $mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class); + if ($this->objectMapperMetadata) { + $mappings = $this->objectMapperMetadata->create($entity); - if (!$mapAttributes) { - return []; - } + if (!$mappings) { + return []; + } + + $resources = array_map( + fn ($mapping) => $this->objectMapper->map($entity, $mapping->target), + $mappings + ); + } else { + $mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class); - // loop over all mappings to fetch all resources mapped to this entity - $resources = array_map( - fn ($mapAttribute) => $this->objectMapper->map($entity, $mapAttribute->newInstance()->target), - $mapAttributes - ); + if (!$mapAttributes) { + return []; + } + + // loop over all mappings to fetch all resources mapped to this entity + $resources = array_map( + fn ($mapAttribute) => $this->objectMapper->map($entity, $mapAttribute->newInstance()->target), + $mapAttributes + ); + } } else { $resources[] = $entity; } diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php index 228e3fa51c7..26ebeef8ef0 100644 --- a/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php @@ -32,8 +32,6 @@ #[Map(target: MappedResourceWithRelationRelatedEntity::class)] class MappedResourceWithRelationRelated { - #[Map(if: false)] public string $id; - public string $name; } diff --git a/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php index 90dae66c76c..5b3eb3560ad 100644 --- a/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php +++ b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php @@ -22,6 +22,7 @@ class MappedResourceWithRelationEntity { #[ORM\Id, ORM\Column] + #[Map(transform: 'to_string')] private ?int $id = null; #[ORM\ManyToOne(targetEntity: MappedResourceWithRelationRelatedEntity::class)] diff --git a/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php index f3462c9a977..5f10689fdd5 100644 --- a/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php +++ b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php @@ -31,4 +31,9 @@ public function getId(): ?int { return $this->id; } + + public function setId(?int $id = null) + { + $this->id = $id; + } }