Skip to content

[CursorPaginator] Various tweaks and improvements#12404

Open
seb-jean wants to merge 1 commit intodoctrine:3.7.xfrom
seb-jean:cursor-pagination-fetch-join
Open

[CursorPaginator] Various tweaks and improvements#12404
seb-jean wants to merge 1 commit intodoctrine:3.7.xfrom
seb-jean:cursor-pagination-fetch-join

Conversation

@seb-jean
Copy link
Copy Markdown

@seb-jean seb-jean commented Mar 19, 2026

Problem

  1. When a DQL query joins a toMany relation, a single entity can span multiple SQL rows. The previous implementation applied setMaxResults() at the SQL level, causing pages to silently return fewer items than expected and incorrect hasMore detection. Raised by @stof in Add cursor-based pagination #12364 (review).

  2. Countable inconsistency — The previous count() returned the number of items on the current page, inconsistent with Paginator which returns the total. Raised by @stof in Add cursor-based pagination #12364 (review).

  3. paginate() only accepted an encoded string — Passing a Cursor instance directly required an unnecessary round-trip through encodeToString(). Raised in
    Allow Cursor instance in CursorPaginator::paginate() instead of only encoded string #12405.

Changes

queryProducesDuplicates defaults to true

CursorPaginatorqueryProducesDuplicates now defaults to true:

  1. First query — fetches paginated root entity IDs via LimitSubqueryOutputWalker / LimitSubqueryWalker
  2. Second query — fetches full results filtered by those IDs via WhereInWalker
<?php

// Before: silently returns wrong results with toMany joins
new CursorPaginator($qb);

// After: two-query strategy (distinct IDs subquery + WHERE IN fetch)
new CursorPaginator($qb); // queryProducesDuplicates: true by default

// Simple queries without toMany joins
new CursorPaginator($qb, queryProducesDuplicates: false);

CursorWalker — Throws LogicException when queryProducesDuplicates: false is used on a query that joins a toMany relation:

// Throws: Cannot use CursorPaginator without queryProducesDuplicates on a query
//         that joins a toMany relation.
new CursorPaginator($qb->leftJoin('a.missions', 'm'), queryProducesDuplicates: false);

getTotalCount() + count()

  • count() — returns the number of items on the current page (no DB query)
  • getTotalCount() — runs a COUNT query (identical implementation to Paginator::count())

Countable is not implemented to avoid triggering an implicit COUNT query.

paginate() accepts Cursor|string|null

paginate() now accepts a Cursor instance directly in addition to an encoded string, removing the need for an unnecessary encode/decode round-trip when the cursor is already available as an object:

// Before: required encoding even when the Cursor instance was already available
$paginator->paginate($cursor->encodeToString(), 15);

// After: pass the Cursor instance directly
$paginator->paginate($cursor, 15);

// Encoded strings and null still work as before
$paginator->paginate($encodedString, 15);
$paginator->paginate(null, 15);

@stof
Copy link
Copy Markdown
Member

stof commented Mar 19, 2026

I suggest using a better name than fetchJoinCollection to describe what it actually means (see the discussion in #11595 (comment))

@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch from d13637d to 61add19 Compare March 19, 2026 12:42
@seb-jean seb-jean changed the title [CursorPaginator] Support toMany joins via fetchJoinCollection [CursorPaginator] Support toMany joins via queryProducesDuplicates Mar 19, 2026
@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch 2 times, most recently from a44d5c9 to ac7634f Compare March 19, 2026 12:54
@seb-jean
Copy link
Copy Markdown
Author

I suggest using a better name than fetchJoinCollection to describe what it actually means (see the discussion in #11595 (comment))

I renamed fetchJoinCollection to queryProducesDuplicates.

@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch from ac7634f to 1f128e4 Compare March 19, 2026 18:49
@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch 3 times, most recently from 7470a01 to dbfe2e4 Compare March 20, 2026 15:12
@seb-jean seb-jean changed the title [CursorPaginator] Support toMany joins via queryProducesDuplicates [CursorPaginator] Various tweaks and improvements Mar 20, 2026
@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch 3 times, most recently from 3ca0823 to 2cfb7c2 Compare March 22, 2026 15:51
@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch from 2cfb7c2 to f7ca25b Compare March 22, 2026 20:12
@greg0ire greg0ire requested a review from stof March 29, 2026 11:18
@seb-jean seb-jean force-pushed the cursor-pagination-fetch-join branch 3 times, most recently from 703c842 to 3d7894b Compare March 29, 2026 15:40
greg0ire
greg0ire previously approved these changes Mar 29, 2026
@greg0ire greg0ire requested a review from derrabus March 29, 2026 16:28
{
protected function isDistinctEnabled(AbstractQuery $query): bool
{
return ($query->getHints()[CursorPaginator::HINT_ENABLE_DISTINCT] ?? true) === true;
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.

couldn't we keep the paginator.distinct.enable hint even when using the cursor paginator ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. CursorPagination\LimitSubqueryWalker has been removed entirely. CursorPaginator now uses Pagination\LimitSubqueryWalker directly, which already checks Paginator::HINT_ENABLE_DISTINCT. The
CursorPaginator::HINT_ENABLE_DISTINCT constant has been removed as well.

/**
* Appends a custom tree walker to the tree walkers hint.
*
* @param class-string $walkerClass
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.

I suggest using class-string<TreeWalker> here for extra safety.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed

}

if ($query->getHint(self::HINT_QUERY_PRODUCES_DUPLICATES) === false) {
foreach ($this->getQueryComponents() as $component) {
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.

Technically, you could have a filtering clause that ensures that a toMany join does not actually produce duplicates in the result. Should we forbid such case from using the more optimized paginated by doing an explicit choice ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point. We removed the automatic detection entirely. Since queryProducesDuplicates: false is already an explicit choice by the developer (mirroring fetchJoinCollection in Paginator)

private readonly AbstractPlatform $platform;
private readonly ResultSetMapping $rsm;
private readonly int $firstResult;
protected int $firstResult;
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.

instead of turning this into a mutable protected parameter just to reset it to 0 in the cursor-related LimitSubqueryOutputWalker child class, I would suggest that the CursorPaginator sets the first result to 0 in the Query itself (just like it already sets the max results in it)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. CursorPagination\LimitSubqueryOutputWalker has been removed entirely. CursorPaginator now calls $subQuery->setFirstResult(0) directly alongside setMaxResults($limit + 1), and uses Pagination\LimitSubqueryOutputWalker directly. $firstResult is back to private readonly in the base class.

return $this->count;
}

private function getCountQuery(): Query
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.

As the counting implementation is the same in both paginators, I suggest extracting an internal trait used in both classes to avoid duplicating maintenance.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. The shared logic (getCountQuery and unbindUnusedQueryParams) has been extracted into an internal CountQuery trait in src/Tools/Pagination/CountQuery.php, used by both Paginator and CursorPaginator.
The $count property has also been moved into the trait.

return $this->items->count();
}

public function getTotalCount(): int
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.

getTotalCount does not seem to be covered by tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed, two tests have been added.

.. note::

Passing ``false`` on a query that joins a to-many relation may throw a
``LogicException`` at runtime when Doctrine can detect the mistake.
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.

this LogicException has now been removed.

@@ -4,42 +4,60 @@

namespace Doctrine\ORM\Tools\CursorPagination;
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.

@greg0ire I suggest moving this CursorPaginator to the Pagination namespace instead of having 2 namespaces related to pagination (with CursorPagination reusing big parts of Pagination). What do you think about that ?
This is a decision we need to take before the 3.7.0 release, as moving the class after that would be a BC break.

use function array_key_exists;

/**
* Provides the count query implementation shared by {@see Paginator} and {@see CursorPaginator}.
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.

This @see referencing a short class name would need a use statement.

$countQuery->setHint(Query::HINT_CUSTOM_OUTPUT_WALKER, CountOutputWalker::class);
$countQuery->setResultSetMapping($rsm);
} else {
$this->appendTreeWalker($countQuery, CountWalker::class);
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.

appendTreeWalker should be moved to the trait as it is used here.

$countQuery->setHint(CountWalker::HINT_DISTINCT, true);
}

if ($this->useOutputWalker($countQuery)) {
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.

useOutputWalker should probably be moved to the trait (or the trait should define an abstract private method for it)

*
* @internal
*/
trait CountQuery
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.

maybe this should be a PaginatorTrait instead, copying more private methods (cloneQuery, appendTreeWalker and convertWhereInIdentifiersToDatabaseValues for instance, and maybe everything about the output walker config)


$this->items = new ArrayCollection($whereInQuery->getResult());
} else {
$this->items = new ArrayCollection($subQuery->getResult());
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.

I would use a local variable storing the array here instead of creating an ArrayCollection that will be dropped just after. the usage of an ArrayCollection is overkill here.

$this->orderByItems = $subQuery->getHint(CursorWalker::HINT_CURSOR_ORDER_BY_ITEMS) ?: [];

if ($this->cursor !== null && $this->cursor->isPrevious()) {
$this->items = new ArrayCollection(array_reverse($this->items->toArray(), true));
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.

if the result is a list, we should not preserve keys when reversing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants