Skip to content

Commit 9eb6662

Browse files
Simon Sperlingalanpoulain
authored andcommitted
[GraphQl] Better pagination support (#2142)
* Better support for graphql pagination Add support for graphql before, last, startcursor * Allow limit 0 for MongoDB * Fix backwards pagination * Add hasPreviousPage
1 parent e2a7194 commit 9eb6662

File tree

11 files changed

+459
-37
lines changed

11 files changed

+459
-37
lines changed

features/graphql/collection.feature

Lines changed: 167 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ Feature: GraphQL collection support
4444
}
4545
}
4646
pageInfo {
47+
startCursor
4748
endCursor
4849
hasNextPage
50+
hasPreviousPage
4951
}
5052
}
5153
}
@@ -55,7 +57,9 @@ Feature: GraphQL collection support
5557
And the header "Content-Type" should be equal to "application/json"
5658
And the JSON node "data.dummies.edges" should have 0 element
5759
And the JSON node "data.dummies.pageInfo.endCursor" should be null
60+
And the JSON node "data.dummies.pageInfo.startCursor" should be null
5861
And the JSON node "data.dummies.pageInfo.hasNextPage" should be false
62+
And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false
5963

6064
@createSchema
6165
Scenario: Retrieve a collection with a nested collection through a GraphQL query
@@ -197,12 +201,12 @@ Feature: GraphQL collection support
197201
Then the response status code should be 200
198202
And the response should be in JSON
199203
And the header "Content-Type" should be equal to "application/json"
200-
And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "Mw=="
204+
And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "MQ=="
201205
And the JSON node "data.dummies.pageInfo.hasNextPage" should be true
202206
And the JSON node "data.dummies.totalCount" should be equal to 4
203207
And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2"
204208
And the JSON node "data.dummies.edges[1].cursor" should be equal to "MQ=="
205-
And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.endCursor" should be equal to "Mw=="
209+
And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.endCursor" should be equal to "MQ=="
206210
And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasNextPage" should be true
207211
And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4
208212
And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy12"
@@ -277,9 +281,11 @@ Feature: GraphQL collection support
277281
And the header "Content-Type" should be equal to "application/json"
278282
And the JSON node "data.dummies.edges" should have 1 element
279283
And the JSON node "data.dummies.pageInfo.hasNextPage" should be false
284+
And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "Mw=="
280285
And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #4"
281286
And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mw=="
282287
And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasNextPage" should be false
288+
And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.endCursor" should be equal to "Mw=="
283289
And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements
284290
And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy44"
285291
And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "Mw=="
@@ -317,6 +323,165 @@ Feature: GraphQL collection support
317323
And the header "Content-Type" should be equal to "application/json"
318324
And the JSON node "data.dummies.edges" should have 0 element
319325

326+
@createSchema
327+
Scenario: Paginate backwards through collections through a GraphQL query
328+
Given there are 4 dummy objects having each 4 relatedDummies
329+
When I send the following GraphQL request:
330+
"""
331+
{
332+
dummies(last: 2) {
333+
edges {
334+
node {
335+
name
336+
relatedDummies(last: 2) {
337+
edges {
338+
node {
339+
name
340+
}
341+
cursor
342+
}
343+
totalCount
344+
pageInfo {
345+
startCursor
346+
hasPreviousPage
347+
}
348+
}
349+
}
350+
cursor
351+
}
352+
totalCount
353+
pageInfo {
354+
startCursor
355+
hasPreviousPage
356+
}
357+
}
358+
}
359+
"""
360+
Then the response status code should be 200
361+
And the response should be in JSON
362+
And the header "Content-Type" should be equal to "application/json"
363+
And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "Mg=="
364+
And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be true
365+
And the JSON node "data.dummies.totalCount" should be equal to 4
366+
And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #4"
367+
And the JSON node "data.dummies.edges[1].cursor" should be equal to "Mw=="
368+
And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.startCursor" should be equal to "Mg=="
369+
And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasPreviousPage" should be true
370+
And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4
371+
And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy34"
372+
And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "Mg=="
373+
When I send the following GraphQL request:
374+
"""
375+
{
376+
dummies(last: 2, before: "Mw==") {
377+
edges {
378+
node {
379+
name
380+
relatedDummies(last: 2, before: "Mg==") {
381+
edges {
382+
node {
383+
name
384+
}
385+
cursor
386+
}
387+
pageInfo {
388+
startCursor
389+
hasPreviousPage
390+
}
391+
}
392+
}
393+
cursor
394+
}
395+
pageInfo {
396+
startCursor
397+
hasPreviousPage
398+
}
399+
}
400+
}
401+
"""
402+
Then the response status code should be 200
403+
And the response should be in JSON
404+
And the header "Content-Type" should be equal to "application/json"
405+
And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2"
406+
And the JSON node "data.dummies.edges[0].cursor" should be equal to "MQ=="
407+
And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13"
408+
And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA=="
409+
When I send the following GraphQL request:
410+
"""
411+
{
412+
dummies(last: 2, before: "MQ==") {
413+
edges {
414+
node {
415+
name
416+
relatedDummies(last: 3, before: "Mg==") {
417+
edges {
418+
node {
419+
name
420+
}
421+
cursor
422+
}
423+
pageInfo {
424+
startCursor
425+
hasPreviousPage
426+
}
427+
}
428+
}
429+
cursor
430+
}
431+
pageInfo {
432+
startCursor
433+
hasPreviousPage
434+
}
435+
}
436+
}
437+
"""
438+
Then the response status code should be 200
439+
And the response should be in JSON
440+
And the header "Content-Type" should be equal to "application/json"
441+
And the JSON node "data.dummies.edges" should have 1 element
442+
And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false
443+
And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "MA=="
444+
And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #1"
445+
And the JSON node "data.dummies.edges[0].cursor" should be equal to "MA=="
446+
And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasPreviousPage" should be false
447+
And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.startCursor" should be equal to "MA=="
448+
And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements
449+
And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy21"
450+
And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "MQ=="
451+
When I send the following GraphQL request:
452+
"""
453+
{
454+
dummies(last: 2, before: "MA==") {
455+
edges {
456+
node {
457+
name
458+
relatedDummies(last: 1, before: "MQ==") {
459+
edges {
460+
node {
461+
name
462+
}
463+
cursor
464+
}
465+
pageInfo {
466+
startCursor
467+
hasPreviousPage
468+
}
469+
}
470+
}
471+
cursor
472+
}
473+
pageInfo {
474+
startCursor
475+
hasPreviousPage
476+
}
477+
}
478+
}
479+
"""
480+
Then the response status code should be 200
481+
And the response should be in JSON
482+
And the header "Content-Type" should be equal to "application/json"
483+
And the JSON node "data.dummies.edges" should have 0 element
484+
320485
@!mongodb
321486
@createSchema
322487
Scenario: Retrieve an item with composite primitive identifiers through a GraphQL query

src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC
5252
return;
5353
}
5454

55+
$context = $this->addCountToContext(clone $aggregationBuilder, $context);
56+
5557
[, $offset, $limit] = $this->pagination->getPagination($resourceClass, $operationName, $context);
5658

5759
$manager = $this->managerRegistry->getManagerForClass($resourceClass);
@@ -105,4 +107,17 @@ public function getResult(Builder $aggregationBuilder, string $resourceClass, st
105107

106108
return new Paginator($aggregationBuilder->execute(), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline());
107109
}
110+
111+
private function addCountToContext(Builder $aggregationBuilder, array $context): array
112+
{
113+
if (!($context['graphql'] ?? false)) {
114+
return $context;
115+
}
116+
117+
if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
118+
$context['count'] = $aggregationBuilder->count('count')->execute()->toArray()[0]['count'];
119+
}
120+
121+
return $context;
122+
}
108123
}

src/Bridge/Doctrine/Orm/Extension/PaginationExtension.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public function __construct(ManagerRegistry $managerRegistry, /* ResourceMetadat
111111
*/
112112
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null, array $context = [])
113113
{
114-
if (null === $pagination = $this->getPagination($resourceClass, $operationName, $context)) {
114+
if (null === $pagination = $this->getPagination($queryBuilder, $resourceClass, $operationName, $context)) {
115115
return;
116116
}
117117

@@ -167,7 +167,7 @@ public function getResult(QueryBuilder $queryBuilder, string $resourceClass = nu
167167
/**
168168
* @throws InvalidArgumentException
169169
*/
170-
private function getPagination(string $resourceClass, ?string $operationName, array $context): ?array
170+
private function getPagination(QueryBuilder $queryBuilder, string $resourceClass, ?string $operationName, array $context): ?array
171171
{
172172
$request = null;
173173
if (null !== $this->requestStack && null === $request = $this->requestStack->getCurrentRequest()) {
@@ -179,6 +179,8 @@ private function getPagination(string $resourceClass, ?string $operationName, ar
179179
return null;
180180
}
181181

182+
$context = $this->addCountToContext($queryBuilder, $context);
183+
182184
return \array_slice($this->pagination->getPagination($resourceClass, $operationName, $context), 1);
183185
}
184186

@@ -267,6 +269,19 @@ private function getPaginationParameter(Request $request, string $parameterName,
267269
return $request->query->get($parameterName, $default);
268270
}
269271

272+
private function addCountToContext(QueryBuilder $queryBuilder, array $context): array
273+
{
274+
if (!($context['graphql'] ?? false)) {
275+
return $context;
276+
}
277+
278+
if (isset($context['filters']['last']) && !isset($context['filters']['before'])) {
279+
$context['count'] = (new DoctrineOrmPaginator($queryBuilder))->count();
280+
}
281+
282+
return $context;
283+
}
284+
270285
/**
271286
* Determines whether the Paginator should fetch join collections, if the root entity uses composite identifiers it should not.
272287
*

src/DataProvider/ArrayPaginator.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\DataProvider;
15+
16+
/**
17+
* Paginator for arrays.
18+
*
19+
* @author Alan Poulain <[email protected]>
20+
*/
21+
final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface
22+
{
23+
private $iterator;
24+
private $firstResult;
25+
private $maxResults;
26+
private $totalItems;
27+
28+
public function __construct(array $results, int $firstResult, int $maxResults)
29+
{
30+
if ($maxResults > 0) {
31+
$this->iterator = new \LimitIterator(new \ArrayIterator($results), $firstResult, $maxResults);
32+
} else {
33+
$this->iterator = new \EmptyIterator();
34+
}
35+
$this->firstResult = $firstResult;
36+
$this->maxResults = $maxResults;
37+
$this->totalItems = \count($results);
38+
}
39+
40+
/**
41+
* {@inheritdoc}
42+
*/
43+
public function getCurrentPage(): float
44+
{
45+
if (0 >= $this->maxResults) {
46+
return 1.;
47+
}
48+
49+
return floor($this->firstResult / $this->maxResults) + 1.;
50+
}
51+
52+
/**
53+
* {@inheritdoc}
54+
*/
55+
public function getLastPage(): float
56+
{
57+
if (0 >= $this->maxResults) {
58+
return 1.;
59+
}
60+
61+
return ceil($this->totalItems / $this->maxResults) ?: 1.;
62+
}
63+
64+
/**
65+
* {@inheritdoc}
66+
*/
67+
public function getItemsPerPage(): float
68+
{
69+
return (float) $this->maxResults;
70+
}
71+
72+
/**
73+
* {@inheritdoc}
74+
*/
75+
public function getTotalItems(): float
76+
{
77+
return (float) $this->totalItems;
78+
}
79+
80+
/**
81+
* {@inheritdoc}
82+
*/
83+
public function count(): int
84+
{
85+
return iterator_count($this->iterator);
86+
}
87+
88+
/**
89+
* {@inheritdoc}
90+
*/
91+
public function getIterator(): \Traversable
92+
{
93+
return $this->iterator;
94+
}
95+
}

0 commit comments

Comments
 (0)