Repository navigation
fix(query): count grouped, distinct and aggregate reads by their rows - #1020
Merged
Merged
Conversation
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>
Contributor
|
Warning Review limit reachedYou'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. View limit details
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. Comment |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
count()handed the adapter only the filters and joins. For agroupBy,distinctor aggregate query, a list total counted the matching documents instead of the rowsaggregate()orfind()returns. Measured on PostgreSQL, count vs real rows:count()This held with and without document-level permissions.
sum()dropped the same queries, so a sum over distinct values added up every document. Consumers pageaggregate()and distinctfind()results with these totals, so the pager was wrong for every such list.Fix
groupBy,havingordistinct,count()andsum()build the adapter queries and collection with the same helpersfetch()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.find()'s query split moves into a sharedplan(). A row count builds the statementfind()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 byLIMIT max, and counts it asSELECT COUNT(1) AS sum FROM (...) table_count. A grouped or distinctsum()adds up a group or selected column of those rows.QueryExceptionthatfind()andaggregate()already throw, rather than answer the document count.sum()adds up a column of the derived table, which can be a dotted alias such asr.score. The dialect builder never compiles thatSUM(), so it would miss fix(aggregates): refuse a floating aggregate that overflows a double #1018's+ 0range check. The adapter wraps it throughrangeChecked():+ 0on MariaDB and MySQL, unchanged on PostgreSQL and SQLite, which raise or returnINFforDatabase::sum()to refuse.Plain counts keep their fast path
I compiled the statements on
mainand on this branch for MySQL and PostgreSQL. Plain, filtered and join counts, with and withoutmax, are byte-identical. For example, MySQL plain:A grouped count now reads:
Tests
tests/unit/RowCountTest.phpruns on SQLite, asadmin(collection grant) and asviewer(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 bymax. 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.phpchecks the SQL of a grouped and a distinctsum():SUM(...) + 0on MariaDB and MySQL, plain on PostgreSQL.tests/unit/SumOverflowTest.phpchecks 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:testCountMatchesTheRowsOfGroupedDistinctAndAggregateReadsandtestSumAddsUpTheRowsOfGroupedAndDistinctReadsrun on every adapter, as both roles. Adapters without aggregates must refuse withQueryException.testSumOfGroupedOrDistinctRowsThatOverflowsIsRefusedcovers overflow above and below the range.Against
main's source, 13 of the 15RowCountTestcases fail, the 4 MariaDB/MySQLRowSumStatementTestcases 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 twoRowCountTestcases that pass onmainare 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
maintoo. The MySQL, MariaDB and MongoDB E2E matrix runs in CI.🤖 Generated with Claude Code