Skip to content

Instantly share code, notes, and snippets.

@jaulz
Created March 28, 2023 17:33
Show Gist options
  • Save jaulz/1b0a8c8f0841668357f274a746943dfe to your computer and use it in GitHub Desktop.
Save jaulz/1b0a8c8f0841668357f274a746943dfe to your computer and use it in GitHub Desktop.
CursorPaginateDirective
<?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