laravel-graphql
laravel-graphql copied to clipboard
[Proof-of-concept] Eager loading and argument filtering!
This is a proof of concept post, the following is functional, however it assumes certain patterns, and the code itself could certainly be improved. You should adapt this for your own use case, or perhaps it could be integrated into the library properly.. Also. Screw Github formatting!
So right now we can eager load relationships, pretty neat https://github.com/Folkloreatelier/laravel-graphql/blob/develop/docs/advanced.md#eager-loading-relationships
But things fall over as soon as you want to filter the relationships.
E.g.
listBooks {
id
title,
content,
chapters(input: {include_deleted: true}) {
id,
title,
deleted_at,
pages(input: {number_gt: 35, include_deleted: true}) {
id,
number,
contents
}
}
}
The recommended solution is to add a resolveChapters
function to the Book
entity,
and then returning a filtered list. But now we're not eager loading! instead we're going to run a new select * from chapters...
query for each book, and of course this scales, so we'd have the same issue for every chapter of every book!
So how can we work around this? The first thing to note here, is that I've got only one input for each relation/query which is the recommended method.
So I've setup a ListChaptersInput
that looks like this (note the new applyToEagerLoad
key?).
We have the same thing setup for ListPagesInput
.
class ListChaptersInput extends BaseType
{
protected $inputObject = true;
protected $attributes = [
'name' => 'ListChaptersInput',
];
public function fields(): array
{
return [
'name' => [
'type' => Type::string(),
'description' => 'The title of the chapter',
'applyToEagerLoad' => function (Builder $query, $value): void {
$query->where($query->qualifyColumn('title'), $value);
},
],
'include_deleted' => [
'type' => Type::boolean(),
'description' => 'Whether to include trashed chapters',
'applyToEagerLoad' => function (Builder $query, $value): void {
$query->withTrashed($value);
},
],
];
}
}
Alright that's cool, now the logic for what the argument means, is located with the definition of the argument itself! But how do we use it?
Well we add a new helper class (please note, this is POC code that has not been refactored!).
namespace App\GraphQL;
use Folklore\GraphQL\Support\Query;
use GraphQL\Language\AST\NodeList;
use GraphQL\Type\Definition\ListOfType;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Database\Eloquent\Builder;
class GQLHelper
{
protected static function mapRelationArguments(NodeList $selectionSet, $nodeType, $variables = [], $outputArgumentList = [], $prefix = '')
{
foreach ($selectionSet as $selectionNode) {
if (!empty($selectionNode->selectionSet)) {
$selectionNodeName = $selectionNode->name->value;
$fieldName = empty($prefix) ? $selectionNodeName : "{$prefix}.{$selectionNodeName}";
$field = $nodeType->getField($selectionNodeName);
$argumentType = empty($field->args) ? null : $field->args[0]->getType();
//build list here
$arguments = [];
if (isset($selectionNode->value->fields)) {
foreach ($selectionNode->value->fields as $argumentField) {
$arguments[$argumentField->name->value] = $argumentField->value->value;
}
} else {
$arguments = $variables[$selectionNode->value->name->value];
}
if ($argumentType && !empty($arguments)) {
$outputArgumentList[$fieldName] = [];
foreach ($arguments as $argumentName => $argumentValue) {
$outputArgumentList[$fieldName][] = [
'name' => $argumentName,
'value' => $argumentValue,
'resolver' => $argumentType->getField($argumentName)->applyToEagerLoad,
];
}
}
$childSelections = $selectionNode->selectionSet->selections;
if (!empty($childSelections)) {
$newNodeType = $field->getType();
if ($newNodeType instanceof ListOfType) {
$newNodeType = $newNodeType->ofType;
}
$outputArgumentList = self::mapRelationArguments($childSelections, $newNodeType, $variables, $outputArgumentList, $fieldName);
}
}
}
return $outputArgumentList;
}
protected static function mapRelations(ResolveInfo $info, $variables)
{
$fields = $info->getFieldSelection(3);
$nodeType = $info->returnType;
if ($nodeType instanceof ListOfType) {
$nodeType = $nodeType->ofType;
}
$arguments = self::mapRelationArguments($info->fieldNodes[0]->selectionSet->selections, $nodeType, $variables);
return collect(array_keys(array_dot($fields)))
->map(function (string $value): string {
return substr($value, 0, strrpos($value, '.') ?: 0);
})->unique()->filter()->flip()
->map(function ($_, $relation) use ($arguments) {
return $arguments[$relation] ?? [];
})->toArray();
}
public static function requestedRelations(ResolveInfo $info, Builder $query)
{
$relations = self::mapRelations($info, $info->variableValues);
foreach ($relations as $relation => $conditions) {
$query->with([
$relation => function (Builder $query) use ($conditions) {
foreach ($conditions as $condition) {
$condition['resolver']($query, $condition['value']);
}
},
]);
}
return $query;
}
public static function requestedFilters(Query $graphQLQuery, ResolveInfo $info, $eloquentQuery)
{
/** @var \GraphQL\Type\Definition\InputObjectType $inputType */
$inputType = $graphQLQuery->args()['input']['type'] ?? null;
if (!$inputType) {
return false;
}
$argumentNode = $info->fieldNodes[0]->arguments[0] ?? null;
$arguments = [];
if (isset($argumentNode->value->fields)) {
foreach ($argumentNode->value->fields as $argumentField) {
$arguments[$argumentField->name->value] = $argumentField->value->value;
}
} else {
$arguments = $info->variableValues[$argumentNode->value->name->value];
}
if (!empty($arguments)) {
foreach($arguments as $argumentName => $argumentValue) {
$inputType->getField($argumentName)->applyFilter($eloquentQuery, $argumentValue);
}
}
}
public static function applyFilters($graphQLQuery, $info, $eloquentQuery)
{
GQLHelper::requestedRelations($info, $eloquentQuery);
GQLHelper::requestedFilters($graphQLQuery, $info, $eloquentQuery);
}
}
There's a lot going on there, but the gist of it is that we use the fieldAST to resolve the input type, then we build up an array that looks like
[
'chapters' => [
['name' => 'include_deleted', 'value' => true, 'resolver' => Closure].
]
'chapters.pages' => [
['name' => 'include_deleted', 'value' => true, 'resolver' => Closure].
['name' => 'number_gt', 'value' => 35, 'resolver' => Closure].
]
]
finally we use that built array to build our $query->with(...)
statement.
Additionally we do something very similar, although a lot simpler with the first party query, not just the relationships, but that can be dropped if you want your queries resolve method to handle the filtering.
So what about the resolvers?
public function resolve($root, $args, $context, ResolveInfo $info): Collection
{
$query = Book::query();
GQLHelper::applyFilters($this, $info, $query);
return $query->all();
}
You can use this package for creating your custom filters :
https://github.com/mohammad-fouladgar/eloquent-builder