Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions docs/proposals/sqlc-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Proposal: `sqlc.switch` — bounded dynamic ORDER BY / WHERE via compile-time branch expansion

Status: **draft** (evox-it fork)
Tracking upstream: discussions/364, issues #2061, #3414, #2060; PRs #4005, #4260, #2859 (all blocked on a canonical design)

## Problem

sqlc has no way to vary the *structure* of a query (sort order, filter shape) at
runtime. The community has asked for "dynamic queries" since 2020. The two
existing workarounds both fail:

1. **`CASE WHEN` in `ORDER BY`/`WHERE`** — defeats the query planner. Postgres
will not use an index when the sort key is hidden inside a `CASE` expression.
2. **Runtime string interpolation** (`sqlc.raw`, `@filter::text`) — every such
proposal has been closed because it reintroduces SQL injection and breaks
sqlc's "type-safe, it's just SQL" guarantee.

## Design goals (derived from maintainer objections)

- **No SQL injection, ever.** User input must never reach the query string.
- **Schema-validated.** Every dynamic fragment must parse as real SQL and
reference real columns at compile time. Bad column = compile error.
- **Planner/index friendly.** The emitted SQL must be a clean static
`ORDER BY col DESC`, never a `CASE` wrapper.
- **Finite + enumerable.** The set of runtime choices is fixed at compile time.
- **Modeled on existing precedent** (`sqlc.slice`, the `sqlc.*` macro family).

## Syntax

```sql
-- name: ListAuthors :many
SELECT * FROM authors
WHERE deleted_at IS NULL
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')
);
```

- `sqlc.switch(@selector, branches…)` sits where a value/expression is grammatically
legal (`ORDER BY` position, `WHERE` position). `@selector` is the runtime chooser.
- `sqlc.when('key', 'sql-fragment')` — `'key'` is the enum value; `'sql-fragment'`
is a **string literal** (must be — `ASC`/`DESC` are not valid inside a function
arg list in any engine grammar). The fragment is an author-authored compile-time
constant.
- `sqlc.else('sql-fragment')` — optional default branch.

## Why string-literal fragments are still safe

The fragment is a constant in the `.sql` file written by the developer, exactly
like the rest of the query. It is **not** runtime input. sqlc re-parses each
fragment in its grammatical context (e.g. `SELECT 1 FROM authors ORDER BY <frag>`)
and validates every column reference against the catalog. A typo or unknown
column fails `sqlc generate`. The only thing that varies at runtime is *which
already-validated branch* is chosen — a closed enum. Injection is structurally
impossible.

## Codegen strategy: compile-time expansion (one function per branch)

This follows Kyle Conroy's own `sqlc.switch()` suggestion in discussions/364
("generate multiple optimized queries at compile time rather than runtime CASE").

A query containing `sqlc.switch` is **expanded by the compiler into N concrete
queries**, one per branch. Each clone has the whole `sqlc.switch(...)` call
replaced in the SQL by that branch's fragment. The resulting query strings are
fully static constants — no runtime `strings.Replace`, no markers.

### Recognition is AST-based, like the other sqlc.* macros

`sqlc.switch`/`when`/`else` are recognized exactly the way `sqlc.arg` and
`sqlc.slice` are: by searching the parsed AST for a `FuncCall` whose schema is
`sqlc` (`astutils.Search`). There is no bespoke SQL lexer. A consequence is that
the feature works in precisely the clauses where the engine parser produces such
a node — i.e. **wherever `sqlc.arg` works**:

| Position | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| WHERE | ✅ | ✅ | ✅ |
| ORDER BY | ✅ | ✅ | ❌ parser drops the clause |

SQLite's parser discards *any* function call in `ORDER BY` (true of plain
`ORDER BY upper(name)` too — see upstream PR #4429), so `sqlc.switch` there is a
compile error rather than silently emitting the unexpanded call. This is the
same limitation `sqlc.arg` has.

Once recognized, the compiler replaces the `sqlc.switch(...)` text span with each
branch's fragment, renames the `-- name:` comment to `<QueryName><BranchKey>`,
and **re-parses each branch as an ordinary query**. Every branch therefore goes
through the normal parser + analyzer, so a bad column reference in a fragment is
a compile error, and the generated query strings are fully static constants — no
runtime markers, no `strings.Replace`.

### Generated Go — v1 (implemented)

One static function per branch:

```go
const listAuthorsNameAsc = `SELECT ... ORDER BY authors.name ASC`
func (q *Queries) ListAuthorsNameAsc(ctx context.Context) ([]Author, error) { ... }

const listAuthorsRecent = `SELECT ... ORDER BY authors.created_at DESC, authors.id DESC`
func (q *Queries) ListAuthorsRecent(ctx context.Context) ([]Author, error) { ... }

const listAuthorsElse = `SELECT ... ORDER BY authors.id ASC`
func (q *Queries) ListAuthorsElse(ctx context.Context) ([]Author, error) { ... }
```

This is the whole upstreamable primitive: pure compile-time expansion in the
compiler, **zero codegen changes** (the branches are ordinary queries, so every
language's codegen gets them for free). It is the conservative core to propose
first.

### Generated Go — v2 (proposed extension)

A generated enum for the selector plus one exported dispatcher that switches on
it. This is a codegen convenience layered on top of v1; it adds codegen surface
(enum synthesis + dispatcher emission) and is best proposed as a follow-up once
the primitive is accepted:

```go
type ListAuthorsSort string
const (
ListAuthorsSortNameAsc ListAuthorsSort = "name_asc"
ListAuthorsSortRecent ListAuthorsSort = "recent"
)
func (q *Queries) ListAuthors(ctx context.Context, sort ListAuthorsSort) ([]Author, error) {
switch sort {
case ListAuthorsSortNameAsc: return q.ListAuthorsNameAsc(ctx)
case ListAuthorsSortRecent: return q.ListAuthorsRecent(ctx)
default: return q.ListAuthorsElse(ctx)
}
}
```

## Naming rules

| Element | Source | Example |
|---|---|---|
| Branch fn | `<QueryName>` + camelize(key) | `ListAuthorsNameAsc` |
| `sqlc.else` fn | `<QueryName>` + `Else` | `ListAuthorsElse` |
| Enum type (v2) | `<QueryName>` + selector name | `ListAuthorsSort` |
| Enum const (v2) | enum type + camelize(key) | `ListAuthorsSortNameAsc` |
| Dispatcher (v2) | `<QueryName>` | `ListAuthors` |

## v1 scope (implemented + tested)

- Recognized in WHERE (all engines) and ORDER BY (PostgreSQL, MySQL); SQLite
ORDER BY is a clear compile error (parser limitation, parity with `sqlc.arg`).
- **Not allowed in the SELECT projection** — branches there could change the
result columns; rejected at compile time.
- One static function per branch. Enum + dispatcher (v2) are the proposed
follow-up.
- Engine-agnostic: the compiler emits ordinary queries, so all codegens benefit;
no codegen changes in v1.
- Golden end-to-end tests for PostgreSQL (stdlib + pgx), MySQL, and SQLite.

## Open questions for upstream

1. Dispatcher on by default, or opt-in via a query annotation?
2. Should `sqlc.else` be mandatory (compile error if a non-exhaustive switch is
possible) or optional (zero-value selector → else)?
3. Fragment validation depth: parse-only vs full type-check of the sort expr.
1 change: 1 addition & 0 deletions internal/cmd/shim.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ func pluginQueries(r *compiler.Result) []*plugin.Query {
Params: params,
Filename: q.Metadata.Filename,
InsertIntoTable: iit,
SwitchGroup: q.Metadata.SwitchGroup,
})
}
return out
Expand Down
1 change: 1 addition & 0 deletions internal/codegen/golang/gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ func Generate(ctx context.Context, req *plugin.GenerateRequest) (*plugin.Generat
if err != nil {
return nil, err
}
shareSwitchGroupStructs(queries, options)

if options.OmitUnusedStructs {
enums, structs = filterUnusedStructs(enums, structs, queries, options.ModelsTypeQualifier())
Expand Down
18 changes: 15 additions & 3 deletions internal/codegen/golang/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ type QueryValue struct {
Typ string
SQLDriver opts.SQLDriver

// DefinedElsewhere suppresses emitting this value's struct definition
// because another query already emits an identical one (e.g. sqlc.switch
// branches that share a single Params/Row struct). The struct is still used
// by value in the method signature.
DefinedElsewhere bool

// ModelQualifier prefixes references to model types when the models file
// lives in a different Go package (e.g. "model."). Empty otherwise.
ModelQualifier string
Expand All @@ -28,7 +34,7 @@ type QueryValue struct {
}

func (v QueryValue) EmitStruct() bool {
return v.Emit
return v.Emit && !v.DefinedElsewhere
}

func (v QueryValue) IsStruct() bool {
Expand Down Expand Up @@ -62,7 +68,7 @@ func (v QueryValue) Pairs() []Argument {
if v.isEmpty() {
return nil
}
if !v.EmitStruct() && v.IsStruct() {
if !v.Emit && v.IsStruct() {
var out []Argument
for _, f := range v.Struct.Fields {
out = append(out, Argument{
Expand Down Expand Up @@ -260,7 +266,10 @@ func (v QueryValue) VariableForField(f Field) string {
if !v.IsStruct() {
return v.Name
}
if !v.EmitStruct() {
// Use v.Emit (single struct param) rather than EmitStruct(): a value whose
// struct is DefinedElsewhere still receives a single struct arg, so fields
// are referenced as arg.Field, not as inlined bare names.
if !v.Emit {
return toLowerCase(f.Name)
}
return v.Name + "." + f.Name
Expand All @@ -279,6 +288,9 @@ type Query struct {
Arg QueryValue
// Used for :copyfrom
Table *plugin.Identifier
// SwitchGroup links the branches expanded from one sqlc.switch() macro so
// they can share a single Params/Row struct.
SwitchGroup string
}

func (q Query) hasRetType() bool {
Expand Down
42 changes: 42 additions & 0 deletions internal/codegen/golang/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ func buildQueries(req *plugin.GenerateRequest, options *opts.Options, enums []En
SQL: query.Text,
Comments: comments,
Table: query.InsertIntoTable,
SwitchGroup: query.SwitchGroup,
}
sqlpkg := parseDriver(options.SqlPackage)

Expand Down Expand Up @@ -471,3 +472,44 @@ func checkIncompatibleFieldTypes(fields []Field) error {
}
return nil
}

// shareSwitchGroupStructs makes the branch functions expanded from a single
// sqlc.switch() macro use one shared Params and Row struct instead of an
// identical copy per branch. All branches of a macro have the same parameters
// and result columns (only the spliced ORDER BY/WHERE fragment differs), so the
// per-query structs are byte-identical; this collapses them to one named after
// the original query (the SwitchGroup), emitted once.
func shareSwitchGroupStructs(queries []Query, options *opts.Options) {
groups := map[string][]int{}
for i := range queries {
if g := queries[i].SwitchGroup; g != "" {
groups[g] = append(groups[g], i)
}
}
for group, idx := range groups {
if len(idx) < 2 {
continue
}
canon := &queries[idx[0]]

// Params: a single struct arg becomes shared. (Few-param queries inline
// their args instead of using a struct, so they are already identical.)
if canon.Arg.IsStruct() && canon.Arg.Emit {
canon.Arg.Struct.Name = StructName(group+"Params", options)
for _, i := range idx[1:] {
queries[i].Arg.Struct = canon.Arg.Struct
queries[i].Arg.DefinedElsewhere = true
}
}

// Row: only when the branch built its own *Row struct. If it reused a
// table model, every branch already shares that model.
if canon.Ret.IsStruct() && canon.Ret.Emit {
canon.Ret.Struct.Name = StructName(group+"Row", options)
for _, i := range idx[1:] {
queries[i].Ret.Struct = canon.Ret.Struct
queries[i].Ret.Emit = false
}
}
}
}
53 changes: 32 additions & 21 deletions internal/compiler/compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,33 +98,44 @@ func (c *Compiler) parseQueries(o opts.Parser) (*Result, error) {
continue
}
for _, stmt := range stmts {
query, err := c.parseQuery(stmt.Raw, src, o)
// A statement may expand into several: a sqlc.case(...) macro
// produces one static query per branch. Without a macro this
// yields the original statement unchanged.
sources, err := c.statementSources(stmt.Raw, src)
if err != nil {
var e *sqlerr.Error
loc := stmt.Raw.Pos()
if errors.As(err, &e) && e.Location != 0 {
loc = e.Location
}
merr.Add(filename, src, loc, err)
// If this rpc unauthenticated error bubbles up, then all future parsing/analysis will fail
if errors.Is(err, rpc.ErrUnauthenticated) {
return nil, merr
}
continue
}
if query == nil {
merr.Add(filename, src, stmt.Raw.Pos(), err)
continue
}
query.Metadata.Filename = filepath.Base(filename)
queryName := query.Metadata.Name
if queryName != "" {
if _, exists := set[queryName]; exists {
merr.Add(filename, src, stmt.Raw.Pos(), fmt.Errorf("duplicate query name: %s", queryName))
for _, ss := range sources {
query, err := c.parseQuery(ss.raw, ss.src, o)
if err != nil {
var e *sqlerr.Error
loc := ss.raw.Pos()
if errors.As(err, &e) && e.Location != 0 {
loc = e.Location
}
merr.Add(filename, ss.src, loc, err)
// If this rpc unauthenticated error bubbles up, then all future parsing/analysis will fail
if errors.Is(err, rpc.ErrUnauthenticated) {
return nil, merr
}
continue
}
set[queryName] = struct{}{}
if query == nil {
continue
}
query.Metadata.SwitchGroup = ss.group
query.Metadata.Filename = filepath.Base(filename)
queryName := query.Metadata.Name
if queryName != "" {
if _, exists := set[queryName]; exists {
merr.Add(filename, ss.src, ss.raw.Pos(), fmt.Errorf("duplicate query name: %s", queryName))
continue
}
set[queryName] = struct{}{}
}
q = append(q, query)
}
q = append(q, query)
}
}
if len(merr.Errs()) > 0 {
Expand Down
Loading
Loading