From f6a8cf0bf68df00be3fc12dd089ba94d39602cfe Mon Sep 17 00:00:00 2001 From: Audain <35590376+audain-dg@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:25:35 +0200 Subject: [PATCH 1/3] feat(doctrine): resolve a related resource to its managed entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resource may declare its mapping in the read direction only — `#[Map(source: Entity::class)]` on the resource — which keeps the entity free of any presentation concern. Reading works; writing does not. A relation typed on another resource is never converted, reaches the entity's property as-is, and PropertyAccess throws a 500: Expected argument of type "?Author", "AuthorResource" given at property path "author" Declaring the reverse mapping only moves the failure: the mapper then builds 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 inserts a duplicate row instead. ManagedEntityTransform resolves the related resource to the managed object it stands for. Nothing is declared per relation: the managed class comes from the related resource's state options, the identifiers from IdentifiersExtractor — never assumed to be called `id`. A to-many arrives as an iterable and every item is resolved. Complements #7698: PersistProcessor::handleLazyObjectRelations() swaps an unmanaged ENTITY for a reference, which is reached when the mapper already produced one; it never converts a resource into an entity. --- .../Common/State/ManagedEntityTransform.php | 137 +++++++++++++++ .../State/ManagedEntityTransformTest.php | 161 ++++++++++++++++++ .../Bundle/Resources/config/doctrine_orm.php | 16 ++ .../ManagedRelationAuthorResource.php | 37 ++++ .../ManagedRelationBookResource.php | 41 +++++ .../ManagedRelation/ManagedRelationAuthor.php | 37 ++++ .../ManagedRelation/ManagedRelationBook.php | 36 ++++ tests/Fixtures/app/config/config_common.yml | 8 + tests/Functional/Doctrine/StateOptionTest.php | 49 +++++- 9 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 src/Doctrine/Common/State/ManagedEntityTransform.php create mode 100644 src/Doctrine/Common/Tests/State/ManagedEntityTransformTest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationAuthorResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php create mode 100644 tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationAuthor.php create mode 100644 tests/Fixtures/TestBundle/Entity/ManagedRelation/ManagedRelationBook.php diff --git a/src/Doctrine/Common/State/ManagedEntityTransform.php b/src/Doctrine/Common/State/ManagedEntityTransform.php new file mode 100644 index 00000000000..180beed2274 --- /dev/null +++ b/src/Doctrine/Common/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\Common\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/Common/Tests/State/ManagedEntityTransformTest.php b/src/Doctrine/Common/Tests/State/ManagedEntityTransformTest.php new file mode 100644 index 00000000000..034064d4ee0 --- /dev/null +++ b/src/Doctrine/Common/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\Common\Tests\State; + +use ApiPlatform\Doctrine\Common\State\ManagedEntityTransform; +use ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Doctrine\Orm\State\Options; +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/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 13806ccf0fb..e30c8ea509d 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\Common\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 00000000000..84667fa9dc8 --- /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 00000000000..1e92d5cccef --- /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\Common\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 00000000000..829727b4778 --- /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 00000000000..1c7d9e227e9 --- /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 0d0e22eafa1..c6edeb99f95 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 1a1c76dd5f5..43d78be91f8 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()); + } } From d0c0561ee8ea8e4b6e7e377815b52631aba1b626 Mon Sep 17 00:00:00 2001 From: Audain <35590376+audain-dg@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:04:01 +0200 Subject: [PATCH 2/3] fix(doctrine): declare symfony/object-mapper in the split packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doctrine-common suite runs standalone in CI, where neither symfony/object-mapper nor api-platform/doctrine-orm is installed. ManagedEntityTransform implements TransformCallableInterface and its test builds an ORM State\Options, so both are fatal there: Class "ApiPlatform\Doctrine\Orm\State\Options" not found Interface "Symfony\Component\ObjectMapper\TransformCallableInterface" not found Tests: 38, Assertions: 127, Errors: 6. symfony/object-mapper joins require-dev and conflict in doctrine-common and doctrine-orm, the way api-platform/state already declares it. The test moves to doctrine-orm: getStateOptionsClass() resolves the managed class by instanceof against the concrete ORM, ODM and Eloquent options, so nothing can stand in for one, and doctrine-common deliberately requires no api-platform sibling package — only doctrine/orm and doctrine/mongodb-odm. --- src/Doctrine/Common/composer.json | 4 +++- .../Tests/State/ManagedEntityTransformTest.php | 4 ++-- src/Doctrine/Orm/composer.json | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) rename src/Doctrine/{Common => Orm}/Tests/State/ManagedEntityTransformTest.php (98%) diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index fdef310498b..c26e4102c5e 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -35,10 +35,12 @@ "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0" }, "conflict": { - "doctrine/persistence": "<1.3" + "doctrine/persistence": "<1.3", + "symfony/object-mapper": "<7.3.4" }, "suggest": { "phpstan/phpdoc-parser": "For PHP documentation support.", diff --git a/src/Doctrine/Common/Tests/State/ManagedEntityTransformTest.php b/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php similarity index 98% rename from src/Doctrine/Common/Tests/State/ManagedEntityTransformTest.php rename to src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php index 034064d4ee0..a6fdcb40412 100644 --- a/src/Doctrine/Common/Tests/State/ManagedEntityTransformTest.php +++ b/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php @@ -11,11 +11,11 @@ declare(strict_types=1); -namespace ApiPlatform\Doctrine\Common\Tests\State; +namespace ApiPlatform\Doctrine\Orm\Tests\State; use ApiPlatform\Doctrine\Common\State\ManagedEntityTransform; -use ApiPlatform\Doctrine\Common\Tests\Fixtures\TestBundle\Entity\Dummy; 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; diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 83263ceed4c..979c472d6bd 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" + } } From 99d4f3decb006c9ad76269be8911e994378d6e68 Mon Sep 17 00:00:00 2001 From: Audain <35590376+audain-dg@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:22:58 +0200 Subject: [PATCH 3/3] fix(doctrine): move ManagedEntityTransform to doctrine-orm The doctrine-orm lowest and minimal-changes jobs install the released doctrine-common instead of linking the workspace, so a test living there cannot see a class this branch adds to doctrine-common: Error: Class "ApiPlatform\Doctrine\Common\State\ManagedEntityTransform" not found Tests: 303, Assertions: 988, Errors: 6. Resolving the managed class through getStateOptionsClass() needs a concrete State\Options, which is ORM, ODM or Eloquent and never doctrine-common, so the class belongs with its test in doctrine-orm. doctrine-common is untouched again. --- src/Doctrine/Common/composer.json | 4 +--- src/Doctrine/{Common => Orm}/State/ManagedEntityTransform.php | 2 +- src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php | 2 +- src/Symfony/Bundle/Resources/config/doctrine_orm.php | 2 +- .../ManagedRelation/ManagedRelationBookResource.php | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) rename src/Doctrine/{Common => Orm}/State/ManagedEntityTransform.php (99%) diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index c26e4102c5e..fdef310498b 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -35,12 +35,10 @@ "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0" }, "conflict": { - "doctrine/persistence": "<1.3", - "symfony/object-mapper": "<7.3.4" + "doctrine/persistence": "<1.3" }, "suggest": { "phpstan/phpdoc-parser": "For PHP documentation support.", diff --git a/src/Doctrine/Common/State/ManagedEntityTransform.php b/src/Doctrine/Orm/State/ManagedEntityTransform.php similarity index 99% rename from src/Doctrine/Common/State/ManagedEntityTransform.php rename to src/Doctrine/Orm/State/ManagedEntityTransform.php index 180beed2274..4c514610ccd 100644 --- a/src/Doctrine/Common/State/ManagedEntityTransform.php +++ b/src/Doctrine/Orm/State/ManagedEntityTransform.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Doctrine\Common\State; +namespace ApiPlatform\Doctrine\Orm\State; use ApiPlatform\Metadata\Exception\ExceptionInterface as MetadataExceptionInterface; use ApiPlatform\Metadata\IdentifiersExtractorInterface; diff --git a/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php b/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php index a6fdcb40412..d5f12fbbd8c 100644 --- a/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php +++ b/src/Doctrine/Orm/Tests/State/ManagedEntityTransformTest.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Doctrine\Orm\Tests\State; -use ApiPlatform\Doctrine\Common\State\ManagedEntityTransform; +use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy; use ApiPlatform\Metadata\ApiResource; diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index e30c8ea509d..b9288ffc857 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -14,7 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ApiPlatform\Doctrine\Common\Metadata\Property\DoctrineDiscriminatorSerializerPropertyMetadataFactory; -use ApiPlatform\Doctrine\Common\State\ManagedEntityTransform; +use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform; use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Doctrine\Orm\Extension\EagerLoadingExtension; diff --git a/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php b/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php index 1e92d5cccef..973fe601321 100644 --- a/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php +++ b/tests/Fixtures/TestBundle/ApiResource/ManagedRelation/ManagedRelationBookResource.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ManagedRelation; -use ApiPlatform\Doctrine\Common\State\ManagedEntityTransform; +use ApiPlatform\Doctrine\Orm\State\ManagedEntityTransform; use ApiPlatform\Doctrine\Orm\State\Options; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get;