Skip to content

Commit

Permalink
feat(graphql): partial pagination for page based pagination (#6120)
Browse files Browse the repository at this point in the history
Co-authored-by: Xavier Leune <xleune@ccmbenchmark.com>
  • Loading branch information
xavierleune and Xavier Leune committed Feb 20, 2024
1 parent c01e10f commit 6b00cea
Show file tree
Hide file tree
Showing 18 changed files with 416 additions and 22 deletions.
121 changes: 121 additions & 0 deletions features/graphql/collection.feature
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,7 @@ Feature: GraphQL collection support
itemsPerPage
lastPage
totalCount
hasNextPage
}
}
}
Expand All @@ -862,6 +863,7 @@ Feature: GraphQL collection support
And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5
And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true
When I send the following GraphQL request:
"""
{
Expand Down Expand Up @@ -970,6 +972,7 @@ Feature: GraphQL collection support
itemsPerPage
lastPage
totalCount
hasNextPage
}
}
}
Expand All @@ -986,3 +989,121 @@ Feature: GraphQL collection support
And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5
And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true
When I send the following GraphQL request:
"""
{
fooDummies(page: 2) {
collection {
id
name
soManies(first: 2) {
edges {
node {
content
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
paginationInfo {
itemsPerPage
lastPage
totalCount
hasNextPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the JSON node "data.fooDummies.collection" should have 2 elements
And the JSON node "data.fooDummies.collection[1].id" should exist
And the JSON node "data.fooDummies.collection[1].name" should exist
And the JSON node "data.fooDummies.collection[1].soManies" should exist
And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements
And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1"
And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA=="
And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5
And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false

@createSchema
Scenario: Retrieve paginated collections using only hasNextPage
Given there are 4 fooDummy objects with fake names
When I send the following GraphQL request:
"""
{
fooDummies(page: 1, itemsPerPage: 2) {
collection {
id
name
soManies(first: 2) {
edges {
node {
content
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
paginationInfo {
hasNextPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the JSON node "data.fooDummies.collection" should have 2 elements
And the JSON node "data.fooDummies.collection[1].id" should exist
And the JSON node "data.fooDummies.collection[1].name" should exist
And the JSON node "data.fooDummies.collection[1].soManies" should exist
And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements
And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1"
And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA=="
And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true
When I send the following GraphQL request:
"""
{
fooDummies(page: 2) {
collection {
id
name
soManies(first: 2) {
edges {
node {
content
}
cursor
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
}
paginationInfo {
hasNextPage
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false
11 changes: 10 additions & 1 deletion src/Doctrine/Odm/Paginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
namespace ApiPlatform\Doctrine\Odm;

use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
use ApiPlatform\State\Pagination\PaginatorInterface;
use Doctrine\ODM\MongoDB\Iterator\Iterator;
use Doctrine\ODM\MongoDB\UnitOfWork;
Expand All @@ -24,7 +25,7 @@
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Alan Poulain <contact@alanpoulain.eu>
*/
final class Paginator implements \IteratorAggregate, PaginatorInterface
final class Paginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface
{
public const LIMIT_ZERO_MARKER_FIELD = '___';
public const LIMIT_ZERO_MARKER = 'limit0';
Expand Down Expand Up @@ -107,6 +108,14 @@ public function count(): int
return is_countable($this->mongoDbOdmIterator->toArray()[0]['results']) ? \count($this->mongoDbOdmIterator->toArray()[0]['results']) : 0;
}

/**
* {@inheritdoc}
*/
public function hasNextPage(): bool
{
return $this->getLastPage() > $this->getCurrentPage();
}

/**
* @throws InvalidArgumentException
*/
Expand Down
7 changes: 4 additions & 3 deletions src/Doctrine/Odm/Tests/PaginatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,14 @@ class PaginatorTest extends TestCase
/**
* @dataProvider initializeProvider
*/
public function testInitialize(int $firstResult, int $maxResults, int $totalItems, int $currentPage, int $lastPage): void
public function testInitialize(int $firstResult, int $maxResults, int $totalItems, int $currentPage, int $lastPage, bool $hasNextPage): void
{
$paginator = $this->getPaginator($firstResult, $maxResults, $totalItems);

$this->assertSame((float) $currentPage, $paginator->getCurrentPage());
$this->assertSame((float) $lastPage, $paginator->getLastPage());
$this->assertSame((float) $maxResults, $paginator->getItemsPerPage());
$this->assertSame($hasNextPage, $paginator->hasNextPage());
}

public function testInitializeWithFacetStageNotApplied(): void
Expand Down Expand Up @@ -203,8 +204,8 @@ private function getPaginatorWithNoCount($firstResult = 1, $maxResults = 15): Pa
public static function initializeProvider(): array
{
return [
'First of three pages of 15 items each' => [0, 15, 42, 1, 3],
'Second of two pages of 10 items each' => [10, 10, 20, 2, 2],
'First of three pages of 15 items each' => [0, 15, 42, 1, 3, true],
'Second of two pages of 10 items each' => [10, 10, 20, 2, 2, false],
];
}
}
24 changes: 24 additions & 0 deletions src/Doctrine/Orm/Extension/DoctrinePaginatorFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Doctrine\Orm\Extension;

use Doctrine\ORM\Tools\Pagination\Paginator;

class DoctrinePaginatorFactory
{
public function getPaginator($query, $fetchJoinCollection): Paginator
{
return new Paginator($query, $fetchJoinCollection);
}
}
42 changes: 41 additions & 1 deletion src/Doctrine/Orm/Paginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,21 @@

namespace ApiPlatform\Doctrine\Orm;

use ApiPlatform\Doctrine\Orm\Extension\DoctrinePaginatorFactory;
use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface;
use ApiPlatform\State\Pagination\PaginatorInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;

/**
* Decorates the Doctrine ORM paginator.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class Paginator extends AbstractPaginator implements PaginatorInterface, QueryAwareInterface
final class Paginator extends AbstractPaginator implements PaginatorInterface, QueryAwareInterface, HasNextPagePaginatorInterface
{
private ?int $totalItems = null;
private ?DoctrinePaginatorFactory $doctrinePaginatorFactory = null;

/**
* {@inheritdoc}
Expand Down Expand Up @@ -52,4 +56,40 @@ public function getQuery(): Query
{
return $this->paginator->getQuery();
}

/**
* {@inheritdoc}
*/
public function hasNextPage(): bool
{
if (isset($this->totalItems)) {
return $this->totalItems > ($this->firstResult + $this->maxResults);
}

$cloneQuery = clone $this->paginator->getQuery();

$cloneQuery->setParameters(clone $this->paginator->getQuery()->getParameters());
$cloneQuery->setCacheable(false);

foreach ($this->paginator->getQuery()->getHints() as $name => $value) {
$cloneQuery->setHint($name, $value);
}

$cloneQuery
->setFirstResult($this->paginator->getQuery()->getFirstResult() + $this->paginator->getQuery()->getMaxResults())
->setMaxResults(1);

if (null !== $this->doctrinePaginatorFactory) {
$fakePaginator = $this->doctrinePaginatorFactory->getPaginator($cloneQuery, $this->paginator->getFetchJoinCollection());
} else {
$fakePaginator = new DoctrinePaginator($cloneQuery, $this->paginator->getFetchJoinCollection());
}

return iterator_count($fakePaginator->getIterator()) > 0;
}

public function setDoctrinePaginatorFactory(?DoctrinePaginatorFactory $doctrinePaginatorFactory = null): void
{
$this->doctrinePaginatorFactory = $doctrinePaginatorFactory;
}
}
41 changes: 41 additions & 0 deletions src/Doctrine/Orm/Tests/Fixtures/Query.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

namespace ApiPlatform\Doctrine\Orm\Tests\Fixtures;

use Doctrine\Common\Collections\ArrayCollection;

/**
* Replace Doctrine\ORM\Query in tests because it cannot be mocked.
*/
Expand All @@ -27,4 +29,43 @@ public function getMaxResults(): ?int
{
return null;
}

public function setFirstResult($firstResult): self
{
return $this;
}

public function setMaxResults($maxResults): self
{
return $this;
}

public function setParameters($parameters): self
{
return $this;
}

public function getParameters()
{
return new ArrayCollection();
}

public function setCacheable($cacheable): self
{
return $this;
}

public function getHints()
{
return [];
}

public function getFetchJoinCollection()
{
return false;
}

public function getResult(): void
{
}
}
Loading

0 comments on commit 6b00cea

Please sign in to comment.