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()); + } +}