Skip to content

Commit bf8afd3

Browse files
Add page-based pagination to GraphQL (#3175)
* Add page-based pagination to GraphQL * Use page_parameter_name Co-authored-by: Alan Poulain <[email protected]>
1 parent 313f4d0 commit bf8afd3

File tree

12 files changed

+358
-55
lines changed

12 files changed

+358
-55
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
* MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144)
66
* GraphQL: Allow to format GraphQL errors based on exceptions (#3063)
7+
* GraphQL: Add page-based pagination (#3175)
78

89
## 2.5.2
910

features/graphql/collection.feature

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,3 +680,102 @@ Feature: GraphQL collection support
680680
And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist
681681
And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist
682682
And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist
683+
684+
@createSchema
685+
Scenario: Retrieve a paginated collection using page-based pagination
686+
Given there are 5 fooDummy objects with fake names
687+
When I send the following GraphQL request:
688+
"""
689+
{
690+
fooDummies(page: 1) {
691+
collection {
692+
id
693+
}
694+
paginationInfo {
695+
itemsPerPage
696+
lastPage
697+
totalCount
698+
}
699+
}
700+
}
701+
"""
702+
Then the response status code should be 200
703+
And the response should be in JSON
704+
And the JSON node "data.fooDummies.collection" should have 3 elements
705+
And the JSON node "data.fooDummies.collection[0].id" should exist
706+
And the JSON node "data.fooDummies.collection[1].id" should exist
707+
And the JSON node "data.fooDummies.collection[2].id" should exist
708+
And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
709+
And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
710+
And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5
711+
When I send the following GraphQL request:
712+
"""
713+
{
714+
fooDummies(page: 2) {
715+
collection {
716+
id
717+
}
718+
}
719+
}
720+
"""
721+
Then the response status code should be 200
722+
And the response should be in JSON
723+
And the JSON node "data.fooDummies.collection" should have 2 elements
724+
When I send the following GraphQL request:
725+
"""
726+
{
727+
fooDummies(page: 3) {
728+
collection {
729+
id
730+
}
731+
}
732+
}
733+
"""
734+
Then the response status code should be 200
735+
And the response should be in JSON
736+
And the JSON node "data.fooDummies.collection" should have 0 elements
737+
738+
@createSchema
739+
Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit
740+
Given there are 5 fooDummy objects with fake names
741+
When I send the following GraphQL request:
742+
"""
743+
{
744+
fooDummies(page: 1, itemsPerPage: 2) {
745+
collection {
746+
id
747+
}
748+
}
749+
}
750+
"""
751+
Then the response status code should be 200
752+
And the response should be in JSON
753+
And the JSON node "data.fooDummies.collection" should have 2 elements
754+
And the JSON node "data.fooDummies.collection[0].id" should exist
755+
And the JSON node "data.fooDummies.collection[1].id" should exist
756+
When I send the following GraphQL request:
757+
"""
758+
{
759+
fooDummies(page: 2, itemsPerPage: 2) {
760+
collection {
761+
id
762+
}
763+
}
764+
}
765+
"""
766+
Then the response status code should be 200
767+
And the response should be in JSON
768+
And the JSON node "data.fooDummies.collection" should have 2 elements
769+
When I send the following GraphQL request:
770+
"""
771+
{
772+
fooDummies(page: 3, itemsPerPage: 2) {
773+
collection {
774+
id
775+
}
776+
}
777+
}
778+
"""
779+
Then the response status code should be 200
780+
And the response should be in JSON
781+
And the JSON node "data.fooDummies.collection" should have 1 element

src/Bridge/Symfony/Bundle/Resources/config/graphql.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
<argument type="service" id="api_platform.graphql.types_container" />
128128
<argument type="service" id="api_platform.graphql.resolver.resource_field" />
129129
<argument type="service" id="api_platform.graphql.fields_builder_locator" />
130+
<argument type="service" id="api_platform.pagination" />
130131
</service>
131132

132133
<service id="api_platform.graphql.fields_builder" class="ApiPlatform\Core\GraphQl\Type\FieldsBuilder" public="false">

src/DataProvider/Pagination.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,18 @@ public function isPartialEnabled(string $resourceClass = null, string $operation
196196
return $this->getEnabled($context, $resourceClass, $operationName, true);
197197
}
198198

199+
public function getOptions(): array
200+
{
201+
return $this->options;
202+
}
203+
204+
public function getGraphQlPaginationType(string $resourceClass, string $operationName): string
205+
{
206+
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
207+
208+
return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'paginationType', 'cursor', true);
209+
}
210+
199211
/**
200212
* Is the classic or partial pagination enabled?
201213
*/

src/GraphQl/Resolver/Stage/SerializeStage.php

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera
5454
if (!$resourceMetadata->getGraphqlAttribute($operationName, 'serialize', true, true)) {
5555
if ($isCollection) {
5656
if ($this->pagination->isGraphQlEnabled($resourceClass, $operationName, $context)) {
57-
return $this->getDefaultPaginatedData();
57+
return 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ?
58+
$this->getDefaultCursorBasedPaginatedData() :
59+
$this->getDefaultPageBasedPaginatedData();
5860
}
5961

6062
return [];
@@ -87,7 +89,9 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera
8789
$data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext);
8890
}
8991
} else {
90-
$data = $this->serializePaginatedCollection($itemOrCollection, $normalizationContext, $context);
92+
$data = 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ?
93+
$this->serializeCursorBasedPaginatedCollection($itemOrCollection, $normalizationContext, $context) :
94+
$this->serializePageBasedPaginatedCollection($itemOrCollection, $normalizationContext);
9195
}
9296
}
9397

@@ -108,7 +112,7 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera
108112
* @throws \LogicException
109113
* @throws \UnexpectedValueException
110114
*/
111-
private function serializePaginatedCollection(iterable $collection, array $normalizationContext, array $context): array
115+
private function serializeCursorBasedPaginatedCollection(iterable $collection, array $normalizationContext, array $context): array
112116
{
113117
$args = $context['args'];
114118

@@ -138,7 +142,7 @@ private function serializePaginatedCollection(iterable $collection, array $norma
138142
}
139143
$offset = 0 > $offset ? 0 : $offset;
140144

141-
$data = $this->getDefaultPaginatedData();
145+
$data = $this->getDefaultCursorBasedPaginatedData();
142146

143147
if (($totalItems = $collection->getTotalItems()) > 0) {
144148
$data['totalCount'] = $totalItems;
@@ -161,11 +165,37 @@ private function serializePaginatedCollection(iterable $collection, array $norma
161165
return $data;
162166
}
163167

164-
private function getDefaultPaginatedData(): array
168+
/**
169+
* @throws \LogicException
170+
*/
171+
private function serializePageBasedPaginatedCollection(iterable $collection, array $normalizationContext): array
172+
{
173+
if (!($collection instanceof PaginatorInterface)) {
174+
throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class));
175+
}
176+
177+
$data = $this->getDefaultPageBasedPaginatedData();
178+
$data['paginationInfo']['totalCount'] = $collection->getTotalItems();
179+
$data['paginationInfo']['lastPage'] = $collection->getLastPage();
180+
$data['paginationInfo']['itemsPerPage'] = $collection->getItemsPerPage();
181+
182+
foreach ($collection as $object) {
183+
$data['collection'][] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext);
184+
}
185+
186+
return $data;
187+
}
188+
189+
private function getDefaultCursorBasedPaginatedData(): array
165190
{
166191
return ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]];
167192
}
168193

194+
private function getDefaultPageBasedPaginatedData(): array
195+
{
196+
return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0.]];
197+
}
198+
169199
private function getDefaultMutationData(array $context): array
170200
{
171201
return ['clientMutationId' => $context['args']['input']['clientMutationId'] ?? null];

src/GraphQl/Type/FieldsBuilder.php

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -254,24 +254,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
254254
$args = [];
255255
if (!$input && null === $mutationName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) {
256256
if ($this->pagination->isGraphQlEnabled($resourceClass, $queryName)) {
257-
$args = [
258-
'first' => [
259-
'type' => GraphQLType::int(),
260-
'description' => 'Returns the first n elements from the list.',
261-
],
262-
'last' => [
263-
'type' => GraphQLType::int(),
264-
'description' => 'Returns the last n elements from the list.',
265-
],
266-
'before' => [
267-
'type' => GraphQLType::string(),
268-
'description' => 'Returns the elements in the list that come before the specified cursor.',
269-
],
270-
'after' => [
271-
'type' => GraphQLType::string(),
272-
'description' => 'Returns the elements in the list that come after the specified cursor.',
273-
],
274-
];
257+
$args = $this->getGraphQlPaginationArgs($resourceClass, $queryName);
275258
}
276259

277260
$args = $this->getFilterArgs($args, $resourceClass, $resourceMetadata, $rootResource, $property, $queryName, $mutationName, $depth);
@@ -299,6 +282,50 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
299282
return null;
300283
}
301284

285+
private function getGraphQlPaginationArgs(string $resourceClass, string $queryName): array
286+
{
287+
$paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $queryName);
288+
289+
if ('cursor' === $paginationType) {
290+
return [
291+
'first' => [
292+
'type' => GraphQLType::int(),
293+
'description' => 'Returns the first n elements from the list.',
294+
],
295+
'last' => [
296+
'type' => GraphQLType::int(),
297+
'description' => 'Returns the last n elements from the list.',
298+
],
299+
'before' => [
300+
'type' => GraphQLType::string(),
301+
'description' => 'Returns the elements in the list that come before the specified cursor.',
302+
],
303+
'after' => [
304+
'type' => GraphQLType::string(),
305+
'description' => 'Returns the elements in the list that come after the specified cursor.',
306+
],
307+
];
308+
}
309+
310+
$paginationOptions = $this->pagination->getOptions();
311+
312+
$args = [
313+
$paginationOptions['page_parameter_name'] => [
314+
'type' => GraphQLType::int(),
315+
'description' => 'Returns the current page.',
316+
],
317+
];
318+
319+
if ($paginationOptions['client_items_per_page']) {
320+
$args[$paginationOptions['items_per_page_parameter_name']] = [
321+
'type' => GraphQLType::int(),
322+
'description' => 'Returns the number of items per page.',
323+
];
324+
}
325+
326+
return $args;
327+
}
328+
302329
private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMetadata $resourceMetadata, string $rootResource, ?string $property, ?string $queryName, ?string $mutationName, int $depth): array
303330
{
304331
if (null === $resourceMetadata || null === $resourceClass) {
@@ -418,7 +445,9 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin
418445
}
419446

420447
if ($this->typeBuilder->isCollection($type)) {
421-
return $this->pagination->isGraphQlEnabled($resourceClass, $queryName ?? $mutationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType) : GraphQLType::listOf($graphqlType);
448+
$operationName = $queryName ?? $mutationName;
449+
450+
return $this->pagination->isGraphQlEnabled($resourceClass, $operationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operationName) : GraphQLType::listOf($graphqlType);
422451
}
423452

424453
return !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName)

0 commit comments

Comments
 (0)