Skip to content

Commit 14d0bee

Browse files
Add GraphQL relationship filters (#2889)
The existing GraphQL filter input currently only allows users to filter by fields directly on the underlying table being filtered. This PR improves the filter input by allowing users to search by relationships as well. In other words, it's now possible to answer queries like "give me the list of builds which have a test named xyz". These filters can be infinitely nested, essentially giving users the ability to query anything in the CDash database, subject to access control limitations. This is an extremely powerful capability which will enable improvements to existing pages, brand new pages which were not previously possible, and 3rd-party extensions using the API.
1 parent 123348c commit 14d0bee

File tree

3 files changed

+628
-32
lines changed

3 files changed

+628
-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: 14 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
-
@@ -231,19 +231,29 @@ parameters:
231231
count: 1
232232
path: app/GraphQL/Directives/FilterDirective.php
233233

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+
234239
-
235240
message: "#^Parameter \\#1 \\$array of function array_key_first expects array, mixed given\\.$#"
236241
count: 1
237242
path: app/GraphQL/Directives/FilterDirective.php
238243

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+
239249
-
240250
message: "#^Parameter \\#2 \\$array of function array_key_exists expects array, mixed given\\.$#"
241-
count: 4
251+
count: 5
242252
path: app/GraphQL/Directives/FilterDirective.php
243253

244254
-
245255
message: "#^Parameter \\#2 \\$filterName of method App\\\\GraphQL\\\\Directives\\\\FilterDirective\\:\\:createMultiFilterInput\\(\\) expects string, mixed given\\.$#"
246-
count: 1
256+
count: 2
247257
path: app/GraphQL/Directives/FilterDirective.php
248258

249259
-

0 commit comments

Comments
 (0)