Skip to content

feat: sqlc.switch — compile-time query branches for dynamic ORDER BY/WHERE (discussion #364)#4485

Open
celso-alexandre wants to merge 7 commits into
sqlc-dev:mainfrom
evox-it:feat/sqlc-switch
Open

feat: sqlc.switch — compile-time query branches for dynamic ORDER BY/WHERE (discussion #364)#4485
celso-alexandre wants to merge 7 commits into
sqlc-dev:mainfrom
evox-it:feat/sqlc-switch

Conversation

@celso-alexandre

Copy link
Copy Markdown

Summary

Implements sqlc.switch — compile-time expansion of dynamic ORDER BY/WHERE branches into one static, fully-validated query per branch.

Follows up on the proposal posted in #364 (comment), opened after @StevenACoffman indicated a PR would be welcome at this point.

-- name: ListAuthors :many
SELECT * FROM authors
ORDER BY sqlc.switch(@sort,
    sqlc.when('name_asc', 'authors.name ASC'),
    sqlc.when('recent',   'authors.created_at DESC, authors.id DESC'),
    sqlc.else(            'authors.id ASC'));

expands at sqlc generate time into ListAuthorsNameAsc, ListAuthorsRecent, and ListAuthorsElse — each a plain static query. No runtime CASE (planner/index friendly), no string interpolation (no injection surface), and every branch is re-parsed and validated against the catalog like any ordinary query.

Design

  • Branch keys and SQL fragments are string literals — compile-time constants, never runtime input.
  • Allowed only in clauses that don't change the result shape (WHERE, ORDER BY); rejected in the SELECT list.
  • Recognized via the AST exactly like sqlc.arg/sqlc.slice; macro logic lives in a dedicated compiler file, with a thin wrapper in parseQueries. The core expansion has zero codegen changes, so all language backends benefit.
  • Multiple sqlc.switch per query are allowed when they declare the same key set (e.g. the same choice driving a CTE pre-sort and the final ORDER BY); expansion stays linear, one function per key, with shared Params/Row structs in the Go backend.

Full design note: docs/proposals/sqlc-switch.md.

Commits

  • feat(compiler): sqlc.switch for compile-time query branches — core expansion + validation
  • feat(golang): multiple sqlc.switch per query + shared Params/Row structs
  • fix(golang): reference shared struct fields as arg.Field in query body
  • fix(sqlite): populate SelectStmt.SortClause during AST conversion — contributed by @fahmifan; SQLite ORDER BY terms were parsed but never propagated, which also blocked sqlc.switch (and sqlc.arg) recognition there
  • fix(compiler): allow sqlite implicit rowid columns in strict ORDER BY validation — populating SortClause made strict_order_by apply to SQLite for the first time; without this, existing queries using ORDER BY rowid would start failing

Testing

  • Golden end-to-end testdata for PostgreSQL, MySQL, and SQLite (internal/endtoend/testdata/sqlc_switch/, order_by_rowid/)
  • Full suite (go test --tags=examples -timeout 20m ./...) green against live PostgreSQL 16 and MySQL 9
  • Dropped the fork into a large production codebase (hundreds of generated queries) via a replace directive and regenerated: the only diffs were in queries that actually use sqlc.switch — everything else came out byte-identical

Disclosure

The initial implementation was AI-generated as a proof of concept to explore the design space; it has since been manually reviewed and tested (unit tests, golden tests across all three engines, and real use in a production project). Details in the discussion post.

Open questions

Carrying over from the discussion, happy to adjust any of these:

  1. Naming: sqlc.switch/when/else — or something else?
  2. Should sqlc.else be mandatory, or is "no match falls through" acceptable?
  3. Scope for a first cut — ORDER BY only, or WHERE too?

🤖 Generated with Claude Code

celso-alexandre and others added 7 commits May 26, 2026 19:52
Add a sqlc.switch(@selector, sqlc.when('key', 'sql'), sqlc.else('sql'))
macro that expands at compile time into one static query per branch, named
<QueryName><BranchKey>. This implements the sqlc.switch() idea floated in
discussions/364 ("generate multiple optimized queries at compile time rather
than runtime CASE").

Each branch fragment is spliced into the query in place of the macro call and
re-parsed as an ordinary query, so:

- the generated SQL is fully static (no runtime CASE, planner uses indexes);
- branch fragments are author-written constants, never runtime input, so there
  is no SQL injection surface;
- a bad column reference in a fragment is a normal compile error;
- a generated name colliding with another query is caught by the existing
  duplicate-query-name check.

Recognition is AST-based, identical to sqlc.arg/sqlc.slice, so it works
wherever those macros parse: WHERE on all engines, ORDER BY on PostgreSQL and
MySQL. SQLite drops function calls in ORDER BY (see sqlc-dev#4429), so it errors there
instead of emitting the unexpanded call. The macro is rejected in the SELECT
projection, where branches could change the result shape.

The only change to existing code is a thin wrapper in parseQueries; all macro
logic lives in the new internal/compiler/expand_switch.go. Includes unit tests
and golden end-to-end tests for PostgreSQL (stdlib + pgx), MySQL, and SQLite,
plus a design note in docs/proposals/sqlc-switch.md.
Allow several sqlc.switch() calls in one query as long as they declare the
same keys (e.g. the same sort applied in a CTE pre-sort and the final ORDER
BY). Expansion stays linear in the number of keys — one function per key, each
call contributing its own fragment — not the cross product.

Branches expanded from a sqlc.switch() now share a single Params and Row struct
named after the original query, instead of an identical copy per branch. All
branches have the same parameters and result columns (only the spliced
fragment differs), so the per-branch structs were byte-identical. A new
SwitchGroup field links the branches from compiler metadata through to the Go
generator, which emits the shared struct once and points every branch at it.
VariableForField used EmitStruct() to decide between arg.Field and an inlined
bare name. A value whose struct is DefinedElsewhere (a shared sqlc.switch
Params struct) still takes a single struct arg, so it must use arg.Field.
Switch to v.Emit to match Pairs().
SQLite ORDER BY clauses were parsed but not propagated into
SelectStmt.SortClause during AST conversion.

This caused ORDER BY expressions, including sqlc.switch(...)
and function calls such as upper(name), to be invisible to
later AST visitors and macro recognition passes.

Co-authored-by: OpenAI Codex <codex@openai.com>
…support

fix(sqlite): populate SelectStmt.SortClause during AST conversion
… validation

Populating SelectStmt.SortClause for SQLite (006f4e8) made the
strict_order_by validation apply to SQLite queries for the first time.
That broke queries ordering by rowid, _rowid_, or oid, which exist on
most SQLite tables but never appear in the declared schema.

Skip validation for those implicit names on the sqlite engine only;
typos in regular column names still fail.

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

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.

2 participants