Skip to content

fix(QueryFormatter): forward GROUP BY to SelectFirst and SelectCount - #573

Merged
Yaraslaut merged 3 commits into
masterfrom
fix/559-groupby-in-first-and-count
Aug 20, 2026
Merged

fix(QueryFormatter): forward GROUP BY to SelectFirst and SelectCount#573
Yaraslaut merged 3 commits into
masterfrom
fix/559-groupby-in-first-and-count

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Fixes #559.

Problem

SqlQueryFormatter::SelectFirst and SelectCount took no groupBy parameter, so a populated ComposedQuery::groupBy could never reach them — .GroupBy(...).First() and .GroupBy(...).Count() silently returned ungrouped results. SelectAll and SelectRange already took it, which is what marks the omission as accidental.

q.FromTable("T").Select().Field("a").GroupBy("a").First().ToSql();
// before: SELECT "a" FROM "T" LIMIT 1        <- GROUP BY dropped
// after:  SELECT "a" FROM "T" GROUP BY "a" LIMIT 1

Change

  • Added std::string_view groupBy to both virtuals, positioned after orderBy to match SelectRange.
  • Implemented in SQLiteQueryFormatter (SelectCount + SelectFirst, inherited by PostgreSQL) and SqlServerQueryFormatter (SelectFirst). The clause is emitted between WHERE and ORDER BY, exactly as SelectAll/SelectRange already do.
  • Forwarded from ComposedQuery::ToSql in the First and Count branches.
  • The DataMapper query builder calls the formatter directly rather than through ToSql, so it had the same bug at 7 further sites — CountImpl, ExistImpl, the three FirstImpl overloads and both FirstImpl(n) overloads. All now pass the builder's groupBy.
  • Documented the grouped-count semantics on both Count() surfaces: SELECT COUNT(*) … GROUP BY x yields one row per group, and the DataMapper's Count() returns the first group's count since it reads a single value.

HAVING is still unsupported — noted in the issue as a separate gap, deliberately left out of scope here.

Tests

Three cases in QueryBuilderTests.cpp covering Count()+GroupBy, First()+GroupBy and First(3)+GroupBy+OrderBy across all three dialects. All three fail on master (GROUP BY absent from the generated SQL) and pass with this change.

Risk

Source/ABI break for any out-of-tree SqlQueryFormatter implementation, as the issue anticipated; every in-tree implementor is updated. Behaviour is unchanged wherever groupBy is empty — the generated SQL is byte-identical, which is why the rest of the suite is unaffected.

Performance: no measurable impact — one extra string_view argument and one stream insertion of an empty view on the unchanged paths.

Verification

  • SQLite / clang-debug (ASan+UBSan+clang-tidy) on this exact commit in a clean tree: 1405 cases, 13641 assertions, all pass (1 pre-existing skip). Build is warning-free; clang-format applied.
  • mssql2022 and postgres (Docker) were run green on this change earlier in the session — 13618 and 13571 assertions respectively, 0 failures — but in a working tree that also carried unrelated in-flight work, and a re-run against this exact commit was not possible because the containers were being used concurrently by another session (the suite drops all tables at fixture setup, so parallel runs corrupt each other). Worth confirming in CI.
  • Not run locally: gcc-release. No GCC toolchain on this machine, so the compiler CI's database legs actually use is unverified here.

…559)

SqlQueryFormatter::SelectFirst and SelectCount took no groupBy parameter, so a
populated ComposedQuery::groupBy could never reach them: .GroupBy(...).First()
and .GroupBy(...).Count() silently produced ungrouped SQL. SelectAll and
SelectRange already accepted it, which is what made the omission an oversight
rather than a deliberate restriction.

Add std::string_view groupBy to both virtuals, emitting it between the WHERE and
ORDER BY clauses exactly as SelectAll/SelectRange do, and forward it from
ComposedQuery::ToSql. The DataMapper query builder calls the formatter directly
rather than going through ToSql, so it carried the same bug at seven further
call sites (CountImpl, ExistImpl, the three FirstImpl overloads and both
FirstImpl(n) overloads); all of them now pass the builder's groupBy.

Counting with a GROUP BY yields one row per group rather than a single total,
so both Count() surfaces document that, including the DataMapper one returning
the first group's count since it reads a single value.

This changes the signature of two pure virtuals, so it is a source and ABI break
for any out-of-tree SqlQueryFormatter implementation. HAVING remains unsupported;
that is tracked separately in the same issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applying /code-review findings on this branch.

Count()'s doc promised "the count of the first group", but SelectCount takes
no orderBy and CountImpl passes none, so no ORDER BY is emitted at all: the
group read back is unspecified and may differ per DBMS and per run, and a
preceding OrderBy is silently dropped. Say that instead.

The three new tests only exercise ComposedQuery::ToSql. The bug being fixed
lived in the seven DataMapper call sites, none of which are covered --
removing `this->_query.groupBy` from any of them keeps the suite green. Add
a "Count() honors GroupBy" section to the existing Query test. The fixture's
four persons split 2/2 on is_active, so `== 2` holds whichever group the
engine returns first (it is 4 without the fix), and the emitted
SELECT COUNT(*) FROM "Person" GROUP BY "is_active" is valid on all three
backends.

Document SelectFirst's new groupBy parameter: the parameter order puts
orderBy before groupBy, the reverse of the required emission order, so an
implementer needs telling that it goes between WHERE and ORDER BY and that
`count` applies to the grouped result set.

Left alone: ExistImpl and the record-returning FirstImpl now combine the
full non-aggregated column list with GROUP BY, so .GroupBy(x).First() and
.Exist() newly error on PostgreSQL (42803) and SQL Server (8120) where they
previously returned a row. That is the SQL the caller asked for and matches
what SelectAll already did, but it is an undeclared per-DBMS behavior change
worth calling out in the PR description.

Not compiled: this worktree has no configured build directory. The added
test uses only APIs already exercised in the same file, and GroupBy is
inherited from SqlBasicSelectQueryBuilder<Derived> returning Derived&, so
.Count() resolves -- but it has not been built or run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Lightweight/QueryFormatter/SQLiteFormatter.hpp 75.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

… singleton

The macOS leg failed in PluginIngestionTests with "Duplicate migration timestamp
20260101000000 detected: 'fixture' conflicts with 'fixture'" - a migration
reported as a duplicate of itself.

`MigrationBase`'s constructor registers `this` with `MigrationManager::GetInstance()`,
but nothing deregisters it. `StubMigration` instances have automatic storage
duration, so each test left a dangling pointer in the singleton's list. Since
`AddMigration` dereferences those pointers to compare timestamps, a later
migration allocated at a recycled address matches itself and is rejected. That
makes the failure depend on allocator address reuse, which is why it surfaced
on macOS and on this branch, which touches nothing migration-related.

Give `StubMigration` the same singleton reset `FakeMigration` already carries for
exactly this reason, and document why it is there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut
Yaraslaut merged commit f3ef8a5 into master Aug 20, 2026
55 of 56 checks passed
@Yaraslaut
Yaraslaut deleted the fix/559-groupby-in-first-and-count branch August 20, 2026 07:23
Yaraslaut added a commit that referenced this pull request Aug 20, 2026
The branch had drifted into conflict with master, which meant GitHub could not
build a merge ref and every pull_request-triggered workflow silently stopped
running: the last full CI this branch saw was on 79bd7bb, and PR Labeler was
the only check reporting since.

Two conflicts, both mechanical:

- QueryBuilders.hpp: master's doc comment wins. #573 made SelectCount forward
  GROUP BY, so the branch's note that a preceding GroupBy is "not part of the
  generated statement" describes behaviour that no longer exists.
- DataMapper.hpp: the eager-loading declarations this branch adds sit directly
  above LoadHasOneThrough's template header, which #580 re-spelled from
  ThroughRecord to ThroughSpec. Kept both: the new declarations and master's
  signature.

Verified after the merge: clang-debug builds clean, doc snippets match, and the
full suite passes on sqlite3 (1426), mssql2022 (1424) and postgres (1425).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GROUP BY is silently dropped by First() and Count()

1 participant