Skip to content

Commit e7d7ac0

Browse files
committed
Add GraphQL relationship filters
1 parent 327829b commit e7d7ac0

File tree

3 files changed

+638
-32
lines changed

3 files changed

+638
-32
lines changed

app/GraphQL/Directives/FilterDirective.php

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,41 @@ public function manipulateArgDefinition(
4747
FieldDefinitionNode &$parentField,
4848
ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType,
4949
): void {
50+
// A hack to get the return type, assuming all lists are paginated...
51+
$returnType = Str::replaceEnd('Connection', '', $parentField->type->type->name->value);
52+
5053
$multiFilterName = ASTHelper::qualifiedArgType($argDefinition, $parentField, $parentType) . 'MultiFilterInput';
5154
$argDefinition->type = Parser::namedType($multiFilterName);
5255

53-
$defaultFilterType = Str::replaceEnd('Connection', '', $parentField->type->type->name->value) . 'FilterInput';
54-
$inputType = $this->directiveArgValue('inputType', $defaultFilterType);
55-
$documentAST->setTypeDefinition($this->createMultiFilterInput($multiFilterName, $inputType));
56+
$defaultFilterType = $returnType . 'FilterInput';
57+
$filterName = $this->directiveArgValue('inputType', $defaultFilterType);
58+
59+
if (
60+
$this->getSubFilterableFieldsForType($argDefinition, $parentType) === []
61+
|| !str_ends_with($parentField->type->type->name->value, 'Connection')
62+
|| $this->getSubFilterableFieldsForType($argDefinition, $documentAST->types[$returnType]) === []
63+
) {
64+
// Don't create a relationship filter input type because this type has no relationships
65+
$documentAST->setTypeDefinition($this->createMultiFilterInput($multiFilterName, $filterName, null));
66+
} else {
67+
$relatedFieldRelationshipFilterName = Str::replaceEnd('Connection', '', $parentField->type->type->name->value) . 'RelationshipFilterInput';
68+
$documentAST->setTypeDefinition($this->createMultiFilterInput($multiFilterName, $filterName, $relatedFieldRelationshipFilterName));
69+
}
70+
71+
// We only have to create the relationship filter input type once per type, and don't create it at all
72+
// if there are no relationships present.
73+
$relationshipFilterName = $parentType->name->value . 'RelationshipFilterInput';
74+
if (
75+
!array_key_exists($relationshipFilterName, $documentAST->types)
76+
&& $this->getSubFilterableFieldsForType($argDefinition, $parentType) !== []
77+
) {
78+
$documentAST->setTypeDefinition(
79+
$this->createRelationshipFilterInput(
80+
$relationshipFilterName,
81+
$this->getSubFilterableFieldsForType($argDefinition, $parentType)
82+
)
83+
);
84+
}
5685
}
5786

5887
/**
@@ -69,37 +98,49 @@ public function handleBuilder(QueryBuilder|EloquentBuilder|Relation $builder, mi
6998
throw new InvalidArgumentException('$value parameter must be array');
7099
}
71100

101+
if ($builder instanceof QueryBuilder) {
102+
throw new InvalidArgumentException('Query builder is not allowed.');
103+
}
104+
72105
$this->applyFilters($builder, $value);
73106

74107
return $builder;
75108
}
76109

77-
protected function applyFilters(QueryBuilder|EloquentBuilder|Relation $builder, mixed $filter, string $context = 'and', ?string $table = null): void
110+
protected function applyFilters(EloquentBuilder|Relation $builder, mixed $filter, string $context = 'and'): void
78111
{
79-
// The query builder isn't able to provide the table being queried, so we pass it down the chain from
80-
// eloquent-derived builders which can.
81-
if ($builder instanceof EloquentBuilder || $builder instanceof Relation) {
82-
$table = $builder->getModel()->getTable();
83-
}
112+
$table = $builder->getModel()->getTable();
84113

85114
if (array_key_exists('all', $filter)) {
86-
$builder->whereNested(
87-
function ($subfilterBuilder) use ($filter, $table): void {
115+
$builder->where(
116+
function ($subfilterBuilder) use ($filter): void {
88117
foreach ($filter['all'] as $subfilter) {
89-
$this->applyFilters($subfilterBuilder, $subfilter, 'and', $table);
118+
$this->applyFilters($subfilterBuilder, $subfilter, 'and');
90119
}
91120
},
92-
$context
121+
boolean: $context,
93122
);
94123
} elseif (array_key_exists('any', $filter)) {
95-
$builder->whereNested(
96-
function ($subfilterBuilder) use ($filter, $table): void {
124+
$builder->where(
125+
function ($subfilterBuilder) use ($filter): void {
97126
foreach ($filter['any'] as $subfilter) {
98-
$this->applyFilters($subfilterBuilder, $subfilter, 'or', $table);
127+
$this->applyFilters($subfilterBuilder, $subfilter, 'or');
99128
}
100129
},
101-
$context,
130+
boolean: $context,
102131
);
132+
} elseif (array_key_exists('has', $filter)) {
133+
$relationshipName = array_key_first($filter['has']);
134+
$relationshipFilters = $filter['has'][$relationshipName];
135+
if ($context === 'and') {
136+
$builder->whereHas($relationshipName, function ($subfilterBuilder) use ($relationshipFilters): void {
137+
$this->applyFilters($subfilterBuilder, $relationshipFilters);
138+
});
139+
} else {
140+
$builder->orWhereHas($relationshipName, function ($subfilterBuilder) use ($relationshipFilters): void {
141+
$this->applyFilters($subfilterBuilder, $relationshipFilters);
142+
});
143+
}
103144
} else {
104145
$operators = [
105146
'eq' => '=',
@@ -147,26 +188,73 @@ function ($subfilterBuilder) use ($filter, $table): void {
147188
* @throws SyntaxError
148189
* @throws JsonException
149190
*/
150-
protected function createMultiFilterInput(string $multiFilterName, string $filterName): InputObjectTypeDefinitionNode
191+
protected function createMultiFilterInput(string $multiFilterName, string $filterName, ?string $relationshipFilterName): InputObjectTypeDefinitionNode
151192
{
193+
if ($relationshipFilterName !== null) {
194+
$hasFilter = '"Find nodes which have one or more related notes which match the provided filter."' . PHP_EOL;
195+
$hasFilter .= 'has: ' . $relationshipFilterName . '@rules(apply: ["prohibits:any,all,eq,ne,gt,lt,contains"])';
196+
} else {
197+
$hasFilter = '';
198+
}
199+
152200
return Parser::inputObjectTypeDefinition(/* @lang GraphQL */ <<<GRAPHQL
153201
input {$multiFilterName} {
154202
"Find nodes which match at least one of the provided filters."
155-
any: [{$multiFilterName}] @rules(apply: ["prohibits:all,eq,ne,gt,lt,contains"])
203+
any: [{$multiFilterName}] @rules(apply: ["prohibits:all,has,eq,ne,gt,lt,contains"])
156204
"Find nodes which match all of the provided filters."
157-
all: [{$multiFilterName}] @rules(apply: ["prohibits:any,eq,ne,gt,lt,contains"])
205+
all: [{$multiFilterName}] @rules(apply: ["prohibits:any,has,eq,ne,gt,lt,contains"])
206+
{$hasFilter}
158207
"Find nodes where the provided field is equal to the provided value."
159-
eq: {$filterName} @rules(apply: ["prohibits:any,all,ne,gt,lt,contains"])
208+
eq: {$filterName} @rules(apply: ["prohibits:any,all,has,ne,gt,lt,contains"])
160209
"Find nodes where the provided field is not equal to the provided value."
161-
ne: {$filterName} @rules(apply: ["prohibits:any,all,eq,gt,lt,contains"])
210+
ne: {$filterName} @rules(apply: ["prohibits:any,all,has,eq,gt,lt,contains"])
162211
"Find nodes where the provided field is greater than the provided value."
163-
gt: {$filterName} @rules(apply: ["prohibits:any,all,eq,ne,lt,contains"])
212+
gt: {$filterName} @rules(apply: ["prohibits:any,all,has,eq,ne,lt,contains"])
164213
"Find nodes where the provided field is less than the provided value."
165-
lt: {$filterName} @rules(apply: ["prohibits:any,all,eq,ne,gt,contains"])
214+
lt: {$filterName} @rules(apply: ["prohibits:any,all,has,eq,ne,gt,contains"])
166215
"Find nodes where the provided field contains the provided value."
167-
contains: {$filterName} @rules(apply: ["prohibits:any,all,eq,ne,gt,lt"])
216+
contains: {$filterName} @rules(apply: ["prohibits:any,all,has,eq,ne,gt,lt"])
168217
}
169218
GRAPHQL
170219
);
171220
}
221+
222+
/**
223+
* @param array<string,string> $subFilterableFields
224+
*
225+
* @throws JsonException
226+
* @throws SyntaxError
227+
*/
228+
protected function createRelationshipFilterInput(string $relationshipFilterName, array $subFilterableFields): InputObjectTypeDefinitionNode
229+
{
230+
$typeDefinition = "input {$relationshipFilterName} {" . PHP_EOL;
231+
foreach ($subFilterableFields as $fieldName => $fieldType) {
232+
$typeDefinition .= "{$fieldName}: {$fieldType}" . PHP_EOL;
233+
}
234+
$typeDefinition .= '}' . PHP_EOL;
235+
return Parser::inputObjectTypeDefinition($typeDefinition);
236+
}
237+
238+
/**
239+
* Find a list of filterable relationships by finding those which return a list and have a @filter directive.
240+
*
241+
* @return array<string,string> A mapping of field names to their respective ...MultiFilterInput types
242+
*/
243+
private function getSubFilterableFieldsForType(InputValueDefinitionNode $argDefinition, ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode $type): array
244+
{
245+
$subFilterableFieldNames = [];
246+
foreach ($type->fields as $field) {
247+
foreach ($field->arguments as $argument) {
248+
foreach ($argument->directives as $directive) {
249+
// We abuse the fact that all list return values are paginated to exclude filterable fields which
250+
// cannot have subqueries.
251+
if ($directive->name->value === 'filter' && str_ends_with($field->type->type->name->value, 'Connection')) {
252+
$subFilterableFieldNames[(string) $field->name->value] = ASTHelper::qualifiedArgType($argDefinition, $field, $type) . 'MultiFilterInput';
253+
break 2;
254+
}
255+
}
256+
}
257+
}
258+
return $subFilterableFieldNames;
259+
}
172260
}

phpstan-baseline.neon

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,12 +193,12 @@ parameters:
193193

194194
-
195195
message: "#^Access to an undefined property GraphQL\\\\Language\\\\AST\\\\ListTypeNode\\|GraphQL\\\\Language\\\\AST\\\\NamedTypeNode\\|GraphQL\\\\Language\\\\AST\\\\NonNullTypeNode\\:\\:\\$name\\.$#"
196-
count: 1
196+
count: 4
197197
path: app/GraphQL/Directives/FilterDirective.php
198198

199199
-
200200
message: "#^Access to an undefined property GraphQL\\\\Language\\\\AST\\\\ListTypeNode\\|GraphQL\\\\Language\\\\AST\\\\NamedTypeNode\\|GraphQL\\\\Language\\\\AST\\\\NonNullTypeNode\\:\\:\\$type\\.$#"
201-
count: 1
201+
count: 4
202202
path: app/GraphQL/Directives/FilterDirective.php
203203

204204
-
@@ -211,6 +211,16 @@ parameters:
211211
count: 1
212212
path: app/GraphQL/Directives/FilterDirective.php
213213

214+
-
215+
message: "#^Dynamic call to static method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\<Illuminate\\\\Database\\\\Eloquent\\\\Model\\>\\:\\:orWhereRaw\\(\\)\\.$#"
216+
count: 1
217+
path: app/GraphQL/Directives/FilterDirective.php
218+
219+
-
220+
message: "#^Dynamic call to static method Illuminate\\\\Database\\\\Eloquent\\\\Builder\\<Illuminate\\\\Database\\\\Eloquent\\\\Model\\>\\:\\:whereRaw\\(\\)\\.$#"
221+
count: 1
222+
path: app/GraphQL/Directives/FilterDirective.php
223+
214224
-
215225
message: "#^Method App\\\\GraphQL\\\\Directives\\\\FilterDirective\\:\\:applyFilters\\(\\) has parameter \\$builder with generic class Illuminate\\\\Database\\\\Eloquent\\\\Builder but does not specify its types\\: TModelClass$#"
216226
count: 1
@@ -221,19 +231,29 @@ parameters:
221231
count: 1
222232
path: app/GraphQL/Directives/FilterDirective.php
223233

234+
-
235+
message: "#^Method App\\\\GraphQL\\\\Directives\\\\FilterDirective\\:\\:handleBuilder\\(\\) never returns Illuminate\\\\Database\\\\Query\\\\Builder so it can be removed from the return type\\.$#"
236+
count: 1
237+
path: app/GraphQL/Directives/FilterDirective.php
238+
224239
-
225240
message: "#^Parameter \\#1 \\$array of function array_key_first expects array, mixed given\\.$#"
226241
count: 1
227242
path: app/GraphQL/Directives/FilterDirective.php
228243

244+
-
245+
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, array\\<string, GraphQL\\\\Language\\\\AST\\\\Node&GraphQL\\\\Language\\\\AST\\\\TypeDefinitionNode\\>\\|GraphQL\\\\Language\\\\AST\\\\NodeList\\<GraphQL\\\\Language\\\\AST\\\\Node&GraphQL\\\\Language\\\\AST\\\\TypeDefinitionNode\\> given\\.$#"
246+
count: 1
247+
path: app/GraphQL/Directives/FilterDirective.php
248+
229249
-
230250
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
231-
count: 4
251+
count: 5
232252
path: app/GraphQL/Directives/FilterDirective.php
233253

234254
-
235255
message: "#^Parameter \\#2 \\$filterName of method App\\\\GraphQL\\\\Directives\\\\FilterDirective\\:\\:createMultiFilterInput\\(\\) expects string, mixed given\\.$#"
236-
count: 1
256+
count: 2
237257
path: app/GraphQL/Directives/FilterDirective.php
238258

239259
-

0 commit comments

Comments
 (0)