Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/Doctrine/Orm/State/ItemProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
31 changes: 31 additions & 0 deletions src/Doctrine/Orm/Tests/Fixtures/Model/EmployeeApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
79 changes: 79 additions & 0 deletions src/Doctrine/Orm/Tests/State/ItemProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
4 changes: 4 additions & 0 deletions tests/Fixtures/app/config/config_common.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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());
}
}
Loading