Skip to content

Commit c05449a

Browse files
committed
fix(doctrine): filter parent link from uri variables in fetch_data=false reference
ItemProvider::provide() with fetch_data=false short-circuits to getReference(). On a subresource IRI it passed the whole $uriVariables — including the parent link (e.g. companyId) — which is not an identifier of the entity, so Doctrine threw UnrecognizedIdentifierFields. Resolve the entity's own identifiers from the operation's identifier-self links (fromClass === entityClass, no relation property) instead of the raw uriVariables keys, mirroring LinksHandlerTrait. This maps each own identifier via Link::getIdentifiers(), so a renamed identifier uriVariable (e.g. /employees/{employeeId} -> field "id") still short-circuits, and it falls back to the link-resolving query when an own identifier is missing. Fixes #8124
1 parent 17b1831 commit c05449a

3 files changed

Lines changed: 205 additions & 3 deletions

File tree

src/Doctrine/Orm/State/ItemProvider.php

Lines changed: 40 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($entityClass, $operation, $uriVariables, $context))) {
63+
return $manager->getReference($entityClass, $identifiers);
6564
}
6665

6766
$repository = $manager->getRepository($entityClass);
@@ -88,4 +87,42 @@ 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 when an own identifier value is missing, so the caller falls through to the query
97+
* that resolves the link.
98+
*
99+
* @param array<string, mixed> $uriVariables
100+
* @param array<string, mixed> $context
101+
*
102+
* @return array<string, mixed>|null
103+
*/
104+
private function getReferenceIdentifiers(string $entityClass, Operation $operation, array $uriVariables, array $context): ?array
105+
{
106+
$identifiers = [];
107+
foreach ($this->getLinks($entityClass, $operation, $context) as $parameterName => $link) {
108+
// Mirrors LinksHandlerTrait: the identifier-self link has no relation property and points to the entity itself.
109+
if ($entityClass !== $link->getFromClass() || $link->getFromProperty() || $link->getToProperty()) {
110+
continue;
111+
}
112+
113+
$identifierProperties = $link->getIdentifiers();
114+
$hasCompositeIdentifiers = 1 < \count($identifierProperties);
115+
foreach ($identifierProperties as $identifierProperty) {
116+
// Composite identifiers are exploded by field name upstream; a single identifier is keyed by its uriVariable name.
117+
$key = $hasCompositeIdentifiers ? $identifierProperty : $parameterName;
118+
if (!\array_key_exists($key, $uriVariables)) {
119+
return null;
120+
}
121+
122+
$identifiers[$identifierProperty] = $uriVariables[$key];
123+
}
124+
}
125+
126+
return $identifiers ?: null;
127+
}
91128
}

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,99 @@ 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+
$managerMock = $this->createMock(EntityManagerInterface::class);
146+
$managerMock->expects($this->once())
147+
->method('getReference')
148+
->with(Employee::class, ['id' => 2])
149+
->willReturn($reference);
150+
151+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
152+
$managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock);
153+
154+
$operation = (new Get())->withUriVariables([
155+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'),
156+
'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']),
157+
])->withName('get')->withClass(Employee::class);
158+
159+
$dataProvider = new ItemProvider(
160+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
161+
$managerRegistryMock,
162+
);
163+
164+
$this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'id' => 2], ['fetch_data' => false]));
165+
}
166+
167+
public function testGetItemWithFetchDataFalseMapsRenamedIdentifierUriVariable(): void
168+
{
169+
$reference = new Employee();
170+
171+
$managerMock = $this->createMock(EntityManagerInterface::class);
172+
$managerMock->expects($this->once())
173+
->method('getReference')
174+
->with(Employee::class, ['id' => 2])
175+
->willReturn($reference);
176+
177+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
178+
$managerRegistryMock->method('getManagerForClass')->with(Employee::class)->willReturn($managerMock);
179+
180+
// The identifier uriVariable is named "employeeId" while the entity's own identifier field is "id".
181+
$operation = (new Get())->withUriVariables([
182+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company'),
183+
'employeeId' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id'])->withParameterName('employeeId'),
184+
])->withName('get')->withClass(Employee::class);
185+
186+
$dataProvider = new ItemProvider(
187+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
188+
$managerRegistryMock,
189+
);
190+
191+
$this->assertSame($reference, $dataProvider->provide($operation, ['companyId' => 1, 'employeeId' => 2], ['fetch_data' => false]));
192+
}
193+
194+
public function testGetItemWithFetchDataFalseFallsBackToQueryWhenOwnIdentifierMissing(): void
195+
{
196+
$returnObject = new \stdClass();
197+
198+
$queryMock = $this->createMock($this->getQueryClass());
199+
$queryMock->method('getOneOrNullResult')->willReturn($returnObject);
200+
201+
$queryBuilderMock = $this->createMock(QueryBuilder::class);
202+
$queryBuilderMock->method('getQuery')->willReturn($queryMock);
203+
$queryBuilderMock->method('getRootAliases')->willReturn(['o']);
204+
205+
$classMetadataMock = $this->createMock(ClassMetadata::class);
206+
$classMetadataMock->method('getIdentifierFieldNames')->willReturn(['id']);
207+
208+
$repositoryMock = $this->createMock(EntityRepository::class);
209+
$repositoryMock->method('createQueryBuilder')->with('o')->willReturn($queryBuilderMock);
210+
211+
$managerMock = $this->createMock(EntityManagerInterface::class);
212+
$managerMock->method('getClassMetadata')->willReturn($classMetadataMock);
213+
$managerMock->method('getRepository')->willReturn($repositoryMock);
214+
// Only the parent link is provided: the own identifier cannot be resolved to a reference,
215+
// so we must fall back to the query that resolves the link instead of calling getReference().
216+
$managerMock->expects($this->never())->method('getReference');
217+
218+
$managerRegistryMock = $this->createMock(ManagerRegistry::class);
219+
$managerRegistryMock->method('getManagerForClass')->willReturn($managerMock);
220+
221+
$operation = (new Get())->withUriVariables([
222+
'companyId' => (new Link())->withFromClass(Company::class)->withToProperty('company')->withIdentifiers(['id']),
223+
'id' => (new Link())->withFromClass(Employee::class)->withIdentifiers(['id']),
224+
])->withName('get')->withClass(Employee::class);
225+
226+
$dataProvider = new ItemProvider(
227+
$this->createStub(ResourceMetadataCollectionFactoryInterface::class),
228+
$managerRegistryMock,
229+
);
230+
231+
$this->assertSame($returnObject, $dataProvider->provide($operation, ['companyId' => 1], ['fetch_data' => false]));
232+
}
233+
141234
public function testQueryResultExtension(): void
142235
{
143236
$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)