From 2fa7e0a4ed3159d1fde423f94b9c2e8441cd95de Mon Sep 17 00:00:00 2001 From: seb-jean Date: Wed, 7 Oct 2026 12:01:49 +0200 Subject: [PATCH] Add OffsetPaginator::getTotalCount() to get the total without fetching rows It runs the same COUNT query as WindowPage::getTotalCount(), and nothing else. The legacy Paginator::count() was the only count-only entry point, and it is deprecated. Fixes #12636 --- UPGRADE.md | 8 +++++ docs/en/tutorials/pagination.rst | 20 +++++++++++ src/Tools/Pagination/OffsetPaginator.php | 33 ++++++++++++++----- tests/Tests/ORM/Functional/PaginationTest.php | 29 ++++++++++++++++ .../Tools/Pagination/OffsetPaginatorTest.php | 23 +++++++++++++ 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index c71f0edc5c..0634f542d8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -85,6 +85,14 @@ immutable, iterable `WindowPage`. } ``` +When only the total is needed, `OffsetPaginator::getTotalCount()` runs the count +query without fetching any row: + +```diff +-$total = count(new Paginator($query, fetchJoinCollection: true)); ++$total = (new OffsetPaginator(fetchJoinCollection: true))->getTotalCount($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..640ae23e0f 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 an "N results" badge, or to cache the total separately +from the pages — ``getTotalCount()`` runs the ``COUNT`` query alone, without +fetching any row: + +.. code-block:: php + + getTotalCount($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::getTotalCount(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..3dacb4edbc 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 getTotalCount()} 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 getTotalCount(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..da94af7c5f 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 testGetTotalCountWithoutPaginating(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->getTotalCount($query)); + self::assertSame($paginator->paginate($query, new Window(0, 4))->getTotalCount(), $paginator->getTotalCount($query)); + } + + public function testGetTotalCountAcceptsAQueryBuilder(): void + { + $queryBuilder = $this->_em->createQueryBuilder() + ->select('g') + ->from(CmsGroup::class, 'g'); + + self::assertSame(3, (new OffsetPaginator())->getTotalCount($queryBuilder)); + } + + public function testGetTotalCountReturnsZeroForAnEmptyResultSet(): 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())->getTotalCount($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..9fd86bda4a 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 testGetTotalCountOnlyExecutesTheCountQuery(): 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))->getTotalCount($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();