Skip to content

feat: aggregate functions and GROUP BY for the table query API#4

Merged
thekevinm merged 1 commit into
faucetdb:mainfrom
jonas136:feat/aggregate-group-by
Jul 2, 2026
Merged

feat: aggregate functions and GROUP BY for the table query API#4
thekevinm merged 1 commit into
faucetdb:mainfrom
jonas136:feat/aggregate-group-by

Conversation

@jonas136

Copy link
Copy Markdown
Contributor

What

Adds aggregate functions and GROUP BY to the table query API. Callers can now
request SUM/COUNT/AVG/MIN/MAX in fields and group with a new group
parameter, computing per-group results server-side instead of paging the whole
table and aggregating in the client.

GET /api/v1/<service>/_table/<table>
      ?fields=region,SUM(amount) AS total,COUNT(*)
      &group=region
      &order=total desc
      &filter=status='open'
{ "resource": [ { "region": "north", "total": 48210, "count": 7 }, ... ] }
  • Aggregates also work without a group (whole-table rollups): fields=SUM(amount).
  • Order by an aggregate's alias. Default alias is <func>_<column> (e.g. sum_amount),
    or count for COUNT(*); override with ... AS alias.
  • Works the same over the MCP faucet_query tool (new group argument).

Why

Today the only way to answer "total per customer" or "count by status" is to pull every
matching row and reduce it client-side. For large tables that means many paginated
requests and a lot of transferred JSON to produce a handful of numbers. The aggregation
is pushed down to the database where it belongs.

Design & safety

  • New internal/query/aggregate.go: ParseProjection turns a mixed plain/aggregate
    field list into []SelectItem; BuildSelectList/BuildGroupBy render the SQL;
    ValidateGroupedProjection rejects non-portable queries (a bare column beside an
    aggregate must appear in GROUP BY; a group requires an explicit field list).
  • connector.SelectRequest gains Projection and GroupBy. All six dialect builders
    emit GROUP BY between WHERE and ORDER BY and share the select-list renderer, so
    behavior is consistent across SQLite/Postgres/MySQL/SQL Server/Oracle/Snowflake.
  • No new injection surface. Aggregate function names come from a fixed allowlist;
    every column and alias is validated with the existing ValidateIdentifier and quoted
    with the dialect's quote function. Values remain parameterized. Injection attempts in
    an aggregate argument or alias are rejected with 400.
  • include_count is skipped for grouped queries (a plain COUNT(*) would count
    underlying rows, not result groups).

Tests

  • internal/query/aggregate_test.go: projection parsing (plain, all aggregates, default
    • explicit alias, mixed order), the *-only-for-COUNT rule, reserved-word aliases,
      injection attempts, grouped-projection validation, and clause rendering.
  • internal/connector/sqlite/aggregate_select_test.go: end-to-end BuildSelect SQL for
    grouped aggregates, alias ordering, multi-column group, and whole-table rollups.
  • make test (race) green; golangci-lint run clean.

Notes

  • This targets main; CONTRIBUTING.md mentions a dev branch but the repo currently
    only has main — happy to retarget.
  • HAVING (filtering on aggregate results) is a natural follow-up and intentionally
    left out to keep this PR focused.
  • Verified end-to-end against a large SQLite database (~150k rows): a grouped
    SUM(...) ... GROUP BY query that previously required paging the full table and
    reducing client-side now returns the same result in a single request.

Support SUM/COUNT/AVG/MIN/MAX in the `fields` parameter plus a new `group`
parameter on the list/query endpoints, so callers can compute per-group
aggregates server-side instead of paging the full result set and summing
client-side.

- query: ParseProjection parses a mixed plain/aggregate field list into
  []SelectItem; BuildSelectList/BuildGroupBy render the clauses;
  ValidateGroupedProjection rejects non-portable mixes (a bare column next
  to an aggregate must appear in GROUP BY; a group needs an explicit field
  list).
- connector: SelectRequest gains Projection and GroupBy; all six dialect
  builders emit GROUP BY (between WHERE and ORDER BY) via the shared
  select-list renderer.
- handler + mcp: parse `group`, build a structured projection, validate
  consistency; grouped queries skip include_count (a plain COUNT(*) would
  count underlying rows, not groups).
- openapi: document the `group` parameter and aggregate `fields` syntax.

Order by an aggregate's alias (default `<func>_<column>`, or COUNT(*) ->
`count`; override with `AS alias`).

Security: aggregate function names come from a fixed allowlist and every
column and alias is validated as a SQL identifier and quoted, so the
feature adds no new injection surface.

Tests cover projection parsing (incl. injection attempts), grouped
validation, clause rendering, and dialect SQL generation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jonas136 jonas136 marked this pull request as ready for review June 29, 2026 20:45
@thekevinm thekevinm merged commit d633128 into faucetdb:main Jul 2, 2026
1 check passed
@thekevinm

Copy link
Copy Markdown
Contributor

@jonas136 Thank you for your contribution!

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.

2 participants