Skip to content

Commit cc5b0ea

Browse files
GraphQL: Support single-valued relationship filtering (#3674)
Our GraphQL filter system currently only supports multi-valued relationships, e.g., filtering builds with failing tests. This PR completes the final piece of the core filtering feature, allowing users to filter by single-valued relationships, such as filtering builds by subproject, site, or configure.
1 parent cc828c3 commit cc5b0ea

3 files changed

Lines changed: 182 additions & 25 deletions

File tree

app/GraphQL/Directives/FilterDirective.php

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,25 @@ public function manipulateArgDefinition(
5656
$defaultFilterType = $returnType . 'FilterInput';
5757
$filterName = $this->directiveArgValue('inputType', $defaultFilterType);
5858

59+
$subFilterableFields = $this->getSubFilterableFieldsForType($documentAST, $argDefinition, $parentType);
60+
foreach ($subFilterableFields as $subMultiFilterName) {
61+
$subTypeName = Str::before($subMultiFilterName, 'MultiFilterInput');
62+
$this->ensureFilterInputTypes($documentAST, $subTypeName, $argDefinition);
63+
}
64+
5965
if (
60-
$this->getSubFilterableFieldsForType($argDefinition, $parentType) === []
66+
$subFilterableFields === []
6167
|| (
6268
!($parentField->type instanceof ListTypeNode)
6369
&& Str::doesntEndWith(ASTHelper::getUnderlyingTypeName($parentField), 'Connection')
6470
)
65-
|| $this->getSubFilterableFieldsForType($argDefinition, $documentAST->types[$returnType]) === []
71+
|| !isset($documentAST->types[$returnType])
72+
|| $this->getSubFilterableFieldsForType($documentAST, $argDefinition, $documentAST->types[$returnType]) === []
6673
) {
6774
// Don't create a relationship filter input type because this type has no relationships
6875
$documentAST->setTypeDefinition($this->createMultiFilterInput($multiFilterName, $filterName, null));
6976
} else {
70-
$relatedFieldRelationshipFilterName = Str::replaceEnd('Connection', '', $parentField->type->type->name->value) . 'RelationshipFilterInput';
77+
$relatedFieldRelationshipFilterName = Str::replaceEnd('Connection', '', ASTHelper::getUnderlyingTypeName($parentField)) . 'RelationshipFilterInput';
7178
$documentAST->setTypeDefinition($this->createMultiFilterInput($multiFilterName, $filterName, $relatedFieldRelationshipFilterName));
7279
}
7380

@@ -76,12 +83,12 @@ public function manipulateArgDefinition(
7683
$relationshipFilterName = $parentType->name->value . 'RelationshipFilterInput';
7784
if (
7885
!array_key_exists($relationshipFilterName, $documentAST->types)
79-
&& $this->getSubFilterableFieldsForType($argDefinition, $parentType) !== []
86+
&& $subFilterableFields !== []
8087
) {
8188
$documentAST->setTypeDefinition(
8289
$this->createRelationshipFilterInput(
8390
$relationshipFilterName,
84-
$this->getSubFilterableFieldsForType($argDefinition, $parentType)
91+
$subFilterableFields
8592
)
8693
);
8794
}
@@ -243,20 +250,102 @@ protected function createRelationshipFilterInput(string $relationshipFilterName,
243250
*
244251
* @return array<string,string> A mapping of field names to their respective ...MultiFilterInput types
245252
*/
246-
private function getSubFilterableFieldsForType(InputValueDefinitionNode $argDefinition, ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode $type): array
253+
private function getSubFilterableFieldsForType(DocumentAST $documentAST, InputValueDefinitionNode $argDefinition, ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode $type): array
247254
{
248255
$subFilterableFieldNames = [];
249256
foreach ($type->fields as $field) {
257+
// Check for hasMany/Connection style relationships with @filter argument
258+
$hasFilterArg = false;
250259
foreach ($field->arguments as $argument) {
251-
if (
252-
ASTHelper::hasDirective($argument, 'filter')
253-
&& Str::endsWith(ASTHelper::getUnderlyingTypeName($field), 'Connection')
254-
) {
255-
$subFilterableFieldNames[(string) $field->name->value] = ASTHelper::qualifiedArgType($argDefinition, $field, $type) . 'MultiFilterInput';
260+
if (ASTHelper::hasDirective($argument, 'filter')) {
261+
$hasFilterArg = true;
256262
break;
257263
}
258264
}
265+
266+
if (
267+
$hasFilterArg
268+
&& Str::endsWith(ASTHelper::getUnderlyingTypeName($field), 'Connection')
269+
) {
270+
$subFilterableFieldNames[(string) $field->name->value] = ASTHelper::qualifiedArgType($argDefinition, $field, $type) . 'MultiFilterInput';
271+
continue;
272+
}
273+
274+
// Check for single-record relationships
275+
if (
276+
ASTHelper::hasDirective($field, 'belongsTo')
277+
|| ASTHelper::hasDirective($field, 'hasOne')
278+
|| ASTHelper::hasDirective($field, 'hasOneThrough')
279+
|| ASTHelper::hasDirective($field, 'morphOne')
280+
|| ASTHelper::hasDirective($field, 'morphTo')
281+
) {
282+
$typeName = ASTHelper::getUnderlyingTypeName($field);
283+
if ($this->isTypeFilterable($documentAST, $typeName)) {
284+
$subFilterableFieldNames[(string) $field->name->value] = $typeName . 'MultiFilterInput';
285+
}
286+
}
259287
}
260288
return $subFilterableFieldNames;
261289
}
290+
291+
private function ensureFilterInputTypes(DocumentAST &$documentAST, string $typeName, InputValueDefinitionNode $argDefinition): void
292+
{
293+
$multiFilterName = $typeName . 'MultiFilterInput';
294+
if (array_key_exists($multiFilterName, $documentAST->types)) {
295+
return;
296+
}
297+
298+
$type = $documentAST->types[$typeName] ?? null;
299+
if (!$type || (!$type instanceof ObjectTypeDefinitionNode && !$type instanceof InterfaceTypeDefinitionNode)) {
300+
return;
301+
}
302+
303+
// Avoid infinite recursion by setting a placeholder
304+
$documentAST->types[$multiFilterName] = null;
305+
306+
$subFilterableFields = $this->getSubFilterableFieldsForType($documentAST, $argDefinition, $type);
307+
308+
$relationshipFilterName = null;
309+
if ($subFilterableFields !== []) {
310+
$relationshipFilterName = $typeName . 'RelationshipFilterInput';
311+
if (!array_key_exists($relationshipFilterName, $documentAST->types)) {
312+
// Ensure related types exist
313+
foreach ($subFilterableFields as $subMultiFilterName) {
314+
$subTypeName = Str::before($subMultiFilterName, 'MultiFilterInput');
315+
$this->ensureFilterInputTypes($documentAST, $subTypeName, $argDefinition);
316+
}
317+
318+
$documentAST->setTypeDefinition($this->createRelationshipFilterInput($relationshipFilterName, $subFilterableFields));
319+
}
320+
}
321+
322+
$filterName = $typeName . 'FilterInput';
323+
$documentAST->setTypeDefinition($this->createMultiFilterInput($multiFilterName, $filterName, $relationshipFilterName));
324+
}
325+
326+
private function isTypeFilterable(DocumentAST $documentAST, string $typeName): bool
327+
{
328+
if (!isset($documentAST->types[$typeName])) {
329+
return false;
330+
}
331+
332+
$type = $documentAST->types[$typeName];
333+
if (!$type instanceof ObjectTypeDefinitionNode && !$type instanceof InterfaceTypeDefinitionNode) {
334+
return false;
335+
}
336+
337+
foreach ($type->fields as $field) {
338+
if (ASTHelper::hasDirective($field, 'filterable')) {
339+
return true;
340+
}
341+
// Also check for arguments with @filter
342+
foreach ($field->arguments as $argument) {
343+
if (ASTHelper::hasDirective($argument, 'filter')) {
344+
return true;
345+
}
346+
}
347+
}
348+
349+
return false;
350+
}
262351
}

phpstan-baseline.neon

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,6 @@ parameters:
144144
count: 1
145145
path: app/Exceptions/Handler.php
146146

147-
-
148-
rawMessage: Access to an undefined property GraphQL\Language\AST\ListTypeNode|GraphQL\Language\AST\NamedTypeNode|GraphQL\Language\AST\NonNullTypeNode::$name.
149-
identifier: property.notFound
150-
count: 1
151-
path: app/GraphQL/Directives/FilterDirective.php
152-
153-
-
154-
rawMessage: Access to an undefined property GraphQL\Language\AST\ListTypeNode|GraphQL\Language\AST\NamedTypeNode|GraphQL\Language\AST\NonNullTypeNode::$type.
155-
identifier: property.notFound
156-
count: 1
157-
path: app/GraphQL/Directives/FilterDirective.php
158-
159147
-
160148
rawMessage: 'Generic type Illuminate\Database\Eloquent\Relations\Relation<Illuminate\Database\Eloquent\Model> in PHPDoc tag @param for parameter $builder does not specify all template types of class Illuminate\Database\Eloquent\Relations\Relation: TRelatedModel, TDeclaringModel, TResult'
161149
identifier: generics.lessTypes
@@ -180,16 +168,34 @@ parameters:
180168
count: 1
181169
path: app/GraphQL/Directives/FilterDirective.php
182170

171+
-
172+
rawMessage: 'Method App\GraphQL\Directives\FilterDirective::ensureFilterInputTypes() throws checked exception GraphQL\Error\SyntaxError but it''s missing from the PHPDoc @throws tag.'
173+
identifier: missingType.checkedException
174+
count: 2
175+
path: app/GraphQL/Directives/FilterDirective.php
176+
177+
-
178+
rawMessage: 'Method App\GraphQL\Directives\FilterDirective::ensureFilterInputTypes() throws checked exception JsonException but it''s missing from the PHPDoc @throws tag.'
179+
identifier: missingType.checkedException
180+
count: 2
181+
path: app/GraphQL/Directives/FilterDirective.php
182+
183183
-
184184
rawMessage: 'Method App\GraphQL\Directives\FilterDirective::handleBuilder() never returns Illuminate\Database\Query\Builder so it can be removed from the return type.'
185185
identifier: return.unusedType
186186
count: 1
187187
path: app/GraphQL/Directives/FilterDirective.php
188188

189+
-
190+
rawMessage: 'Only booleans are allowed in a negated boolean, (GraphQL\Language\AST\Node&GraphQL\Language\AST\TypeDefinitionNode)|null given.'
191+
identifier: booleanNot.exprNotBoolean
192+
count: 1
193+
path: app/GraphQL/Directives/FilterDirective.php
194+
189195
-
190196
rawMessage: '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.'
191197
identifier: argument.type
192-
count: 1
198+
count: 2
193199
path: app/GraphQL/Directives/FilterDirective.php
194200

195201
-
@@ -205,11 +211,17 @@ parameters:
205211
path: app/GraphQL/Directives/FilterDirective.php
206212

207213
-
208-
rawMessage: 'Parameter #2 $type of method App\GraphQL\Directives\FilterDirective::getSubFilterableFieldsForType() expects GraphQL\Language\AST\InterfaceTypeDefinitionNode|GraphQL\Language\AST\ObjectTypeDefinitionNode, GraphQL\Language\AST\Node&GraphQL\Language\AST\TypeDefinitionNode given.'
214+
rawMessage: 'Parameter #3 $type of method App\GraphQL\Directives\FilterDirective::getSubFilterableFieldsForType() expects GraphQL\Language\AST\InterfaceTypeDefinitionNode|GraphQL\Language\AST\ObjectTypeDefinitionNode, GraphQL\Language\AST\Node&GraphQL\Language\AST\TypeDefinitionNode given.'
209215
identifier: argument.type
210216
count: 1
211217
path: app/GraphQL/Directives/FilterDirective.php
212218

219+
-
220+
rawMessage: 'array<string, GraphQL\Language\AST\Node&GraphQL\Language\AST\TypeDefinitionNode>|GraphQL\Language\AST\NodeList<GraphQL\Language\AST\Node&GraphQL\Language\AST\TypeDefinitionNode> does not accept null.'
221+
identifier: offsetAssign.valueType
222+
count: 1
223+
path: app/GraphQL/Directives/FilterDirective.php
224+
213225
-
214226
rawMessage: Access to an undefined property GraphQL\Language\AST\BooleanValueNode|GraphQL\Language\AST\EnumValueNode|GraphQL\Language\AST\FloatValueNode|GraphQL\Language\AST\IntValueNode|GraphQL\Language\AST\ListValueNode|GraphQL\Language\AST\NullValueNode|GraphQL\Language\AST\ObjectValueNode|GraphQL\Language\AST\StringValueNode|GraphQL\Language\AST\VariableNode::$value.
215227
identifier: property.notFound

tests/Feature/GraphQL/FilterTest.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,4 +1127,60 @@ public function testFilterNonPaginatedList(): void
11271127
],
11281128
]);
11291129
}
1130+
1131+
public function testFilterByBelongsToRelationship(): void
1132+
{
1133+
$site1 = $this->makeSite(['name' => 'site1']);
1134+
$build1 = $this->projects['public1']->builds()->create([
1135+
'name' => Str::uuid()->toString(),
1136+
'uuid' => Str::uuid()->toString(),
1137+
'siteid' => $site1->id,
1138+
]);
1139+
1140+
$site2 = $this->makeSite(['name' => 'site2']);
1141+
$build2 = $this->projects['public1']->builds()->create([
1142+
'name' => Str::uuid()->toString(),
1143+
'uuid' => Str::uuid()->toString(),
1144+
'siteid' => $site2->id,
1145+
]);
1146+
1147+
$this->actingAs($this->users['admin'])->graphQL('
1148+
query($sitename: String!, $projectId: ID!) {
1149+
project(id: $projectId) {
1150+
builds(filters: {
1151+
has: {
1152+
site: {
1153+
eq: {
1154+
name: $sitename
1155+
}
1156+
}
1157+
}
1158+
}) {
1159+
edges {
1160+
node {
1161+
name
1162+
}
1163+
}
1164+
}
1165+
}
1166+
}
1167+
', [
1168+
'projectId' => $this->projects['public1']->id,
1169+
'sitename' => $site1->name,
1170+
])->assertExactJson([
1171+
'data' => [
1172+
'project' => [
1173+
'builds' => [
1174+
'edges' => [
1175+
[
1176+
'node' => [
1177+
'name' => $build1->name,
1178+
],
1179+
],
1180+
],
1181+
],
1182+
],
1183+
],
1184+
]);
1185+
}
11301186
}

0 commit comments

Comments
 (0)