Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions app/GraphQL/Directives/FilterDirective.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\GraphQL\Directives;

use GraphQL\Error\InvariantViolation;
use GraphQL\Error\SyntaxError;
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
Expand All @@ -20,11 +21,13 @@
use Illuminate\Support\Str;
use InvalidArgumentException;
use JsonException;
use Nuwave\Lighthouse\OrderBy\OrderByDirective;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
use Nuwave\Lighthouse\Support\Utils;

final class FilterDirective extends BaseDirective implements ArgBuilderDirective, ArgManipulator
{
Expand All @@ -41,6 +44,7 @@ public static function definition(): string
/**
* @throws SyntaxError
* @throws JsonException
* @throws InvariantViolation
*/
public function manipulateArgDefinition(
DocumentAST &$documentAST,
Expand Down Expand Up @@ -92,6 +96,130 @@ public function manipulateArgDefinition(
)
);
}

// For list/Connection fields, inject an orderBy argument covering all @filterable fields.
$isListField = $parentField->type instanceof ListTypeNode
|| Str::endsWith(ASTHelper::getUnderlyingTypeName($parentField), 'Connection');

if ($isListField) {
$this->injectOrderByArgument($documentAST, $parentField, $parentType);
}
}

/**
* Inject an orderBy argument into $parentField for all @filterable fields on the return type,
* then invoke OrderByDirective's manipulateArgDefinition so the enum types are generated.
*
* @throws InvariantViolation
* @throws SyntaxError
* @throws JsonException
*/
private function injectOrderByArgument(
DocumentAST &$documentAST,
FieldDefinitionNode &$parentField,
ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType,
): void {
// Avoid injecting twice if @filter is somehow applied more than once on this field
foreach ($parentField->arguments as $existingArg) {
if ($existingArg->name->value === 'orderBy') {
return;
}
}

$returnTypeName = Str::replaceEnd('Connection', '', ASTHelper::getUnderlyingTypeName($parentField));
$returnType = $documentAST->types[$returnTypeName] ?? null;

if (!$returnType instanceof ObjectTypeDefinitionNode && !$returnType instanceof InterfaceTypeDefinitionNode) {
return;
}

// Collect filterable fields: GraphQL field name -> DB column name
/** @var array<string, string> $filterableFields fieldName => dbColumn */
$filterableFields = [];
foreach ($returnType->fields as $field) {
$isFilterable = false;
foreach ($field->directives as $directive) {
if ($directive->name->value === 'filterable') {
$isFilterable = true;
break;
}
}

if (!$isFilterable) {
continue;
}

$graphqlFieldName = $field->name->value;

// Use @rename attribute value as the DB column name, otherwise use the field name
$dbColumn = $graphqlFieldName;
foreach ($field->directives as $directive) {
if ($directive->name->value === 'rename') {
$renameValue = $directive->arguments[0]->value;
if (property_exists($renameValue, 'value')) {
$dbColumn = (string) $renameValue->value;
}
break;
}
}

$filterableFields[$graphqlFieldName] = $dbColumn;
}

if ($filterableFields === []) {
return;
}

// Build a custom columns enum using GraphQL field names as user-visible enum values,
// with @enum(value: "dbColumn") to map each to the actual database column.
$placeholderArg = Parser::inputValueDefinition('orderBy: _');
$enumName = ASTHelper::qualifiedArgType(
$placeholderArg,
$parentField,
$parentType,
) . 'OrderByColumn';

$enumValues = [];
foreach ($filterableFields as $fieldName => $dbColumn) {
$enumValueName = Utils::toEnumValueName($fieldName);
$enumValues[] = "{$enumValueName} @enum(value: \"{$dbColumn}\")";
}
$enumValuesString = implode("\n ", $enumValues);

$documentAST->setTypeDefinition(Parser::enumTypeDefinition(/* @lang GraphQL */ <<<GRAPHQL
"Allowed columns for orderBy on this field."
enum {$enumName} {
{$enumValuesString}
}
GRAPHQL));

// Default to ascending order by id if id is a filterable field
$idEnumName = isset($filterableFields['id']) ? Utils::toEnumValueName('id') : null;
$defaultValue = $idEnumName !== null ? " = [{column: {$idEnumName}, order: ASC}]" : '';

$orderByArg = Parser::inputValueDefinition(/* @lang GraphQL */ <<<GRAPHQL
"Sort the results by one or more filterable fields."
orderBy: _{$defaultValue} @orderBy(columnsEnum: "{$enumName}")
GRAPHQL);

$parentField->arguments[] = $orderByArg;

// Manually invoke OrderByDirective's manipulateArgDefinition so it generates the enum types.
// hydrate() expects the DirectiveNode itself (the @orderBy node on the arg) plus the node it is attached to.
$orderByDirectiveNode = null;
foreach ($orderByArg->directives as $directive) {
if ($directive->name->value === 'orderBy') {
$orderByDirectiveNode = $directive;
break;
}
}

if ($orderByDirectiveNode !== null) {
/** @var OrderByDirective $orderByDirective */
$orderByDirective = app(OrderByDirective::class);
$orderByDirective->hydrate($orderByDirectiveNode, $orderByArg);
$orderByDirective->manipulateArgDefinition($documentAST, $orderByArg, $parentField, $parentType);
}
}

/**
Expand Down
50 changes: 25 additions & 25 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ type Query {
"Get multiple users."
users(
filters: _ @filter
): [User!]! @paginate(type: CONNECTION) @orderBy(column: "id")
): [User!]! @paginate(type: CONNECTION)

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

"Find a single build by ID."
build(
Expand Down Expand Up @@ -214,7 +214,7 @@ type User {

projects(
filters: _ @filter
): [Project!]! @hasMany(type: CONNECTION, scopes: ["forUser"]) @orderBy(column: "id")
): [Project!]! @hasMany(type: CONNECTION, scopes: ["forUser"])

authenticationTokens: [AuthToken!]! @canRoot(ability: "viewAuthenticationTokens") @hasMany(type: CONNECTION) @orderBy(column: "id")
}
Expand Down Expand Up @@ -345,7 +345,7 @@ type Project {

builds(
filters: _ @filter
): [Build!]! @hasMany(type: CONNECTION) @orderBy(column: "id")
): [Build!]! @hasMany(type: CONNECTION)

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

"Users with the lowest user role for this project."
basicUsers: [User!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
Expand Down Expand Up @@ -842,14 +842,14 @@ type Build {
"Test results associated with this build."
tests(
filters: _ @filter
): [Test!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Test!]! @hasMany(type: CONNECTION)

"""
A list of warnings and errors submitted for this build.
"""
buildErrors(
filters: _ @filter
): [BuildError!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [BuildError!]! @hasMany(type: CONNECTION)

project: Project! @belongsTo

Expand All @@ -873,11 +873,11 @@ type Build {
"""
children(
filters: _ @filter
): [Build!]! @hasMany(type: CONNECTION) @orderBy(column: "id")
): [Build!]! @hasMany(type: CONNECTION)

coverage(
filters: _ @filter
): [Coverage!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Coverage!]! @belongsToMany(type: CONNECTION)

"""
Get the percentage of lines covered in all files starting with the provided path.
Expand All @@ -890,15 +890,15 @@ type Build {

labels(
filters: _ @filter
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Label!]! @belongsToMany(type: CONNECTION)

targets(
filters: _ @filter
): [Target!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
): [Target!]! @hasMany(type: CONNECTION)

commands(
filters: _ @filter
): [BuildCommand!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
): [BuildCommand!]! @hasMany(type: CONNECTION)

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

Expand All @@ -910,7 +910,7 @@ type Build {

dynamicAnalyses(
filters: _ @filter
): [DynamicAnalysis!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
): [DynamicAnalysis!]! @hasMany(type: CONNECTION)

updateStep: Update @belongsTo

Expand Down Expand Up @@ -949,15 +949,15 @@ type Test {

testMeasurements(
filters: _ @filter
): [TestMeasurement!]! @hasMany @orderBy(column: "id", direction: DESC)
): [TestMeasurement!]! @hasMany

testImages(
filters: _ @filter
): [TestImage!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [TestImage!]! @hasMany(type: CONNECTION)

labels(
filters: _ @filter
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Label!]! @belongsToMany(type: CONNECTION)
}

enum TestStatus {
Expand Down Expand Up @@ -1036,15 +1036,15 @@ type BuildCommand @model(class: "App\\Models\\BuildCommand") {

measurements(
filters: _ @filter
): [BuildMeasurement!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
): [BuildMeasurement!]! @hasMany(type: CONNECTION)

"""
Output filenames and corresponding sizes associated with this command. Most command types only
produce one output file.
"""
outputs(
filters: _ @filter
): [BuildCommandOutput!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
): [BuildCommandOutput!]! @hasMany(type: CONNECTION)
}


Expand Down Expand Up @@ -1135,7 +1135,7 @@ type BuildError {

labels(
filters: _ @filter
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
): [Label!]! @belongsToMany(type: CONNECTION)
}


Expand Down Expand Up @@ -1169,7 +1169,7 @@ type Site {
"The users who have signed up to maintain this site."
maintainers(
filters: _ @filter
): [User!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id")
): [User!]! @belongsToMany(type: CONNECTION)
}


Expand Down Expand Up @@ -1313,7 +1313,7 @@ type Coverage @model(class: "App\\Models\\CoverageView") {

labels(
filters: _ @filter
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Label!]! @belongsToMany(type: CONNECTION)
}


Expand Down Expand Up @@ -1360,11 +1360,11 @@ type Target {

commands(
filters: _ @filter
): [BuildCommand!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: ASC)
): [BuildCommand!]! @hasMany(type: CONNECTION)

labels(
filters: _ @filter
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Label!]! @belongsToMany(type: CONNECTION)
}


Expand Down Expand Up @@ -1419,7 +1419,7 @@ type DynamicAnalysis {

labels(
filters: _ @filter
): [Label!]! @belongsToMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [Label!]! @belongsToMany(type: CONNECTION)
}


Expand Down Expand Up @@ -1451,7 +1451,7 @@ type Update @model(class: "App\\Models\\BuildUpdate") {

updateFiles(
filters: _ @filter
): [UpdateFile!]! @hasMany(type: CONNECTION) @orderBy(column: "id", direction: DESC)
): [UpdateFile!]! @hasMany(type: CONNECTION)
}


Expand Down
Loading