Created
March 28, 2023 17:33
-
-
Save jaulz/1b0a8c8f0841668357f274a746943dfe to your computer and use it in GitHub Desktop.
CursorPaginateDirective
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace App\GraphQL\Directives; | |
use Error; | |
use GraphQL\Language\AST\FieldDefinitionNode; | |
use GraphQL\Language\AST\InterfaceTypeDefinitionNode; | |
use GraphQL\Language\AST\NonNullTypeNode; | |
use GraphQL\Language\AST\ObjectTypeDefinitionNode; | |
use GraphQL\Language\Parser; | |
use GraphQL\Type\Definition\InterfaceType; | |
use GraphQL\Type\Definition\NonNull; | |
use GraphQL\Type\Definition\ObjectType; | |
use Nuwave\Lighthouse\Exceptions\DefinitionException; | |
use Nuwave\Lighthouse\Schema\AST\ASTHelper; | |
use Nuwave\Lighthouse\Schema\AST\DocumentAST; | |
use Nuwave\Lighthouse\Schema\Values\FieldValue; | |
use Nuwave\Lighthouse\Support\Contracts\ComplexityResolverDirective; | |
use Nuwave\Lighthouse\Support\Contracts\FieldManipulator; | |
use Nuwave\Lighthouse\Support\Contracts\FieldResolver; | |
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext; | |
use Illuminate\Database\Eloquent\Model; | |
use Illuminate\Database\Eloquent\Relations\Relation; | |
use Illuminate\Pagination\Cursor; | |
use Illuminate\Support\Collection; | |
use Nuwave\Lighthouse\Execution\ResolveInfo; | |
class CursorPaginateDirective extends Directive implements | |
FieldResolver, | |
FieldManipulator, | |
ComplexityResolverDirective | |
{ | |
public static function definition(): string | |
{ | |
return /** @lang GraphQL */ | |
<<<'GRAPHQL' | |
""" | |
Query multiple entries as a cursor paginated list. | |
""" | |
directive @cursorPaginate on FIELD_DEFINITION | |
GRAPHQL; | |
} | |
/** | |
* Returns a field resolver function. | |
* | |
* @return Resolver | |
*/ | |
public function resolveField(FieldValue $fieldValue): callable | |
{ | |
return function ( | |
mixed $root, | |
array $args, | |
GraphQLContext $context, | |
ResolveInfo $resolveInfo | |
): array { | |
$this->validateMutuallyExclusiveClientArguments($args, ['last', 'first']); | |
$this->validateMutuallyExclusiveClientArguments($args, ['last', 'after']); | |
$this->validateMutuallyExclusiveClientArguments($args, [ | |
'first', | |
'before', | |
]); | |
// Get query | |
$relationName = $resolveInfo->fieldName; | |
if ( | |
$root instanceof Model && | |
method_exists($root, $relationName) && | |
$root->{$relationName}() instanceof Relation | |
) { | |
$builder = $root->{$relationName}()->newQuery(); | |
} else { | |
$builder = $this->getModelClass()::query(); | |
} | |
// Run builder through enhancer | |
$builder = $resolveInfo->enhanceBuilder( | |
$builder, | |
$this->directiveArgValue('scopes', []), | |
$root, | |
$args, | |
$context, | |
$resolveInfo | |
); | |
$first = $args['first'] ?? $this->defaultCount(); | |
$last = $args['last'] ?? $this->defaultCount(); | |
$after = rescue( | |
fn() => Cursor::fromEncoded($args['after'] ?? ''), | |
null, | |
false | |
); | |
$before = rescue( | |
fn() => Cursor::fromEncoded($args['before'] ?? ''), | |
null, | |
false | |
); | |
$maxCount = $this->maxCount(); | |
// Check positive count | |
if ($first < 0) { | |
throw new Error(self::requestedLessThanZeroItems($first)); | |
} | |
// Make sure the maximum pagination count is not exceeded | |
if ($maxCount !== null && $first > $maxCount) { | |
throw new Error(self::requestedTooManyItems($maxCount, $first)); | |
} | |
// Retrieve result | |
if ($before) { | |
$paginator = $builder->cursorPaginate( | |
$last, | |
['*'], | |
'cursor', | |
new Cursor( | |
collect($before->toArray()) | |
->forget('__pointsToNextItems') | |
->toArray(), | |
false | |
) | |
); | |
} else { | |
$paginator = $builder->cursorPaginate($first, ['*'], 'cursor', $after); | |
} | |
return [ | |
'builder' => $builder, | |
'paginator' => $paginator, | |
]; | |
}; | |
} | |
/** | |
* Manipulate the AST based on a field definition. | |
*/ | |
public function manipulateFieldDefinition( | |
DocumentAST &$documentAST, | |
FieldDefinitionNode &$fieldDefinition, | |
ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode &$parentType | |
): void { | |
$fieldTypeName = ASTHelper::getUnderlyingTypeName($fieldDefinition); | |
$fieldType = $documentAST->types[$fieldTypeName]; | |
$resourceDirective = ASTHelper::directiveDefinition($fieldType, 'resource'); | |
// Throw in case of an invalid schema definition to remind the developer | |
if (!$resourceDirective) { | |
throw new DefinitionException( | |
'Directive @resources must be used in combination with a type that has the @resource directive.' | |
); | |
} | |
// Create connection type | |
$connectionEdgeName = "{$fieldTypeName}Edge"; | |
$connectionTypeName = "{$fieldTypeName}Connection"; | |
$connectionFieldName = addslashes(self::class); | |
$connectionType = Parser::objectTypeDefinition( | |
/** @lang GraphQL */ | |
<<<GRAPHQL | |
"A paginated list of {$fieldTypeName} edges." | |
type {$connectionTypeName} { | |
"Pagination information about the list of edges." | |
pageInfo: PageInfo! @field(resolver: "{$connectionFieldName}@resolvePageInfo") | |
"A list of {$fieldTypeName} edges." | |
edges: [{$connectionEdgeName}!]! @field(resolver: "{$connectionFieldName}@resolveEdge") | |
} | |
GRAPHQL | |
); | |
$this->addPaginationWrapperType( | |
$documentAST, | |
$connectionType, | |
ASTHelper::modelName($fieldType) | |
); | |
// Create edge type | |
$connectionEdge = | |
$edgeType ?? | |
($documentAST->types[$connectionEdgeName] ?? | |
Parser::objectTypeDefinition( | |
/** @lang GraphQL */ | |
<<<GRAPHQL | |
"An edge that contains a node of type {$fieldTypeName} and a cursor." | |
type {$connectionEdgeName} { | |
"The {$fieldTypeName} node." | |
node: {$fieldTypeName}! | |
"A unique cursor that can be used for pagination." | |
cursor: String! | |
} | |
GRAPHQL | |
)); | |
$documentAST->setTypeDefinition($connectionEdge); | |
$fieldDefinition->arguments[] = Parser::inputValueDefinition( | |
self::countArgument('first', $this->defaultCount(), $this->maxCount()) | |
); | |
$fieldDefinition->arguments[] = Parser::inputValueDefinition( | |
/** @lang GraphQL */ | |
<<<'GRAPHQL' | |
"A cursor after which elements are returned." | |
after: String | |
GRAPHQL | |
); | |
$fieldDefinition->arguments[] = Parser::inputValueDefinition( | |
self::countArgument('last', $this->defaultCount(), $this->maxCount()) | |
); | |
$fieldDefinition->arguments[] = Parser::inputValueDefinition( | |
/** @lang GraphQL */ | |
<<<'GRAPHQL' | |
"A cursor before which elements are returned." | |
before: String | |
GRAPHQL | |
); | |
$fieldDefinition->type = $this->paginationResultType($connectionTypeName); | |
$parentType->fields = ASTHelper::mergeUniqueNodeList( | |
$parentType->fields, | |
[$fieldDefinition], | |
true | |
); | |
} | |
/** | |
* Return a callable to use for calculating the complexity of a field. | |
* | |
* @return ComplexityFn | |
*/ | |
public function complexityResolver(FieldValue $fieldValue): callable | |
{ | |
return static function (int $childrenComplexity, array $args): int { | |
/** | |
* @see PaginationManipulator::firstArgument(). | |
*/ | |
$first = $args['first'] ?? null; | |
$expectedNumberOfChildren = is_int($first) ? $first : 1; | |
return // Default complexity for this field itself | |
1 + | |
// Scale children complexity by the expected number of results | |
$childrenComplexity * $expectedNumberOfChildren; | |
}; | |
} | |
protected function addPaginationWrapperType( | |
DocumentAST &$documentAST, | |
ObjectTypeDefinitionNode $objectType, | |
string $modelName | |
): void { | |
$typeName = $objectType->name->value; | |
// Reuse existing types to preserve directives or other modifications made to it | |
$existingType = $documentAST->types[$typeName] ?? null; | |
if ($existingType !== null) { | |
if (!$existingType instanceof ObjectTypeDefinitionNode) { | |
throw new DefinitionException( | |
"Expected object type for pagination wrapper {$typeName}, found {$objectType->kind} instead." | |
); | |
} | |
$objectType = $existingType; | |
} | |
// Assign model so it can be used in resolveField method | |
$objectType->directives[] = Parser::constDirective( | |
/** @lang GraphQL */ | |
'@model(class: "' . addslashes($modelName) . '")' | |
); | |
$documentAST->setTypeDefinition($objectType); | |
} | |
protected function paginationResultType(string $typeName): NonNullTypeNode | |
{ | |
$typeNode = Parser::typeReference( | |
/** @lang GraphQL */ | |
"{$typeName}!" | |
); | |
assert( | |
$typeNode instanceof NonNullTypeNode, | |
'We do not wrap the typename in [], so this will never be a ListOfTypeNode.' | |
); | |
return $typeNode; | |
} | |
protected function defaultCount(): ?int | |
{ | |
return $this->directiveArgValue( | |
'defaultCount', | |
config('lighthouse.pagination.default_count') | |
); | |
} | |
protected function maxCount(): ?int | |
{ | |
return $this->directiveArgValue( | |
'maxCount', | |
config('lighthouse.pagination.max_count') | |
); | |
} | |
public static function requestedLessThanZeroItems(int $count): string | |
{ | |
return "Requested pagination amount must be non-negative, got {$count}."; | |
} | |
public static function requestedTooManyItems( | |
int $maxCount, | |
int $actualCount | |
): string { | |
return "Maximum number of {$maxCount} requested items exceeded, got {$actualCount}. Fetch smaller chunks."; | |
} | |
/** | |
* Build the count argument definition string, considering default and max values. | |
*/ | |
protected static function countArgument( | |
string $name, | |
?int $defaultCount = null, | |
?int $maxCount = null | |
): string { | |
$description = '"Limits number of fetched items.'; | |
if ($maxCount) { | |
$description .= " Maximum allowed value: {$maxCount}."; | |
} | |
$description .= "\"\n"; | |
$definition = $name . ': Int'; | |
if ($defaultCount) { | |
$definition .= " = {$defaultCount}"; | |
} | |
return $description . $definition; | |
} | |
/** | |
* @param array{ builder: Builder, paginator: CursorPaginator } $result | |
* | |
* @return array{ | |
* hasNextPage: bool, | |
* hasPreviousPage: bool, | |
* startCursor: string|null, | |
* endCursor: string|null, | |
* total: int, | |
* count: int, | |
* } | |
*/ | |
public function resolvePageInfo(array $result): array | |
{ | |
$paginator = $result['paginator']; | |
$builder = $result['builder']; | |
$items = collect($paginator->items()); | |
$firstItem = $items->first(); | |
$lastItem = $items->last(); | |
return [ | |
'hasNextPage' => !!$paginator->nextCursor(), | |
'hasPreviousPage' => !!$paginator->previousCursor(), | |
'startCursor' => | |
$firstItem !== null | |
? $paginator->getCursorForItem($firstItem)->encode() | |
: null, | |
'endCursor' => | |
$lastItem !== null | |
? $paginator->getCursorForItem($lastItem)->encode() | |
: null, | |
'total' => fn () => $builder->count(), | |
'count' => $items->count(), | |
]; | |
} | |
/** | |
* @param array{ builder: Builder, paginator: CursorPaginator } $result | |
* @param array<string, mixed> $args | |
* | |
* @return Collection<int, array<string, mixed>> | |
*/ | |
public function resolveEdge( | |
array $result, | |
array $args, | |
GraphQLContext $context, | |
ResolveInfo $resolveInfo | |
): Collection { | |
$paginator = $result['paginator']; | |
$items = collect($paginator->items()); | |
// We know those types because we manipulated them during PaginationManipulator | |
$nonNullList = $resolveInfo->returnType; | |
assert($nonNullList instanceof NonNull); | |
$objectLikeType = $nonNullList->getInnermostType(); | |
assert( | |
$objectLikeType instanceof ObjectType || | |
$objectLikeType instanceof InterfaceType | |
); | |
$returnTypeFields = $objectLikeType->getFields(); | |
return $items->map(static function ($item, int $index) use ( | |
$returnTypeFields, | |
$paginator | |
): array { | |
$data = []; | |
foreach ($returnTypeFields as $field) { | |
switch ($field->name) { | |
case 'cursor': | |
$data['cursor'] = $paginator->getCursorForItem($item)->encode(); | |
break; | |
case 'node': | |
$data['node'] = $item; | |
break; | |
default: | |
// All other fields on the return type are assumed to be part | |
// of the edge, so we try to locate them in the pivot attribute | |
if (isset($item->pivot->{$field->name})) { | |
$data[$field->name] = $item->pivot->{$field->name}; | |
} | |
} | |
} | |
return $data; | |
}); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment