diff --git a/composer.json b/composer.json index 995f25bb3c..7be5ffa20a 100644 --- a/composer.json +++ b/composer.json @@ -181,7 +181,7 @@ "symfony/maker-bundle": "^1.24", "symfony/mercure-bundle": "*", "symfony/messenger": "^6.4 || ^7.0", - "symfony/object-mapper": "^7.3", + "symfony/object-mapper": "7.4.x-dev", "symfony/routing": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/security-core": "^6.4 || ^7.0", 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..b089891e3f --- /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 (!\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); + } + } +} diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index 0d926c573c..891f194b5a 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), $operation->getClass()); + $data = $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context), $operation->getClass()); + + 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..b2cd35df62 100644 --- a/src/Symfony/Bundle/Resources/config/state/object_mapper.xml +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.xml @@ -4,13 +4,27 @@ 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..228e3fa51c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithRelationRelated.php @@ -0,0 +1,39 @@ + + * + * 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\NotExposed; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + operations: [ + new NotExposed( + stateOptions: new Options(entityClass: MappedResourceWithRelationRelatedEntity::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], + ), + ], + graphQlOperations: [] +)] +#[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 0075cd996d..8b5c94d625 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -17,9 +17,13 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FirstResource; 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\ApiResource\SecondResource; 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\Fixtures\TestBundle\Entity\SameEntity; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -36,12 +40,12 @@ final class MappingTest extends ApiTestCase */ public static function getResources(): array { - return [MappedResource::class, MappedResourceOdm::class, FirstResource::class, SecondResource::class]; + return [MappedResource::class, MappedResourceOdm::class, FirstResource::class, SecondResource::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'); } @@ -77,7 +81,7 @@ public function testShouldMapToTheCorrectResource(): void $this->markTestSkipped('MongoDB not tested.'); } - if (!$this->getContainer()->has('object_mapper')) { + if (!$this->getContainer()->has('api_platform.object_mapper')) { $this->markTestSkipped('ObjectMapper not installed'); } @@ -94,6 +98,44 @@ public function testShouldMapToTheCorrectResource(): void ]]); } + 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();