Skip to content

Commit a62e089

Browse files
alanpoulaindunglas
authored andcommitted
Fix Doctrine query for nested subresources (#1608)
1 parent 3d4fea5 commit a62e089

File tree

6 files changed

+267
-117
lines changed

6 files changed

+267
-117
lines changed

features/main/relation.feature

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Feature: Relations support
1919
"@context": "/contexts/ThirdLevel",
2020
"@id": "/third_levels/1",
2121
"@type": "ThirdLevel",
22+
"fourthLevel": null,
2223
"id": 1,
2324
"level": 3,
2425
"test": true
@@ -64,7 +65,11 @@ Feature: Relations support
6465
"name": null,
6566
"symfony": "symfony",
6667
"dummyDate": null,
67-
"thirdLevel": "/third_levels/1",
68+
"thirdLevel": {
69+
"@id": "/third_levels/1",
70+
"@type": "ThirdLevel",
71+
"fourthLevel": null
72+
},
6873
"relatedToDummyFriend": [],
6974
"dummyBoolean": null,
7075
"embeddedDummy": null,
@@ -258,7 +263,8 @@ Feature: Relations support
258263
"thirdLevel": {
259264
"@id": "/third_levels/1",
260265
"@type": "ThirdLevel",
261-
"level": 3
266+
"level": 3,
267+
"fourthLevel": null
262268
}
263269
}
264270
}

features/main/subresource.feature

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ Feature: Subresource support
1212
And the JSON should be equal to:
1313
"""
1414
{
15-
"@context": "/contexts/Answer",
16-
"@id": "/answers/1",
17-
"@type": "Answer",
18-
"id": 1,
19-
"content": "42",
20-
"question": "/questions/1",
21-
"relatedQuestions": [
22-
"/questions/1"
23-
]
15+
"@context": "/contexts/Answer",
16+
"@id": "/answers/1",
17+
"@type": "Answer",
18+
"id": 1,
19+
"content": "42",
20+
"question": "/questions/1",
21+
"relatedQuestions": [
22+
"/questions/1"
23+
]
2424
}
2525
"""
2626

@@ -35,23 +35,43 @@ Feature: Subresource support
3535
"@id": "/questions/1/answer/related_questions",
3636
"@type": "hydra:Collection",
3737
"hydra:member": [
38-
{
39-
"@id": "/questions/1",
40-
"@type": "Question",
41-
"content": "What's the answer to the Ultimate Question of Life, the Universe and Everything?",
42-
"id": 1,
43-
"answer": "/answers/1"
44-
}
38+
{
39+
"@id": "/questions/1",
40+
"@type": "Question",
41+
"content": "What's the answer to the Ultimate Question of Life, the Universe and Everything?",
42+
"id": 1,
43+
"answer": "/answers/1"
44+
}
4545
],
4646
"hydra:totalItems": 1
4747
}
4848
"""
4949

50+
Scenario: Create a fourth level
51+
When I add "Content-Type" header equal to "application/ld+json"
52+
And I send a "POST" request to "/fourth_levels" with body:
53+
"""
54+
{"level": 4}
55+
"""
56+
Then the response status code should be 201
57+
And the response should be in JSON
58+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
59+
And the JSON should be equal to:
60+
"""
61+
{
62+
"@context": "/contexts/FourthLevel",
63+
"@id": "/fourth_levels/1",
64+
"@type": "FourthLevel",
65+
"id": 1,
66+
"level": 4
67+
}
68+
"""
69+
5070
Scenario: Create a third level
5171
When I add "Content-Type" header equal to "application/ld+json"
5272
And I send a "POST" request to "/third_levels" with body:
5373
"""
54-
{"level": 3}
74+
{"level": 3, "fourthLevel": "/fourth_levels/1"}
5575
"""
5676
Then the response status code should be 201
5777
And the response should be in JSON
@@ -62,6 +82,7 @@ Feature: Subresource support
6282
"@context": "/contexts/ThirdLevel",
6383
"@id": "/third_levels/1",
6484
"@type": "ThirdLevel",
85+
"fourthLevel": "/fourth_levels/1",
6586
"id": 1,
6687
"level": 3,
6788
"test": true
@@ -125,7 +146,11 @@ Feature: Subresource support
125146
"name": "Hello",
126147
"symfony": "symfony",
127148
"dummyDate": null,
128-
"thirdLevel": "/third_levels/1",
149+
"thirdLevel": {
150+
"@id": "/third_levels/1",
151+
"@type": "ThirdLevel",
152+
"fourthLevel": "/fourth_levels/1"
153+
},
129154
"relatedToDummyFriend": [],
130155
"dummyBoolean": null,
131156
"embeddedDummy": [],
@@ -138,7 +163,11 @@ Feature: Subresource support
138163
"name": null,
139164
"symfony": "symfony",
140165
"dummyDate": null,
141-
"thirdLevel": "/third_levels/1",
166+
"thirdLevel": {
167+
"@id": "/third_levels/1",
168+
"@type": "ThirdLevel",
169+
"fourthLevel": "/fourth_levels/1"
170+
},
142171
"relatedToDummyFriend": [],
143172
"dummyBoolean": null,
144173
"embeddedDummy": [],
@@ -193,7 +222,11 @@ Feature: Subresource support
193222
"name": "Hello",
194223
"symfony": "symfony",
195224
"dummyDate": null,
196-
"thirdLevel": "/third_levels/1",
225+
"thirdLevel": {
226+
"@id": "/third_levels/1",
227+
"@type": "ThirdLevel",
228+
"fourthLevel": "/fourth_levels/1"
229+
},
197230
"relatedToDummyFriend": [],
198231
"dummyBoolean": null,
199232
"embeddedDummy": [],
@@ -233,7 +266,7 @@ Feature: Subresource support
233266
}
234267
"""
235268

236-
Scenario: Get the embedded relation collection
269+
Scenario: Get the embedded relation collection at the third level
237270
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level"
238271
And the response status code should be 200
239272
And the response should be in JSON
@@ -244,12 +277,29 @@ Feature: Subresource support
244277
"@context": "/contexts/ThirdLevel",
245278
"@id": "/third_levels/1",
246279
"@type": "ThirdLevel",
280+
"fourthLevel": "/fourth_levels/1",
247281
"id": 1,
248282
"level": 3,
249283
"test": true
250284
}
251285
"""
252286

287+
Scenario: Get the embedded relation collection at the fourth level
288+
When I send a "GET" request to "/dummies/1/related_dummies/1/third_level/fourth_level"
289+
And the response status code should be 200
290+
And the response should be in JSON
291+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
292+
And the JSON should be equal to:
293+
"""
294+
{
295+
"@context": "/contexts/FourthLevel",
296+
"@id": "/fourth_levels/1",
297+
"@type": "FourthLevel",
298+
"id": 1,
299+
"level": 4
300+
}
301+
"""
302+
253303
Scenario: Get offers subresource from aggregate offers subresource
254304
Given I have a product with offers
255305
When I send a "GET" request to "/dummy_products/2/offers/1/offers"

src/Bridge/Doctrine/Orm/SubresourceDataProvider.php

Lines changed: 74 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Doctrine\Common\Persistence\ManagerRegistry;
2929
use Doctrine\ORM\EntityManagerInterface;
3030
use Doctrine\ORM\Mapping\ClassMetadataInfo;
31+
use Doctrine\ORM\QueryBuilder;
3132

3233
/**
3334
* Subresource data provider for the Doctrine ORM.
@@ -79,82 +80,10 @@ public function getSubresource(string $resourceClass, array $identifiers, array
7980
throw new ResourceClassNotSupportedException('The given resource class is not a subresource.');
8081
}
8182

82-
$originAlias = 'o';
83-
$queryBuilder = $repository->createQueryBuilder($originAlias);
8483
$queryNameGenerator = new QueryNameGenerator();
85-
$previousQueryBuilder = null;
86-
$previousAlias = null;
87-
88-
$num = \count($context['identifiers']);
89-
90-
while ($num--) {
91-
list($identifier, $identifierResourceClass) = $context['identifiers'][$num];
92-
$previousAssociationProperty = $context['identifiers'][$num + 1][0] ?? $context['property'];
93-
94-
$manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);
95-
96-
if (!$manager instanceof EntityManagerInterface) {
97-
throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager.");
98-
}
99-
100-
$classMetadata = $manager->getClassMetadata($identifierResourceClass);
101-
102-
if (!$classMetadata instanceof ClassMetadataInfo) {
103-
throw new RuntimeException("The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo.");
104-
}
105-
106-
$qb = $manager->createQueryBuilder();
107-
$alias = $queryNameGenerator->generateJoinAlias($identifier);
108-
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
109-
$normalizedIdentifiers = isset($identifiers[$identifier]) ? $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass) : [];
110-
111-
switch ($relationType) {
112-
//MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
113-
case ClassMetadataInfo::MANY_TO_MANY:
114-
$joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);
115-
116-
$qb->select($joinAlias)
117-
->from($identifierResourceClass, $alias)
118-
->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
119-
120-
break;
121-
case ClassMetadataInfo::ONE_TO_MANY:
122-
$mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
123-
124-
// first pass, o.property instead of alias.property
125-
if (null === $previousQueryBuilder) {
126-
$originAlias = "$originAlias.$mappedBy";
127-
} else {
128-
$previousAlias = "$previousAlias.$mappedBy";
129-
}
130-
131-
$qb->select($alias)
132-
->from($identifierResourceClass, $alias);
133-
break;
134-
default:
135-
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
136-
->from($identifierResourceClass, $alias);
137-
}
138-
139-
// Add where clause for identifiers
140-
foreach ($normalizedIdentifiers as $key => $value) {
141-
$placeholder = $queryNameGenerator->generateParameterName($key);
142-
$qb->andWhere("$alias.$key = :$placeholder");
143-
$queryBuilder->setParameter($placeholder, $value);
144-
}
145-
146-
// recurse queries
147-
if (null === $previousQueryBuilder) {
148-
$previousQueryBuilder = $qb;
149-
} else {
150-
$previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL()));
151-
}
152-
153-
$previousAlias = $alias;
154-
}
15584

15685
/*
157-
* The following translate to this pseudo-dql:
86+
* The following recursively translates to this pseudo-dql:
15887
*
15988
* SELECT thirdLevel WHERE thirdLevel IN (
16089
* SELECT thirdLevel FROM relatedDummies WHERE relatedDummies = ? AND relatedDummies IN (
@@ -164,9 +93,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array
16493
*
16594
* By using subqueries, we're forcing the SQL execution plan to go through indexes on doctrine identifiers.
16695
*/
167-
$queryBuilder->where(
168-
$queryBuilder->expr()->in($originAlias, $previousQueryBuilder->getDQL())
169-
);
96+
$queryBuilder = $this->buildQuery($identifiers, $context, $queryNameGenerator, $repository->createQueryBuilder($alias = 'o'), $alias, \count($context['identifiers']));
17097

17198
if (true === $context['collection']) {
17299
foreach ($this->collectionExtensions as $extension) {
@@ -195,4 +122,75 @@ public function getSubresource(string $resourceClass, array $identifiers, array
195122

196123
return $context['collection'] ? $query->getResult() : $query->getOneOrNullResult();
197124
}
125+
126+
/**
127+
* @throws RuntimeException
128+
*/
129+
private function buildQuery(array $identifiers, array $context, QueryNameGenerator $queryNameGenerator, QueryBuilder $previousQueryBuilder, string $previousAlias, int $remainingIdentifiers, QueryBuilder $topQueryBuilder = null): QueryBuilder
130+
{
131+
if ($remainingIdentifiers <= 0) {
132+
return $previousQueryBuilder;
133+
}
134+
135+
$topQueryBuilder = $topQueryBuilder ?? $previousQueryBuilder;
136+
137+
list($identifier, $identifierResourceClass) = $context['identifiers'][$remainingIdentifiers - 1];
138+
$previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];
139+
140+
$manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);
141+
142+
if (!$manager instanceof EntityManagerInterface) {
143+
throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager.");
144+
}
145+
146+
$classMetadata = $manager->getClassMetadata($identifierResourceClass);
147+
148+
if (!$classMetadata instanceof ClassMetadataInfo) {
149+
throw new RuntimeException(
150+
"The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo."
151+
);
152+
}
153+
154+
$qb = $manager->createQueryBuilder();
155+
$alias = $queryNameGenerator->generateJoinAlias($identifier);
156+
$relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
157+
$normalizedIdentifiers = isset($identifiers[$identifier]) ? $this->normalizeIdentifiers(
158+
$identifiers[$identifier],
159+
$manager,
160+
$identifierResourceClass
161+
) : [];
162+
163+
switch ($relationType) {
164+
// MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
165+
case ClassMetadataInfo::MANY_TO_MANY:
166+
$joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);
167+
168+
$qb->select($joinAlias)
169+
->from($identifierResourceClass, $alias)
170+
->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
171+
break;
172+
case ClassMetadataInfo::ONE_TO_MANY:
173+
$mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
174+
$previousAlias = "$previousAlias.$mappedBy";
175+
176+
$qb->select($alias)
177+
->from($identifierResourceClass, $alias);
178+
break;
179+
default:
180+
$qb->select("IDENTITY($alias.$previousAssociationProperty)")
181+
->from($identifierResourceClass, $alias);
182+
}
183+
184+
// Add where clause for identifiers
185+
foreach ($normalizedIdentifiers as $key => $value) {
186+
$placeholder = $queryNameGenerator->generateParameterName($key);
187+
$qb->andWhere("$alias.$key = :$placeholder");
188+
$topQueryBuilder->setParameter($placeholder, $value);
189+
}
190+
191+
// Recurse queries
192+
$qb = $this->buildQuery($identifiers, $context, $queryNameGenerator, $qb, $alias, --$remainingIdentifiers, $topQueryBuilder);
193+
194+
return $previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL()));
195+
}
198196
}

0 commit comments

Comments
 (0)