Skip to content

Commit

Permalink
feat(graphql): @stream directive (#101)
Browse files Browse the repository at this point in the history
  • Loading branch information
LastDragon-ru committed Oct 18, 2023
2 parents 9fb9df4 + bc6aa4e commit 2382b6c
Show file tree
Hide file tree
Showing 59 changed files with 5,438 additions and 31 deletions.
4 changes: 4 additions & 0 deletions docs/shared/Links.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
[pkg:graphql]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql

[pkg:graphql#@searchBy]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql/docs/Directives/@searchBy.md

[pkg:graphql#@sortBy]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql/docs/Directives/@sortBy.md

[pkg:graphql#Printer]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql#Printer

[pkg:graphql-printer]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql-printer
Expand Down
12 changes: 11 additions & 1 deletion packages/graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ Probably the most powerful directive to provide sort (`order by` conditions) for

[Read more](<docs/Directives/@sortBy.md>).

## `@stream` 🧪

Unlike the `@paginate` (and similar) directive, the `@stream` provides a uniform way to perform Offset/Limit and Cursor pagination of Eloquent/Query/Scout builders. Filtering and sorting enabled by default via [`@searchBy`][pkg:graphql#@searchBy] and [`@sortBy`][pkg:graphql#@sortBy] directives.

[Read more](<docs/Directives/@stream.md>).

[//]: # (end: ac98e04e18d99ce0a6af07947adce086ad2450bda152abe31548ebe09831ec9a)

# Scalars
Expand Down Expand Up @@ -468,6 +474,10 @@ This package is the part of Awesome Set of Packages for Laravel. Please use the
[//]: # (start: d8baa2418c8dbf3ba09f9b223885c4326bee3e69a2dc0873e243f0d34e002a85)
[//]: # (warning: Generated automatically. Do not edit.)

[pkg:graphql-printer]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql-printer
[pkg:graphql#@searchBy]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql/docs/Directives/@searchBy.md

[pkg:graphql#@sortBy]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql/docs/Directives/@sortBy.md

[pkg:graphql-printer]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql-printer

[//]: # (end: d8baa2418c8dbf3ba09f9b223885c4326bee3e69a2dc0873e243f0d34e002a85)
1 change: 1 addition & 0 deletions packages/graphql/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"lastdragon-ru/lara-asp-core": "self.version",
"lastdragon-ru/lara-asp-eloquent": "self.version",
"lastdragon-ru/lara-asp-graphql-printer": "self.version",
"lastdragon-ru/lara-asp-serializer": "self.version",
"symfony/polyfill-php83": "^1.28"
},
"require-dev": {
Expand Down
45 changes: 45 additions & 0 deletions packages/graphql/defaults/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,36 @@
* GraphQL Settings
* -----------------------------------------------------------------------------
*
* Note: You need to clear/rebuild the cached schema and IDE helper files after change.
*
* @see https://lighthouse-php.com/master/api-reference/commands.html#clear-cache
* @see https://lighthouse-php.com/master/api-reference/commands.html#ide-helper
*
* @var array{
* search_by: array{
* operators: array<string, list<string|class-string<Operator>>>,
* },
* sort_by: array{
* operators: array<string, list<string|class-string<Operator>>>,
* },
* stream: array{
* search: array{
* name: string,
* enabled: bool,
* },
* sort: array{
* name: string,
* enabled: bool,
* },
* limit: array{
* name: string,
* default: int<1, max>,
* max: int<1, max>,
* },
* cursor: array{
* name: string,
* }
* }
* } $settings
*/
$settings = [
Expand Down Expand Up @@ -56,6 +79,28 @@
// empty
],
],

/**
* Settings for {@see \LastDragon_ru\LaraASP\GraphQL\Stream\Definitions\StreamDirective @stream} directive.
*/
'stream' => [
'search' => [
'name' => 'where',
'enabled' => true,
],
'sort' => [
'name' => 'order',
'enabled' => true,
],
'limit' => [
'name' => 'limit',
'default' => 25,
'max' => 100,
],
'cursor' => [
'name' => 'cursor',
],
],
];

return $settings;
186 changes: 186 additions & 0 deletions packages/graphql/docs/Directives/@stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# `@stream` 🧪

Unlike the `@paginate` (and similar) directive, the `@stream` provides a uniform way to perform Offset/Limit and Cursor pagination of Eloquent/Query/Scout builders. Filtering and sorting enabled by default via [`@searchBy`][pkg:graphql#@searchBy] and [`@sortBy`][pkg:graphql#@sortBy] directives.

> [!NOTE]
>
> The directive is experimental. The true cursor pagination is not implemented yet, the limit/offset is used internally. Any feedback would be greatly appreciated.
[include:exec]: <../../../../dev/artisan dev:directive @stream>
[//]: # (start: b2ddd8b19275728a6fd7ac74834e6b54f9db2e66abd7c4f7abbf176dd9b8f38e)
[//]: # (warning: Generated automatically. Do not edit.)

```graphql
"""
Splits list of items into the chunks and returns one chunk specified
by an offset or a cursor.
"""
directive @stream(
"""
Overrides default builder. Useful if the standard detection
algorithm doesn't fit/work. By default, the directive will use
the field and its type to determine the Builder to query.
"""
builder: StreamBuilder

"""
Overrides default unique key. Useful if the standard detection
algorithm doesn't fit/work. By default, the directive will use
the name of field with `ID!` type.
"""
key: String

"""
Overrides default limit.
"""
limit: Int

"""
Overrides default searchable status.
"""
searchable: Boolean

"""
Overrides default sortable status.
"""
sortable: Boolean
)
on
| FIELD_DEFINITION

"""
Explicit builder. Only one of fields allowed.
"""
input StreamBuilder {
"""
The reference to a function that provides a Builder instance.
"""
builder: String

"""
The class name of the model to query.
"""
model: String

"""
The relation name to query.
"""
relation: String
}
```

[//]: # (end: b2ddd8b19275728a6fd7ac74834e6b54f9db2e66abd7c4f7abbf176dd9b8f38e)

## Motivation

Out the box Laravel and so Lighthouse supporting the following pagination types:

* Page pagination (default) - page/size pagination with counting
* Simple pagination - page/size, but without counting
* Cursor pagination - previous/next only

Probably still most used "Page pagination" is always performing counting of items, even if the count of items is not needed and not queried. For huge datasets counting may be extremely slow, especially with filtering/sorting. In modern single-page application (SPA) we can query `count` only ones to render pagination and just navigate between pages after.

Why not "Simple pagination" that does not perform counting? Well, Lighthouse (that just utilizes Laravel APIs) does not provide a way to get a total count of items, unfortunately. Moreover, it still will use limit/offset, which is usually slower than the Cursor.

Another edge case, all paginator types has a different GraphQL types and thus cannot be used in the same query. This is means that if you need Page and Cursor pagination, you will need to create two fields.

Also, there is no Offset/Limit pagination out the box, that may be preferred over page/size in some cases.

## How to use

Schema:

```graphql
type Query {
test: [Object!]! @stream
}

type Object {
id: ID!
value: String
}
```

Query:

```graphql
query example(
$limit: Int!,
$cursor: StreamCursor,
$where: SearchByConditionObject,
$order: [SortByClauseObject!],
) {
objects(where: $where, order: $order, limit: $limit, cursor: $cursor) {
items {
id
value
}
length
navigation {
previous
current
next
}
}
}
```

Offset/Limit pagination:

```json
{
"limit": 10,
"cursor": 5,
"where": null,
"order": null
}
```

Cursor pagination:

```json
{
"limit": 10,
"cursor": "... cursor string ...",
"where": null,
"order": null
}
```

## Builder precedence

* Explicit via `builder` argument
* Query/Type/Resolver (see [resolver precedence](https://lighthouse-php.com/master/the-basics/fields.html#resolver-precedence))
* Model (for the root query)
* Relation (based on field and type names)

## Scout

To use Scout, you just need to add `@search` to an argument, the same as for `@paginate`.

```graphql
type Query {
test(search: String! @search): [Object!]! @stream
}

type Object {
id: ID!
value: String
}
```

Keep in mind:

* There is no way to use limit/offset, so the directive converts them into page/size and then slice results
* Some engines may perform counting (seems actual for `Database` only)

[include:file]: ../../../../docs/shared/Links.md
[//]: # (start: a170145c7adc0561ead408b0ea3a4b46e2e8f45ebc2744984ceb8c1b49822cd1)
[//]: # (warning: Generated automatically. Do not edit.)

[pkg:graphql#@searchBy]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql/docs/Directives/@searchBy.md

[pkg:graphql#@sortBy]: https://github.com/LastDragon-ru/lara-asp/tree/main/packages/graphql/docs/Directives/@sortBy.md

[//]: # (end: a170145c7adc0561ead408b0ea3a4b46e2e8f45ebc2744984ceb8c1b49822cd1)
1 change: 1 addition & 0 deletions packages/graphql/phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<env verbatim="true" name="APP_DEBUG" value="true"/>
<env verbatim="true" name="DB_CONNECTION" value="sqlite"/>
<env verbatim="true" name="DB_DATABASE" value=":memory:"/>
<env verbatim="true" name="SCOUT_DRIVER" value="null"/>
<env verbatim="true" name="TESTBENCH_CONVERT_DEPRECATIONS_TO_EXCEPTIONS" value="true"/>
</php>
</phpunit>
9 changes: 3 additions & 6 deletions packages/graphql/src/Builder/BuilderInfoDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,9 @@ protected function getBuilderInfo(
}

// Scout?
$scout = $manipulator->findArgument(
$field->getField(),
static function (mixed $argument) use ($manipulator): bool {
return $manipulator->getDirective($argument, SearchDirective::class) !== null;
},
);
$scout = $field->hasArgument(static function (mixed $argument) use ($manipulator): bool {
return $manipulator->getDirective($argument, SearchDirective::class) !== null;
});

if ($scout) {
return $this->getBuilderInfoInstance(ScoutBuilder::class);
Expand Down
52 changes: 33 additions & 19 deletions packages/graphql/src/Builder/Manipulator.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use Illuminate\Container\Container;
use Illuminate\Support\Str;
use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\Operator;
use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\Scope;
use LastDragon_ru\LaraASP\GraphQL\Builder\Contracts\TypeDefinition;
Expand All @@ -35,6 +36,7 @@
use LastDragon_ru\LaraASP\GraphQL\Builder\Sources\InterfaceSource;
use LastDragon_ru\LaraASP\GraphQL\Builder\Sources\ObjectSource;
use LastDragon_ru\LaraASP\GraphQL\Builder\Sources\Source;
use LastDragon_ru\LaraASP\GraphQL\Stream\Directives\Directive as StreamDirective;
use LastDragon_ru\LaraASP\GraphQL\Utils\AstManipulator;
use Nuwave\Lighthouse\Pagination\PaginateDirective;
use Nuwave\Lighthouse\Pagination\PaginationType;
Expand Down Expand Up @@ -323,32 +325,44 @@ protected function removeFakeTypeDefinition(string $name): void {
public function getPlaceholderTypeDefinitionNode(
FieldDefinitionNode|FieldDefinition $field,
): TypeDefinitionNode|Type|null {
$node = null;
$paginate = $this->getDirective($field, PaginateDirective::class);

if ($paginate) {
$type = $this->getTypeName($this->getTypeDefinition($field));
$pagination = (new class() extends PaginateDirective {
public function getPaginationType(PaginateDirective $directive): PaginationType {
return $directive->paginationType();
$node = $this->getTypeDefinition($field);
$name = $this->getTypeName($node);
$directives = [
StreamDirective::class,
PaginateDirective::class,
];

foreach ($directives as $directive) {
$directive = $this->getDirective($field, $directive);
$type = null;

if ($directive instanceof StreamDirective) {
$type = Str::singular(mb_substr($name, 0, -mb_strlen(StreamDirective::Name)));
} elseif ($directive instanceof PaginateDirective) {
$pagination = (new class() extends PaginateDirective {
public function getPaginationType(PaginateDirective $directive): PaginationType {
return $directive->paginationType();
}
})->getPaginationType($directive);

if ($pagination->isPaginator()) {
$type = mb_substr($name, 0, -mb_strlen('Paginator'));
} elseif ($pagination->isSimple()) {
$type = mb_substr($name, 0, -mb_strlen('SimplePaginator'));
} elseif ($pagination->isConnection()) {
$type = mb_substr($name, 0, -mb_strlen('Connection'));
} else {
// empty
}
})->getPaginationType($paginate);

if ($pagination->isPaginator()) {
$type = mb_substr($type, 0, -mb_strlen('Paginator'));
} elseif ($pagination->isSimple()) {
$type = mb_substr($type, 0, -mb_strlen('SimplePaginator'));
} elseif ($pagination->isConnection()) {
$type = mb_substr($type, 0, -mb_strlen('Connection'));
} else {
// empty
// empty
}

if ($type) {
$node = $this->getTypeDefinition($type);

break;
}
} else {
$node = $this->getTypeDefinition($field);
}

return $node;
Expand Down
Loading

0 comments on commit 2382b6c

Please sign in to comment.