Skip to content

Commit f220063

Browse files
author
abluchet
committed
Fix filter Eager Loading #944
1 parent d65c84b commit f220063

File tree

10 files changed

+448
-13
lines changed

10 files changed

+448
-13
lines changed

features/bootstrap/FeatureContext.php

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
* file that was distributed with this source code.
1010
*/
1111

12+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Bar;
1213
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeItem;
1314
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeLabel;
1415
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeRelation;
1516
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy;
1617
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy;
18+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Foo;
1719
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy;
1820
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelationEmbedder;
1921
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy;
@@ -135,18 +137,21 @@ public function thereIsDummyObjectsWithRelatedDummy($nb)
135137
}
136138

137139
/**
138-
* @Given there is :nb dummy objects with relatedDummies
140+
* @Given there is :nb dummy objects having each :nbrelated relatedDummies
139141
*/
140-
public function thereIsDummyObjectsWithRelatedDummies($nb)
142+
public function thereIsDummyObjectsWithRelatedDummies($nb, $nbrelated)
141143
{
142144
for ($i = 1; $i <= $nb; ++$i) {
143-
$relatedDummy = new RelatedDummy();
144-
$relatedDummy->setName('RelatedDummy #'.$i);
145-
146145
$dummy = new Dummy();
147146
$dummy->setName('Dummy #'.$i);
148147
$dummy->setAlias('Alias #'.($nb - $i));
149-
$dummy->addRelatedDummy($relatedDummy);
148+
149+
for ($j = 1; $j <= $nbrelated; ++$j) {
150+
$relatedDummy = new RelatedDummy();
151+
$relatedDummy->setName('RelatedDummy'.$j.$i);
152+
$this->manager->persist($relatedDummy);
153+
$dummy->addRelatedDummy($relatedDummy);
154+
}
150155

151156
$this->manager->persist($relatedDummy);
152157
$this->manager->persist($dummy);
@@ -311,4 +316,28 @@ public function thereIsAFileConfigDummyObject()
311316
$this->manager->persist($fileConfigDummy);
312317
$this->manager->flush();
313318
}
319+
320+
/**
321+
* @Given there is a Foo entity with related bars
322+
*/
323+
public function thereIsAFooEntityWithRelatedBars()
324+
{
325+
$foo = new Foo();
326+
$this->manager->persist($foo);
327+
328+
$bar1 = new Bar();
329+
$bar1->setProp('toto');
330+
$bar1->setFoo($foo);
331+
$this->manager->persist($bar1);
332+
333+
$bar2 = new Bar();
334+
$bar2->setProp('tata');
335+
$bar2->setFoo($foo);
336+
$this->manager->persist($bar2);
337+
338+
$foo->setBars([$bar1, $bar2]);
339+
$this->manager->persist($foo);
340+
341+
$this->manager->flush();
342+
}
314343
}

features/doctrine/date_filter.feature

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,18 +389,18 @@ Feature: Date filter on collections
389389
And the JSON should be equal to:
390390
"""
391391
{
392-
"@context": "\/contexts\/Dummy",
393-
"@id": "\/dummies",
392+
"@context": "/contexts/Dummy",
393+
"@id": "/dummies",
394394
"@type": "hydra:Collection",
395395
"hydra:member": [],
396396
"hydra:totalItems": 0,
397397
"hydra:view": {
398-
"@id": "\/dummies?relatedDummy.dummyDate%5Bafter%5D=2015-04-28",
398+
"@id": "/dummies?relatedDummy.dummyDate%5Bafter%5D=2015-04-28",
399399
"@type": "hydra:PartialCollectionView"
400400
},
401401
"hydra:search": {
402402
"@type": "hydra:IriTemplate",
403-
"hydra:template": "\/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}",
403+
"hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}",
404404
"hydra:variableRepresentation": "BasicRepresentation",
405405
"hydra:mapping": [
406406
{
@@ -463,6 +463,12 @@ Feature: Date filter on collections
463463
"property": "dummy",
464464
"required": false
465465
},
466+
{
467+
"@type": "IriTemplateMapping",
468+
"variable": "relatedDummies.name",
469+
"property": "relatedDummies.name",
470+
"required": false
471+
},
466472
{
467473
"@type": "IriTemplateMapping",
468474
"variable": "order[id]",

features/doctrine/search_filter.feature

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,56 @@ Feature: Search filter on collections
44
I need to search for collections properties
55

66
@createSchema
7+
Scenario: Test #944
8+
Given there is a Foo entity with related bars
9+
When I send a "GET" request to "/foos?bars.prop=toto"
10+
Then the response status code should be 200
11+
And print last JSON response
12+
And the JSON should be equal to:
13+
"""
14+
{
15+
"@context": "/contexts/Foo",
16+
"@id": "/foos",
17+
"@type": "hydra:Collection",
18+
"hydra:member": [
19+
{
20+
"@id": "/foos/1",
21+
"@type": "Foo",
22+
"bars": [
23+
{
24+
"@id": "/bars/1",
25+
"@type": "Bar",
26+
"prop": "toto"
27+
},
28+
{
29+
"@id": "/bars/2",
30+
"@type": "Bar",
31+
"prop": "tata"
32+
}
33+
]
34+
}
35+
],
36+
"hydra:totalItems": 1,
37+
"hydra:view": {
38+
"@id": "/foos?bars.prop=toto",
39+
"@type": "hydra:PartialCollectionView"
40+
},
41+
"hydra:search": {
42+
"@type": "hydra:IriTemplate",
43+
"hydra:template": "/foos{?bars.prop}",
44+
"hydra:variableRepresentation": "BasicRepresentation",
45+
"hydra:mapping": [
46+
{
47+
"@type": "IriTemplateMapping",
48+
"variable": "bars.prop",
49+
"property": "bars.prop",
50+
"required": false
51+
}
52+
]
53+
}
54+
}
55+
"""
56+
757
Scenario: Search collection by name (partial)
858
Given there is "30" dummy objects
959
When I send a "GET" request to "/dummies?name=my"
@@ -42,7 +92,6 @@ Feature: Search filter on collections
4292
"""
4393

4494
Scenario: Search collection by name (partial case insensitive)
45-
Given there is "30" dummy objects
4695
When I send a "GET" request to "/dummies?dummy=somedummytest1"
4796
Then the response status code should be 200
4897
And the response should be in JSON
@@ -167,3 +216,17 @@ Feature: Search filter on collections
167216
}
168217
}
169218
"""
219+
220+
@createSchema
221+
@dropSchema
222+
Scenario: Search related collection by name
223+
Given there is 3 dummy objects having each 3 relatedDummies
224+
When I add "Accept" header equal to "application/hal+json"
225+
And I send a "GET" request to "/dummies?relatedDummies.name=RelatedDummy1"
226+
Then the response status code should be 200
227+
And the response should be in JSON
228+
And the JSON node "_embedded.item" should have 3 elements
229+
And the JSON node "_embedded.item[0]._links.relatedDummies" should have 3 elements
230+
And the JSON node "_embedded.item[1]._links.relatedDummies" should have 3 elements
231+
And the JSON node "_embedded.item[2]._links.relatedDummies" should have 3 elements
232+

features/main/crud.feature

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Feature: Create-Retrieve-Update-Delete
123123
"hydra:totalItems": 1,
124124
"hydra:search": {
125125
"@type": "hydra:IriTemplate",
126-
"hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}",
126+
"hydra:template": "/dummies{?id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,order[id],order[name],order[relatedDummy.symfony],dummyDate[before],dummyDate[after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[after],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],dummyBoolean,dummyFloat,dummyPrice}",
127127
"hydra:variableRepresentation": "BasicRepresentation",
128128
"hydra:mapping": [
129129
{
@@ -186,6 +186,12 @@ Feature: Create-Retrieve-Update-Delete
186186
"property": "dummy",
187187
"required": false
188188
},
189+
{
190+
"@type": "IriTemplateMapping",
191+
"variable": "relatedDummies.name",
192+
"property": "relatedDummies.name",
193+
"required": false
194+
},
189195
{
190196
"@type": "IriTemplateMapping",
191197
"variable": "order[id]",
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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+
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Extension;
13+
14+
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
15+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
16+
use Doctrine\ORM\Mapping\ClassMetadataInfo;
17+
use Doctrine\ORM\Query\Expr\Join;
18+
use Doctrine\ORM\QueryBuilder;
19+
20+
/**
21+
* Fixes filters on OneToMany associations
22+
* https://github.com/api-platform/core/issues/944.
23+
*/
24+
final class FilterEagerLoadingExtension implements QueryCollectionExtensionInterface
25+
{
26+
private $resourceMetadataFactory;
27+
private $forceEager;
28+
29+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, $forceEager = true)
30+
{
31+
$this->resourceMetadataFactory = $resourceMetadataFactory;
32+
$this->forceEager = $forceEager;
33+
}
34+
35+
/**
36+
* {@inheritdoc}
37+
*/
38+
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
39+
{
40+
if (false === $this->isForceEager($resourceClass, ['collection_operation_name' => $operationName])) {
41+
return;
42+
}
43+
44+
$entityManager = $queryBuilder->getEntityManager();
45+
$classMetadata = $entityManager->getClassMetadata($resourceClass);
46+
47+
//If no where part, nothing to do
48+
$wherePart = $queryBuilder->getDQLPart('where');
49+
50+
if (!$wherePart) {
51+
return;
52+
}
53+
54+
$whereParts = $wherePart->getParts();
55+
$joinParts = $queryBuilder->getDQLPart('join');
56+
57+
//Keep a clone of the current queryBuilder to avoid alteration if it's not necessary
58+
$queryBuilderClone = clone $queryBuilder;
59+
$queryBuilderClone->resetDQLPart('where');
60+
61+
$repository = $entityManager->getRepository($resourceClass);
62+
63+
$altered = 0;
64+
65+
foreach ($joinParts as $i => $joins) {
66+
foreach ($joins as $j => $join) {
67+
$fieldName = explode('.', $join->getJoin())[1];
68+
$joinAlias = $join->getAlias();
69+
70+
$mapping = $classMetadata->getAssociationMapping($fieldName);
71+
72+
//Not a OneToMany join
73+
if ($mapping['type'] !== ClassMetadataInfo::ONE_TO_MANY) {
74+
break;
75+
}
76+
77+
//Create a query that starts from the target entity
78+
$qb = $entityManager->getRepository($mapping['targetEntity'])->createQueryBuilder($joinAlias);
79+
80+
//Find where parts that belong to the sub query
81+
$qbWhereParts = [];
82+
foreach ($whereParts as $key => $where) {
83+
if (false === strpos($where, $joinAlias)) {
84+
continue;
85+
}
86+
87+
$qbWhereParts[] = $where;
88+
unset($whereParts[$key]);
89+
}
90+
91+
if (0 === count($qbWhereParts)) {
92+
break;
93+
}
94+
95+
//We did alter the query
96+
++$altered;
97+
98+
$qb->select("IDENTITY($joinAlias.{$mapping['mappedBy']})");
99+
100+
foreach ($qbWhereParts as $where) {
101+
$qb->add('where', $where);
102+
}
103+
104+
$queryBuilderClone->andWhere($queryBuilderClone->expr()->in('o', str_replace($joinAlias, $queryNameGenerator->generateJoinAlias($joinAlias), $qb->getDQL())));
105+
}
106+
}
107+
108+
if (0 === $altered) {
109+
return;
110+
}
111+
112+
//It should be altered, create the WHERE clause again
113+
$queryBuilder->resetDQLPart('where');
114+
foreach ($queryBuilderClone->getDQLPart('where')->getParts() as $wherePart) {
115+
$queryBuilder->add('where', $wherePart);
116+
}
117+
}
118+
119+
/**
120+
* Does an operation force eager?
121+
*
122+
* @param string $resourceClass
123+
* @param array $options
124+
*
125+
* @return bool
126+
*/
127+
private function isForceEager(string $resourceClass, array $options): bool
128+
{
129+
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
130+
131+
if (isset($options['collection_operation_name'])) {
132+
$forceEager = $resourceMetadata->getCollectionOperationAttribute($options['collection_operation_name'], 'force_eager', null, true);
133+
} elseif (isset($options['item_operation_name'])) {
134+
$forceEager = $resourceMetadata->getItemOperationAttribute($options['item_operation_name'], 'force_eager', null, true);
135+
} else {
136+
$forceEager = $resourceMetadata->getAttribute('force_eager');
137+
}
138+
139+
return is_bool($forceEager) ? $forceEager : $this->forceEager;
140+
}
141+
}

src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@
105105
<tag name="api_platform.doctrine.orm.query_extension.collection" priority="32" />
106106
</service>
107107

108+
<!-- This needs to be executed right after the filter extension -->
109+
110+
<service id="api_platform.doctrine.orm.query_extension.filter_eager_loading" class="ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterEagerLoadingExtension" public="false">
111+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
112+
<argument>%api_platform.eager_loading.force_eager%</argument>
113+
114+
<tag name="api_platform.doctrine.orm.query_extension.collection" priority="31" />
115+
</service>
116+
108117
<service id="api_platform.doctrine.orm.query_extension.pagination" class="ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\PaginationExtension" public="false">
109118
<argument type="service" id="doctrine" />
110119
<argument type="service" id="request_stack" />

tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ private function getContainerBuilderProphecy()
248248
'api_platform.doctrine.orm.order_filter',
249249
'api_platform.doctrine.orm.query_extension.eager_loading',
250250
'api_platform.doctrine.orm.query_extension.filter',
251+
'api_platform.doctrine.orm.query_extension.filter_eager_loading',
251252
'api_platform.doctrine.orm.query_extension.order',
252253
'api_platform.doctrine.orm.query_extension.pagination',
253254
'api_platform.doctrine.orm.range_filter',

0 commit comments

Comments
 (0)