From 70b8d87d639c55e4afa5fda189b07ee01b2920bc Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 16 Jul 2025 17:11:36 +0200 Subject: [PATCH] fix(state): object-mapper reuse related entity --- .../ObjectMapper/ClearObjectMapInterface.php | 25 ++++++++ src/State/ObjectMapper/ObjectMapper.php | 51 ++++++++++++++++ src/State/Processor/ObjectMapperProcessor.php | 9 ++- .../Resources/config/state/object_mapper.xml | 17 +++++- .../MappedResourceWithRelation.php | 43 +++++++++++++ .../MappedResourceWithRelationRelated.php | 33 ++++++++++ .../MappedResourceWithRelationEntity.php | 60 +++++++++++++++++++ ...appedResourceWithRelationRelatedEntity.php | 34 +++++++++++ tests/Functional/MappingTest.php | 46 +++++++++++++- 9 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 src/State/ObjectMapper/ClearObjectMapInterface.php create mode 100644 src/State/ObjectMapper/ObjectMapper.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelation.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php create mode 100644 tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php create mode 100644 tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php diff --git a/src/State/ObjectMapper/ClearObjectMapInterface.php b/src/State/ObjectMapper/ClearObjectMapInterface.php new file mode 100644 index 0000000000..5c66aa5a82 --- /dev/null +++ b/src/State/ObjectMapper/ClearObjectMapInterface.php @@ -0,0 +1,25 @@ + + * + * 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 new file mode 100644 index 0000000000..4c494530a8 --- /dev/null +++ b/src/State/ObjectMapper/ObjectMapper.php @@ -0,0 +1,51 @@ + + * + * 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; + +use Symfony\Component\ObjectMapper\ObjectMapperAwareInterface; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +final class ObjectMapper implements ObjectMapperInterface, ClearObjectMapInterface +{ + 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); + } + } + + public function map(object $source, object|string|null $target = null): object + { + if (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); + } + } +} diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index 2c379a68bc..fe0d42b099 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -14,6 +14,7 @@ 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\ObjectMapperInterface; @@ -42,6 +43,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->decorated->process($data, $operation, $uriVariables, $context); } - return $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context)); + $data = $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context)); + + if ($this->objectMapper instanceof ClearObjectMapInterface) { + $this->objectMapper->clearObjectMap(); + } + + return $data; } } diff --git a/src/Symfony/Bundle/Resources/config/state/object_mapper.xml b/src/Symfony/Bundle/Resources/config/state/object_mapper.xml index 7d2f0f2426..88eebbd282 100644 --- a/src/Symfony/Bundle/Resources/config/state/object_mapper.xml +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.xml @@ -4,13 +4,24 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - + + + + + + + + + + + + + - + diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelation.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelation.php new file mode 100644 index 0000000000..b8c55dc4de --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelation.php @@ -0,0 +1,43 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(entityClass: MappedResourceWithRelationEntity::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], + extraProperties: [ + 'standard_put' => true, + ], + operations: [ + new Get(), + new Put(allowCreate: true), + ] +)] +#[Map(target: MappedResourceWithRelationEntity::class)] +class MappedResourceWithRelation +{ + public ?string $id = null; + #[Map(if: false)] + public ?string $relationName = null; + #[Map(target: 'related')] + public ?MappedResourceWithRelationRelated $relation = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php new file mode 100644 index 0000000000..b3a67bb788 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php @@ -0,0 +1,33 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[NotExposed( + stateOptions: new Options(entityClass: MappedResourceWithRelationRelatedEntity::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], +)] +#[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 new file mode 100644 index 0000000000..90dae66c76 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationEntity.php @@ -0,0 +1,60 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelation; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ORM\Entity] +#[Map(target: MappedResourceWithRelation::class)] +class MappedResourceWithRelationEntity +{ + #[ORM\Id, ORM\Column] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: MappedResourceWithRelationRelatedEntity::class)] + #[Map(target: 'relation')] + #[Map(target: 'relationName', transform: [self::class, 'transformRelation'])] + private ?MappedResourceWithRelationRelatedEntity $related = null; + + public static function transformRelation($value, $source) + { + return $source->getRelated()->name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id = null) + { + $this->id = $id; + + return $this; + } + + public function getRelated(): ?MappedResourceWithRelationRelatedEntity + { + return $this->related; + } + + public function setRelated(?MappedResourceWithRelationRelatedEntity $related): self + { + $this->related = $related; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php new file mode 100644 index 0000000000..f3462c9a97 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedResourceWithRelationRelatedEntity.php @@ -0,0 +1,34 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelationRelated; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ORM\Entity] +#[Map(target: MappedResourceWithRelationRelated::class)] +class MappedResourceWithRelationRelatedEntity +{ + #[ORM\Id, ORM\Column, ORM\GeneratedValue] + private ?int $id = null; + + #[ORM\Column] + public string $name; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index c032e6822c..04b36cf180 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -16,8 +16,12 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelationRelated; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ODM\MongoDB\DocumentManager; @@ -33,12 +37,12 @@ final class MappingTest extends ApiTestCase */ public static function getResources(): array { - return [MappedResource::class, MappedResourceOdm::class]; + return [MappedResource::class, MappedResourceOdm::class, MappedResourceWithRelation::class, MappedResourceWithRelationRelated::class]; } public function testShouldMapBetweenResourceAndEntity(): void { - if (!$this->getContainer()->has('object_mapper')) { + if (!$this->getContainer()->has('api_platform.object_mapper')) { $this->markTestSkipped('ObjectMapper not installed'); } @@ -68,6 +72,44 @@ public function testShouldMapBetweenResourceAndEntity(): void $this->assertJsonContains(['username' => 'ba zar']); } + public function testMapPutAllowCreate(): void + { + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB is not tested'); + } + + $this->recreateSchema([MappedResourceWithRelationEntity::class, MappedResourceWithRelationRelatedEntity::class]); + $manager = $this->getManager(); + + $e = new MappedResourceWithRelationRelatedEntity(); + $e->name = 'test'; + $manager->persist($e); + $manager->flush(); + + self::createClient()->request('PUT', '/mapped_resource_with_relations/4', [ + 'json' => [ + '@id' => '/mapped_resource_with_relations/4', + 'relation' => '/mapped_resource_with_relation_relateds/'.$e->getId(), + ], + 'headers' => [ + 'content-type' => 'application/ld+json', + ], + ]); + + $this->assertJsonContains([ + '@context' => '/contexts/MappedResourceWithRelation', + '@id' => '/mapped_resource_with_relations/4', + '@type' => 'MappedResourceWithRelation', + 'id' => '4', + 'relationName' => 'test', + 'relation' => '/mapped_resource_with_relation_relateds/1', + ]); + } + private function loadFixtures(): void { $manager = $this->getManager();