Skip to content

Fix CursorPaginator cursors ordered by a field of a joined entity - #12671

Merged
GromNaN merged 1 commit into
doctrine:3.7.xfrom
guillaume-sainthillier:cursor-paginator-order-by-joined-field
Oct 30, 2026
Merged

GromNaN merged 1 commit into
doctrine:3.7.xfrom
guillaume-sainthillier:cursor-paginator-order-by-joined-field

Conversation

@guillaume-sainthillier

@guillaume-sainthillier guillaume-sainthillier commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #12670

CursorPaginator::getParametersForItem() read every ORDER BY value from the result item, even when the ORDER BY item is on a joined alias, so building a cursor crashed with Call to a member function getValue() on null.

CursorWalker now records, for each ORDER BY item, the single-valued associations that lead from the root alias to the item's alias, using the query components (parent / relation). CursorPaginator follows them from the root entity to read the value:

  • each entity along the way is initialized, as it is an uninitialized proxy when its alias is not selected;
  • if an association along the way is null, the cursor value is null.

Results that are not entities (e.g. SELECT NEW DTOs) keep reading the value from the property named after the field, as before.

When the alias cannot be reached that way (joined through a collection, or not joined from the root alias), building the cursor throws a LogicException that names the ORDER BY item. There is no single value to take for an entity in that case. The exception is thrown when the cursor is built, not when the query is walked, so first pages that work today keep working.

Scalar and mixed results, mentioned in the issue, are not covered here.

@greg0ire

Copy link
Copy Markdown
Member

@seb-jean please review

Comment on lines +59 to +60
$from = $selectStatement->fromClause->identificationVariableDeclarations;
$rootAlias = reset($from)->rangeVariableDeclaration->aliasIdentificationVariable;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This takes the first FROM alias as the alias of the result items, but with queryProducesDuplicates: false the items can come from another alias: an entity join or a second FROM root. getAssociationPath() then returns null for the items' own alias, so building a cursor throws, even when the query is only ordered by fields of the returned entity:

$query = $em->createQuery(
    'SELECT u FROM CmsEmail e JOIN CmsUser u ON u.email = e ORDER BY u.username ASC, u.id ASC',
);
$page = (new CursorPaginator(4, false))->paginate($query);
$page->getNextCursor();
// LogicException: Cannot build a cursor for the ORDER BY item "u.username": its alias must be
// the root alias, or be joined from it through single-valued associations only.

SELECT u FROM CmsEmail e, CmsUser u WHERE u.email = e ... throws the same way. Before this PR, both queries paginate correctly, because every value was read from the item itself. (With queryProducesDuplicates: true, they were already unsupported.)

Taking the root alias from the SELECT clause fixes both, and the existing pagination tests still pass with it: replace these two lines with $rootAlias = $this->getRootAlias($selectStatement); and add (with use function is_string;):

/**
 * Returns the alias of the entities the query returns: the first selected
 * alias that is not fetch joined, or else the first alias of the FROM clause.
 */
private function getRootAlias(SelectStatement $selectStatement): string
{
    $queryComponents = $this->getQueryComponents();

    foreach ($selectStatement->selectClause->selectExpressions as $selectExpression) {
        if (is_string($selectExpression->expression) && ! isset($queryComponents[$selectExpression->expression]['parent'])) {
            return $selectExpression->expression;
        }
    }

    $from = $selectStatement->fromClause->identificationVariableDeclarations;

    return reset($from)->rangeVariableDeclaration->aliasIdentificationVariable;
}

guillaume-sainthillier added a commit to guillaume-sainthillier/orm that referenced this pull request Oct 15, 2026
Unbind the parameters only used in the SELECT clause when the id query is
built with the LimitSubqueryWalker, which replaces the select clause, like
the other paginators do (doctrine#12669).

Read the cursor values of ORDER BY items on a joined alias by following the
single-valued associations from the root entity, initializing the proxies
along the way, and throw a clear exception when the alias cannot be reached
that way (doctrine#12671).
guillaume-sainthillier added a commit to guillaume-sainthillier/orm that referenced this pull request Oct 22, 2026
Read the cursor values of ORDER BY items on a joined alias by following the
single-valued associations from the root entity, initializing the proxies
along the way, and keep the property fallback for results that are not
entities. Throw a clear exception when the alias cannot be reached that way.

See doctrine#12671
@GromNaN GromNaN added this to the 3.7.6 milestone Oct 30, 2026
@GromNaN GromNaN added the Bug label Oct 30, 2026
getParametersForItem() read every ORDER BY value from the result item, even
when the ORDER BY item was on a joined alias, so building a cursor crashed
with "Call to a member function getValue() on null".

CursorWalker now records the single-valued associations that lead from the
root alias to the item's alias, and CursorPaginator follows them to read the
value. Proxies are initialized, and an alias reached through a collection is
rejected with a LogicException. Results that are not entities keep reading
the property named after the field.

Tests cover a selected joined entity, an unselected one behind a proxy, a
DTO result, a collection join, and a nested association path.
@GromNaN
GromNaN force-pushed the cursor-paginator-order-by-joined-field branch from 7c5554c to 1b9c127 Compare October 30, 2026 10:48
@GromNaN

GromNaN commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Rebased the branch on current 3.7.x and squashed it into a single commit.
Added a test for an ORDER BY on a nested association path (article -> user -> email).
The null association behavior is unchanged; the fail-on-deprecations job failure comes from two unrelated DBAL deprecations.

@GromNaN
GromNaN merged commit 4487f85 into doctrine:3.7.x Oct 30, 2026
130 of 131 checks passed
@GromNaN

GromNaN commented Oct 30, 2026

Copy link
Copy Markdown
Member

Thank you @guillaume-sainthillier

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CursorPaginator crashes building a cursor when ordering by a field of a joined entity

4 participants