Skip to content

Commit 0d9bcde

Browse files
authored
fix(doctrine): filter parent link from uri variables in fetch_data=false reference (#8295)
Fixes #8124
1 parent 553f6d3 commit 0d9bcde

3 files changed

Lines changed: 260 additions & 3 deletions

File tree

src/Doctrine/Orm/State/ItemProvider.php

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5959
}
6060

6161
$fetchData = $context['fetch_data'] ?? true;
62-
if (!$fetchData && \array_key_exists('id', $uriVariables)) {
63-
// todo : if uriVariables don't contain the id, this fails. This should behave like it does in the following code
64-
return $manager->getReference($entityClass, $uriVariables);
62+
if (!$fetchData && null !== ($identifiers = $this->getReferenceIdentifiers($manager, $entityClass, $operation, $uriVariables, $context))) {
63+
return $manager->getReference($entityClass, $identifiers);
6564
}
6665

6766
$repository = $manager->getRepository($entityClass);
@@ -88,4 +87,50 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
8887

8988
return $queryBuilder->getQuery()->getOneOrNullResult();
9089
}
90+
91+
/**
92+
* Builds the [identifierField => value] map for getReference() from the resource's own identifier
93+
* links, ignoring parent/relation links a subresource carries (e.g. "companyId") which are not
94+
* identifiers of the entity and would make getReference() throw UnrecognizedIdentifierFields.
95+
*
96+
* Returns null (so the caller falls through to the link-resolving query) when an own identifier
97+
* value is missing, or when a resource identifier is not a Doctrine identifier of the entity
98+
* (e.g. a uuid exposed as the API identifier while the table key is "id") and therefore cannot be
99+
* turned into a reference.
100+
*
101+
* @param array<string, mixed> $uriVariables
102+
* @param array<string, mixed> $context
103+
*
104+
* @return array<string, mixed>|null
105+
*/
106+
private function getReferenceIdentifiers(EntityManagerInterface $manager, string $entityClass, Operation $operation, array $uriVariables, array $context): ?array
107+
{
108+
$identifierFields = array_flip($manager->getClassMetadata($entityClass)->getIdentifierFieldNames());
109+
110+
$identifiers = [];
111+
foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) {
112+
// Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself.
113+
if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) {
114+
continue;
115+
}
116+
117+
$identifierProperties = $link->getIdentifiers();
118+
$hasCompositeIdentifiers = 1 < \count($identifierProperties);
119+
foreach ($identifierProperties as $identifierProperty) {
120+
if (!isset($identifierFields[$identifierProperty])) {
121+
return null;
122+
}
123+
124+
// Composite identifiers are exploded by field name upstream; a single identifier is keyed by its uriVariable name.
125+
$key = $hasCompositeIdentifiers ? $identifierProperty : $parameterName;
126+
if (!\array_key_exists($key, $uriVariables)) {
127+
return null;
128+
}
129+
130+
$identifiers[$identifierProperty] = $uriVariables[$key];
131+
}
132+
}
133+
134+
return $identifiers ?: null;
135+
}
91136
}

src/Doctrine/Orm/Tests/State/ItemProviderTest.php

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,146 @@ public function testGetItemDoubleIdentifier(): void
138138
$this->assertEquals($returnObject, $dataProvider->provide($operation, ['ida' => 1, 'idb' => 2], $context));
139139
}
140140

141+
public function testGetItemWithFetchDataFalseOnSubresourceFiltersParentLink(): void
142+
{
143+
$reference = new Employee();
144+
145+
$classMetadataMock = $this->createMock(ClassMetadata::class);
146+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
147+
148+
$managerMock = $this->createMock(EntityManagerInterface::class);
149+
$managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock);
150+
$managerMock->expects($this->once())
151+
->method('getReference')
152+
->with(Employee::class, ['id' => 2])
153+
->willReturn($reference);
154+
155+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
156+
$managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock);
157+
158+
$operation = (new Get())->withUriVariables([
159+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'),
160+
'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']),
161+
])->withName('get')->withClass(Employee::class);
162+
163+
$dataProvider = new ItemProvider(
164+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
165+
$managerRegistryMock,
166+
);
167+
168+
$this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'id' => 2], ['fetch_data' => false]));
169+
}
170+
171+
public function testGetItemWithFetchDataFalseMapsRenamedIdentifierUriVariable(): void
172+
{
173+
$reference = new Employee();
174+
175+
$classMetadataMock = $this->createMock(ClassMetadata::class);
176+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
177+
178+
$managerMock = $this->createMock(EntityManagerInterface::class);
179+
$managerMock->method('getClassMetadata')->with(Employee::class)->willReturn($classMetadataMock);
180+
$managerMock->expects($this->once())
181+
->method('getReference')
182+
->with(Employee::class, ['id' => 2])
183+
->willReturn($reference);
184+
185+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
186+
$managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock);
187+
188+
// The identifier uriVariable is named "employeeId" while the entity's own identifier field is "id".
189+
$operation = (new Get())->withUriVariables([
190+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'),
191+
'employeeId' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id'])->withParameterName('employeeId'),
192+
])->withName('get')->withClass(Employee::class);
193+
194+
$dataProvider = new ItemProvider(
195+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
196+
$managerRegistryMock,
197+
);
198+
199+
$this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'employeeId' => 2], ['fetch_data' => false]));
200+
}
201+
202+
public function testGetItemWithFetchDataFalseFallsBackToQueryWhenOwnIdentifierMissing(): void
203+
{
204+
$returnObject = new \stdClass();
205+
206+
$queryMock = $this->createMock($this->getQueryClass());
207+
$queryMock->method('getOneOrNullResult')->willReturn($returnObject);
208+
209+
$queryBuilderMock = $this->createMock(QueryBuilder::class);
210+
$queryBuilderMock->method('getQuery')->willReturn($queryMock);
211+
$queryBuilderMock->method('getRootAliases')->willReturn(['o']);
212+
213+
$classMetadataMock = $this->createMock(ClassMetadata::class);
214+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
215+
216+
$repositoryMock = $this->createMock(EntityRepository::class);
217+
$repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock);
218+
219+
$managerMock = $this->createMock(EntityManagerInterface::class);
220+
$managerMock->method('getClassMetadata')->willReturn($classMetadataMock);
221+
$managerMock->method('getRepository')->willReturn($repositoryMock);
222+
// Only the parent link is provided: the own identifier cannot be resolved to a reference,
223+
// so we must fall back to the query that resolves the link instead of calling getReference().
224+
$managerMock->expects($this->never())->method('getReference');
225+
226+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
227+
$managerRegistryMock->method('getManagerForClass')->willReturn($managerMock);
228+
229+
$operation = (new Get())->withUriVariables([
230+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company')->withIdentifiers(['id']),
231+
'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']),
232+
])->withName('get')->withClass(Employee::class);
233+
234+
$dataProvider = new ItemProvider(
235+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
236+
$managerRegistryMock,
237+
);
238+
239+
$this->assertSame($returnObject, $dataProvider->provide($operation, ['companyId' => 1], ['fetch_data' => false]));
240+
}
241+
242+
public function testGetItemWithFetchDataFalseFallsBackToQueryWhenIdentifierIsNotADoctrineIdentifier(): void
243+
{
244+
$returnObject = new \stdClass();
245+
246+
$queryMock = $this->createMock($this->getQueryClass());
247+
$queryMock->method('getOneOrNullResult')->willReturn($returnObject);
248+
249+
$queryBuilderMock = $this->createMock(QueryBuilder::class);
250+
$queryBuilderMock->method('getQuery')->willReturn($queryMock);
251+
$queryBuilderMock->method('getRootAliases')->willReturn(['o']);
252+
253+
// The Doctrine identifier is "id" while the resource exposes "uuid" as its API identifier:
254+
// getReference() cannot be built from "uuid", so we must fall back to the query.
255+
$classMetadataMock = $this->createMock(ClassMetadata::class);
256+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
257+
258+
$repositoryMock = $this->createMock(EntityRepository::class);
259+
$repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock);
260+
261+
$managerMock = $this->createMock(EntityManagerInterface::class);
262+
$managerMock->method('getClassMetadata')->willReturn($classMetadataMock);
263+
$managerMock->method('getRepository')->willReturn($repositoryMock);
264+
$managerMock->expects($this->never())->method('getReference');
265+
266+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
267+
$managerRegistryMock->method('getManagerForClass')->willReturn($managerMock);
268+
269+
$operation = (new Get())->withUriVariables([
270+
'uuid' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['uuid'])->withParameterName('uuid'),
271+
])->withName('get')->withClass(Employee::class);
272+
273+
$dataProvider = new ItemProvider(
274+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
275+
$managerRegistryMock,
276+
);
277+
278+
$this->assertSame($returnObject, $dataProvider->provide($operation, ['uuid' => '61817181-0ecc-42fb-a6e7-d97f2ddcb344'], ['fetch_data' => false]));
279+
}
280+
141281
public function testQueryResultExtension(): void
142282
{
143283
$returnObject = new \stdClass();
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Doctrine;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
final class FetchDataFalseSubresourceTest extends ApiTestCase
23+
{
24+
use RecreateSchemaTrait;
25+
use SetupClassResourcesTrait;
26+
27+
protected static ?bool $alwaysBootKernel = false;
28+
29+
/**
30+
* @return class-string[]
31+
*/
32+
public static function getResources(): array
33+
{
34+
return [Company::class, Employee::class];
35+
}
36+
37+
public function testGetResourceFromSubresourceIriWithFetchDataFalseReturnsReference(): void
38+
{
39+
$container = static::getContainer();
40+
if ('mongodb' === $container->getParameter('kernel.environment')) {
41+
$this->markTestSkipped('getReference()/UnrecognizedIdentifierFields is ORM specific.');
42+
}
43+
44+
$this->recreateSchema([Company::class, Employee::class]);
45+
46+
$manager = $this->getManager();
47+
$company = new Company();
48+
$company->name = 'test';
49+
$manager->persist($company);
50+
51+
$employees = [];
52+
for ($i = 0; $i < 3; ++$i) {
53+
$employee = new Employee();
54+
$employee->name = "Employee number $i";
55+
$employee->company = $company;
56+
$manager->persist($employee);
57+
$employees[] = $employee;
58+
}
59+
$manager->flush();
60+
61+
$employee = $employees[1];
62+
$iri = \sprintf('/companies/%d/employees/%d', $company->getId(), $employee->getId());
63+
64+
// fetch_data=false short-circuits to EntityManager::getReference(); the subresource IRI carries the
65+
// parent link "companyId" which is not an identifier of Employee and used to raise
66+
// Doctrine\ORM\Exception\UnrecognizedIdentifierFields ('companyId'). See #8124.
67+
$reference = $container->get('api_platform.iri_converter')->getResourceFromIri($iri, ['fetch_data' => false]);
68+
69+
$this->assertInstanceOf(Employee::class, $reference);
70+
$this->assertSame($employee->getId(), $reference->getId());
71+
}
72+
}

0 commit comments

Comments
 (0)