Skip to content

Group the subquery filter by the columns it is ordered on - #19580

Open
dereuromark wants to merge 2 commits into
5.xfrom
fix-subquery-order-group-by
Open

Group the subquery filter by the columns it is ordered on#19580
dereuromark wants to merge 2 commits into
5.xfrom
fix-subquery-order-group-by

Conversation

@dereuromark

@dereuromark dereuromark commented Aug 2, 2026

Copy link
Copy Markdown
Member

Follow-up to #19538.

The filtering subquery of the subquery strategy reduces its SELECT to the binding key and groups by it. Since 5.3 it keeps the ORDER BY when the source query is limited, and since #19538 that ORDER BY actually reaches the generated SQL - it used to be wiped as a side effect of iterateParts(). Ordering the subquery by a column that is in neither the GROUP BY nor an aggregate is invalid on Postgres:

SELECT Countries.iso2 FROM countries Countries
WHERE Countries.id = 1
GROUP BY Countries.iso2
ORDER BY Countries.sort DESC
LIMIT 1
SQLSTATE[42803]: Grouping error: 7 ERROR: column "Countries.sort" must appear in the GROUP BY clause or be used in an aggregate function

Grouping by a primary key hides it, because Postgres then resolves the remaining columns of that table through functional dependency. That is exactly what the existing coverage does (orderBy(['Authors.id' => 'DESC'])), so CI stayed green. It breaks as soon as the binding key is not the primary key, or the ORDER BY points at a joined table.

Real-world report: a hasMany with 'bindingKey' => 'iso2' plus a default table order started erroring on Postgres the day 5.4.0 was released, on plugin code that had not changed.

What changed

_subqueryFields() now adds the ordered columns to the GROUP BY, and only looks at the ORDER BY when the subquery is actually going to keep it (_buildSubquery() drops it when there is no limit, and the columns would then be dead weight in the reduced SELECT).

Left out of the GROUP BY on purpose:

  • aggregates, which must not be grouped
  • constant SELECT values such as select(['score' => 100]), which are not columns
  • plain SQL fragments such as orderBy('Articles.title DESC'), where the column cannot be told apart from the sort direction

Named parts, orderByAsc()/orderByDesc() and expression parts are all covered.

Grouping by additional columns can only split groups, never merge them, so the first N groups still cover the N rows the parent query matched - the filtered key set stays complete.

The aggregate detection that the HAVING branch already carried is extracted into isAggregate() and shared.

Since 5.3 the filtering subquery of the subquery strategy keeps its ORDER BY
when the source query is limited, and since 5.4 that ORDER BY actually survives
into the generated SQL. The subquery reduces its SELECT to the binding key and
groups by it, so ordering it by anything else leaves Postgres with a column that
appears in neither the GROUP BY nor an aggregate:

    SELECT Countries.iso2 FROM countries Countries
    WHERE Countries.id = 1
    GROUP BY Countries.iso2
    ORDER BY Countries.sort DESC
    LIMIT 1

    SQLSTATE[42803]: Grouping error: column "Countries.sort" must appear in the
    GROUP BY clause or be used in an aggregate function

Grouping by a primary key hides this, as Postgres then resolves the other
columns of that table through functional dependency, which is why the existing
coverage stayed green. Any other binding key, or an ORDER BY on a joined table,
fails outright.

The ordered columns now join the GROUP BY. Constant SELECT values and aggregates
stay out of it, and plain SQL fragments are left alone since their column cannot
be told apart from the sort direction. Grouping by more columns never drops a
binding key the parent query matched, so the filtered set stays complete.
@dereuromark dereuromark added this to the 5.4.2 milestone Aug 2, 2026
Comment thread src/ORM/Association/Loader/SelectLoader.php Outdated
Comment thread src/ORM/Association/Loader/SelectLoader.php Outdated
…ore prefix

The SQL assertion filtered the logged statements by "FROM comments", which only
matches while identifier quoting is off, so it found nothing in a full suite run.
It now collects the statements through a local logger and picks the only grouped
one, independent of quoting and of the global Log configuration.
@dereuromark

Copy link
Copy Markdown
Member Author

Renamed both, thanks. Also pushed a fix for the SQL assertion in the new test: it filtered logged statements by a raw FROM comments string, which only matches while identifier quoting is off, so it found nothing in a full suite run. It now collects statements through a local logger and picks the only grouped one.

Comment on lines +1972 to +1985
$logger = new class extends AbstractLogger {
/**
* @var array<string>
*/
public array $messages = [];

/**
* @inheritDoc
*/
public function log($level, string|Stringable $message, array $context = []): void
{
$this->messages[] = (string)$message;
}
};

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 use Cake\Log\Engine\ArrayLog here?

@ryanolton

Copy link
Copy Markdown

@dereuromark thanks for this PR. I had noticed similar Postgres errors since moving to 5.4.1 (I assume due to the subquery strategy change) but the updates here resolve all my issues so far. I look forward to hopefully seeing this in 5.4.2, very much appreciated.

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.

4 participants