diff --git a/UPGRADE.md b/UPGRADE.md index c71f0edc5c..5512ad7667 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -85,6 +85,14 @@ immutable, iterable `WindowPage`. } ``` +When only the total is needed, `OffsetPaginator::count()` runs the count query +without fetching any row: + +```diff +-$total = count(new Paginator($query, fetchJoinCollection: true)); ++$total = (new OffsetPaginator(fetchJoinCollection: true))->count($query); +``` + `Paginator::setUseOutputWalkers()` becomes the `useOutputWalkers` constructor argument of `OffsetPaginator`. diff --git a/docs/en/tutorials/pagination.rst b/docs/en/tutorials/pagination.rst index ef04306da4..29f79fc3a2 100644 --- a/docs/en/tutorials/pagination.rst +++ b/docs/en/tutorials/pagination.rst @@ -81,6 +81,21 @@ Because the returned ``WindowPage`` is immutable and carries no temporal coupling, its accessors can be called in any order, and building another page never affects a page you already hold. +When only the total is needed — to clamp a requested page number before +fetching it, to display a "N results" badge, or to cache the total separately +from the pages — ``count()`` runs the ``COUNT`` query alone, without fetching +any row: + +.. code-block:: php + + count($query); + $lastPage = max(1, (int) ceil($total / 25)); + + $page = $paginator->paginate($query, Window::fromPageNumberAndSize(min($requestedPage, $lastPage), 25)); + How Offset Pagination Works ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -131,6 +146,11 @@ API Reference ``WindowPage``. All page accessors below live on the returned page. Throws an ``InvalidArgumentException`` if ``$position`` is not a ``Window``. +``OffsetPaginator::count(Query|QueryBuilder $query): int`` + Executes the ``COUNT`` query only and returns the total number of matching + root entities, without fetching any row. It is the same query as the one + ``WindowPage::getTotalCount()`` runs. + ``WindowPage::getItems(): array`` Returns the raw entity array for the current page. diff --git a/src/Tools/Pagination/OffsetPaginator.php b/src/Tools/Pagination/OffsetPaginator.php index ade467b1f5..ebfcde49a5 100644 --- a/src/Tools/Pagination/OffsetPaginator.php +++ b/src/Tools/Pagination/OffsetPaginator.php @@ -25,7 +25,8 @@ * configuration: a single instance can be shared as a service and reused for * any query and any page. This avoids the implicit offset handling and the * stateful API of the legacy {@see Paginator}, which this class is intended to - * replace. + * replace. When only the total is needed, {@see count()} runs the COUNT query + * without fetching any row. * * @template-covariant T * @implements PaginatorInterface @@ -89,12 +90,28 @@ public function paginate(Query|QueryBuilder $query, mixed $position = null): Win $this->fetchJoinCollection, )); - return new WindowPage($items, function () use ($query): int { - try { - return (int) array_sum(array_map('current', $this->getCountQuery($query)->getScalarResult())); - } catch (NoResultException) { - return 0; - } - }, $position); + return new WindowPage($items, fn (): int => $this->countResolvedQuery($query), $position); + } + + /** + * Executes the COUNT query only and returns the total number of matching + * root entities, without fetching any row. + * + * This is the same COUNT query as the one {@see WindowPage::getTotalCount()} + * runs, for when the total is needed on its own: before choosing the window + * to fetch, or without any listing at all. + */ + public function count(Query|QueryBuilder $query): int + { + return $this->countResolvedQuery($this->resolveQuery($query)); + } + + private function countResolvedQuery(Query $query): int + { + try { + return (int) array_sum(array_map('current', $this->getCountQuery($query)->getScalarResult())); + } catch (NoResultException) { + return 0; + } } } diff --git a/tests/Tests/ORM/Functional/PaginationTest.php b/tests/Tests/ORM/Functional/PaginationTest.php index ca998a0215..b2076b1a79 100644 --- a/tests/Tests/ORM/Functional/PaginationTest.php +++ b/tests/Tests/ORM/Functional/PaginationTest.php @@ -83,6 +83,35 @@ public function testCountWithFetchJoin($useOutputWalkers): void self::assertSame(9, $page->getTotalCount()); } + #[DataProvider('useOutputWalkersAndFetchJoinCollection')] + public function testCountWithoutPaginating(bool $useOutputWalkers, bool $fetchJoinCollection): void + { + $dql = 'SELECT u, g FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.groups g WHERE u.id > :min'; + $query = $this->_em->createQuery($dql)->setParameter('min', 0); + + $paginator = new OffsetPaginator($fetchJoinCollection, $useOutputWalkers); + + self::assertSame(9, $paginator->count($query)); + self::assertSame($paginator->paginate($query, new Window(0, 4))->getTotalCount(), $paginator->count($query)); + } + + public function testCountAcceptsAQueryBuilder(): void + { + $queryBuilder = $this->_em->createQueryBuilder() + ->select('g') + ->from(CmsGroup::class, 'g'); + + self::assertSame(3, (new OffsetPaginator())->count($queryBuilder)); + } + + public function testCountReturnsZeroForAnEmptyResultSet(): void + { + $dql = 'SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u WHERE u.id < 0'; + $query = $this->_em->createQuery($dql); + + self::assertSame(0, (new OffsetPaginator())->count($query)); + } + public function testOffsetPaginatorReturnsFirstPage(): void { $dql = 'SELECT u FROM Doctrine\Tests\Models\CMS\CmsUser u ORDER BY u.id ASC'; diff --git a/tests/Tests/ORM/Tools/Pagination/OffsetPaginatorTest.php b/tests/Tests/ORM/Tools/Pagination/OffsetPaginatorTest.php index c827e75f7b..be38379a5b 100644 --- a/tests/Tests/ORM/Tools/Pagination/OffsetPaginatorTest.php +++ b/tests/Tests/ORM/Tools/Pagination/OffsetPaginatorTest.php @@ -137,6 +137,29 @@ public function testCountQueryIsOnlyExecutedWhenTheTotalCountIsRequested(): void self::assertSame(2, $executedQueries, 'The COUNT query result is memoized.'); } + #[AllowMockObjectsWithoutExpectations] + public function testCountOnlyExecutesTheCountQuery(): void + { + $executedSql = []; + $resultStub = $this->createStub(Result::class); + $this->connection + ->method('executeQuery') + ->willReturnCallback(static function (string $sql) use (&$executedSql, $resultStub): Result { + $executedSql[] = $sql; + + return $resultStub; + }); + + $this->hydrator->method('hydrateAll')->willReturn([[3]]); + + $query = new Query($this->em); + $query->setDQL('SELECT u FROM Doctrine\\Tests\\Models\\CMS\\CmsUser u'); + + self::assertSame(3, (new OffsetPaginator(true, false))->count($query)); + self::assertCount(1, $executedSql, 'Only the COUNT query has been executed.'); + self::assertStringStartsWith('SELECT count(DISTINCT', $executedSql[0]); + } + public function testPaginatingDoesCareAboutExtraParametersWithoutOutputWalkersWhenResultIsNotEmpty(): void { $result = $this->getMockBuilder(Result::class)->disableOriginalConstructor()->getMock();