Skip to content

Commit

Permalink
fix(graphql): partial pagination support (#3223)
Browse files Browse the repository at this point in the history
  • Loading branch information
adrientiburce committed Mar 18, 2021
1 parent 47be2c5 commit f981634
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -3,6 +3,7 @@
## 2.6.4

* Doctrine: Fix purging HTTP cache for unreadable relations (#3441)
* GraphQL: Partial pagination support (#3223)
* GraphQL: Manage `pagination_use_output_walkers` and `pagination_fetch_join_collection` for operations (#3311)
* Swagger UI: Remove Google fonts (#4112)
* Doctrine: Revert #3774 support for binary UUID in search filter (#4134)
Expand Down
119 changes: 119 additions & 0 deletions features/graphql/collection.feature
Expand Up @@ -482,6 +482,125 @@ Feature: GraphQL collection support
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.dummies.edges" should have 0 element

@!mongodb
@createSchema
Scenario: Paginate through a collection through a GraphQL query with a partial pagination
Given there are 4 of these so many objects
When I send the following GraphQL request:
"""
{
soManies(first: 2) {
edges {
node {
content
}
cursor
}
totalCount
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "MA=="
And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "MQ=="
And the JSON node "data.soManies.pageInfo.hasNextPage" should be false
And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be false
And the JSON node "data.soManies.totalCount" should be equal to 0
And the JSON node "data.soManies.edges[1].node.content" should be equal to "Many #2"
And the JSON node "data.soManies.edges[1].cursor" should be equal to "MQ=="
When I send the following GraphQL request:
"""
{
soManies(first: 2, after: "MQ==") {
edges {
node {
content
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mg=="
And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw=="
And the JSON node "data.soManies.pageInfo.hasNextPage" should be false
And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true
And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #3"
And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mg=="
When I send the following GraphQL request:
"""
{
soManies(first: 2, after: "Mg==") {
edges {
node {
content
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.soManies.edges" should have 1 element
And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mw=="
And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw=="
And the JSON node "data.soManies.pageInfo.hasNextPage" should be false
And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true
And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #4"
And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mw=="
When I send the following GraphQL request:
"""
{
soManies(first: 2, after: "Mw==") {
edges {
node {
content
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "data.soManies.edges" should have 0 element
And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "NA=="
And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw=="
And the JSON node "data.soManies.pageInfo.hasNextPage" should be false
And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true

@createSchema
Scenario: Retrieve a collection with pagination disabled
Given there are 4 foo objects with fake names
Expand Down
40 changes: 24 additions & 16 deletions src/GraphQl/Resolver/Stage/SerializeStage.php
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Core\DataProvider\Pagination;
use ApiPlatform\Core\DataProvider\PaginatorInterface;
use ApiPlatform\Core\DataProvider\PartialPaginatorInterface;
use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait;
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
Expand Down Expand Up @@ -122,12 +123,12 @@ private function serializeCursorBasedPaginatedCollection(iterable $collection, a
{
$args = $context['args'];

if (!($collection instanceof PaginatorInterface)) {
throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class));
if (!($collection instanceof PartialPaginatorInterface)) {
throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s or %s.', PaginatorInterface::class, PartialPaginatorInterface::class));
}

$offset = 0;
$totalItems = $collection->getTotalItems();
$totalItems = 1; // For partial pagination, always consider there is at least one item.
$nbPageItems = $collection->count();
if (isset($args['after'])) {
$after = base64_decode($args['after'], true);
Expand All @@ -136,28 +137,35 @@ private function serializeCursorBasedPaginatedCollection(iterable $collection, a
}
$offset = 1 + (int) $after;
}
if (isset($args['before'])) {
$before = base64_decode($args['before'], true);
if (false === $before || '' === $args['before']) {
throw new \UnexpectedValueException('' === $args['before'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['before']));

if ($collection instanceof PaginatorInterface) {
$totalItems = $collection->getTotalItems();

if (isset($args['before'])) {
$before = base64_decode($args['before'], true);
if (false === $before || '' === $args['before']) {
throw new \UnexpectedValueException('' === $args['before'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['before']));
}
$offset = (int) $before - $nbPageItems;
}
if (isset($args['last']) && !isset($args['before'])) {
$offset = $totalItems - $args['last'];
}
$offset = (int) $before - $nbPageItems;
}
if (isset($args['last']) && !isset($args['before'])) {
$offset = $totalItems - $args['last'];
}

$offset = 0 > $offset ? 0 : $offset;

$data = $this->getDefaultCursorBasedPaginatedData();

if (($totalItems = $collection->getTotalItems()) > 0) {
$data['totalCount'] = $totalItems;
if ($totalItems > 0) {
$data['pageInfo']['startCursor'] = base64_encode((string) $offset);
$end = $offset + $nbPageItems - 1;
$data['pageInfo']['endCursor'] = base64_encode((string) ($end >= 0 ? $end : 0));
$itemsPerPage = $collection->getItemsPerPage();
$data['pageInfo']['hasNextPage'] = (float) ($itemsPerPage > 0 ? $offset % $itemsPerPage : $offset) + $itemsPerPage * $collection->getCurrentPage() < $totalItems;
$data['pageInfo']['hasPreviousPage'] = $offset > 0;
if ($collection instanceof PaginatorInterface) {
$data['totalCount'] = $totalItems;
$itemsPerPage = $collection->getItemsPerPage();
$data['pageInfo']['hasNextPage'] = (float) ($itemsPerPage > 0 ? $offset % $itemsPerPage : $offset) + $itemsPerPage * $collection->getCurrentPage() < $totalItems;
}
}

$index = 0;
Expand Down
6 changes: 6 additions & 0 deletions tests/GraphQl/Resolver/Stage/SerializeStageTest.php
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Core\DataProvider\ArrayPaginator;
use ApiPlatform\Core\DataProvider\Pagination;
use ApiPlatform\Core\DataProvider\PartialPaginatorInterface;
use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStage;
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface;
Expand Down Expand Up @@ -144,6 +145,9 @@ public function testApplyCollectionWithPagination(iterable $collection, array $a

public function applyCollectionWithPaginationProvider(): array
{
$partialPaginatorProphecy = $this->prophesize(PartialPaginatorInterface::class);
$partialPaginatorProphecy->count()->willReturn(2);

return [
'not paginator' => [[], [], null, \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'],
'empty paginator' => [new ArrayPaginator([], 0, 0), [], ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]],
Expand All @@ -155,6 +159,8 @@ public function applyCollectionWithPaginationProvider(): array
'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'],
'paginator with empty before cursor' => [new ArrayPaginator([], 0, 0), ['before' => ''], null, \UnexpectedValueException::class, 'Empty cursor is invalid'],
'paginator with last' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['last' => 2], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]],
'partial paginator' => [$partialPaginatorProphecy->reveal(), [], ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => 'MA==', 'endCursor' => 'MQ==', 'hasNextPage' => false, 'hasPreviousPage' => false]]],
'partial paginator with after cursor' => [$partialPaginatorProphecy->reveal(), ['after' => 'MA=='], ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]],
];
}

Expand Down

0 comments on commit f981634

Please sign in to comment.