diff --git a/src/Doctrine/Orm/State/ManagedEntityTransform.php b/src/Doctrine/Orm/State/ManagedEntityTransform.php new file mode 100644 index 0000000000..4c514610cc --- /dev/null +++ b/src/Doctrine/Orm/State/ManagedEntityTransform.php @@ -0,0 +1,137 @@ + + * + * 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\Doctrine\Orm\State; + +use ApiPlatform\Metadata\Exception\ExceptionInterface as MetadataExceptionInterface; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * Resolves a related API Resource to the managed Doctrine object it stands for. + * + * The object mapper builds objects and has no identity map. When a resource declares its + * mapping in the read direction only — `#[Map(source: Entity::class)]` on the resource, + * which is what keeps the entity free of any presentation concern — a relation typed on + * another resource is never converted, and lands on the entity as-is: + * + * Expected argument of type "?Author", "AuthorResource" given at property path "author" + * + * Declaring the reverse mapping is not enough either: the mapper would then build a fresh + * entity from the resource's scalars — right identifier, an instance Doctrine has never + * seen — and the flush raises "A new entity was found through the relationship". + * Cascading it inserts a duplicate row instead. + * + * Nothing has to be declared per relation: the managed class is read from the related + * resource's state options, and the identifiers from its metadata — never assumed to be + * called `id`, since a resource keyed on a natural code is just as valid. + * + * #[Map(target: 'author', transform: ManagedEntityTransform::class)] + * public ?AuthorResource $author = null; + * + * A to-many arrives as an iterable of resources and every item is resolved, which is what + * `MapCollection` needs on the write side. + * + * @implements TransformCallableInterface + * + * @experimental + */ +final class ManagedEntityTransform implements TransformCallableInterface +{ + use StateOptionsTrait; + + /** @var array */ + private array $managedClasses = []; + + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly IdentifiersExtractorInterface $identifiersExtractor, + ) { + } + + public function __invoke(mixed $value, object $source, ?object $target): mixed + { + if (is_iterable($value)) { + $resolved = []; + + foreach ($value as $key => $item) { + $resolved[$key] = $this->resolve($item); + } + + return $resolved; + } + + return $this->resolve($value); + } + + private function resolve(mixed $value): mixed + { + if (!\is_object($value) || null === ($class = $this->managedClass($value::class))) { + return $value; + } + + if (!$manager = $this->managerRegistry->getManagerForClass($class)) { + return $value; + } + + try { + $identifiers = $this->identifiersExtractor->getIdentifiersFromItem($value); + } catch (MetadataExceptionInterface) { + return $value; + } + + // A resource without a complete identifier stands for no row: hand it back untouched + // rather than guessing, and let the caller deal with an unresolved relation. + if (!$identifiers || \count($identifiers) !== \count(array_filter($identifiers, static fn (mixed $identifier): bool => null !== $identifier))) { + return $value; + } + + return $manager->find($class, 1 === \count($identifiers) ? current($identifiers) : $identifiers) ?? $value; + } + + /** + * @param class-string $resourceClass + * + * @return class-string|null + */ + private function managedClass(string $resourceClass): ?string + { + if (isset($this->managedClasses[$resourceClass])) { + return $this->managedClasses[$resourceClass] ?: null; + } + + try { + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + } catch (MetadataExceptionInterface) { + $this->managedClasses[$resourceClass] = false; + + return null; + } + + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() ?? [] as $operation) { + if ($class = $this->getStateOptionsClass($operation)) { + return $this->managedClasses[$resourceClass] = $class; + } + } + } + + $this->managedClasses[$resourceClass] = false; + + return null; + } +} diff --git a/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php b/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php new file mode 100644 index 0000000000..d5f12fbbd8 --- /dev/null +++ b/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php @@ -0,0 +1,161 @@ + + * + * 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\Doctrine\Orm\Tests\State; + +use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; + +class ManagedEntityTransformTest extends TestCase +{ + use ProphecyTrait; + + public function testItResolvesTheRelatedResourceToItsManagedEntity(): void + { + $resource = new ManagedEntityTransformTestResource(); + $resource->id = 1; + $entity = new Dummy(); + + $objectManagerProphecy = $this->prophesize(ObjectManager::class); + $objectManagerProphecy->find(Dummy::class, 1)->willReturn($entity)->shouldBeCalled(); + + $transform = $this->transform($objectManagerProphecy->reveal(), ['id' => 1]); + + $this->assertSame($entity, $transform($resource, new \stdClass(), null)); + } + + /** + * A to-many arrives as an iterable of resources; every item needs resolving, or Doctrine is + * handed resource objects for an association. + */ + public function testItResolvesEveryItemOfACollection(): void + { + $resource = new ManagedEntityTransformTestResource(); + $resource->id = 1; + $entity = new Dummy(); + + $objectManagerProphecy = $this->prophesize(ObjectManager::class); + $objectManagerProphecy->find(Dummy::class, 1)->willReturn($entity); + + $transform = $this->transform($objectManagerProphecy->reveal(), ['id' => 1]); + + $this->assertSame([$entity, $entity], $transform([$resource, $resource], new \stdClass(), null)); + } + + /** + * The identifier is not assumed to be called `id`: a resource keyed on a natural code is just + * as valid, and hardcoding `id` would silently resolve nothing there. + */ + public function testItReadsAnIdentifierThatIsNotCalledId(): void + { + $resource = new ManagedEntityTransformTestResource(); + $entity = new Dummy(); + + $objectManagerProphecy = $this->prophesize(ObjectManager::class); + $objectManagerProphecy->find(Dummy::class, 'FR')->willReturn($entity)->shouldBeCalled(); + + $transform = $this->transform($objectManagerProphecy->reveal(), ['code' => 'FR']); + + $this->assertSame($entity, $transform($resource, new \stdClass(), null)); + } + + public function testItLeavesAValueThatIsNotAResourceUntouched(): void + { + $value = new \stdClass(); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(Argument::any())->shouldNotBeCalled(); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(\stdClass::class)->willThrow(new ResourceClassNotFoundException()); + + $transform = new ManagedEntityTransform( + $managerRegistryProphecy->reveal(), + $resourceMetadataCollectionFactoryProphecy->reveal(), + $this->prophesize(IdentifiersExtractorInterface::class)->reveal(), + ); + + $this->assertSame($value, $transform($value, new \stdClass(), null)); + } + + /** + * A resource without a complete identifier stands for no row: hand it back rather than guess. + */ + public function testItLeavesAResourceWithoutIdentifierUntouched(): void + { + $resource = new ManagedEntityTransformTestResource(); + + $objectManagerProphecy = $this->prophesize(ObjectManager::class); + $objectManagerProphecy->find(Argument::cetera())->shouldNotBeCalled(); + + $transform = $this->transform($objectManagerProphecy->reveal(), ['id' => null]); + + $this->assertSame($resource, $transform($resource, new \stdClass(), null)); + } + + public function testItLeavesAScalarUntouched(): void + { + $transform = new ManagedEntityTransform( + $this->prophesize(ManagerRegistry::class)->reveal(), + $this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(), + $this->prophesize(IdentifiersExtractorInterface::class)->reveal(), + ); + + $this->assertSame('a string', $transform('a string', new \stdClass(), null)); + } + + /** + * @param array $identifiers + */ + private function transform(ObjectManager $objectManager, array $identifiers): ManagedEntityTransform + { + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($objectManager); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(ManagedEntityTransformTestResource::class)->willReturn( + new ResourceMetadataCollection(ManagedEntityTransformTestResource::class, [ + (new ApiResource())->withOperations(new Operations([ + 'get' => (new Get())->withStateOptions(new Options(entityClass: Dummy::class)), + ])), + ]) + ); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem(Argument::type(ManagedEntityTransformTestResource::class))->willReturn($identifiers); + + return new ManagedEntityTransform( + $managerRegistryProphecy->reveal(), + $resourceMetadataCollectionFactoryProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), + ); + } +} + +class ManagedEntityTransformTestResource +{ + public ?int $id = null; +} diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 83263ceed4..979c472d6b 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -39,6 +39,7 @@ "ramsey/uuid-doctrine": "^2.0", "symfony/cache": "^7.4 || ^8.0", "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", "symfony/property-access": "^7.4 || ^8.0", "symfony/serializer": "^7.4 || ^8.0", "symfony/property-info": "^7.4 || ^8.0", @@ -80,5 +81,8 @@ "test": "./vendor/bin/phpunit" }, "minimum-stability": "beta", - "prefer-stable": true + "prefer-stable": true, + "conflict": { + "symfony/object-mapper": "<7.3.4" + } } diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 13806ccf0f..b9288ffc85 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -14,6 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory; +use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform; use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Doctrine\Orm\Extension\EagerLoadingExtension; @@ -42,6 +43,7 @@ use ApiPlatform\Doctrine\Orm\State\ItemProvider; use ApiPlatform\Doctrine\Orm\State\LinksHandler; use Doctrine\Persistence\Mapping\ClassMetadataFactory; +use Symfony\Component\ObjectMapper\TransformCallableInterface; return function (ContainerConfigurator $container) { $services = $container->services(); @@ -302,4 +304,18 @@ service('doctrine'), ]) ->tag('api_platform.doctrine.orm.links_handler', ['key' => 'api_platform.doctrine.orm.links_handler']); + + if (interface_exists(TransformCallableInterface::class)) { + // Registered under its own class name: the object mapper resolves a `transform` by the + // string it is given, and that is the class name users write in the attribute. + $services->set(ManagedEntityTransform::class) + ->args([ + service('doctrine'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.api.identifiers_extractor'), + ]) + ->tag('object_mapper.transform_callable'); + + $services->alias('api_platform.doctrine.orm.object_mapper.managed_entity_transform', ManagedEntityTransform::class); + } }; diff --git a/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationAuthorResource.php b/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationAuthorResource.php new file mode 100644 index 0000000000..84667fa9dc --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationAuthorResource.php @@ -0,0 +1,37 @@ + + * + * 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\ManagedRelation; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ManagedRelation\ManagedRelationAuthor; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + operations: [ + new Get(), + new Post(), + ], + shortName: 'ManagedRelationAuthor', + stateOptions: new Options(entityClass: ManagedRelationAuthor::class) +)] +#[Map(source: ManagedRelationAuthor::class)] +class ManagedRelationAuthorResource +{ + public ?int $id = null; + + public string $name = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php b/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php new file mode 100644 index 0000000000..973fe60132 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php @@ -0,0 +1,41 @@ + + * + * 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\ManagedRelation; + +use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ManagedRelation\ManagedRelationBook; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + operations: [ + new Get(), + new Post(), + ], + shortName: 'ManagedRelationBook', + stateOptions: new Options(entityClass: ManagedRelationBook::class) +)] +#[Map(source: ManagedRelationBook::class)] +class ManagedRelationBookResource +{ + public ?int $id = null; + + public string $title = ''; + + #[Map(target: 'author', transform: ManagedEntityTransform::class)] + public ?ManagedRelationAuthorResource $author = null; +} diff --git a/tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationAuthor.php b/tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationAuthor.php new file mode 100644 index 0000000000..829727b477 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationAuthor.php @@ -0,0 +1,37 @@ + + * + * 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\ManagedRelation; + +use Doctrine\ORM\Mapping as ORM; + +/** + * Carries no mapping attribute on purpose: the whole point of declaring the mapping in the + * read direction, on the resource, is that the entity knows nothing about the presentation. + */ +#[ORM\Entity] +class ManagedRelationAuthor +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $name = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationBook.php b/tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationBook.php new file mode 100644 index 0000000000..1c7d9e227e --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationBook.php @@ -0,0 +1,36 @@ + + * + * 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\ManagedRelation; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class ManagedRelationBook +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $title = ''; + + #[ORM\ManyToOne(targetEntity: ManagedRelationAuthor::class)] + public ?ManagedRelationAuthor $author = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 0d0e22eafa..c6edeb99f9 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -127,6 +127,14 @@ services: autowire: true autoconfigure: true + # Registered as services so the #[Map] attributes they carry are collected the way an + # application collects them: that is what feeds Symfony's reverse class map, and with it + # the entity -> resource direction on read. + ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ManagedRelation\: + resource: '../../TestBundle/ApiResource/ManagedRelation' + autowire: true + autoconfigure: true + ApiPlatform\Tests\Fixtures\TestBundle\State\AttributeResourceProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\AttributeResourceProvider' tags: diff --git a/tests/Functional/Doctrine/StateOptionTest.php b/tests/Functional/Doctrine/StateOptionTest.php index 1a1c76dd5f..43d78be91f 100644 --- a/tests/Functional/Doctrine/StateOptionTest.php +++ b/tests/Functional/Doctrine/StateOptionTest.php @@ -17,9 +17,13 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6039\UserApi; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689\Issue7689CategoryDto; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7689\Issue7689ProductDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ManagedRelation\ManagedRelationAuthorResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ManagedRelation\ManagedRelationBookResource; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6039\Issue6039EntityUser; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689\Issue7689Category; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7689\Issue7689Product; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ManagedRelation\ManagedRelationAuthor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ManagedRelation\ManagedRelationBook; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use Symfony\Component\ObjectMapper\Metadata\ReverseClassObjectMapperMetadataFactory; @@ -36,7 +40,7 @@ final class StateOptionTest extends ApiTestCase */ public static function getResources(): array { - return [UserApi::class, Issue7689ProductDto::class, Issue7689CategoryDto::class]; + return [UserApi::class, Issue7689ProductDto::class, Issue7689CategoryDto::class, ManagedRelationBookResource::class, ManagedRelationAuthorResource::class]; } public function testDtoWithEntityClassOptionCollection(): void @@ -91,4 +95,47 @@ public function testPostWithEntityClassOption(): void $this->assertNotNull($product->category); $this->assertEquals(1, $product->category->getId()); } + + /** + * A resource may declare its mapping in the read direction only — `#[Map(source: Entity::class)]` + * on the resource — which is what keeps the entity free of any presentation concern. The relation + * then has to be resolved to the managed entity, or the resource itself reaches the entity's + * property and PropertyAccess throws. + */ + public function testPostWithRelationMappedFromTheResourceOnly(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested.'); + } + + // The write direction is what this test pins, but the response maps the entity back to the + // resource, and that direction reads the mapping through the reverse class map. + if (!class_exists(ReverseClassObjectMapperMetadataFactory::class)) { + $this->markTestSkipped('This test requires symfony/object-mapper >= 8.1'); + } + + $this->recreateSchema([ManagedRelationBook::class, ManagedRelationAuthor::class]); + $manager = static::getContainer()->get('doctrine')->getManager(); + + $author = new ManagedRelationAuthor(); + $author->name = 'author'; + $manager->persist($author); + $manager->flush(); + $iri = '/managed_relation_authors/'.$author->getId(); + + static::createClient()->request('POST', '/managed_relation_books', ['json' => [ + 'title' => 'book', + 'author' => $iri, + ]]); + $this->assertResponseStatusCodeSame(201); + + $book = $manager->getRepository(ManagedRelationBook::class)->findOneBy(['title' => 'book']); + $this->assertNotNull($book->author); + $this->assertSame($author->getId(), $book->author->getId()); + + // The relation must reuse the MANAGED entity: a detached one rebuilt from the resource's + // scalars is what raises "A new entity was found through the relationship", and cascading + // it would insert a second author instead. + $this->assertCount(1, $manager->getRepository(ManagedRelationAuthor::class)->findAll()); + } }