Skip to content

Commit d80c717

Browse files
Allow clients to specify GraphQL relationship order (#3750)
All relationships currently have a hardcoded ordering by ID, with the direction chosen somewhat arbitrarily based on the expected page render order. This commit switches the default order for all filterable relationships to ID, ascending. This commit also adds the ability for clients to specify an `orderBy` input parameter which accepts a list of columns and their respective orders. For example: `orderBy: [{column: NAME, order: ASC}]`. This capability allows us to integrate client-side table sorting with the GraphQL queries used to fill those tables. Additionally, this feature will allow us to use GraphQL to query data for plots throughout the site.
1 parent 3bae0b3 commit d80c717

6 files changed

Lines changed: 431 additions & 44 deletions

File tree

app/GraphQL/Directives/FilterDirective.php

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace App\GraphQL\Directives;
66

7+
use GraphQL\Error\InvariantViolation;
78
use GraphQL\Error\SyntaxError;
89
use GraphQL\Language\AST\FieldDefinitionNode;
910
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
@@ -20,11 +21,13 @@
2021
use Illuminate\Support\Str;
2122
use InvalidArgumentException;
2223
use JsonException;
24+
use Nuwave\Lighthouse\OrderBy\OrderByDirective;
2325
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
2426
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
2527
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
2628
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
2729
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
30+
use Nuwave\Lighthouse\Support\Utils;
2831

2932
final class FilterDirective extends BaseDirective implements ArgBuilderDirective, ArgManipulator
3033
{
@@ -41,6 +44,7 @@ public static function definition(): string
4144
/**
4245
* @throws SyntaxError
4346
* @throws JsonException
47+
* @throws InvariantViolation
4448
*/
4549
public function manipulateArgDefinition(
4650
DocumentAST &$documentAST,
@@ -92,6 +96,130 @@ public function manipulateArgDefinition(
9296
)
9397
);
9498
}
99+
100+
// For list/Connection fields, inject an orderBy argument covering all @filterable fields.
101+
$isListField = $parentField->type instanceof ListTypeNode
102+
|| Str::endsWith(ASTHelper::getUnderlyingTypeName($parentField), 'Connection');
103+
104+
if ($isListField) {
105+
$this->injectOrderByArgument($documentAST, $parentField, $parentType);
106+
}
107+
}
108+
109+
/**
110+
* Inject an orderBy argument into $parentField for all @filterable fields on the return type,
111+
* then invoke OrderByDirective's manipulateArgDefinition so the enum types are generated.
112+
*
113+
* @throws InvariantViolation
114+
* @throws SyntaxError
115+
* @throws JsonException
116+
*/
117+
private function injectOrderByArgument(
118+
DocumentAST &$documentAST,
119+
FieldDefinitionNode &$parentField,
120+
ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType,
121+
): void {
122+
// Avoid injecting twice if @filter is somehow applied more than once on this field
123+
foreach ($parentField->arguments as $existingArg) {
124+
if ($existingArg->name->value === 'orderBy') {
125+
return;
126+
}
127+
}
128+
129+
$returnTypeName = Str::replaceEnd('Connection', '', ASTHelper::getUnderlyingTypeName($parentField));
130+
$returnType = $documentAST->types[$returnTypeName] ?? null;
131+
132+
if (!$returnType instanceof ObjectTypeDefinitionNode && !$returnType instanceof InterfaceTypeDefinitionNode) {
133+
return;
134+
}
135+
136+
// Collect filterable fields: GraphQL field name -> DB column name
137+
/** @var array<string, string> $filterableFields fieldName => dbColumn */
138+
$filterableFields = [];
139+
foreach ($returnType->fields as $field) {
140+
$isFilterable = false;
141+
foreach ($field->directives as $directive) {
142+
if ($directive->name->value === 'filterable') {
143+
$isFilterable = true;
144+
break;
145+
}
146+
}
147+
148+
if (!$isFilterable) {
149+
continue;
150+
}
151+
152+
$graphqlFieldName = $field->name->value;
153+
154+
// Use @rename attribute value as the DB column name, otherwise use the field name
155+
$dbColumn = $graphqlFieldName;
156+
foreach ($field->directives as $directive) {
157+
if ($directive->name->value === 'rename') {
158+
$renameValue = $directive->arguments[0]->value;
159+
if (property_exists($renameValue, 'value')) {
160+
$dbColumn = (string) $renameValue->value;
161+
}
162+
break;
163+
}
164+
}
165+
166+
$filterableFields[$graphqlFieldName] = $dbColumn;
167+
}
168+
169+
if ($filterableFields === []) {
170+
return;
171+
}
172+
173+
// Build a custom columns enum using GraphQL field names as user-visible enum values,
174+
// with @enum(value: "dbColumn") to map each to the actual database column.
175+
$placeholderArg = Parser::inputValueDefinition('orderBy: _');
176+
$enumName = ASTHelper::qualifiedArgType(
177+
$placeholderArg,
178+
$parentField,
179+
$parentType,
180+
) . 'OrderByColumn';
181+
182+
$enumValues = [];
183+
foreach ($filterableFields as $fieldName => $dbColumn) {
184+
$enumValueName = Utils::toEnumValueName($fieldName);
185+
$enumValues[] = "{$enumValueName} @enum(value: \"{$dbColumn}\")";
186+
}
187+
$enumValuesString = implode("\n ", $enumValues);
188+
189+
$documentAST->setTypeDefinition(Parser::enumTypeDefinition(/* @lang GraphQL */ <<<GRAPHQL
190+
"Allowed columns for orderBy on this field."
191+
enum {$enumName} {
192+
{$enumValuesString}
193+
}
194+
GRAPHQL));
195+
196+
// Default to ascending order by id if id is a filterable field
197+
$idEnumName = isset($filterableFields['id']) ? Utils::toEnumValueName('id') : null;
198+
$defaultValue = $idEnumName !== null ? " = [{column: {$idEnumName}, order: ASC}]" : '';
199+
200+
$orderByArg = Parser::inputValueDefinition(/* @lang GraphQL */ <<<GRAPHQL
201+
"Sort the results by one or more filterable fields."
202+
orderBy: _{$defaultValue} @orderBy(columnsEnum: "{$enumName}")
203+
GRAPHQL);
204+
205+
$parentField->arguments[] = $orderByArg;
206+
207+
// Manually invoke OrderByDirective's manipulateArgDefinition so it generates the enum types.
208+
// hydrate() expects the DirectiveNode itself (the @orderBy node on the arg) plus the node it is attached to.
209+
$orderByDirectiveNode = null;
210+
foreach ($orderByArg->directives as $directive) {
211+
if ($directive->name->value === 'orderBy') {
212+
$orderByDirectiveNode = $directive;
213+
break;
214+
}
215+
}
216+
217+
if ($orderByDirectiveNode !== null) {
218+
/** @var OrderByDirective $orderByDirective */
219+
$orderByDirective = app(OrderByDirective::class);
220+
$orderByDirective->hydrate($orderByDirectiveNode, $orderByArg);
221+
$orderByDirective->manipulateArgDefinition($documentAST, $orderByArg, $parentField, $parentType);
222+
}
95223
}
96224

97225
/**

graphql/schema.graphql

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type Query {
2929
"Get multiple users."
3030
users(
3131
filters: _ @filter
32-
): [User!]! @paginate(type: CONNECTION) @orderBy(column: "id")
32+
): [User!]! @paginate(type: CONNECTION)
3333

3434
"Find a single project."
3535
project(
@@ -43,7 +43,7 @@ type Query {
4343
"List the projects available to the current user."
4444
projects(
4545
filters: _ @filter
46-
): [Project!]! @paginate(scopes: ["forUser"], type: CONNECTION) @orderBy(column: "id")
46+
): [Project!]! @paginate(scopes: ["forUser"], type: CONNECTION)
4747

4848
"Find a single build by ID."
4949
build(
@@ -228,7 +228,7 @@ type User {
228228

229229
projects(
230230
filters: _ @filter
231-
): [Project!]! @hasMany(type: CONNECTION, scopes: ["forUser"]) @orderBy(column: "id")
231+
): [Project!]! @hasMany(type: CONNECTION, scopes: ["forUser"])
232232

233233
authenticationTokens: [AuthToken!]! @canRoot(ability: "viewAuthenticationTokens") @hasMany(type: CONNECTION) @orderBy(column: "id")
234234
}
@@ -359,7 +359,7 @@ type Project {
359359

360360
builds(
361361
filters: _ @filter
362-
): [Build!]! @hasMany(type: CONNECTION) @orderBy(column: "id")
362+
): [Build!]! @hasMany(type: CONNECTION)
363363

364364
"""
365365
An efficient way to get the number of builds matching a given filter.
@@ -377,7 +377,7 @@ type Project {
377377
"The sites which have submitted a build to this project."
378378
sites(
379379
filters: _ @filter
380-
): [Site!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
380+
): [Site!]! @belongsToMany(type: CONNECTION)
381381

382382
"Users with the lowest user role for this project."
383383
basicUsers: [User!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
@@ -856,14 +856,14 @@ type Build {
856856
"Test results associated with this build."
857857
tests(
858858
filters: _ @filter
859-
): [Test!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
859+
): [Test!]! @hasMany(type: CONNECTION)
860860

861861
"""
862862
A list of warnings and errors submitted for this build.
863863
"""
864864
buildErrors(
865865
filters: _ @filter
866-
): [BuildError!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
866+
): [BuildError!]! @hasMany(type: CONNECTION)
867867

868868
project: Project! @belongsTo
869869

@@ -887,11 +887,11 @@ type Build {
887887
"""
888888
children(
889889
filters: _ @filter
890-
): [Build!]! @hasMany(type: CONNECTION) @orderBy(column: "id")
890+
): [Build!]! @hasMany(type: CONNECTION)
891891

892892
coverage(
893893
filters: _ @filter
894-
): [Coverage!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
894+
): [Coverage!]! @belongsToMany(type: CONNECTION)
895895

896896
coverageDiffs: [CoverageDiff!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
897897

@@ -906,15 +906,15 @@ type Build {
906906

907907
labels(
908908
filters: _ @filter
909-
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
909+
): [Label!]! @belongsToMany(type: CONNECTION)
910910

911911
targets(
912912
filters: _ @filter
913-
): [Target!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
913+
): [Target!]! @hasMany(type: CONNECTION)
914914

915915
commands(
916916
filters: _ @filter
917-
): [BuildCommand!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
917+
): [BuildCommand!]! @hasMany(type: CONNECTION)
918918

919919
urls: [UploadedUrl!]! @hasMany(relation: "uploadedFiles", type: CONNECTION, scopes: ["urls"]) @orderBy(column: "id", direction: ASC)
920920

@@ -926,7 +926,7 @@ type Build {
926926

927927
dynamicAnalyses(
928928
filters: _ @filter
929-
): [DynamicAnalysis!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
929+
): [DynamicAnalysis!]! @hasMany(type: CONNECTION)
930930

931931
updateStep: Update @belongsTo
932932

@@ -965,15 +965,15 @@ type Test {
965965

966966
testMeasurements(
967967
filters: _ @filter
968-
): [TestMeasurement!]! @hasMany @orderBy(column: "id", direction: DESC)
968+
): [TestMeasurement!]! @hasMany
969969

970970
testImages(
971971
filters: _ @filter
972-
): [TestImage!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
972+
): [TestImage!]! @hasMany(type: CONNECTION)
973973

974974
labels(
975975
filters: _ @filter
976-
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
976+
): [Label!]! @belongsToMany(type: CONNECTION)
977977
}
978978

979979
enum TestStatus {
@@ -1052,15 +1052,15 @@ type BuildCommand @model(class: "App\\Models\\BuildCommand") {
10521052

10531053
measurements(
10541054
filters: _ @filter
1055-
): [BuildMeasurement!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
1055+
): [BuildMeasurement!]! @hasMany(type: CONNECTION)
10561056

10571057
"""
10581058
Output filenames and corresponding sizes associated with this command. Most command types only
10591059
produce one output file.
10601060
"""
10611061
outputs(
10621062
filters: _ @filter
1063-
): [BuildCommandOutput!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
1063+
): [BuildCommandOutput!]! @hasMany(type: CONNECTION)
10641064
}
10651065

10661066

@@ -1151,7 +1151,7 @@ type BuildError {
11511151

11521152
labels(
11531153
filters: _ @filter
1154-
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
1154+
): [Label!]! @belongsToMany(type: CONNECTION)
11551155
}
11561156

11571157

@@ -1185,7 +1185,7 @@ type Site {
11851185
"The users who have signed up to maintain this site."
11861186
maintainers(
11871187
filters: _ @filter
1188-
): [User!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
1188+
): [User!]! @belongsToMany(type: CONNECTION)
11891189
}
11901190

11911191

@@ -1329,7 +1329,7 @@ type Coverage @model(class: "App\\Models\\CoverageView") {
13291329

13301330
labels(
13311331
filters: _ @filter
1332-
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
1332+
): [Label!]! @belongsToMany(type: CONNECTION)
13331333
}
13341334

13351335

@@ -1401,11 +1401,11 @@ type Target {
14011401

14021402
commands(
14031403
filters: _ @filter
1404-
): [BuildCommand!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
1404+
): [BuildCommand!]! @hasMany(type: CONNECTION)
14051405

14061406
labels(
14071407
filters: _ @filter
1408-
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
1408+
): [Label!]! @belongsToMany(type: CONNECTION)
14091409
}
14101410

14111411

@@ -1460,7 +1460,7 @@ type DynamicAnalysis {
14601460

14611461
labels(
14621462
filters: _ @filter
1463-
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
1463+
): [Label!]! @belongsToMany(type: CONNECTION)
14641464
}
14651465

14661466

@@ -1492,7 +1492,7 @@ type Update @model(class: "App\\Models\\BuildUpdate") {
14921492

14931493
updateFiles(
14941494
filters: _ @filter
1495-
): [UpdateFile!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
1495+
): [UpdateFile!]! @hasMany(type: CONNECTION)
14961496
}
14971497

14981498

0 commit comments

Comments
 (0)