Skip to content

feat(sql): HAVING — post-aggregation filter (SQLR-52)#161

Merged
joaoh82 merged 1 commit into
mainfrom
worktree-sql-having
Jun 9, 2026
Merged

feat(sql): HAVING — post-aggregation filter (SQLR-52)#161
joaoh82 merged 1 commit into
mainfrom
worktree-sql-having

Conversation

@joaoh82

@joaoh82 joaoh82 commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

WHERE is the pre-aggregate filter; HAVING is the post-aggregate one. This lifts the NotImplemented and closes the Phase 9e aggregates story (SQLR-52).

SELECT dept, COUNT(*) FROM emp GROUP BY dept HAVING COUNT(*) > 1;
SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept HAVING total > 100000;
SELECT dept FROM emp GROUP BY dept HAVING COUNT(*) > 1 AND SUM(salary) > 100;

Design

Parser (src/sql/parser/select.rs):

  • SelectQuery grows having: Option<Expr>, carried raw from sqlparser exactly like WHERE's selection.
  • HAVING without GROUP BY is rejected with a typed NotImplemented ("use WHERE for row-level filters or restructure with a subquery"). SQLite's degenerate single-group form isn't worth the executor branch in v0.
  • HAVING + JOIN stays covered by the existing GROUP-BY-over-JOIN rejection (SQLR-6 is the follow-up increment).

Executor (src/sql/executor.rs):

  • lower_having_expr rewrites aggregate calls in the HAVING tree into identifiers naming their output slot (SUM(salary)"SUM(salary)"), registering hidden trailing projection slots for aggregates and GROUP BY keys referenced only in HAVING — so SELECT dept FROM emp GROUP BY dept HAVING COUNT(*) > 1 computes the count without projecting it.
  • A new GroupRowScope (third RowScope impl) resolves those identifiers against each group's output row through the shared expression evaluator — comparisons, AND/OR/NOT, arithmetic, IS NULL, LIKE, IN (list) all work in HAVING for free, with the same NULL-as-false collapse WHERE applies (design-decisions §13).
  • filter_groups_by_having runs after aggregation and before DISTINCT / ORDER BY / LIMIT; hidden slots are stripped after filtering so they never leak into output width.

Tests

Engine suite 612 → 625 (+13), all green:

  • COUNT(*) / SUM thresholds; aggregate alias (HAVING total > 100); lowercase call form
  • Aggregate only in HAVING (hidden slot); GROUP BY key only in HAVING
  • Compound AND predicate; composition with ORDER BY + LIMIT; all-groups-excluded
  • NULL aggregate collapses to false (all-NULL SUM group dropped)
  • HAVING without GROUP BY → typed error; out-of-scope column → typed error
  • All four JOIN flavors + GROUP BY + HAVING → clean NotImplemented, no wrong results

Verification

  • CI shape: cargo build / test / fmt --check / clippy (no new warnings) / doc — all pass
  • REPL smoke (in-memory + file-backed with close/reopen round-trip) — correct rows, clean rejection message
  • web/ npm run build — pass (three web files updated where they claimed HAVING was unsupported)

Docs

supported-sql.md (new "HAVING semantics" section + syntax block), sql-engine.md aggregation pipeline, roadmap.md shipped entry, README extras list, design-decisions.md §13 wording, web docs page / sql-ref / roadmap component.

Ref: SQLR-52

🤖 Generated with Claude Code

WHERE filters rows before grouping; HAVING filters groups after
aggregation. Closes the Phase 9e aggregates story.

Parser (src/sql/parser/select.rs):
- SelectQuery grows `having: Option<Expr>`, passed through raw from
  sqlparser like WHERE. parse_aggregate_call / AggregateFn::from_name
  exposed pub(crate) for the executor's HAVING lowering.
- HAVING without GROUP BY rejected with a typed NotImplemented (the
  degenerate single-group form SQLite allows isn't worth the executor
  branch in v0). HAVING + JOIN stays covered by the existing
  GROUP-BY-over-JOIN rejection (SQLR-6 is the follow-up).

Executor (src/sql/executor.rs):
- lower_having_expr rewrites aggregate calls in the HAVING tree to
  identifiers naming their output slot (SUM(salary) → "SUM(salary)"),
  registering hidden trailing projection slots for aggregates and
  GROUP BY keys referenced only in HAVING so aggregate_rows computes
  them alongside the visible ones.
- New GroupRowScope resolves those identifiers against the group's
  output row through the shared expression evaluator — comparisons,
  AND/OR/NOT, arithmetic, IS NULL, LIKE, IN all work, with the same
  NULL-as-false collapse WHERE applies (design-decisions §13).
- filter_groups_by_having runs after aggregation, before DISTINCT /
  ORDER BY / LIMIT; hidden slots are stripped after filtering.

Tests: +13 executor tests (612 → 625 in the engine suite): COUNT/SUM
thresholds, aggregate alias, aggregate-only-in-HAVING, group-key-only-
in-HAVING, compound AND, ORDER BY/LIMIT composition, all-groups-
excluded, NULL-aggregate collapse, lowercase call form, no-GROUP-BY
rejection, out-of-scope column rejection, all four JOIN flavors
rejected cleanly.

Docs: supported-sql.md (HAVING semantics section + syntax block),
sql-engine.md aggregation pipeline, roadmap.md shipped entry, README,
design-decisions §13 wording, web docs page / sql-ref / roadmap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rust-sqlite Ready Ready Preview, Comment Jun 9, 2026 9:57pm

Request Review

@joaoh82 joaoh82 merged commit d285c38 into main Jun 9, 2026
20 checks passed
joaoh82 added a commit that referenced this pull request Jun 10, 2026
…ets) (#163)

* docs(release): bump displayed version to 0.13.0 (site + install snippets)

Same shape as #160 for v0.12.0: the Cargo install snippets in README,
docs/ask.md, and docs/embedding.md, plus the site-wide version constant
in web/src/lib/site.ts. v0.13.0 shipped HAVING (SQLR-52, #161).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(playground): refresh vendored WASM bundle to the 0.13.0 engine

The /playground pkg is a pinned copy Vercel never rebuilds, so it was
still serving the pre-0.12 engine (last refreshed in SQLR-42). Rebuilt
via the documented flow in examples/wasm-playground/README.md; only the
.wasm binary changed (no SDK API change, glue files identical). Also
picks up sdk/wasm/Cargo.lock's 0.13.0 bump — sdk/wasm sits outside the
workspace, so the Release PR's lock refresh couldn't reach it.

Smoke-verified in Node: GROUP BY ... HAVING total > 100 returns the
filtered group through the new bundle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 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