From 9b44dbc6d9c1a5855267df1c3260c60bafb4c964 Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Tue, 7 Jul 2026 16:05:06 +0100 Subject: [PATCH] fix(doctrine): fetch_data=false reference for stateOptions resources getReferenceIdentifiers() keeps only the link that points at the resource itself, selecting it by comparing the link's fromClass to $entityClass, the stateOptions entity class. A Link's fromClass is always the resource class, so for a DTO resource the two never coincide: the fetch_data=false fast path returned null and fell through to the full item query, running the item query extensions it was meant to skip. A relation SearchFilter then fails open. getIdFromValue() resolves the IRI via getResourceFromIri, an item query extension excludes the row, the null is swallowed, and no constraint is emitted at all. Compare against $operation->getClass() instead. The entity identifier guard below is unchanged, so a resource whose API identifier is not a Doctrine identifier still falls through; for a resource that is not a DTO, $operation->getClass() === $entityClass and behavior is identical. --- src/Doctrine/Orm/State/ItemProvider.php | 6 +- .../Orm/Tests/Fixtures/Model/EmployeeApi.php | 31 ++++++++ .../Orm/Tests/State/ItemProviderTest.php | 79 +++++++++++++++++++ .../FetchDataFalseStateOptions.php | 36 +++++++++ ...etchDataFalseStateOptionsItemExtension.php | 37 +++++++++ .../FetchDataFalseStateOptionsEntity.php | 36 +++++++++ tests/Fixtures/app/config/config_common.yml | 4 + ...FetchDataFalseStateOptionsResourceTest.php | 73 +++++++++++++++++ 8 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php create mode 100644 tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php create mode 100644 tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php create mode 100644 tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index 536f6bb611f..369996d4c9c 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -109,8 +109,10 @@ private function getReferenceIdentifiers(EntityManagerInterface $manager, string $identifiers = []; foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) { - // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself. - if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { + // Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the resource itself. + // Links are expressed in resource terms, so the identifier-self link's fromClass is the resource class + // ($operation->getClass()), which differs from $entityClass for a DTO/stateOptions resource. + if ($operation->getClass() !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) { continue; } diff --git a/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php b/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php new file mode 100644 index 00000000000..c5008dbd047 --- /dev/null +++ b/src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php @@ -0,0 +1,31 @@ + + * + * 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\Fixtures\Model; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Employee; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +/** + * DTO resource backed by the {@see Employee} entity through stateOptions: the resource + * class differs from the Doctrine entity class, which is what exercises the + * fetch_data=false getReference() fast-path for DTO/stateOptions resources. + */ +#[ApiResource(stateOptions: new Options(entityClass: Employee::class))] +#[Get] +class EmployeeApi +{ + public ?int $id = null; +} diff --git a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php index 8fbb549636e..4eba41322d7 100644 --- a/src/Doctrine/Orm/Tests/State/ItemProviderTest.php +++ b/src/Doctrine/Orm/Tests/State/ItemProviderTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Company; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Employee; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\OperationResource; +use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Model\EmployeeApi; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; @@ -278,6 +279,84 @@ public function testGetItemWithFetchDataFalseFallsBackToQueryWhenIdentifierIsNot $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); } + public function testGetItemWithFetchDataFalseReturnsReferenceForStateOptionsResource(): void + { + $reference = new Employee(); + + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock); + $managerMock->expects($this->once()) + ->method('getReference') + ->with(Employee::class, ['id' => 2]) + ->willReturn($reference); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock); + + // DTO resource: the operation class (EmployeeApi) differs from the stateOptions entity class (Employee), + // so the identifier-self link's fromClass is the resource class, not the entity class. + $operation = (new Get())->withUriVariables([ + 'id' => (new Link())->withFromClass(EmployeeApi::class)->withIdentifiers(['id']), + ])->withStateOptions(new Options(entityClass: Employee::class))->withName('get')->withClass(EmployeeApi::class); + + // The reference fast-path must return before the item query is built, so item extensions never run. + $extensionMock = $this->createMock(QueryItemExtensionInterface::class); + $extensionMock->expects($this->never())->method('applyToItem'); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + [$extensionMock] + ); + + $this->assertSame($reference, $dataProvider->provide($operation, ['id' => 2], ['fetch_data' => false])); + } + + public function testGetItemWithFetchDataFalseFallsBackToQueryForStateOptionsResourceWhenIdentifierIsNotADoctrineIdentifier(): void + { + $returnObject = new \stdClass(); + + $queryMock = $this->createMock($this->getQueryClass()); + $queryMock->method('getOneOrNullResult')->willReturn($returnObject); + + $queryBuilderMock = $this->createMock(QueryBuilder::class); + $queryBuilderMock->method('getQuery')->willReturn($queryMock); + $queryBuilderMock->method('getRootAliases')->willReturn(['o']); + + // Even for a DTO resource, the entity-identifier guard holds: the resource exposes "uuid" as its + // API identifier while the Doctrine identifier is "id", so getReference() cannot be built and we + // must fall back to the link-resolving query. + $classMetadataMock = $this->createMock(ClassMetadata::class); + $classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']); + + $repositoryMock = $this->createMock(EntityRepository::class); + $repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock); + + $managerMock = $this->createMock(EntityManagerInterface::class); + $managerMock->method('getClassMetadata')->willReturn($classMetadataMock); + $managerMock->method('getRepository')->willReturn($repositoryMock); + $managerMock->expects($this->never())->method('getReference'); + + $managerRegistryMock = $this->createMock(ManagerRegistry::class); + $managerRegistryMock->method('getManagerForClass')->willReturn($managerMock); + + // A no-op handleLinks mirrors what DoctrineOrmResourceCollectionMetadataFactory injects for a + // stateOptions resource in production; the unit test bypasses that factory (see CollectionProviderTest). + $operation = (new Get())->withUriVariables([ + 'uuid' => (new Link())->withFromClass(EmployeeApi::class)->withIdentifiers(['uuid'])->withParameterName('uuid'), + ])->withStateOptions(new Options(entityClass: Employee::class, handleLinks: static fn () => null))->withName('get')->withClass(EmployeeApi::class); + + $dataProvider = new ItemProvider( + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + $managerRegistryMock, + ); + + $this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false])); + } + public function testQueryResultExtension(): void { $returnObject = new \stdClass(); diff --git a/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php b/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.php new file mode 100644 index 00000000000..20aa8cfe0ec --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/FetchDataFalseStateOptions.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\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; + +/** + * DTO resource whose class differs from its Doctrine entity: the identifier link's fromClass is this + * resource class, so getReferenceIdentifiers() must compare against it (not the entity class) for the + * fetch_data=false reference fast-path to trigger. + */ +#[ApiResource( + shortName: 'FetchDataFalseStateOptions', + operations: [new Get()], + stateOptions: new Options(entityClass: FetchDataFalseStateOptionsEntity::class), +)] +class FetchDataFalseStateOptions +{ + public ?int $id = null; + + public ?string $name = null; +} diff --git a/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php b/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.php new file mode 100644 index 00000000000..2a49737067a --- /dev/null +++ b/tests/Fixtures/TestBundle/Doctrine/Orm/FetchDataFalseStateOptionsItemExtension.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\Doctrine\Orm; + +use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; +use Doctrine\ORM\QueryBuilder; + +/** + * Excludes every FetchDataFalseStateOptionsEntity row from item queries. A fetch_data=false request must + * return a getReference() and skip the query entirely, so this extension must never run for it; if it does, + * the item resolves to null. Scoped to the fixture entity so it does not affect other resources. + */ +final class FetchDataFalseStateOptionsItemExtension implements QueryItemExtensionInterface +{ + public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void + { + if (FetchDataFalseStateOptionsEntity::class !== $resourceClass) { + return; + } + + $queryBuilder->andWhere('1 = 0'); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php b/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.php new file mode 100644 index 00000000000..80f805c13e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FetchDataFalseStateOptionsEntity.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; + +use Doctrine\ORM\Mapping as ORM; + +/** + * Backing entity for the FetchDataFalseStateOptions DTO resource. + */ +#[ORM\Entity] +class FetchDataFalseStateOptionsEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\Column(length: 255)] + public ?string $name = 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 d03cbd48b35..48da39cf1b4 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -131,6 +131,10 @@ services: tags: - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\FetchDataFalseStateOptionsItemExtension: + tags: + - name: 'api_platform.doctrine.orm.query_extension.item' + ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\DummyCollectionDtoProvider' public: false diff --git a/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php b/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php new file mode 100644 index 00000000000..f493fd8a561 --- /dev/null +++ b/tests/Functional/Doctrine/FetchDataFalseStateOptionsResourceTest.php @@ -0,0 +1,73 @@ + + * + * 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\Functional\Doctrine; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FetchDataFalseStateOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FetchDataFalseStateOptionsEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class FetchDataFalseStateOptionsResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FetchDataFalseStateOptions::class]; + } + + public function testFetchDataFalseReturnsReferenceForStateOptionsResourceAndSkipsItemExtensions(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped('getReference() is ORM specific.'); + } + + $this->recreateSchema([FetchDataFalseStateOptionsEntity::class]); + + $manager = $this->getManager(); + $entity = new FetchDataFalseStateOptionsEntity(); + $entity->name = 'test'; + $manager->persist($entity); + $manager->flush(); + $id = $entity->id; + $manager->clear(); + + /** @var ResourceMetadataCollectionFactoryInterface $factory */ + $factory = $container->get('api_platform.metadata.resource.metadata_collection_factory'); + $operation = $factory->create(FetchDataFalseStateOptions::class)->getOperation(); + + /** @var ProviderInterface $provider */ + $provider = $container->get('api_platform.doctrine.orm.state.item_provider'); + + // FetchDataFalseStateOptionsItemExtension excludes every row from the item query. With fetch_data=false + // the provider must return EntityManager::getReference() and never build that query, so the extension is + // skipped and the reference is returned. Before the DTO fix the resource-vs-entity class mismatch made + // getReferenceIdentifiers() return null, the provider fell through to the query, the extension ran, and + // this resolved to null. + $reference = $provider->provide($operation, ['id' => $id], ['fetch_data' => false]); + + $this->assertInstanceOf(FetchDataFalseStateOptionsEntity::class, $reference); + $this->assertSame($id, $reference->getId()); + } +}