diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature index 9a10eaf905d..afc2dc097ec 100644 --- a/features/graphql/collection.feature +++ b/features/graphql/collection.feature @@ -846,6 +846,7 @@ Feature: GraphQL collection support itemsPerPage lastPage totalCount + hasNextPage } } } @@ -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: """ { @@ -970,6 +972,7 @@ Feature: GraphQL collection support itemsPerPage lastPage totalCount + hasNextPage } } } @@ -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 diff --git a/src/Doctrine/Odm/Paginator.php b/src/Doctrine/Odm/Paginator.php index 4bd028ec0e0..b39d6ee1b4f 100644 --- a/src/Doctrine/Odm/Paginator.php +++ b/src/Doctrine/Odm/Paginator.php @@ -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; @@ -24,7 +25,7 @@ * @author Kévin Dunglas * @author Alan Poulain */ -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'; @@ -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 */ diff --git a/src/Doctrine/Odm/Tests/PaginatorTest.php b/src/Doctrine/Odm/Tests/PaginatorTest.php index 1bae284239e..08646af589b 100644 --- a/src/Doctrine/Odm/Tests/PaginatorTest.php +++ b/src/Doctrine/Odm/Tests/PaginatorTest.php @@ -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 @@ -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], ]; } } diff --git a/src/Doctrine/Orm/Extension/DoctrinePaginatorFactory.php b/src/Doctrine/Orm/Extension/DoctrinePaginatorFactory.php new file mode 100644 index 00000000000..f7daadb8d35 --- /dev/null +++ b/src/Doctrine/Orm/Extension/DoctrinePaginatorFactory.php @@ -0,0 +1,24 @@ + + * + * 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); + } +} diff --git a/src/Doctrine/Orm/Paginator.php b/src/Doctrine/Orm/Paginator.php index 4e1dff694d4..2256a62f2bf 100644 --- a/src/Doctrine/Orm/Paginator.php +++ b/src/Doctrine/Orm/Paginator.php @@ -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 */ -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} @@ -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; + } } diff --git a/src/Doctrine/Orm/Tests/Fixtures/Query.php b/src/Doctrine/Orm/Tests/Fixtures/Query.php index b0003a82265..f6536af2004 100644 --- a/src/Doctrine/Orm/Tests/Fixtures/Query.php +++ b/src/Doctrine/Orm/Tests/Fixtures/Query.php @@ -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. */ @@ -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 + { + } } diff --git a/src/Doctrine/Orm/Tests/PaginatorTest.php b/src/Doctrine/Orm/Tests/PaginatorTest.php index 87f73249c57..db440323793 100644 --- a/src/Doctrine/Orm/Tests/PaginatorTest.php +++ b/src/Doctrine/Orm/Tests/PaginatorTest.php @@ -13,11 +13,14 @@ namespace ApiPlatform\Doctrine\Orm\Tests; +use ApiPlatform\Doctrine\Orm\Extension\DoctrinePaginatorFactory; use ApiPlatform\Doctrine\Orm\Paginator; use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Query; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; class PaginatorTest extends TestCase @@ -27,13 +30,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 testInitializeWithQueryFirstResultNotApplied(): void @@ -90,11 +94,60 @@ private function getPaginatorWithMalformedQuery(bool $maxResults = false): void new Paginator($doctrinePaginator->reveal()); } + public function testHasNextPageShouldNotMakeQueryIfTotalPagesHasBeenCalled(): void + { + $query = $this->prophesize(Query::class); + $query->getFirstResult()->willReturn(1)->shouldBeCalled(); + $query->getMaxResults()->willReturn(15)->shouldBeCalled(); + $query->setMaxResults(Argument::any())->shouldNotBeCalled(); + + $doctrinePaginator = $this->prophesize(DoctrinePaginator::class); + + $doctrinePaginator->getQuery()->willReturn($query->reveal())->shouldBeCalled(); + $doctrinePaginator->count()->willReturn(42); + + $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + + $paginator = new Paginator($doctrinePaginator->reveal()); + $paginator->getTotalItems(); + $this->assertTrue($paginator->hasNextPage()); + } + + public function testHasNextPageShouldMakeQueryIfTotalPagesHasNotBeenCalled(): void + { + $query = $this->prophesize(Query::class); + $query->getFirstResult()->willReturn(1)->shouldBeCalled(); + $query->getMaxResults()->willReturn(15)->shouldBeCalled(); + $query->getParameters()->willReturn(new ArrayCollection())->shouldBeCalled(); + $query->setParameters(Argument::any())->willReturn($query->reveal())->shouldBeCalled(); + $query->setCacheable(false)->willReturn($query->reveal())->shouldBeCalled(); + $query->setMaxResults(1)->shouldBeCalled(); + $query->getHints()->willReturn([])->shouldBeCalled(); + $query->setFirstResult(Argument::any())->willReturn($query->reveal())->shouldBeCalled(); + + $doctrinePaginator = $this->prophesize(DoctrinePaginator::class); + + $doctrinePaginator->getQuery()->willReturn($query->reveal())->shouldBeCalled(); + $doctrinePaginator->count()->willReturn(42); + $doctrinePaginator->getFetchJoinCollection()->willReturn(false); + + $doctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + + $secondDoctrinePaginator = $this->prophesize(DoctrinePaginator::class); + $secondDoctrinePaginator->getIterator()->will(fn (): \ArrayIterator => new \ArrayIterator()); + $doctrinePaginatorFactory = $this->prophesize(DoctrinePaginatorFactory::class); + $doctrinePaginatorFactory->getPaginator(Argument::any(), Argument::any())->willReturn($secondDoctrinePaginator->reveal()); + + $paginator = new Paginator($doctrinePaginator->reveal()); + $paginator->setDoctrinePaginatorFactory($doctrinePaginatorFactory->reveal()); + $this->assertFalse($paginator->hasNextPage()); + } + 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], ]; } } diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php index 27df556be7e..e10298f0a1e 100644 --- a/src/GraphQl/Resolver/Stage/SerializeStage.php +++ b/src/GraphQl/Resolver/Stage/SerializeStage.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; @@ -206,6 +207,12 @@ private function serializePageBasedPaginatedCollection(iterable $collection, arr } $data['paginationInfo']['lastPage'] = $collection->getLastPage(); } + if (isset($selection['paginationInfo']['hasNextPage'])) { + if (!($collection instanceof HasNextPagePaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s to return hasNextPage field.', HasNextPagePaginatorInterface::class)); + } + $data['paginationInfo']['hasNextPage'] = $collection->hasNextPage(); + } } foreach ($collection as $object) { @@ -222,7 +229,7 @@ private function getDefaultCursorBasedPaginatedData(): array private function getDefaultPageBasedPaginatedData(): array { - return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0.]]; + return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0., 'hasNextPage' => false]]; } private function getDefaultMutationData(array $context): array diff --git a/src/GraphQl/State/Processor/NormalizeProcessor.php b/src/GraphQl/State/Processor/NormalizeProcessor.php index 31a6db19cdd..a502c12ff5c 100644 --- a/src/GraphQl/State/Processor/NormalizeProcessor.php +++ b/src/GraphQl/State/Processor/NormalizeProcessor.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; @@ -218,6 +219,12 @@ private function serializePageBasedPaginatedCollection(iterable $collection, arr } $data['paginationInfo']['lastPage'] = $collection->getLastPage(); } + if (isset($selection['paginationInfo']['hasNextPage'])) { + if (!($collection instanceof HasNextPagePaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s to return hasNextPage field.', HasNextPagePaginatorInterface::class)); + } + $data['paginationInfo']['hasNextPage'] = $collection->hasNextPage(); + } } foreach ($collection as $object) { diff --git a/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php index b7790daa0b1..afcf85ffbef 100644 --- a/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php +++ b/src/GraphQl/Tests/Resolver/Stage/SerializeStageTest.php @@ -189,15 +189,19 @@ public static function applyCollectionWithPaginationProvider(): iterable yield 'page - not paginator, itemsPerPage requested' => [[], [], null, true, ['paginationInfo' => ['itemsPerPage' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\PartialPaginatorInterface to return itemsPerPage field.']; yield 'page - not paginator, lastPage requested' => [[], [], null, true, ['paginationInfo' => ['lastPage' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\PaginatorInterface to return lastPage field.']; yield 'page - not paginator, totalCount requested' => [[], [], null, true, ['paginationInfo' => ['totalCount' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\PaginatorInterface to return totalCount field.']; + yield 'page - not paginator, hasNextPage requested' => [[], [], null, true, ['paginationInfo' => ['hasNextPage' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\HasNextPagePaginatorInterface to return hasNextPage field.']; yield 'page - empty paginator - itemsPerPage requested' => [new ArrayPaginator([], 0, 0), [], ['collection' => [], 'paginationInfo' => ['itemsPerPage' => .0]], true, ['paginationInfo' => ['itemsPerPage' => true]]]; yield 'page - empty paginator - lastPage requested' => [new ArrayPaginator([], 0, 0), [], ['collection' => [], 'paginationInfo' => ['lastPage' => 1.0]], true, ['paginationInfo' => ['lastPage' => true]]]; yield 'page - empty paginator - totalCount requested' => [new ArrayPaginator([], 0, 0), [], ['collection' => [], 'paginationInfo' => ['totalCount' => .0]], true, ['paginationInfo' => ['totalCount' => true]]]; + yield 'page - empty paginator - hasNextPage requested' => [new ArrayPaginator([], 0, 0), [], ['collection' => [], 'paginationInfo' => ['hasNextPage' => false]], true, ['paginationInfo' => ['hasNextPage' => true]]]; yield 'page - paginator page 1 - itemsPerPage requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['itemsPerPage' => 2.0]], true, ['paginationInfo' => ['itemsPerPage' => true]]]; yield 'page - paginator page 1 - lastPage requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['lastPage' => 2.0]], true, ['paginationInfo' => ['lastPage' => true]]]; yield 'page - paginator page 1 - totalCount requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['totalCount' => 3.0]], true, ['paginationInfo' => ['totalCount' => true]]]; + yield 'page - paginator page 1 - hasNextPage requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['hasNextPage' => true]], true, ['paginationInfo' => ['hasNextPage' => true]]]; yield 'page - paginator with page - itemsPerPage requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 2, 2), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['itemsPerPage' => 2.0]], true, ['paginationInfo' => ['itemsPerPage' => true]]]; yield 'page - paginator with page - lastPage requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 2, 2), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['lastPage' => 2.0]], true, ['paginationInfo' => ['lastPage' => true]]]; yield 'page - paginator with page - totalCount requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 2, 2), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['totalCount' => 3.0]], true, ['paginationInfo' => ['totalCount' => true]]]; + yield 'page - paginator with page - hasNextPage requested' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 2, 2), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['hasNextPage' => false]], true, ['paginationInfo' => ['hasNextPage' => true]]]; } /** diff --git a/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php b/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php index bf14494abcd..e34c8a3b743 100644 --- a/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php +++ b/src/GraphQl/Tests/State/Processor/NormalizeProcessorTest.php @@ -120,14 +120,18 @@ public static function processCollection(): iterable yield 'page - not paginator, itemsPerPage requested' => [[], (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], [], ['paginationInfo' => ['itemsPerPage' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\PartialPaginatorInterface to return itemsPerPage field.']; yield 'page - not paginator, lastPage requested' => [[], (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], [], ['paginationInfo' => ['lastPage' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\PaginatorInterface to return lastPage field.']; yield 'page - not paginator, totalCount requested' => [[], (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], [], ['paginationInfo' => ['totalCount' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\PaginatorInterface to return totalCount field.']; + yield 'page - not paginator, hasNextPage requested' => [[], (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], [], ['paginationInfo' => ['hasNextPage' => true]], \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\State\Pagination\HasNextPagePaginatorInterface to return hasNextPage field.']; yield 'page - empty paginator - itemsPerPage requested' => [new ArrayPaginator([], 0, 0), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [], 'paginationInfo' => ['itemsPerPage' => .0]], ['paginationInfo' => ['itemsPerPage' => true]]]; yield 'page - empty paginator - lastPage requested' => [new ArrayPaginator([], 0, 0), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [], 'paginationInfo' => ['lastPage' => 1.0]], ['paginationInfo' => ['lastPage' => true]]]; yield 'page - empty paginator - totalCount requested' => [new ArrayPaginator([], 0, 0), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [], 'paginationInfo' => ['totalCount' => .0]], ['paginationInfo' => ['totalCount' => true]]]; + yield 'page - empty paginator - hasNextPage requested' => [new ArrayPaginator([], 0, 0), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [], 'paginationInfo' => ['hasNextPage' => false]], ['paginationInfo' => ['hasNextPage' => true]]]; yield 'page - paginator page 1 - itemsPerPage requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 0, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['itemsPerPage' => 2.0]], ['paginationInfo' => ['itemsPerPage' => true]]]; yield 'page - paginator page 1 - lastPage requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 0, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['lastPage' => 2.0]], ['paginationInfo' => ['lastPage' => true]]]; yield 'page - paginator page 1 - totalCount requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 0, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['totalCount' => 3.0]], ['paginationInfo' => ['totalCount' => true]]]; + yield 'page - paginator page 1 - hasNextPage requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 0, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item'], ['normalized_item']], 'paginationInfo' => ['hasNextPage' => true]], ['paginationInfo' => ['hasNextPage' => true]]]; yield 'page - paginator with page - itemsPerPage requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 2, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['itemsPerPage' => 2.0]], ['paginationInfo' => ['itemsPerPage' => true]]]; yield 'page - paginator with page - lastPage requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 2, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['lastPage' => 2.0]], ['paginationInfo' => ['lastPage' => true]]]; yield 'page - paginator with page - totalCount requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 2, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['totalCount' => 3.0]], ['paginationInfo' => ['totalCount' => true]]]; + yield 'page - paginator with page - hasNextPage requested' => [new ArrayPaginator([(object) ['test' => 'a'], (object) ['test' => 'b'], (object) ['test' => 'c']], 2, 2), (new QueryCollection(class: 'foo'))->withPaginationType('page'), [], ['collection' => [['normalized_item']], 'paginationInfo' => ['hasNextPage' => false]], ['paginationInfo' => ['hasNextPage' => true]]]; } } diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 06b63e25c50..bd1a390d3b0 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -332,6 +332,7 @@ private function getPageBasedPaginationFields(GraphQLType $resourceType): array 'itemsPerPage' => GraphQLType::nonNull(GraphQLType::int()), 'lastPage' => GraphQLType::nonNull(GraphQLType::int()), 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), + 'hasNextPage' => GraphQLType::nonNull(GraphQLType::boolean()), ], ]; $paginationInfoObjectType = new ObjectType($paginationInfoObjectTypeConfiguration); diff --git a/src/State/Pagination/ArrayPaginator.php b/src/State/Pagination/ArrayPaginator.php index ace94a9cace..3de9ab6e8f1 100644 --- a/src/State/Pagination/ArrayPaginator.php +++ b/src/State/Pagination/ArrayPaginator.php @@ -18,7 +18,7 @@ * * @author Alan Poulain */ -final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface +final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface { private \Traversable $iterator; private readonly int $firstResult; @@ -92,4 +92,12 @@ public function getIterator(): \Traversable { return $this->iterator; } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + return $this->getCurrentPage() < $this->getLastPage(); + } } diff --git a/src/State/Pagination/HasNextPagePaginatorInterface.php b/src/State/Pagination/HasNextPagePaginatorInterface.php new file mode 100644 index 00000000000..211e73569bb --- /dev/null +++ b/src/State/Pagination/HasNextPagePaginatorInterface.php @@ -0,0 +1,22 @@ + + * + * 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\State\Pagination; + +interface HasNextPagePaginatorInterface +{ + /** + * Does this collection offer a next page. + */ + public function hasNextPage(): bool; +} diff --git a/src/State/Pagination/TraversablePaginator.php b/src/State/Pagination/TraversablePaginator.php index 8749e3b2029..8a4624eed5f 100644 --- a/src/State/Pagination/TraversablePaginator.php +++ b/src/State/Pagination/TraversablePaginator.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State\Pagination; -final class TraversablePaginator implements \IteratorAggregate, PaginatorInterface +final class TraversablePaginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface { public function __construct(private readonly \Traversable $traversable, private readonly float $currentPage, private readonly float $itemsPerPage, private readonly float $totalItems) { @@ -78,4 +78,12 @@ public function getIterator(): \Traversable { return $this->traversable; } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + return $this->getCurrentPage() < $this->getLastPage(); + } } diff --git a/tests/Fixtures/Query.php b/tests/Fixtures/Query.php index 603de64a3c6..712138dc990 100644 --- a/tests/Fixtures/Query.php +++ b/tests/Fixtures/Query.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests\Fixtures; +use Doctrine\Common\Collections\ArrayCollection; + /** * Replace Doctrine\ORM\Query in tests because it cannot be mocked. */ @@ -27,4 +29,43 @@ public function getMaxResults(): ?int { return null; } + + public function setMaxResults($maxResults): self + { + return $this; + } + + public function setFirstResult($firstResult): 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 + { + } } diff --git a/tests/State/Pagination/ArrayPaginatorTest.php b/tests/State/Pagination/ArrayPaginatorTest.php index 524462c59f1..d172dd7faac 100644 --- a/tests/State/Pagination/ArrayPaginatorTest.php +++ b/tests/State/Pagination/ArrayPaginatorTest.php @@ -24,7 +24,7 @@ class ArrayPaginatorTest extends TestCase /** * @dataProvider initializeProvider */ - public function testInitialize(array $results, $firstResult, $maxResults, $currentItems, $totalItems, $currentPage, $lastPage): void + public function testInitialize(array $results, $firstResult, $maxResults, $currentItems, $totalItems, $currentPage, $lastPage, $hasNextPage): void { $paginator = new ArrayPaginator($results, $firstResult, $maxResults); @@ -33,15 +33,16 @@ public function testInitialize(array $results, $firstResult, $maxResults, $curre $this->assertSame((float) $lastPage, $paginator->getLastPage()); $this->assertSame((float) $maxResults, $paginator->getItemsPerPage()); $this->assertCount($currentItems, $paginator); + $this->assertSame($hasNextPage, $paginator->hasNextPage()); } public static function initializeProvider(): array { return [ - 'First of three pages of 3 items each' => [[0, 1, 2, 3, 4, 5, 6], 0, 3, 3, 7, 1, 3], - 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 3, 3, 2, 5, 2, 2], - 'Empty results' => [[], 0, 2, 0, 0, 1, 1], - '0 for max results' => [[0, 1, 2, 3], 2, 0, 0, 4, 1, 1], + 'First of three pages of 3 items each' => [[0, 1, 2, 3, 4, 5, 6], 0, 3, 3, 7, 1, 3, true], + 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 3, 3, 2, 5, 2, 2, false], + 'Empty results' => [[], 0, 2, 0, 0, 1, 1, false], + '0 for max results' => [[0, 1, 2, 3], 2, 0, 0, 4, 1, 1, false], ]; } } diff --git a/tests/State/Pagination/TraversablePaginatorTest.php b/tests/State/Pagination/TraversablePaginatorTest.php index 4f49a8f353d..517edbee0d1 100644 --- a/tests/State/Pagination/TraversablePaginatorTest.php +++ b/tests/State/Pagination/TraversablePaginatorTest.php @@ -27,7 +27,8 @@ public function testInitialize( float $perPage, float $totalItems, float $lastPage, - int $currentItems + int $currentItems, + bool $hasNextPage ): void { $traversable = new \ArrayIterator($results); @@ -38,6 +39,7 @@ public function testInitialize( self::assertSame($lastPage, $paginator->getLastPage()); self::assertSame($perPage, $paginator->getItemsPerPage()); self::assertCount($currentItems, $paginator); + self::assertSame($hasNextPage, $paginator->hasNextPage()); self::assertSame($results, iterator_to_array($paginator)); } @@ -45,11 +47,11 @@ public function testInitialize( public static function initializeProvider(): array { return [ - 'First of three pages of 3 items each' => [[0, 1, 2, 3, 4, 5, 6], 1, 3, 7, 3, 3], - 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 2, 3, 5, 2, 2], - 'Empty results' => [[], 1, 2, 0, 1, 0], - '0 items per page' => [[0, 1, 2, 3], 1, 0, 4, 1, 4], - 'Total items less than items per page' => [[0, 1, 2], 1, 4, 3, 1, 3], + 'First of three pages of 3 items each' => [[0, 1, 2, 3, 4, 5, 6], 1, 3, 7, 3, 3, true], + 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 2, 3, 5, 2, 2, false], + 'Empty results' => [[], 1, 2, 0, 1, 0, false], + '0 items per page' => [[0, 1, 2, 3], 1, 0, 4, 1, 4, false], + 'Total items less than items per page' => [[0, 1, 2], 1, 4, 3, 1, 3, false], ]; } }