Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
20 changes: 20 additions & 0 deletions docs/en/tutorials/pagination.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fetching it, to display a "N results" badge, or to cache the total separately
fetching it, to display an "N results" badge, or to cache the total separately

from the pages — ``count()`` runs the ``COUNT`` query alone, without fetching

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could count end up being ambiguous? Is there a possibility that we add a method that allows to count pages in the future? If yes name this one getTotalCount, and the other getPageCount() or something like that?

any row:

.. code-block:: php

<?php
$paginator = new OffsetPaginator();

$total = $paginator->count($query);
$lastPage = max(1, (int) ceil($total / 25));

$page = $paginator->paginate($query, Window::fromPageNumberAndSize(min($requestedPage, $lastPage), 25));

How Offset Pagination Works
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -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.

Expand Down
33 changes: 25 additions & 8 deletions src/Tools/Pagination/OffsetPaginator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, Window>
Expand Down Expand Up @@ -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;
}
}
}
29 changes: 29 additions & 0 deletions tests/Tests/ORM/Functional/PaginationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
23 changes: 23 additions & 0 deletions tests/Tests/ORM/Tools/Pagination/OffsetPaginatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading