Skip to content

Commit b4074ef

Browse files
teohhanhuivincentchalamon
authored andcommitted
Doctrine filters: Allow nested properties (across relations)
1 parent 8a62339 commit b4074ef

File tree

10 files changed

+885
-94
lines changed

10 files changed

+885
-94
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.0.0 beta 4
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/Filter/AbstractFilter.php

Lines changed: 104 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\RequestUtils;
1718
use Symfony\Component\HttpFoundation\Request;
1819

1920
/**
@@ -61,15 +62,102 @@ protected function getClassMetadata(ResourceInterface $resource)
6162
}
6263

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

75163
/**
@@ -81,6 +169,20 @@ protected function isPropertyEnabled($property)
81169
*/
82170
protected function extractProperties(Request $request)
83171
{
172+
$needsFixing = false;
173+
174+
if (null !== $this->properties) {
175+
foreach ($this->properties as $property => $value) {
176+
if ($this->isPropertyNested($property) && $request->query->has(str_replace('.', '_', $property))) {
177+
$needsFixing = true;
178+
}
179+
}
180+
}
181+
182+
if ($needsFixing) {
183+
$request = RequestUtils::getFixedRequest($request);
184+
}
185+
84186
return $request->query->all();
85187
}
86188
}

Doctrine/Orm/Filter/DateFilter.php

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313

1414
use Doctrine\ORM\QueryBuilder;
1515
use Dunglas\ApiBundle\Api\ResourceInterface;
16+
use Dunglas\ApiBundle\Doctrine\Orm\Util\QueryUtils;
1617
use Symfony\Component\HttpFoundation\Request;
1718

1819
/**
1920
* Filters the collection by date intervals.
2021
*
2122
* @author Kévin Dunglas <[email protected]>
2223
* @author Théo FIDRY <[email protected]>
24+
* @author Vincent Chalamon <[email protected]>
2325
*/
2426
class DateFilter extends AbstractFilter
2527
{
@@ -48,20 +50,43 @@ public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder, R
4850

4951
foreach ($this->extractProperties($request) as $property => $values) {
5052
// Expect $values to be an array having the period as keys and the date value as values
51-
if (!isset($fieldNames[$property]) || !is_array($values) || !$this->isPropertyEnabled($property)) {
53+
if (
54+
!isset($fieldNames[$property]) ||
55+
!is_array($values) ||
56+
!$this->isPropertyEnabled($property) ||
57+
!$this->isPropertyMapped($property, $resource)
58+
) {
5259
continue;
5360
}
5461

62+
$alias = 'o';
63+
$field = $property;
64+
65+
if ($this->isPropertyNested($property)) {
66+
$propertyParts = $this->splitPropertyParts($property);
67+
68+
$parentAlias = $alias;
69+
70+
foreach ($propertyParts['associations'] as $association) {
71+
$alias = QueryUtils::generateJoinAlias($association);
72+
$queryBuilder->join(sprintf('%s.%s', $parentAlias, $association), $alias);
73+
$parentAlias = $alias;
74+
}
75+
76+
$field = $propertyParts['field'];
77+
}
78+
5579
$nullManagement = isset($this->properties[$property]) ? $this->properties[$property] : null;
5680

5781
if (self::EXCLUDE_NULL === $nullManagement) {
58-
$queryBuilder->andWhere($queryBuilder->expr()->isNotNull(sprintf('o.%s', $property)));
82+
$queryBuilder->andWhere($queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field)));
5983
}
6084

6185
if (isset($values[self::PARAMETER_BEFORE])) {
6286
$this->addWhere(
6387
$queryBuilder,
64-
$property,
88+
$alias,
89+
$field,
6590
self::PARAMETER_BEFORE,
6691
$values[self::PARAMETER_BEFORE],
6792
$nullManagement
@@ -71,7 +96,8 @@ public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder, R
7196
if (isset($values[self::PARAMETER_AFTER])) {
7297
$this->addWhere(
7398
$queryBuilder,
74-
$property,
99+
$alias,
100+
$field,
75101
self::PARAMETER_AFTER,
76102
$values[self::PARAMETER_AFTER],
77103
$nullManagement
@@ -81,44 +107,39 @@ public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder, R
81107
}
82108

83109
/**
84-
* Adds the where clause accordingly to the choosed null management.
110+
* Adds the where clause according to the chosen null management.
85111
*
86112
* @param QueryBuilder $queryBuilder
87-
* @param string $property
88-
* @param string $parameter
113+
* @param string $alias
114+
* @param string $field
115+
* @param string $operator
89116
* @param string $value
90117
* @param int|null $nullManagement
91118
*/
92-
private function addWhere(QueryBuilder $queryBuilder, $property, $parameter, $value, $nullManagement)
119+
private function addWhere(QueryBuilder $queryBuilder, $alias, $field, $operator, $value, $nullManagement)
93120
{
94-
$queryParameter = sprintf('date_%s_%s', $parameter, $property);
95-
$where = sprintf('o.%s %s= :%s', $property, self::PARAMETER_BEFORE === $parameter ? '<' : '>', $queryParameter);
96-
97-
$queryBuilder->setParameter($queryParameter, new \DateTime($value));
121+
$valueParameter = QueryUtils::generateParameterName(sprintf('%s_%s', $field, $operator));
122+
$baseWhere = sprintf('%s.%s %s :%s', $alias, $field, self::PARAMETER_BEFORE === $operator ? '<=' : '>=', $valueParameter);
98123

99124
if (null === $nullManagement || self::EXCLUDE_NULL === $nullManagement) {
100-
$queryBuilder->andWhere($where);
101-
102-
return;
103-
}
104-
105-
if (
106-
(self::PARAMETER_BEFORE === $parameter && self::INCLUDE_NULL_BEFORE === $nullManagement)
125+
$queryBuilder->andWhere($baseWhere);
126+
} elseif (
127+
(self::PARAMETER_BEFORE === $operator && self::INCLUDE_NULL_BEFORE === $nullManagement)
107128
||
108-
(self::PARAMETER_AFTER === $parameter && self::INCLUDE_NULL_AFTER === $nullManagement)
129+
(self::PARAMETER_AFTER === $operator && self::INCLUDE_NULL_AFTER === $nullManagement)
109130
) {
110131
$queryBuilder->andWhere($queryBuilder->expr()->orX(
111-
$where,
112-
$queryBuilder->expr()->isNull(sprintf('o.%s', $property))
132+
$baseWhere,
133+
$queryBuilder->expr()->isNull(sprintf('%s.%s', $alias, $field))
134+
));
135+
} else {
136+
$queryBuilder->andWhere($queryBuilder->expr()->andX(
137+
$baseWhere,
138+
$queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $field))
113139
));
114-
115-
return;
116140
}
117141

118-
$queryBuilder->andWhere($queryBuilder->expr()->andX(
119-
$where,
120-
$queryBuilder->expr()->isNotNull(sprintf('o.%s', $property))
121-
));
142+
$queryBuilder->setParameter($valueParameter, new \DateTime($value));
122143
}
123144

124145
/**

Doctrine/Orm/Filter/OrderFilter.php

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

1920
/**
@@ -52,19 +53,39 @@ public function __construct(ManagerRegistry $managerRegistry, $orderParameter, a
5253
public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder, Request $request)
5354
{
5455
$properties = $this->extractProperties($request);
55-
$fieldNames = array_flip($this->getClassMetadata($resource)->getFieldNames());
5656

5757
foreach ($properties as $property => $order) {
58-
if (!$this->isPropertyEnabled($property) || !isset($fieldNames[$property])) {
58+
if (!$this->isPropertyEnabled($property) || !$this->isPropertyMapped($property, $resource)) {
5959
continue;
60-
} elseif ('' === $order && isset($this->properties[$property])) {
60+
}
61+
62+
if ('' === $order && isset($this->properties[$property])) {
6163
$order = $this->properties[$property];
6264
}
6365

6466
$order = strtoupper($order);
65-
if ('ASC' === $order || 'DESC' === $order) {
66-
$queryBuilder->addOrderBy(sprintf('o.%s', $property), $order);
67+
if (!in_array($order, ['ASC', 'DESC'])) {
68+
continue;
69+
}
70+
71+
$alias = 'o';
72+
$field = $property;
73+
74+
if ($this->isPropertyNested($property)) {
75+
$propertyParts = $this->splitPropertyParts($property);
76+
77+
$parentAlias = $alias;
78+
79+
foreach ($propertyParts['associations'] as $association) {
80+
$alias = QueryUtils::generateJoinAlias($association);
81+
$queryBuilder->join(sprintf('%s.%s', $parentAlias, $association), $alias);
82+
$parentAlias = $alias;
83+
}
84+
85+
$field = $propertyParts['field'];
6786
}
87+
88+
$queryBuilder->addOrderBy(sprintf('%s.%s', $alias, $field), $order);
6889
}
6990
}
7091

@@ -74,16 +95,22 @@ public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder, R
7495
public function getDescription(ResourceInterface $resource)
7596
{
7697
$description = [];
77-
$metadata = $this->getClassMetadata($resource);
78-
79-
foreach ($metadata->getFieldNames() as $fieldName) {
80-
if ($this->isPropertyEnabled($fieldName)) {
81-
$description[sprintf('%s[%s]', $this->orderParameter, $fieldName)] = [
82-
'property' => $fieldName,
83-
'type' => 'string',
84-
'required' => false,
85-
];
98+
99+
$properties = $this->properties;
100+
if (null === $properties) {
101+
$properties = array_fill_keys($this->getClassMetadata($resource)->getFieldNames(), null);
102+
}
103+
104+
foreach ($properties as $property => $order) {
105+
if (!$this->isPropertyMapped($property, $resource)) {
106+
continue;
86107
}
108+
109+
$description[sprintf('%s[%s]', $this->orderParameter, $property)] = [
110+
'property' => $property,
111+
'type' => 'string',
112+
'required' => false,
113+
];
87114
}
88115

89116
return $description;

0 commit comments

Comments
 (0)