Skip to content

fix(query): count grouped, distinct and aggregate reads by their rows - #1020

Merged
abnegate merged 3 commits into
mainfrom
qfix-count
Oct 15, 2026
Merged

abnegate merged 3 commits into
mainfrom
qfix-count

Conversation

@abnegate

Copy link
Copy Markdown
Member

Problem

count() handed the adapter only the filters and joins. For a groupBy, distinct or aggregate query, a list total counted the matching documents instead of the rows aggregate() or find() returns. Measured on PostgreSQL, count vs real rows:

Shape count() Rows
groupBy 9 4
distinct 9 4
plain aggregate 9 1
join + groupBy 8 3
distinct over a join 7 5

This held with and without document-level permissions. sum() dropped the same queries, so a sum over distinct values added up every document. Consumers page aggregate() and distinct find() results with these totals, so the pager was wrong for every such list.

Fix

  • Database. For a query with aggregates, groupBy, having or distinct, count() and sum() build the adapter queries and collection with the same helpers fetch() uses (readQueries(), readCollection(), assertReadable()). They authorise joins the same way and skip permissions under the same rule (main collection granted, no joins). Plain, filtered and join-only counts take the existing path unchanged.
  • SQL adapters. find()'s query split moves into a shared plan(). A row count builds the statement find() builds through the same builder setup, including the emulated full outer join paths on MariaDB, MySQL and SQLite. It drops order, cursor and page, bounds the statement by LIMIT max, and counts it as SELECT COUNT(1) AS sum FROM (...) table_count. A grouped or distinct sum() adds up a group or selected column of those rows.
  • MongoDB, Memory and Redis. They now refuse these counts with the QueryException that find() and aggregate() already throw, rather than answer the document count.
  • Overflow (follows fix(aggregates): refuse a floating aggregate that overflows a double #1018). A grouped or distinct sum() adds up a column of the derived table, which can be a dotted alias such as r.score. The dialect builder never compiles that SUM(), so it would miss fix(aggregates): refuse a floating aggregate that overflows a double #1018's + 0 range check. The adapter wraps it through rangeChecked(): + 0 on MariaDB and MySQL, unchanged on PostgreSQL and SQLite, which raise or return INF for Database::sum() to refuse.

Plain counts keep their fast path

I compiled the statements on main and on this branch for MySQL and PostgreSQL. Plain, filtered and join counts, with and without max, are byte-identical. For example, MySQL plain:

SELECT COUNT(1) AS `sum` FROM `database`.`namespace_products` AS `table_main` WHERE `_uid` IN (SELECT _document FROM `database`.`namespace_products_perms` WHERE _permission IN (?) AND _type = ?)

A grouped count now reads:

SELECT COUNT(1) AS `sum` FROM (SELECT COUNT(*) AS `n`, `category` FROM ... WHERE `_uid` IN (...perms...) GROUP BY `category`) AS `table_count`

Tests

  • tests/unit/RowCountTest.php runs on SQLite, as admin (collection grant) and as viewer (document permissions). It covers groupBy, groupBy with having, distinct, plain aggregate, left join + groupBy, join + groupBy, distinct over a join, a distinct joined attribute, a distinct join without a select, and the emulated full outer join with groupBy and with distinct. Each count equals the rows the read returns, ignores the page, and is capped by max. It also checks that document counts (plain, filtered, join) are unchanged, that grouped and distinct sums are right, that summing an attribute the groups do not hold is refused, and that Memory refuses these counts.
  • tests/unit/Adapter/RowSumStatementTest.php checks the SQL of a grouped and a distinct sum(): SUM(...) + 0 on MariaDB and MySQL, plain on PostgreSQL.
  • tests/unit/SumOverflowTest.php checks that a grouped or distinct sum past the range of a double is refused on SQLite in both fetch modes, and that the largest finite one is still answered.
  • tests/e2e/Adapter/Scopes/AggregationTests.php:
    • testCountMatchesTheRowsOfGroupedDistinctAndAggregateReads and testSumAddsUpTheRowsOfGroupedAndDistinctReads run on every adapter, as both roles. Adapters without aggregates must refuse with QueryException.
    • testSumOfGroupedOrDistinctRowsThatOverflowsIsRefused covers overflow above and below the range.

Against main's source, 13 of the 15 RowCountTest cases fail, the 4 MariaDB/MySQL RowSumStatementTest cases fail, and both count/sum E2Es fail on SQLite and PostgreSQL ("10 is identical to 4", "20 matches expected 15"). They all pass here. The two RowCountTest cases that pass on main are the unchanged document counts and the distinct join without a select, which is there for MySQL's duplicate-column rule.

Verified locally: Pint and both PHPStan configurations are clean. The new E2Es pass on SQLite and PostgreSQL 18. The unit suite passes apart from the Swoole coroutine pool tests, which segfault on this machine's PHP build on main too. The MySQL, MariaDB and MongoDB E2E matrix runs in CI.

🤖 Generated with Claude Code

abnegate and others added 3 commits October 15, 2026 23:19
count() handed the adapter only the filters and joins, so a list total for a
groupBy, distinct or aggregate query counted the matching documents instead of
the rows aggregate() or find() returns: 9 instead of 4 for a groupBy, 9 instead
of 1 for a plain aggregate, 8 instead of 3 for a left join plus groupBy, 7
instead of 5 for distinct over a join, with or without row-level permissions.
sum() dropped the same queries.

For those shapes the Database now builds the adapter queries and collection
through the same helpers fetch() uses (readQueries(), readCollection(),
assertReadable()), with the same join authorisation and permission skip. The
SQL adapter builds the statement find() builds through a shared plan() and the
same builder setup, without order, cursor and page, bounded by LIMIT max, and
counts it as SELECT COUNT(1) AS sum FROM (...) table_count. That keeps the
permission and join hooks identical between results and count, including the
emulated full outer join paths on MariaDB, MySQL and SQLite. sum() over
grouped or distinct rows adds up a groupBy or selected attribute of those rows.

Adapters without aggregate support (Mongo, Memory, Redis) now refuse these
counts with the QueryException find() and aggregate() already throw, rather
than return the document count. Plain and join-only counts are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d mysql

sum() over grouped or distinct rows adds up a column of the derived
table as a raw SUM(), which the dialect builder never compiles, so it
missed the `+ 0` that #1018 puts on every floating aggregate. MariaDB and
MySQL would answer such a sum that overflowed a double with 0 instead of
raising 1690.

The column can be a dotted alias such as `r.score`, which the builder
would split into two identifiers, so the adapter wraps the expression
itself through rangeChecked(): unchanged on PostgreSQL and SQLite, which
already raise or return INF for Database::sum() to refuse, and `+ 0` on
MariaDB and MySQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MySQL refuses a derived table with two columns of the same name. A
distinct join without a select projects the main table's columns next to
the joined `r._uid`, so the count holds it to keep those names apart on
every adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: utopia-php/database/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4311c712-af91-487f-90e1-103bc1216da8
📥 Commits

Reviewing files that changed from the base of the PR and between 107dbf5 and 81d2fc0.

📒 Files selected for processing: 8
  • src/Database/Adapter/MariaDB.php
  • src/Database/Adapter/SQL.php
  • src/Database/Adapter/SQL/Plan.php
  • src/Database/Trait/Documents.php
  • tests/e2e/Adapter/Scopes/AggregationTests.php
  • tests/unit/Adapter/RowSumStatementTest.php
  • tests/unit/RowCountTest.php
  • tests/unit/SumOverflowTest.php
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@abnegate
abnegate merged commit e6dba8d into main Oct 15, 2026
24 checks passed
@abnegate
abnegate deleted the qfix-count branch October 15, 2026 10:58
ArnabChatterjee20k pushed a commit to ArnabChatterjee20k/appwrite that referenced this pull request Oct 15, 2026
…he library

countRows() re-ran a grouped, distinct or aggregate read for up to
APP_LIMIT_COUNT rows because count() ignored groupBy, distinct and
aggregates. utopia-php/database#1020 counts them by their rows, so the
total goes back to count() with APP_LIMIT_COUNT.

The relationship order validator and its unit tests go too:
utopia-php/database#1023 refuses ordering by a relationship that holds no
column with the same message, as a QueryException the list already
answers with 400 general_query_invalid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant