Skip to content

Commit db3586e

Browse files
committed
Conditions on joins are grouped with non-join conditions
This is a major change in the filtering syntax. First, the following terms were renamed as follow: - `conditions` => `groups` - `fields` => `conditions` - `conditionLogic` => `groupLogic` - `fieldsLogic` => `conditionsLogic` This is hopefully less confusing and more semantically correct. We now have ordered `groups` of unordered `conditions`. Second, the `joins` was moved from top-level to within a `group`. And the join itself cannot define a logical operator anymore. Instead the `conditionsLogic` of a specific `group` is used for all conditions in that group, recursively including all conditions coming from joins. This allow to use different conditions on a same join across different groups and still keep control of how they are combined with the `groupLogic`. A concrete example of that lives in `alternative-groups-with-multiple-joins.php`.
1 parent 9493719 commit db3586e

25 files changed

+836
-589
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,10 @@ It is possible to expose generic filtering for entity fields and their types to
385385
create and apply generic filters. This expose basic SQL-like syntax that should cover most simple
386386
cases.
387387

388+
Filters are structured in an ordered list of groups. Each group contains an unordered set of joins
389+
and conditions on fields. For simple case a single group of a few conditions would probably be enough.
390+
But the ordered list of group allow more advanced filtering with `OR` logic between a set of conditions.
391+
388392
In the case of the `Post` class, it would generate [that GraphQL schema](tests/data/PostFilter.graphqls)
389393
for filtering, and for sorting it would be [that simpler schema](tests/data/PostSorting.graphqls).
390394

phpstan.neon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ parameters:
1010
- '~^Instanceof between string and GraphQL\\Type\\Definition\\Type will always evaluate to false\.$~'
1111
- '~^Parameter #1 \$wrappedType of static method GraphQL\\Type\\Definition\\Type::~'
1212
- '~^Parameter #2 \$type of static method GraphQL\\Doctrine\\Utils::getOperatorTypeName~'
13-
- '~^Method GraphQL\\Doctrine\\Factory\\Type\\FilterTypeFactory\:\:getOperators\(\) should return array\<GraphQL\\Type\\Definition\\LeafType\> but returns array\<GraphQL\\Type\\Definition\\LeafType\|GraphQL\\Type\\Definition\\Type\>\.~'
13+
- '~^Method GraphQL\\Doctrine\\Factory\\Type\\FilterGroupConditionTypeFactory::getOperators\(\) should return array\<GraphQL\\Type\\Definition\\LeafType\> but returns array\<GraphQL\\Type\\Definition\\LeafType\|GraphQL\\Type\\Definition\\Type\>\.~'
1414
- '~::__construct\(\) does not call parent constructor from GraphQL\\Doctrine\\Definition\\EntityID\.$~'
15-
- '~Property GraphQL\\Doctrine\\Annotation\\Field\:\:\$description \(string\) does not accept string\|null\.$~'
15+
- '~Property GraphQL\\Doctrine\\Annotation\\Field::\$description \(string\) does not accept string\|null\.$~'

src/Factory/FilteredQueryBuilderFactory.php

Lines changed: 92 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ final class FilteredQueryBuilderFactory extends AbstractFactory
3232
*/
3333
private $queryBuilder;
3434

35+
/**
36+
* @var string[]
37+
*/
38+
private $dqlConditions = [];
39+
40+
/**
41+
* @var string[]
42+
*/
43+
private $uniqueJoins = [];
44+
3545
public function __construct(Types $types, EntityManager $entityManager, SortingTypeFactory $sortingTypeFactory)
3646
{
3747
parent::__construct($types, $entityManager);
@@ -42,122 +52,108 @@ public function create(string $className, array $filter, array $sorting): QueryB
4252
{
4353
$this->uniqueNameFactory = new UniqueNameFactory();
4454
$alias = $this->uniqueNameFactory->createAliasName($className);
55+
$this->dqlConditions = [];
56+
$this->uniqueJoins = [];
4557

4658
$this->queryBuilder = $this->entityManager->getRepository($className)->createQueryBuilder($alias);
4759
$metadata = $this->entityManager->getClassMetadata($className);
4860
$type = $this->types->getFilter($className);
4961

50-
$this->applyJoinsAndFilters($metadata, $type, $filter, $alias);
62+
$this->applyGroups($metadata, $type, $filter, $alias);
5163
$this->applySorting($className, $sorting, $alias);
5264

5365
return $this->queryBuilder;
5466
}
5567

5668
/**
57-
* Apply both joins and filters to the query builder
69+
* Apply filters to the query builder
5870
*
5971
* @param ClassMetadata $metadata
6072
* @param InputObjectType $type
6173
* @param array $filter
6274
* @param string $alias
75+
*
76+
* @throws \Exception
6377
*/
64-
private function applyJoinsAndFilters(ClassMetadata $metadata, InputObjectType $type, array $filter, string $alias): void
78+
private function applyGroups(ClassMetadata $metadata, InputObjectType $type, array $filter, string $alias): void
6579
{
66-
$this->applyJoins($metadata, $filter, $alias);
67-
$this->applyFilters($metadata, $type, $filter, $alias);
80+
$typeFields = $type->getField('groups')->type->getWrappedType(true)->getField('conditions')->type->getWrappedType(true);
81+
foreach ($filter['groups'] ?? [] as $group) {
82+
$this->applyJoinsAndFilters($metadata, $alias, $typeFields, $group['joins'] ?? [], $group['conditions'] ?? []);
83+
$this->applyCollectedDqlConditions($group);
84+
}
6885
}
6986

7087
/**
71-
* Apply filters to the query builder
88+
* Apply both joins and filters to the query builder
7289
*
7390
* @param ClassMetadata $metadata
74-
* @param InputObjectType $type
75-
* @param array $filter
7691
* @param string $alias
92+
* @param InputObjectType $typeFields
93+
* @param array $joins
94+
* @param array $conditions
7795
*
78-
* @throws \Exception
96+
* @throws \Doctrine\ORM\Mapping\MappingException
7997
*/
80-
private function applyFilters(ClassMetadata $metadata, InputObjectType $type, array $filter, string $alias): void
98+
private function applyJoinsAndFilters(ClassMetadata $metadata, string $alias, InputObjectType $typeFields, array $joins, array $conditions): void
8199
{
82-
$typeFields = $type->getField('conditions')->type->getWrappedType(true)->getField('fields')->type->getWrappedType(true);
83-
foreach ($filter['conditions'] ?? [] as $conditions) {
84-
$dqlConditions = $this->getDqlConditions($metadata, $conditions['fields'], $typeFields, $alias);
85-
86-
$this->applyDqlConditions($conditions, $dqlConditions);
87-
}
100+
$this->applyJoins($metadata, $joins, $alias);
101+
$this->collectDqlConditions($metadata, $conditions, $typeFields, $alias);
88102
}
89103

90104
/**
91105
* Gather all DQL conditions for the given array of fields
92106
*
93107
* @param ClassMetadata $metadata
94-
* @param array $allFields
108+
* @param array $allConditions
95109
* @param InputObjectType $typeFields
96110
* @param string $alias
97-
*
98-
* @return array
99111
*/
100-
private function getDqlConditions(ClassMetadata $metadata, array $allFields, InputObjectType $typeFields, string $alias): array
112+
private function collectDqlConditions(ClassMetadata $metadata, array $allConditions, InputObjectType $typeFields, string $alias): void
101113
{
102-
$dqlConditions = [];
103-
foreach ($allFields as $fields) {
104-
foreach ($fields as $field => $fieldConditions) {
105-
if ($fieldConditions === null) {
114+
foreach ($allConditions as $conditions) {
115+
foreach ($conditions as $field => $operators) {
116+
if ($operators === null) {
106117
continue;
107118
}
108119

109120
/** @var InputObjectType $typeField */
110121
$typeField = $typeFields->getField($field)->type;
111122

112-
foreach ($fieldConditions as $operator => $operatorArgs) {
113-
$operatorField = $typeField->getField($operator);
123+
foreach ($operators as $operatorName => $operatorArgs) {
124+
$operatorField = $typeField->getField($operatorName);
114125

115126
/** @var AbstractOperator $operatorType */
116127
$operatorType = $operatorField->type;
117128

118-
$condition = $operatorType->getDqlCondition($this->uniqueNameFactory, $metadata, $this->queryBuilder, $alias, $field, $operatorArgs);
119-
if ($condition) {
120-
$dqlConditions[] = $condition;
129+
$dqlCondition = $operatorType->getDqlCondition($this->uniqueNameFactory, $metadata, $this->queryBuilder, $alias, $field, $operatorArgs);
130+
if ($dqlCondition) {
131+
$this->dqlConditions[] = $dqlCondition;
121132
}
122133
}
123134
}
124135
}
125-
126-
return $dqlConditions;
127136
}
128137

129138
/**
130139
* Apply joins to the query builder
131140
*
132141
* @param ClassMetadata $metadata
133-
* @param array $filter
142+
* @param array $joins
134143
* @param string $alias
135144
*
136145
* @throws \Doctrine\ORM\Mapping\MappingException
137146
*/
138-
private function applyJoins(ClassMetadata $metadata, array $filter, string $alias): void
147+
private function applyJoins(ClassMetadata $metadata, array $joins, string $alias): void
139148
{
140-
foreach ($filter['joins'] ?? [] as $field => $join) {
141-
$relationship = $alias . '.' . $field;
142-
$joinedAlias = $this->uniqueNameFactory->createAliasName($field);
143-
144-
if ($join['type'] === 'innerJoin') {
145-
$this->queryBuilder->innerJoin($relationship, $joinedAlias);
146-
} else {
147-
$this->queryBuilder->leftJoin($relationship, $joinedAlias);
148-
}
149-
150-
// TODO: For now we assume the query will always access some field on the relation, so we optimize SQL by
151-
// fetching those objects in a single SQL query. But this should be revisited by either exposing an option
152-
// to the API so the client could decide to select or not the relations, or even better to detect in GraphQL
153-
// query if it's actually used or not.
154-
$this->queryBuilder->addSelect($joinedAlias);
149+
foreach ($joins as $field => $join) {
150+
$joinedAlias = $this->createJoin($alias, $field, $join['type']);
155151

156-
if (isset($join['filter'])) {
152+
if (isset($join['joins']) || isset($join['conditions'])) {
157153
$targetClassName = $metadata->getAssociationMapping($field)['targetEntity'];
158154
$targetMetadata = $this->entityManager->getClassMetadata($targetClassName);
159-
$type = $this->types->getFilter($targetClassName);
160-
$this->applyJoinsAndFilters($targetMetadata, $type, $join['filter'], $joinedAlias);
155+
$type = $this->types->getFilterGroupCondition($targetClassName);
156+
$this->applyJoinsAndFilters($targetMetadata, $joinedAlias, $type, $join['joins'] ?? [], $join['conditions'] ?? []);
161157
}
162158
}
163159
}
@@ -182,27 +178,63 @@ private function applySorting(string $className, array $sorting, string $alias):
182178
}
183179

184180
/**
185-
* Apply DQL conditions on the query builder
181+
* Apply collected DQL conditions on the query builder and reset them
186182
*
187-
* @param array $conditions
188-
* @param array $dqlConditions
183+
* @param array $group
189184
*/
190-
private function applyDqlConditions(array $conditions, array $dqlConditions): void
185+
private function applyCollectedDqlConditions(array $group): void
191186
{
192-
if (!$dqlConditions) {
187+
if (!$this->dqlConditions) {
193188
return;
194189
}
195190

196-
if ($conditions['fieldsLogic'] === 'AND') {
197-
$fieldsDql = $this->queryBuilder->expr()->andX(...$dqlConditions);
191+
if ($group['conditionsLogic'] === 'AND') {
192+
$fieldsDql = $this->queryBuilder->expr()->andX(...$this->dqlConditions);
198193
} else {
199-
$fieldsDql = $this->queryBuilder->expr()->orX(...$dqlConditions);
194+
$fieldsDql = $this->queryBuilder->expr()->orX(...$this->dqlConditions);
200195
}
201196

202-
if ($conditions['conditionLogic'] === 'AND') {
197+
if ($group['groupLogic'] === 'AND') {
203198
$this->queryBuilder->andWhere($fieldsDql);
204199
} else {
205200
$this->queryBuilder->orWhere($fieldsDql);
206201
}
202+
203+
$this->dqlConditions = [];
204+
}
205+
206+
/**
207+
* Create a join, but only if it does not exist yet
208+
*
209+
* @param string $alias
210+
* @param string $field
211+
* @param string $joinType
212+
*
213+
* @return string
214+
*/
215+
private function createJoin(string $alias, string $field, string $joinType): string
216+
{
217+
$relationship = $alias . '.' . $field;
218+
$key = $relationship . '.' . $joinType;
219+
220+
if (!isset($this->uniqueJoins[$key])) {
221+
$joinedAlias = $this->uniqueNameFactory->createAliasName($field);
222+
223+
if ($joinType === 'innerJoin') {
224+
$this->queryBuilder->innerJoin($relationship, $joinedAlias);
225+
} else {
226+
$this->queryBuilder->leftJoin($relationship, $joinedAlias);
227+
}
228+
229+
// TODO: For now we assume the query will always access some field on the relation, so we optimize SQL by
230+
// fetching those objects in a single SQL query. But this should be revisited by either exposing an option
231+
// to the API so the client could decide to select or not the relations, or even better to detect in GraphQL
232+
// query if it's actually used or not.
233+
$this->queryBuilder->addSelect($joinedAlias);
234+
235+
$this->uniqueJoins[$key] = $joinedAlias;
236+
}
237+
238+
return $this->uniqueJoins[$key];
207239
}
208240
}

0 commit comments

Comments
 (0)