Skip to content

Commit e1344ec

Browse files
committed
Merge pull request #337 from vincentchalamon/filter_nested
Doctrine filters: Allow nested properties (across relations)
2 parents 8a62339 + 6721baa commit e1344ec

29 files changed

+2464
-1888
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## 1.1.0
4+
5+
* Support nested properties in Doctrine filters
6+
* Add method to avoid naming collision of DQL join alias and bound parameter name
7+
38
## 1.0.0 beta 3
49

510
* The Hydra documentation URL is now `/apidoc` (was `/vocab`)

Doctrine/Orm/DataProvider.php

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrineOrmPaginator;
1717
use Doctrine\ORM\QueryBuilder;
1818
use Dunglas\ApiBundle\Doctrine\Orm\Filter\FilterInterface;
19+
use Dunglas\ApiBundle\Doctrine\Orm\Util\QueryChecker;
1920
use Dunglas\ApiBundle\Model\DataProviderInterface;
2021
use Dunglas\ApiBundle\Api\ResourceInterface;
2122
use Symfony\Component\HttpFoundation\Request;
@@ -140,6 +141,14 @@ public function getCollection(ResourceInterface $resource, Request $request)
140141
return $this->getPaginator($queryBuilder);
141142
}
142143

144+
/**
145+
* {@inheritdoc}
146+
*/
147+
public function supports(ResourceInterface $resource)
148+
{
149+
return null !== $this->managerRegistry->getManagerForClass($resource->getEntityClass());
150+
}
151+
143152
/**
144153
* Gets the paginator.
145154
*
@@ -151,16 +160,49 @@ protected function getPaginator(QueryBuilder $queryBuilder)
151160
{
152161
$doctrineOrmPaginator = new DoctrineOrmPaginator($queryBuilder);
153162
// Disable output walkers by default (performance)
154-
$doctrineOrmPaginator->setUseOutputWalkers(false);
163+
$doctrineOrmPaginator->setUseOutputWalkers($this->useOutputWalkers($queryBuilder));
155164

156165
return new Paginator($doctrineOrmPaginator);
157166
}
158167

159168
/**
160-
* {@inheritdoc}
169+
* Determines whether output walkers should be used.
170+
*
171+
* @param QueryBuilder $queryBuilder
172+
*
173+
* @return bool
161174
*/
162-
public function supports(ResourceInterface $resource)
175+
private function useOutputWalkers(QueryBuilder $queryBuilder)
163176
{
164-
return null !== $this->managerRegistry->getManagerForClass($resource->getEntityClass());
177+
/*
178+
* "Cannot count query that uses a HAVING clause. Use the output walkers for pagination"
179+
*
180+
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/CountWalker.php#L50
181+
*/
182+
if (QueryChecker::hasHavingClause($queryBuilder)) {
183+
return true;
184+
}
185+
/*
186+
* "Paginating an entity with foreign key as identifier only works when using the Output Walkers. Call Paginator#setUseOutputWalkers(true) before iterating the paginator."
187+
*
188+
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L87
189+
*/
190+
if (QueryChecker::hasRootEntityWithForeignKeyIdentifier($queryBuilder, $this->managerRegistry)) {
191+
return true;
192+
}
193+
/*
194+
* "Cannot select distinct identifiers from query with LIMIT and ORDER BY on a column from a fetch joined to-many association. Use output walkers."
195+
*
196+
* @see https://github.com/doctrine/doctrine2/blob/900b55d16afdcdeb5100d435a7166d3a425b9873/lib/Doctrine/ORM/Tools/Pagination/LimitSubqueryWalker.php#L149
197+
*/
198+
if (
199+
QueryChecker::hasMaxResults($queryBuilder)
200+
&& QueryChecker::hasOrderByOnToManyJoin($queryBuilder, $this->managerRegistry)
201+
) {
202+
return true;
203+
}
204+
205+
// Disable output walkers by default (performance)
206+
return false;
165207
}
166208
}

Doctrine/Orm/Filter/AbstractFilter.php

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Doctrine\Common\Persistence\ManagerRegistry;
1515
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
1616
use Dunglas\ApiBundle\Api\ResourceInterface;
17+
use Dunglas\ApiBundle\Util\RequestParser;
1718
use Symfony\Component\HttpFoundation\Request;
1819

1920
/**
@@ -23,6 +24,7 @@
2324
*
2425
* @author Kévin Dunglas <[email protected]>
2526
* @author Théo FIDRY <[email protected]>
27+
* @author Vincent Chalamon <[email protected]>
2628
*/
2729
abstract class AbstractFilter implements FilterInterface
2830
{
@@ -61,15 +63,102 @@ protected function getClassMetadata(ResourceInterface $resource)
6163
}
6264

6365
/**
64-
* Is the given property enabled?
66+
* Determines whether the given property is enabled.
6567
*
6668
* @param string $property
6769
*
6870
* @return bool
6971
*/
7072
protected function isPropertyEnabled($property)
7173
{
72-
return null === $this->properties || array_key_exists($property, $this->properties);
74+
if (null === $this->properties) {
75+
// to ensure sanity, nested properties must still be explicitly enabled
76+
return !$this->isPropertyNested($property);
77+
}
78+
79+
return array_key_exists($property, $this->properties);
80+
}
81+
82+
/**
83+
* Determines whether the given property is mapped.
84+
*
85+
* @param string $property
86+
* @param ResourceInterface $resource
87+
* @param bool $allowAssociation
88+
*
89+
* @return bool
90+
*/
91+
protected function isPropertyMapped($property, ResourceInterface $resource, $allowAssociation = false)
92+
{
93+
if ($this->isPropertyNested($property)) {
94+
$propertyParts = $this->splitPropertyParts($property);
95+
$metadata = $this->getNestedMetadata($resource, $propertyParts['associations']);
96+
$property = $propertyParts['field'];
97+
} else {
98+
$metadata = $this->getClassMetadata($resource);
99+
}
100+
101+
return $metadata->hasField($property) || ($allowAssociation && $metadata->hasAssociation($property));
102+
}
103+
104+
/**
105+
* Determines whether the given property is nested.
106+
*
107+
* @param string $property
108+
*
109+
* @return bool
110+
*/
111+
protected function isPropertyNested($property)
112+
{
113+
return false !== strpos($property, '.');
114+
}
115+
116+
/**
117+
* Gets nested class metadata for the given resource.
118+
*
119+
* @param ResourceInterface $resource
120+
* @param string[] $associations
121+
*
122+
* @return ClassMetadata
123+
*/
124+
protected function getNestedMetadata(ResourceInterface $resource, array $associations)
125+
{
126+
$metadata = $this->getClassMetadata($resource);
127+
128+
foreach ($associations as $association) {
129+
if ($metadata->hasAssociation($association)) {
130+
$associationClass = $metadata->getAssociationTargetClass($association);
131+
132+
$metadata = $this
133+
->managerRegistry
134+
->getManagerForClass($associationClass)
135+
->getClassMetadata($associationClass)
136+
;
137+
}
138+
}
139+
140+
return $metadata;
141+
}
142+
143+
/**
144+
* Splits the given property into parts.
145+
*
146+
* Returns an array with the following keys:
147+
* - associations: array of associations according to nesting order
148+
* - field: string holding the actual field (leaf node)
149+
*
150+
* @param string $property
151+
*
152+
* @return array
153+
*/
154+
protected function splitPropertyParts($property)
155+
{
156+
$parts = explode('.', $property);
157+
158+
return [
159+
'associations' => array_slice($parts, 0, -1),
160+
'field' => end($parts),
161+
];
73162
}
74163

75164
/**
@@ -81,6 +170,20 @@ protected function isPropertyEnabled($property)
81170
*/
82171
protected function extractProperties(Request $request)
83172
{
173+
$needsFixing = false;
174+
175+
if (null !== $this->properties) {
176+
foreach ($this->properties as $property => $value) {
177+
if ($this->isPropertyNested($property) && $request->query->has(str_replace('.', '_', $property))) {
178+
$needsFixing = true;
179+
}
180+
}
181+
}
182+
183+
if ($needsFixing) {
184+
$request = RequestParser::parseAndDuplicateRequest($request);
185+
}
186+
84187
return $request->query->all();
85188
}
86189
}

0 commit comments

Comments
 (0)