From 038673b180d12c84081ec52f0b95feec7c83b34c Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Sat, 20 Jun 2026 11:44:37 +0700 Subject: [PATCH 1/8] feat: update note --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3565546dc..833a8cdfb 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,12 @@ ORDER BY id ASC; -- name: SearchUsersOrdered :many SELECT * FROM users WHERE - 1 = 1 + TRUE AND email = @email -- :if @email ORDER BY id ASC, -- :if @id_asc - id DESC -- :if @id_desc + id DESC, -- :if @id_desc + TRUE ``` **Generated Go** @@ -209,6 +210,8 @@ func (q *SearchQueries) SearchUsers(ctx context.Context, db DBTX, arg SearchUser } ``` +Note: We use TRUE to prevent SQL errors when a line is omitted. + **Annotation rules** | Style | Syntax | Behaviour | From e9e406e653ca02e529538bf35df3fbfc9831c85d Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Sat, 20 Jun 2026 11:45:42 +0700 Subject: [PATCH 2/8] feat: update note --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 833a8cdfb..dc4652f2f 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ ORDER BY TRUE ``` +Note: We use TRUE to prevent SQL errors when a line is omitted. + **Generated Go** For SearchUsers @@ -210,8 +212,6 @@ func (q *SearchQueries) SearchUsers(ctx context.Context, db DBTX, arg SearchUser } ``` -Note: We use TRUE to prevent SQL errors when a line is omitted. - **Annotation rules** | Style | Syntax | Behaviour | From 75ec1e49b14748c92ddbbd55951ce0dadecedc25 Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Wed, 24 Jun 2026 16:49:57 +0700 Subject: [PATCH 3/8] fix: Inefficient string concatenation in call to WriteString --- example/db/dynfilter.go | 3 ++- internal/templates/dynfilterCode.tmpl | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/example/db/dynfilter.go b/example/db/dynfilter.go index aba7539ed..671ccd21a 100644 --- a/example/db/dynfilter.go +++ b/example/db/dynfilter.go @@ -103,7 +103,8 @@ func dynCompile(annotatedSQL string) *dynCompiledQuery { } // Unconditional line: accumulate into the static buffer. - staticBuf.WriteString(sep + line) + staticBuf.WriteString(sep) + staticBuf.WriteString(line) } flushStatic() diff --git a/internal/templates/dynfilterCode.tmpl b/internal/templates/dynfilterCode.tmpl index bc7204d43..ce3782c15 100644 --- a/internal/templates/dynfilterCode.tmpl +++ b/internal/templates/dynfilterCode.tmpl @@ -95,7 +95,8 @@ func dynCompile(annotatedSQL string) *dynCompiledQuery { } // Unconditional line: accumulate into the static buffer. - staticBuf.WriteString(sep + line) + staticBuf.WriteString(sep) + staticBuf.WriteString(line) } flushStatic() From dc6078ba4930e1aa788949fa7e7250ce60046434 Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Wed, 24 Jun 2026 16:54:42 +0700 Subject: [PATCH 4/8] bump: version --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dc4652f2f..0d4a771f0 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ version: '2' plugins: - name: golang wasm: - url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.0/sqlc-gen-go.wasm - sha256: a652e2c2c25d2b0332b4d8a8d0476e6674cda615f74090a8ead91aacba9704f3 + url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.1/sqlc-gen-go.wasm + sha256: f6a02c8cb363c56bb7a7de1d1012a65e800897696de9d28b938563af02e3ce81 sql: - schema: schema.sql queries: query.sql @@ -62,8 +62,8 @@ sql: plugins: - name: golang wasm: - url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.0/sqlc-gen-go.wasm - sha256: a652e2c2c25d2b0332b4d8a8d0476e6674cda615f74090a8ead91aacba9704f3 + url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.1/sqlc-gen-go.wasm + sha256: f6a02c8cb363c56bb7a7de1d1012a65e800897696de9d28b938563af02e3ce81 sql: - engine: postgresql codegen: From 28aeb0410eb582a8fc965542394fa7fb2c64daec Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Thu, 25 Jun 2026 23:01:18 +0700 Subject: [PATCH 5/8] feat: add CTE column validation and ANY array type guard - Add `enable_validate_cte` option that validates column references across CTEs and tables before code generation, failing fast with a clear error instead of generating broken code - Add `anyParamRe` validation in dynfilter to reject `ANY($N)` without an array type cast at parse time, preventing silent runtime failures - Add CONTRIBUTING.md and MIGRATION.md docs - Rewrite README to frame the plugin as an AI harness for SQL-driven development Co-Authored-By: Claude Sonnet 4.6 --- CONTRIBUTING.md | 44 ++ MIGRATION.md | 51 ++ README.md | 285 ++------ example/db/cte.sql.go | 865 +++++++++++++++++++++++++ example/db/mock/cte.sql.go | 267 ++++++++ example/queries/cte.sql | 253 ++++++++ example/sqlc.yaml | 2 + internal/cte_validate.go | 779 ++++++++++++++++++++++ internal/cte_validate_falsepos_test.go | 196 ++++++ internal/cte_validate_test.go | 649 +++++++++++++++++++ internal/dynfilter.go | 23 + internal/dynfilter_test.go | 42 ++ internal/gen.go | 6 + internal/opts/options.go | 1 + 14 files changed, 3240 insertions(+), 223 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 MIGRATION.md create mode 100644 example/db/cte.sql.go create mode 100644 example/db/mock/cte.sql.go create mode 100644 example/queries/cte.sql create mode 100644 internal/cte_validate.go create mode 100644 internal/cte_validate_falsepos_test.go create mode 100644 internal/cte_validate_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..c8037411a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing + +## Building from source + +```sh +make all # produces bin/sqlc-gen-go and bin/sqlc-gen-go.wasm +``` + +To use a local build: + +```yaml +plugins: +- name: golang + wasm: + url: file:///path/to/bin/sqlc-gen-go.wasm + sha256: "" # optional since sqlc v1.24.0 +``` + +## Testing + +```sh +make test # plugin internals + generated-code unit tests (no DB) +make example-e2e # end-to-end tests against a real PostgreSQL database +``` + +Run a single test: + +```sh +go test ./internal/... -run TestName +``` + +Run fuzz tests: + +```sh +go test ./internal/opts/... -fuzz FuzzOverride +``` + +## Benchmarks + +`emit_dynamic_filter` uses a pre-compiled query (parsed once at init, `Build()` per request) rather than parsing on every call. It is ~14–25× faster than one-shot parsing and matches or beats hand-written builders, with allocations down to 3–10 per call. Source and full numbers: [`example/bench/`](example/bench/). + +```sh +cd example && go test ./bench -bench=. -benchmem -run='^$' +``` diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 000000000..93dd187eb --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,51 @@ +# Migrating from sqlc's built-in Go codegen + +Two changes are required: + +1. Add a top-level `plugins` entry pointing to the WASM plugin. +2. Replace `gen.go` with `codegen`, referencing the plugin by name. Move all options into the `options` block; `out` moves up one level. + +**Before:** +```yaml +sql: +- engine: postgresql + gen: + go: + package: db + out: db + emit_json_tags: true +``` + +**After:** +```yaml +plugins: +- name: golang + wasm: + url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.1/sqlc-gen-go.wasm + sha256: f6a02c8cb363c56bb7a7de1d1012a65e800897696de9d28b938563af02e3ce81 +sql: +- engine: postgresql + codegen: + - plugin: golang + out: db + options: + package: db + emit_json_tags: true +``` + +Global `overrides`/`go` move to `options`/``: + +```yaml +options: + golang: + rename: + id: "Identifier" + overrides: + - db_type: "timestamptz" + nullable: true + engine: postgresql + go_type: + import: "gopkg.in/guregu/null.v4" + package: "null" + type: "Time" +``` diff --git a/README.md b/README.md index 0d4a771f0..e75761fc4 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,67 @@ # sqlc-gen-go -A sqlc plugin that generates type-safe Go database access code from SQL. Runs as a WASM plugin (recommended) or standalone binary. +> Write SQL. Generate Go. Let the compiler be your AI's feedback loop. -## Usage +sqlc-gen-go is a sqlc plugin designed as an **AI harness** — a tight, deterministic loop where AI writes SQL, the generator produces type-safe Go instantly, and the compiler catches mistakes before anything runs. -```yaml -version: '2' -plugins: -- name: golang - wasm: - url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.1/sqlc-gen-go.wasm - sha256: f6a02c8cb363c56bb7a7de1d1012a65e800897696de9d28b938563af02e3ce81 -sql: -- schema: schema.sql - queries: query.sql - engine: postgresql - codegen: - - plugin: golang - out: db - options: - package: db - sql_package: pgx/v5 -``` +## Why this matters for AI-driven development -## Building from source +When AI writes database code, the most valuable thing you can give it is **fast, deterministic feedback**. sqlc-gen-go provides exactly that: -```sh -make all # produces bin/sqlc-gen-go and bin/sqlc-gen-go.wasm -``` +1. **Write SQL** — AI drafts queries directly against your schema +2. **`sqlc generate`** — produces type-safe Go structs and methods in milliseconds +3. **Compile** — the Go compiler catches type mismatches immediately +4. **Validate** — `enable_validate_cte` rejects invalid column references before code is generated +5. **Filter dynamically** — `emit_dynamic_filter` lets AI annotate SQL with `-- :if @param` to generate optional WHERE/ORDER BY clauses at runtime, no hand-written query builders needed +6. **Test** — `go_generate_mock` gives AI-written tests something to run against instantly +7. **Observe** — `emit_tracing` injects spans into every method automatically, no boilerplate -To use a local build: +The loop is: **SQL → generate → compile → test → repeat.** No ORM guessing, no runtime surprises, no hand-written boilerplate for AI to get wrong. -```yaml -plugins: -- name: golang - wasm: - url: file:///path/to/bin/sqlc-gen-go.wasm - sha256: "" # optional since sqlc v1.24.0 -``` +Dynamic filtering is the feature that makes this harness practical for real-world queries. Instead of AI generating dozens of query variants or fragile string-concatenation builders, a single annotated SQL query handles every runtime combination — and the generated code is still fully type-safe. -## Migrating from sqlc's built-in Go codegen - -Two changes are required: - -1. Add a top-level `plugins` entry pointing to the WASM plugin. -2. Replace `gen.go` with `codegen`, referencing the plugin by name. Move all options into the `options` block; `out` moves up one level. - -**Before:** -```yaml -sql: -- engine: postgresql - gen: - go: - package: db - out: db - emit_json_tags: true -``` +## Usage -**After:** ```yaml +version: '2' plugins: - name: golang wasm: url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.1/sqlc-gen-go.wasm sha256: f6a02c8cb363c56bb7a7de1d1012a65e800897696de9d28b938563af02e3ce81 sql: -- engine: postgresql +- schema: schema.sql + queries: query.sql + engine: postgresql codegen: - plugin: golang out: db options: package: db + sql_package: pgx/v5 + emit_interface: true emit_json_tags: true -``` + emit_prepared_queries: true + emit_per_file_queries: true + emit_err_nil_if_no_rows: true + emit_result_struct_pointers: true + disable_result_slice_pointers: true + emit_dynamic_filter: true + enable_validate_cte: true + go_generate_mock: "mockgen -source=$GOFILE -destination=mock/$GOFILE -package=mock" + emit_tracing: + import: "go.opentelemetry.io/otel" + package: "otel" + code: + - 'ctx, span := otel.Tracer("{{.StructName}}").Start(ctx, "{{.MethodName}}")' + - "defer span.End()" +``` + +> For all basic sqlc options (schema, queries, engine, etc.) see the [sqlc configuration reference](https://docs.sqlc.dev/en/stable/reference/config.html). -Global `overrides`/`go` move to `options`/``: +## Migrating from sqlc's built-in Go codegen -```yaml -options: - golang: - rename: - id: "Identifier" - overrides: - - db_type: "timestamptz" - nullable: true - engine: postgresql - go_type: - import: "gopkg.in/guregu/null.v4" - package: "null" - type: "Time" -``` +See [MIGRATION.md](MIGRATION.md) for step-by-step migration instructions. ## Advanced Options @@ -149,85 +121,39 @@ options: ### `emit_dynamic_filter` -Enables optional WHERE/ORDER BY clauses controlled at runtime via `-- :if @param` annotations in SQL. - -When a parameter is marked with `:if`, the generated code: -- Makes the parameter a pointer (`*T`) in the params struct — `nil` means "skip this clause" -- Adds a `bool` field for flag-only parameters (e.g. ORDER BY toggles that appear only in `:if` annotations, not as `col = $N` predicate values) -- Calls the generated `DynamicSQL()` helper at runtime to strip inactive lines and renumber placeholders +Optional WHERE/ORDER BY clauses controlled at runtime via `-- :if @param` annotations. A `:if` parameter becomes a pointer (`*T`) in the params struct — `nil` skips its clause; flag-only params become `bool` fields. ```yaml options: emit_dynamic_filter: true ``` -**SQL annotations** - ```sql -- name: SearchUsers :many SELECT * FROM users -WHERE - 1 = 1 - AND email = @email -- :if @email -- omit this line if email is nil (inline style) - -- :if @phone -- omit the next line if phone is nil (top-level style) - AND phone = @phone - AND EXISTS ( -- :if @has_orders -- flag-only boolean; omit this block when false - SELECT 1 FROM orders - WHERE orders.user_id = users.id - AND orders.created_at >= @orders_since -- :if @orders_since - ) -ORDER BY id ASC; - --- name: SearchUsersOrdered :many -SELECT * FROM users WHERE TRUE - AND email = @email -- :if @email + -- :if @phone + AND phone = @phone + -- :if @has_orders + AND EXISTS ( + SELECT 1 FROM orders WHERE orders.user_id = users.id + ) ORDER BY - id ASC, -- :if @id_asc + id ASC, -- :if @id_asc id DESC, -- :if @id_desc - TRUE -``` - -Note: We use TRUE to prevent SQL errors when a line is omitted. - -**Generated Go** - -For SearchUsers -```go -var _searchUsersDynQ = dynCompile(SearchUsers) - -type SearchUsersParams struct { - Email *string // nil → clause skipped - Phone *string // nil → clause skipped - OrdersSince *time.Time // nil → clause skipped - HasOrders bool // false → EXISTS block skipped -} - -func (q *SearchQueries) SearchUsers(ctx context.Context, db DBTX, arg SearchUsersParams) ([]*User, error) { -... - dynQuery, dynArgs := _searchUsersDynQ.Build([]any{arg.Email, arg.Phone, arg.OrdersSince, arg.HasOrders}) - rows, err := db.Query(ctx, dynQuery, dynArgs...) -... -} + TRUE; ``` -**Annotation rules** +Use `TRUE` as the leading predicate (and trailing `ORDER BY` term) so the clause stays valid when lines are omitted. | Style | Syntax | Behaviour | |---|---|---| -| Inline | `AND col = @param -- :if @param` | Skip this line if param is nil/false | -| Inline (multi-param) | `AND col = @param -- :if @a @b` | Skip this line if **any** listed param is nil/false | | Top-level | `-- :if @param` on its own line | Skip the **next** line if param is nil/false | -| Top-level Block `( )` | `-- :if @flag` then `AND EXISTS (` on the next line | Skip the **next** line if param is nil/false; if that line opens a paren block, skip the **entire block** (until matching `)`) | -| Inline Block `( )` | `AND EXISTS ( -- :if @flag` | Skip the entire parenthesized block (until matching `)`) if flag is false/nil | - -Two helpers are emitted into `dynfilter.go` in the output package: - -- **`dynCompile(query)`** — default behavior; pre-compiles the annotated SQL once at package init into a `dynCompiledQuery`. Each generated query uses this via a package-level `var _..DynQ = dynCompile(...)`, then calls `.Build(args)` per request with no per-call scanning. -- **`DynamicSQL(query, args)`** — one-shot helper; parses and filters on every call. Available for ad-hoc use. +| Top-level (multi-param) | `-- :if @a @b` on its own line | Skip the **next** line if **any** listed param is nil/false | +| Top-level Block `( )` | `-- :if @flag` then a line opening `(` | Skip the **entire block** (until matching `)`) if flag is nil/false | -After filtering, remaining `$N` placeholders are renumbered sequentially and the args slice is trimmed to match, preventing "expected N arguments, got M" errors. +Two helpers are emitted into `dynfilter.go`: `dynCompile(query)` (default — pre-compiled once at init, called per request via `.Build(args)`) and `DynamicSQL(query, args)` (one-shot, parses on every call). Both renumber remaining `$N` placeholders and trim args to match. --- @@ -271,112 +197,25 @@ Running `go generate ./...` produces a mock per SQL file (with `emit_per_file_qu --- -## Test Coverage +### `enable_validate_cte` -### Plugin internals (`internal/`) +When `true`, validates column references across CTEs and tables before code generation. Each query is checked so that every `table.column` / unqualified column reference resolves to a real column in the relevant table or CTE; an invalid reference fails `sqlc generate` with an explanatory error instead of producing broken code. -Run with: +Disabled by default (no validation overhead). Enable it per codegen output: -```sh -go test ./internal/... -coverprofile=coverage.out -covermode=atomic -go tool cover -func=coverage.out +```yaml +options: + enable_validate_cte: true ``` -| Package | Coverage | -|---|---| -| `internal` | 88.0% | -| `internal/opts` | 89.4% | -| `internal/inflection` | 100.0% | -| **Total** | **88.2%** | - -Key areas at 100%: `enum.go`, `field.go` (all case-style helpers), `inflection/singular.go`, `opts/enum.go` (driver/package validation), `opts/options.go ValidateOpts`, `reserved.go`, `struct.go`, `imports.go` (merge/sort/interface/copyfrom/batch). - -### Generated code (`example/test/`) +Example failure for a query referencing a non-existent `amount1` column: -Unit tests for the generated Go code — no database required. Covers `DynamicSQL` SQL-building logic, generated query SQL strings, and dynamic filter / ORDER BY combinations. - -```sh -cd example -go test ./test/... -v ``` - -**51 passing test cases** across: - -| Test | Sub-tests | What is covered | -|---|---|---| -| `TestDynamicSQL` | 22 | Placeholder remapping, gap handling, ORDER BY clauses, orphaned WHERE/GROUP BY/HAVING cleanup, EXISTS blocks | -| `TestSearchUsers` | 9 | Optional email/phone/date filter combinations on generated search query | -| `TestSearchUsersOrdered` | 4 | ORDER BY flag combinations | -| `TestSearchUsersByContact` | 4 | Multi-param optional filter | -| `TestSearchUsersWithSameNameAndEmail` | 2 | Nil vs non-nil shared-column filter | -| `TestSearchUsersWithBlock` | 2 | EXISTS block conditional inclusion | -| `TestSearchUsersWithTopStyle` | 2 | Top-level `:if` annotation style | -| `TestSearchUsersOrderedByID` | 4 | ASC/DESC flag combinations with optional filters | -| `TestGetUserWithLock` | 2 | `FOR UPDATE` / `FOR SHARE` SQL generation | - -### End-to-end (`example/e2e/`) - -Integration tests that run queries against a real PostgreSQL database (`postgres://postgres:postgres@localhost:6432/sqlc-test`). Covers the same scenarios as the unit tests but validates actual query execution and result mapping. - -```sh -make example-e2e +query "GetExclusiveHighValueUsers": column "amount1" not found in any table in scope (orders) ``` -| Test | What is covered | -|---|---| -| `TestSearchUsers` | Optional email/phone/date filters against real rows | -| `TestSearchUsersOrdered` | ORDER BY flag combinations | -| `TestSearchUsersByContact` | Multi-param optional filter | -| `TestSearchUsersWithSameNameAndEmail` | Nil vs non-nil shared-column filter | -| `TestSearchUsersOrderedByID` | ASC/DESC flag combinations with optional filters | -| `TestGetUserWithLock` | `FOR UPDATE` / `FOR SHARE` locking | - --- -## Benchmarks - -Benchmarks compare three approaches for dynamic SQL construction (`emit_dynamic_filter`). Source: [`example/bench/`](example/bench/). +## Contributing -```sh -cd example -go test ./bench -bench=. -benchmem -count=3 -run='^$' -``` - -Three strategies under test: - -| Strategy | Description | -|---|---| -| **DynamicSQL** | One-shot helper; parses the annotated SQL on every call | -| **PreCompiled** | SQL parsed once at package init into a `dynCompiledQuery`; `Build()` called per request — no per-call scanning | -| **Manual** | Hand-written `strings.Builder` with `fmt.Fprintf` per condition | - -### Small query (5 params, 4 optional) - -| Benchmark | ns/op | req/s | B/op | allocs/op | -|---|---:|---:|---:|---:| -| DynamicSQL — no optional | 2,285 | ~438 K | 3,688 | 46 | -| **PreCompiled — no optional** | **180** | **~5.56 M** | **304** | **3** | -| Manual — no optional | 138 | ~7.25 M | 368 | 5 | -| DynamicSQL — all optional | 2,478 | ~403 K | 4,168 | 49 | -| **PreCompiled — all optional** | **409** | **~2.44 M** | **784** | **6** | -| Manual — all optional | 442 | ~2.26 M | 688 | 6 | - -### Large query (21 params, 20 optional) - -| Benchmark | ns/op | req/s | B/op | allocs/op | -|---|---:|---:|---:|---:| -| DynamicSQL — no optional | 5,375 | ~186 K | 7,472 | 119 | -| **PreCompiled — no optional** | **226** | **~4.43 M** | **304** | **3** | -| Manual — no optional | 198 | ~5.05 M | 640 | 5 | -| DynamicSQL — all optional | 6,843 | ~146 K | 9,552 | 126 | -| **PreCompiled — all optional** | **944** | **~1.06 M** | **2,384** | **10** | -| Manual — all optional | 2,473 | ~404 K | 1,921 | 27 | - -Results on Intel Core i7-11800H @ 2.30GHz. - -### Takeaways - -- **PreCompiled is ~14–25× faster than `DynamicSQL`** and matches manual for the no-optional case. -- **For the all-optional large query, PreCompiled is 2.3× faster than manual** — `Build` writes pre-split string literals directly vs `fmt.Fprintf` per condition. -- **Allocations drop from 46–126 down to 3–10**, matching or beating manual. -- A typical DB round-trip is ~1 ms; PreCompiled overhead is ~150–800 ns — effectively free in practice. +See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions, how to run tests, and benchmarks. diff --git a/example/db/cte.sql.go b/example/db/cte.sql.go new file mode 100644 index 000000000..9f584fa20 --- /dev/null +++ b/example/db/cte.sql.go @@ -0,0 +1,865 @@ +//go:generate mockgen -source=$GOFILE -destination=mock/$GOFILE -package=mock + +// Code generated by sqlc. DO NOT EDIT. +// source: cte.sql + +package db + +import ( + "context" + "errors" + "time" + + tracing "example" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + decimal "github.com/shopspring/decimal" +) + +func NewCteQueries() *CteQueries { + return &CteQueries{} +} + +type CteQueries struct { +} + +const DeleteUserAndReturnOrders = `-- name: DeleteUserAndReturnOrders :many +WITH deleted_user AS ( + DELETE FROM orders WHERE user_id = $1 RETURNING id, user_id, amount, status, created_at +) +SELECT id, user_id, amount, status, created_at FROM deleted_user +` + +type DeleteUserAndReturnOrdersParams struct { + UserID int64 +} + +type DeleteUserAndReturnOrdersRow struct { + ID int64 + UserID int64 + Amount decimal.Decimal + Status string + CreatedAt time.Time +} + +func (q *CteQueries) DeleteUserAndReturnOrders(ctx context.Context, db DBTX, arg DeleteUserAndReturnOrdersParams) ([]DeleteUserAndReturnOrdersRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.DeleteUserAndReturnOrders") + defer tracer.End() + rows, err := db.Query(ctx, DeleteUserAndReturnOrders, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteUserAndReturnOrdersRow + for rows.Next() { + var i DeleteUserAndReturnOrdersRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Amount, + &i.Status, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetActiveUsersWithSubquery = `-- name: GetActiveUsersWithSubquery :many +WITH user_activity AS ( + SELECT u.id as user_id, u.name, + (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count, + (SELECT COALESCE(SUM(o.amount), 0) FROM orders o WHERE o.user_id = u.id) as total_spent + FROM users u +) +SELECT user_activity.user_id, user_activity.name, + user_activity.order_count, user_activity.total_spent +FROM user_activity +WHERE user_activity.order_count > 0 +ORDER BY user_activity.total_spent DESC +` + +type GetActiveUsersWithSubqueryRow struct { + UserID int64 + Name string + OrderCount int64 + TotalSpent interface{} +} + +// CTE with nested subquery in SELECT +func (q *CteQueries) GetActiveUsersWithSubquery(ctx context.Context, db DBTX) ([]GetActiveUsersWithSubqueryRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetActiveUsersWithSubquery") + defer tracer.End() + rows, err := db.Query(ctx, GetActiveUsersWithSubquery) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetActiveUsersWithSubqueryRow + for rows.Next() { + var i GetActiveUsersWithSubqueryRow + if err := rows.Scan( + &i.UserID, + &i.Name, + &i.OrderCount, + &i.TotalSpent, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetAllEntities = `-- name: GetAllEntities :many +WITH all_entities AS ( + SELECT id as entity_id, name as entity_name, 'user' as entity_type, created_at + FROM users + UNION ALL + SELECT id as entity_id, status as entity_name, 'order' as entity_type, created_at + FROM orders +) +SELECT all_entities.entity_id, all_entities.entity_name, + all_entities.entity_type, all_entities.created_at +FROM all_entities +ORDER BY all_entities.created_at DESC +` + +type GetAllEntitiesRow struct { + EntityID int64 + EntityName string + EntityType string + CreatedAt time.Time +} + +// CTE with UNION ALL +func (q *CteQueries) GetAllEntities(ctx context.Context, db DBTX) ([]GetAllEntitiesRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetAllEntities") + defer tracer.End() + rows, err := db.Query(ctx, GetAllEntities) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllEntitiesRow + for rows.Next() { + var i GetAllEntitiesRow + if err := rows.Scan( + &i.EntityID, + &i.EntityName, + &i.EntityType, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetExclusiveHighValueUsers = `-- name: GetExclusiveHighValueUsers :many +WITH high_value_users AS ( + SELECT DISTINCT user_id FROM orders WHERE amount > 100 + EXCEPT + SELECT DISTINCT user_id FROM orders WHERE status = 'cancelled' +) +SELECT u.id, u.name, u.email +FROM users u +INNER JOIN high_value_users hvu ON hvu.user_id = u.id +ORDER BY u.name +` + +type GetExclusiveHighValueUsersRow struct { + ID int64 + Name string + Email string +} + +// CTE with EXCEPT +func (q *CteQueries) GetExclusiveHighValueUsers(ctx context.Context, db DBTX) ([]GetExclusiveHighValueUsersRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetExclusiveHighValueUsers") + defer tracer.End() + rows, err := db.Query(ctx, GetExclusiveHighValueUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetExclusiveHighValueUsersRow + for rows.Next() { + var i GetExclusiveHighValueUsersRow + if err := rows.Scan(&i.ID, &i.Name, &i.Email); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetRevenueGrowth = `-- name: GetRevenueGrowth :many +WITH monthly_revenue AS ( + SELECT DATE_TRUNC('month', created_at)::DATE as month, + COUNT(*) as order_count, + SUM(amount) as revenue + FROM orders + WHERE created_at >= $1 AND created_at < $2 + GROUP BY DATE_TRUNC('month', created_at) +), +with_lag AS ( + SELECT month, order_count, revenue, + LAG(revenue) OVER (ORDER BY month) as prev_revenue, + LAG(order_count) OVER (ORDER BY month) as prev_order_count + FROM monthly_revenue +), +with_growth AS ( + SELECT month, order_count, revenue, + prev_revenue, + CASE + WHEN prev_revenue IS NOT NULL AND prev_revenue > 0 + THEN ROUND(((revenue - prev_revenue) / prev_revenue) * 100, 2) + ELSE NULL + END as growth_pct, + SUM(revenue) OVER (ORDER BY month) as cumulative_revenue + FROM with_lag +) +SELECT month, order_count, revenue, prev_revenue, growth_pct, cumulative_revenue +FROM with_growth +ORDER BY month +` + +type GetRevenueGrowthParams struct { + Since time.Time + Until time.Time +} + +type GetRevenueGrowthRow struct { + Month pgtype.Date + OrderCount int64 + Revenue int64 + PrevRevenue interface{} + GrowthPct interface{} + CumulativeRevenue int64 +} + +// Generate a date series and compute month-over-month revenue with growth rate +func (q *CteQueries) GetRevenueGrowth(ctx context.Context, db DBTX, arg GetRevenueGrowthParams) ([]GetRevenueGrowthRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetRevenueGrowth") + defer tracer.End() + rows, err := db.Query(ctx, GetRevenueGrowth, arg.Since, arg.Until) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRevenueGrowthRow + for rows.Next() { + var i GetRevenueGrowthRow + if err := rows.Scan( + &i.Month, + &i.OrderCount, + &i.Revenue, + &i.PrevRevenue, + &i.GrowthPct, + &i.CumulativeRevenue, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetTopSpenders = `-- name: GetTopSpenders :many +WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent + FROM orders + GROUP BY user_id + HAVING SUM(amount) >= $2 +), +user_info AS ( + SELECT u.id, u.name, u.email, ot.order_count, ot.total_spent + FROM users u + INNER JOIN order_totals ot ON ot.user_id = u.id +) +SELECT id, name, email, order_count, total_spent FROM user_info +ORDER BY total_spent DESC +LIMIT $1 +` + +type GetTopSpendersParams struct { + MaxResults int32 + MinSpent decimal.Decimal +} + +type GetTopSpendersRow struct { + ID int64 + Name string + Email string + OrderCount int64 + TotalSpent interface{} +} + +func (q *CteQueries) GetTopSpenders(ctx context.Context, db DBTX, arg GetTopSpendersParams) ([]GetTopSpendersRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetTopSpenders") + defer tracer.End() + rows, err := db.Query(ctx, GetTopSpenders, arg.MaxResults, arg.MinSpent) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTopSpendersRow + for rows.Next() { + var i GetTopSpendersRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.OrderCount, + &i.TotalSpent, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserCohortRetention = `-- name: GetUserCohortRetention :many +WITH user_cohorts AS ( + SELECT users.id as user_id, + DATE_TRUNC('month', users.created_at)::DATE as cohort_month + FROM users +), +cohort_orders AS ( + SELECT user_cohorts.cohort_month, + COUNT(DISTINCT user_cohorts.user_id) as active_users, + COUNT(orders.id) as total_orders + FROM user_cohorts + INNER JOIN orders ON orders.user_id = user_cohorts.user_id + WHERE orders.created_at >= user_cohorts.cohort_month + AND orders.created_at < user_cohorts.cohort_month + INTERVAL '1 year' + GROUP BY user_cohorts.cohort_month +) +SELECT cohort_month, active_users, total_orders FROM cohort_orders +ORDER BY cohort_month +` + +type GetUserCohortRetentionRow struct { + CohortMonth pgtype.Date + ActiveUsers int64 + TotalOrders int64 +} + +// Cohort analysis: users grouped by signup month with ordering activity counts +func (q *CteQueries) GetUserCohortRetention(ctx context.Context, db DBTX) ([]GetUserCohortRetentionRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetUserCohortRetention") + defer tracer.End() + rows, err := db.Query(ctx, GetUserCohortRetention) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserCohortRetentionRow + for rows.Next() { + var i GetUserCohortRetentionRow + if err := rows.Scan(&i.CohortMonth, &i.ActiveUsers, &i.TotalOrders); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserRank = `-- name: GetUserRank :one +WITH ranked_users AS ( + SELECT u.id as user_id, u.name, u.email, + COALESCE(SUM(o.amount), 0) as total_spent, + RANK() OVER (ORDER BY COALESCE(SUM(o.amount), 0) DESC) as spend_rank + FROM users u + LEFT JOIN orders o ON o.user_id = u.id + GROUP BY u.id, u.name, u.email +) +SELECT user_id, name, email, total_spent, spend_rank FROM ranked_users +WHERE user_id = $1 +` + +type GetUserRankParams struct { + UserID int64 +} + +type GetUserRankRow struct { + UserID int64 + Name string + Email string + TotalSpent interface{} + SpendRank int64 +} + +func (q *CteQueries) GetUserRank(ctx context.Context, db DBTX, arg GetUserRankParams) (*GetUserRankRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetUserRank") + defer tracer.End() + row := db.QueryRow(ctx, GetUserRank, arg.UserID) + var i GetUserRankRow + err := row.Scan( + &i.UserID, + &i.Name, + &i.Email, + &i.TotalSpent, + &i.SpendRank, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return &i, err +} + +const GetUserSpendingTiers = `-- name: GetUserSpendingTiers :many +WITH order_stats AS ( + SELECT user_id, + COUNT(*) as order_count, + COALESCE(SUM(amount), 0) as total_spent, + COALESCE(AVG(amount), 0) as avg_order, + COALESCE(MAX(amount), 0) as max_order, + MIN(o.created_at) as first_order_at, + MAX(o.created_at) as last_order_at + FROM orders o + GROUP BY user_id +), +spending_percentiles AS ( + SELECT user_id, order_count, total_spent, avg_order, max_order, + first_order_at, last_order_at, + PERCENT_RANK() OVER (ORDER BY total_spent) as spend_percentile, + NTILE(4) OVER (ORDER BY total_spent) as spend_quartile + FROM order_stats +), +tiered_users AS ( + SELECT sp.user_id, sp.order_count, sp.total_spent, sp.avg_order, sp.max_order, sp.first_order_at, sp.last_order_at, sp.spend_percentile, sp.spend_quartile, + CASE + WHEN spend_quartile = 4 THEN 'platinum' + WHEN spend_quartile = 3 THEN 'gold' + WHEN spend_quartile = 2 THEN 'silver' + ELSE 'bronze' + END as tier + FROM spending_percentiles sp +) +SELECT u.id, u.name, u.email, + tu.order_count, tu.total_spent, tu.avg_order, tu.max_order, + tu.first_order_at, tu.last_order_at, + tu.spend_percentile, tu.tier +FROM users u +INNER JOIN tiered_users tu ON tu.user_id = u.id +ORDER BY tu.total_spent DESC +` + +type GetUserSpendingTiersRow struct { + ID int64 + Name string + Email string + OrderCount int64 + TotalSpent interface{} + AvgOrder interface{} + MaxOrder interface{} + FirstOrderAt interface{} + LastOrderAt interface{} + SpendPercentile float64 + Tier string +} + +// Multi-step pipeline: aggregate → percentile → classify into tiers +func (q *CteQueries) GetUserSpendingTiers(ctx context.Context, db DBTX) ([]GetUserSpendingTiersRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetUserSpendingTiers") + defer tracer.End() + rows, err := db.Query(ctx, GetUserSpendingTiers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserSpendingTiersRow + for rows.Next() { + var i GetUserSpendingTiersRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.OrderCount, + &i.TotalSpent, + &i.AvgOrder, + &i.MaxOrder, + &i.FirstOrderAt, + &i.LastOrderAt, + &i.SpendPercentile, + &i.Tier, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUserTiersBySubquery = `-- name: GetUserTiersBySubquery :many +WITH user_tiers AS ( + SELECT u.id as user_id, u.name, + CASE + WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 10 THEN 'platinum' + WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 5 THEN 'gold' + WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 0 THEN 'silver' + ELSE 'bronze' + END as tier + FROM users u +) +SELECT user_tiers.user_id, user_tiers.name, user_tiers.tier +FROM user_tiers +ORDER BY user_tiers.user_id +` + +type GetUserTiersBySubqueryRow struct { + UserID int64 + Name string + Tier string +} + +// CTE with CASE containing subquery +func (q *CteQueries) GetUserTiersBySubquery(ctx context.Context, db DBTX) ([]GetUserTiersBySubqueryRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetUserTiersBySubquery") + defer tracer.End() + rows, err := db.Query(ctx, GetUserTiersBySubquery) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserTiersBySubqueryRow + for rows.Next() { + var i GetUserTiersBySubqueryRow + if err := rows.Scan(&i.UserID, &i.Name, &i.Tier); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUsersWithOrderTotals = `-- name: GetUsersWithOrderTotals :many +WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent + FROM orders + GROUP BY user_id +) +SELECT u.id, u.name, u.email, ot.order_count, ot.total_spent +FROM users u +INNER JOIN order_totals ot ON ot.user_id = u.id +ORDER BY ot.total_spent DESC +` + +type GetUsersWithOrderTotalsRow struct { + ID int64 + Name string + Email string + OrderCount int64 + TotalSpent interface{} +} + +func (q *CteQueries) GetUsersWithOrderTotals(ctx context.Context, db DBTX) ([]GetUsersWithOrderTotalsRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetUsersWithOrderTotals") + defer tracer.End() + rows, err := db.Query(ctx, GetUsersWithOrderTotals) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersWithOrderTotalsRow + for rows.Next() { + var i GetUsersWithOrderTotalsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.OrderCount, + &i.TotalSpent, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetUsersWithOrders = `-- name: GetUsersWithOrders :many +WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1) +) +SELECT active_users.id, active_users.name, active_users.email +FROM active_users +ORDER BY active_users.name +` + +type GetUsersWithOrdersRow struct { + ID int64 + Name string + Email string +} + +// CTE with EXISTS filter +func (q *CteQueries) GetUsersWithOrders(ctx context.Context, db DBTX) ([]GetUsersWithOrdersRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.GetUsersWithOrders") + defer tracer.End() + rows, err := db.Query(ctx, GetUsersWithOrders) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersWithOrdersRow + for rows.Next() { + var i GetUsersWithOrdersRow + if err := rows.Scan(&i.ID, &i.Name, &i.Email); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const SearchUsersWithStats = `-- name: SearchUsersWithStats :many +WITH user_order_stats AS ( + SELECT orders.user_id, + COUNT(*) as order_count, + COALESCE(SUM(orders.amount), 0) as total_spent, + MAX(orders.created_at) as last_order_at + FROM orders + GROUP BY orders.user_id +) +SELECT users.id, users.name, users.email, users.created_at, + COALESCE(user_order_stats.order_count, 0) as order_count, + COALESCE(user_order_stats.total_spent, 0) as total_spent, + user_order_stats.last_order_at +FROM users +LEFT JOIN user_order_stats ON user_order_stats.user_id = users.id +WHERE 1 = 1 + AND users.name = $1 -- :if $1 + AND users.email = $2 -- :if $2 +ORDER BY + users.created_at DESC, -- :if $3 + users.name ASC +` + +var _searchUsersWithStatsDynQ = dynCompile(SearchUsersWithStats) + +type SearchUsersWithStatsParams struct { + Name *string + Email *string + OrderByCreated bool +} + +type SearchUsersWithStatsRow struct { + ID int64 + Name string + Email string + CreatedAt time.Time + OrderCount int64 + TotalSpent interface{} + LastOrderAt interface{} +} + +// CTE combined with dynamic filters on base table columns +func (q *CteQueries) SearchUsersWithStats(ctx context.Context, db DBTX, arg SearchUsersWithStatsParams) ([]SearchUsersWithStatsRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.SearchUsersWithStats") + defer tracer.End() + dynQuery, dynArgs := _searchUsersWithStatsDynQ.Build([]any{arg.Name, arg.Email, arg.OrderByCreated}) + rows, err := db.Query(ctx, dynQuery, dynArgs...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchUsersWithStatsRow + for rows.Next() { + var i SearchUsersWithStatsRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.CreatedAt, + &i.OrderCount, + &i.TotalSpent, + &i.LastOrderAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const TransferUserOrders = `-- name: TransferUserOrders :many +WITH transferred AS ( + UPDATE orders + SET user_id = $1 + WHERE user_id = $2 AND status = $3 + RETURNING id, user_id, amount, status, created_at +), +from_user AS ( + SELECT name as from_name FROM users WHERE id = $2 +), +to_user AS ( + SELECT name as to_name FROM users WHERE id = $1 +) +SELECT t.id, t.amount, t.status, t.created_at, + fu.from_name, tu.to_name +FROM transferred t +CROSS JOIN from_user fu +CROSS JOIN to_user tu +ORDER BY t.created_at +` + +type TransferUserOrdersParams struct { + ToUserID int64 + FromUserID int64 + Status string +} + +type TransferUserOrdersRow struct { + ID int64 + Amount decimal.Decimal + Status string + CreatedAt time.Time + FromName string + ToName string +} + +// Writable CTE chain: update orders to new user, then return the transferred orders with both user names +func (q *CteQueries) TransferUserOrders(ctx context.Context, db DBTX, arg TransferUserOrdersParams) ([]TransferUserOrdersRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.TransferUserOrders") + defer tracer.End() + rows, err := db.Query(ctx, TransferUserOrders, arg.ToUserID, arg.FromUserID, arg.Status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TransferUserOrdersRow + for rows.Next() { + var i TransferUserOrdersRow + if err := rows.Scan( + &i.ID, + &i.Amount, + &i.Status, + &i.CreatedAt, + &i.FromName, + &i.ToName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const UpsertAndReturnUser = `-- name: UpsertAndReturnUser :one +WITH upserted AS ( + INSERT INTO users (name, email) + VALUES ($1, $2) + ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name + RETURNING id, name, email, created_at, phone +) +SELECT id, name, email, created_at, phone FROM upserted +` + +type UpsertAndReturnUserParams struct { + Name string + Email string +} + +type UpsertAndReturnUserRow struct { + ID int64 + Name string + Email string + CreatedAt time.Time + Phone *string +} + +func (q *CteQueries) UpsertAndReturnUser(ctx context.Context, db DBTX, arg UpsertAndReturnUserParams) (*UpsertAndReturnUserRow, error) { + ctx, tracer := tracing.StartTracing(ctx, "CteQueries.UpsertAndReturnUser") + defer tracer.End() + row := db.QueryRow(ctx, UpsertAndReturnUser, arg.Name, arg.Email) + var i UpsertAndReturnUserRow + err := row.Scan( + &i.ID, + &i.Name, + &i.Email, + &i.CreatedAt, + &i.Phone, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return &i, err +} + +type CteQuerier interface { + DeleteUserAndReturnOrders(ctx context.Context, db DBTX, arg DeleteUserAndReturnOrdersParams) ([]DeleteUserAndReturnOrdersRow, error) + // CTE with nested subquery in SELECT + GetActiveUsersWithSubquery(ctx context.Context, db DBTX) ([]GetActiveUsersWithSubqueryRow, error) + // CTE with UNION ALL + GetAllEntities(ctx context.Context, db DBTX) ([]GetAllEntitiesRow, error) + // CTE with EXCEPT + GetExclusiveHighValueUsers(ctx context.Context, db DBTX) ([]GetExclusiveHighValueUsersRow, error) + // Generate a date series and compute month-over-month revenue with growth rate + GetRevenueGrowth(ctx context.Context, db DBTX, arg GetRevenueGrowthParams) ([]GetRevenueGrowthRow, error) + GetTopSpenders(ctx context.Context, db DBTX, arg GetTopSpendersParams) ([]GetTopSpendersRow, error) + // Cohort analysis: users grouped by signup month with ordering activity counts + GetUserCohortRetention(ctx context.Context, db DBTX) ([]GetUserCohortRetentionRow, error) + GetUserRank(ctx context.Context, db DBTX, arg GetUserRankParams) (*GetUserRankRow, error) + // Multi-step pipeline: aggregate → percentile → classify into tiers + GetUserSpendingTiers(ctx context.Context, db DBTX) ([]GetUserSpendingTiersRow, error) + // CTE with CASE containing subquery + GetUserTiersBySubquery(ctx context.Context, db DBTX) ([]GetUserTiersBySubqueryRow, error) + GetUsersWithOrderTotals(ctx context.Context, db DBTX) ([]GetUsersWithOrderTotalsRow, error) + // CTE with EXISTS filter + GetUsersWithOrders(ctx context.Context, db DBTX) ([]GetUsersWithOrdersRow, error) + // CTE combined with dynamic filters on base table columns + SearchUsersWithStats(ctx context.Context, db DBTX, arg SearchUsersWithStatsParams) ([]SearchUsersWithStatsRow, error) + // Writable CTE chain: update orders to new user, then return the transferred orders with both user names + TransferUserOrders(ctx context.Context, db DBTX, arg TransferUserOrdersParams) ([]TransferUserOrdersRow, error) + UpsertAndReturnUser(ctx context.Context, db DBTX, arg UpsertAndReturnUserParams) (*UpsertAndReturnUserRow, error) +} + +var _ CteQuerier = (*CteQueries)(nil) diff --git a/example/db/mock/cte.sql.go b/example/db/mock/cte.sql.go new file mode 100644 index 000000000..b5ad550cd --- /dev/null +++ b/example/db/mock/cte.sql.go @@ -0,0 +1,267 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: cte.sql.go +// +// Generated by this command: +// +// mockgen -source=cte.sql.go -destination=mock/cte.sql.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + db "example/db" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockCteQuerier is a mock of CteQuerier interface. +type MockCteQuerier struct { + ctrl *gomock.Controller + recorder *MockCteQuerierMockRecorder + isgomock struct{} +} + +// MockCteQuerierMockRecorder is the mock recorder for MockCteQuerier. +type MockCteQuerierMockRecorder struct { + mock *MockCteQuerier +} + +// NewMockCteQuerier creates a new mock instance. +func NewMockCteQuerier(ctrl *gomock.Controller) *MockCteQuerier { + mock := &MockCteQuerier{ctrl: ctrl} + mock.recorder = &MockCteQuerierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCteQuerier) EXPECT() *MockCteQuerierMockRecorder { + return m.recorder +} + +// DeleteUserAndReturnOrders mocks base method. +func (m *MockCteQuerier) DeleteUserAndReturnOrders(ctx context.Context, arg1 db.DBTX, arg db.DeleteUserAndReturnOrdersParams) ([]db.DeleteUserAndReturnOrdersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserAndReturnOrders", ctx, arg1, arg) + ret0, _ := ret[0].([]db.DeleteUserAndReturnOrdersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteUserAndReturnOrders indicates an expected call of DeleteUserAndReturnOrders. +func (mr *MockCteQuerierMockRecorder) DeleteUserAndReturnOrders(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserAndReturnOrders", reflect.TypeOf((*MockCteQuerier)(nil).DeleteUserAndReturnOrders), ctx, arg1, arg) +} + +// GetActiveUsersWithSubquery mocks base method. +func (m *MockCteQuerier) GetActiveUsersWithSubquery(ctx context.Context, arg1 db.DBTX) ([]db.GetActiveUsersWithSubqueryRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveUsersWithSubquery", ctx, arg1) + ret0, _ := ret[0].([]db.GetActiveUsersWithSubqueryRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveUsersWithSubquery indicates an expected call of GetActiveUsersWithSubquery. +func (mr *MockCteQuerierMockRecorder) GetActiveUsersWithSubquery(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveUsersWithSubquery", reflect.TypeOf((*MockCteQuerier)(nil).GetActiveUsersWithSubquery), ctx, arg1) +} + +// GetAllEntities mocks base method. +func (m *MockCteQuerier) GetAllEntities(ctx context.Context, arg1 db.DBTX) ([]db.GetAllEntitiesRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllEntities", ctx, arg1) + ret0, _ := ret[0].([]db.GetAllEntitiesRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllEntities indicates an expected call of GetAllEntities. +func (mr *MockCteQuerierMockRecorder) GetAllEntities(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllEntities", reflect.TypeOf((*MockCteQuerier)(nil).GetAllEntities), ctx, arg1) +} + +// GetExclusiveHighValueUsers mocks base method. +func (m *MockCteQuerier) GetExclusiveHighValueUsers(ctx context.Context, arg1 db.DBTX) ([]db.GetExclusiveHighValueUsersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExclusiveHighValueUsers", ctx, arg1) + ret0, _ := ret[0].([]db.GetExclusiveHighValueUsersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExclusiveHighValueUsers indicates an expected call of GetExclusiveHighValueUsers. +func (mr *MockCteQuerierMockRecorder) GetExclusiveHighValueUsers(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExclusiveHighValueUsers", reflect.TypeOf((*MockCteQuerier)(nil).GetExclusiveHighValueUsers), ctx, arg1) +} + +// GetRevenueGrowth mocks base method. +func (m *MockCteQuerier) GetRevenueGrowth(ctx context.Context, arg1 db.DBTX, arg db.GetRevenueGrowthParams) ([]db.GetRevenueGrowthRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRevenueGrowth", ctx, arg1, arg) + ret0, _ := ret[0].([]db.GetRevenueGrowthRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRevenueGrowth indicates an expected call of GetRevenueGrowth. +func (mr *MockCteQuerierMockRecorder) GetRevenueGrowth(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRevenueGrowth", reflect.TypeOf((*MockCteQuerier)(nil).GetRevenueGrowth), ctx, arg1, arg) +} + +// GetTopSpenders mocks base method. +func (m *MockCteQuerier) GetTopSpenders(ctx context.Context, arg1 db.DBTX, arg db.GetTopSpendersParams) ([]db.GetTopSpendersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTopSpenders", ctx, arg1, arg) + ret0, _ := ret[0].([]db.GetTopSpendersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTopSpenders indicates an expected call of GetTopSpenders. +func (mr *MockCteQuerierMockRecorder) GetTopSpenders(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTopSpenders", reflect.TypeOf((*MockCteQuerier)(nil).GetTopSpenders), ctx, arg1, arg) +} + +// GetUserCohortRetention mocks base method. +func (m *MockCteQuerier) GetUserCohortRetention(ctx context.Context, arg1 db.DBTX) ([]db.GetUserCohortRetentionRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserCohortRetention", ctx, arg1) + ret0, _ := ret[0].([]db.GetUserCohortRetentionRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserCohortRetention indicates an expected call of GetUserCohortRetention. +func (mr *MockCteQuerierMockRecorder) GetUserCohortRetention(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCohortRetention", reflect.TypeOf((*MockCteQuerier)(nil).GetUserCohortRetention), ctx, arg1) +} + +// GetUserRank mocks base method. +func (m *MockCteQuerier) GetUserRank(ctx context.Context, arg1 db.DBTX, arg db.GetUserRankParams) (*db.GetUserRankRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserRank", ctx, arg1, arg) + ret0, _ := ret[0].(*db.GetUserRankRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserRank indicates an expected call of GetUserRank. +func (mr *MockCteQuerierMockRecorder) GetUserRank(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserRank", reflect.TypeOf((*MockCteQuerier)(nil).GetUserRank), ctx, arg1, arg) +} + +// GetUserSpendingTiers mocks base method. +func (m *MockCteQuerier) GetUserSpendingTiers(ctx context.Context, arg1 db.DBTX) ([]db.GetUserSpendingTiersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserSpendingTiers", ctx, arg1) + ret0, _ := ret[0].([]db.GetUserSpendingTiersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserSpendingTiers indicates an expected call of GetUserSpendingTiers. +func (mr *MockCteQuerierMockRecorder) GetUserSpendingTiers(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSpendingTiers", reflect.TypeOf((*MockCteQuerier)(nil).GetUserSpendingTiers), ctx, arg1) +} + +// GetUserTiersBySubquery mocks base method. +func (m *MockCteQuerier) GetUserTiersBySubquery(ctx context.Context, arg1 db.DBTX) ([]db.GetUserTiersBySubqueryRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserTiersBySubquery", ctx, arg1) + ret0, _ := ret[0].([]db.GetUserTiersBySubqueryRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserTiersBySubquery indicates an expected call of GetUserTiersBySubquery. +func (mr *MockCteQuerierMockRecorder) GetUserTiersBySubquery(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserTiersBySubquery", reflect.TypeOf((*MockCteQuerier)(nil).GetUserTiersBySubquery), ctx, arg1) +} + +// GetUsersWithOrderTotals mocks base method. +func (m *MockCteQuerier) GetUsersWithOrderTotals(ctx context.Context, arg1 db.DBTX) ([]db.GetUsersWithOrderTotalsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersWithOrderTotals", ctx, arg1) + ret0, _ := ret[0].([]db.GetUsersWithOrderTotalsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsersWithOrderTotals indicates an expected call of GetUsersWithOrderTotals. +func (mr *MockCteQuerierMockRecorder) GetUsersWithOrderTotals(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersWithOrderTotals", reflect.TypeOf((*MockCteQuerier)(nil).GetUsersWithOrderTotals), ctx, arg1) +} + +// GetUsersWithOrders mocks base method. +func (m *MockCteQuerier) GetUsersWithOrders(ctx context.Context, arg1 db.DBTX) ([]db.GetUsersWithOrdersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersWithOrders", ctx, arg1) + ret0, _ := ret[0].([]db.GetUsersWithOrdersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsersWithOrders indicates an expected call of GetUsersWithOrders. +func (mr *MockCteQuerierMockRecorder) GetUsersWithOrders(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersWithOrders", reflect.TypeOf((*MockCteQuerier)(nil).GetUsersWithOrders), ctx, arg1) +} + +// SearchUsersWithStats mocks base method. +func (m *MockCteQuerier) SearchUsersWithStats(ctx context.Context, arg1 db.DBTX, arg db.SearchUsersWithStatsParams) ([]db.SearchUsersWithStatsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchUsersWithStats", ctx, arg1, arg) + ret0, _ := ret[0].([]db.SearchUsersWithStatsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchUsersWithStats indicates an expected call of SearchUsersWithStats. +func (mr *MockCteQuerierMockRecorder) SearchUsersWithStats(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchUsersWithStats", reflect.TypeOf((*MockCteQuerier)(nil).SearchUsersWithStats), ctx, arg1, arg) +} + +// TransferUserOrders mocks base method. +func (m *MockCteQuerier) TransferUserOrders(ctx context.Context, arg1 db.DBTX, arg db.TransferUserOrdersParams) ([]db.TransferUserOrdersRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TransferUserOrders", ctx, arg1, arg) + ret0, _ := ret[0].([]db.TransferUserOrdersRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TransferUserOrders indicates an expected call of TransferUserOrders. +func (mr *MockCteQuerierMockRecorder) TransferUserOrders(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransferUserOrders", reflect.TypeOf((*MockCteQuerier)(nil).TransferUserOrders), ctx, arg1, arg) +} + +// UpsertAndReturnUser mocks base method. +func (m *MockCteQuerier) UpsertAndReturnUser(ctx context.Context, arg1 db.DBTX, arg db.UpsertAndReturnUserParams) (*db.UpsertAndReturnUserRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertAndReturnUser", ctx, arg1, arg) + ret0, _ := ret[0].(*db.UpsertAndReturnUserRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertAndReturnUser indicates an expected call of UpsertAndReturnUser. +func (mr *MockCteQuerierMockRecorder) UpsertAndReturnUser(ctx, arg1, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertAndReturnUser", reflect.TypeOf((*MockCteQuerier)(nil).UpsertAndReturnUser), ctx, arg1, arg) +} diff --git a/example/queries/cte.sql b/example/queries/cte.sql new file mode 100644 index 000000000..8faae7e35 --- /dev/null +++ b/example/queries/cte.sql @@ -0,0 +1,253 @@ +-- name: GetUsersWithOrderTotals :many +WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent + FROM orders + GROUP BY user_id +) +SELECT u.id, u.name, u.email, ot.order_count, ot.total_spent +FROM users u +INNER JOIN order_totals ot ON ot.user_id = u.id +ORDER BY ot.total_spent DESC; + +-- name: GetTopSpenders :many +WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent + FROM orders + GROUP BY user_id + HAVING SUM(amount) >= @min_spent +), +user_info AS ( + SELECT u.id, u.name, u.email, ot.order_count, ot.total_spent + FROM users u + INNER JOIN order_totals ot ON ot.user_id = u.id +) +SELECT * FROM user_info +ORDER BY total_spent DESC +LIMIT @max_results; + +-- name: GetUserRank :one +WITH ranked_users AS ( + SELECT u.id as user_id, u.name, u.email, + COALESCE(SUM(o.amount), 0) as total_spent, + RANK() OVER (ORDER BY COALESCE(SUM(o.amount), 0) DESC) as spend_rank + FROM users u + LEFT JOIN orders o ON o.user_id = u.id + GROUP BY u.id, u.name, u.email +) +SELECT user_id, name, email, total_spent, spend_rank FROM ranked_users +WHERE user_id = @user_id; + +-- name: UpsertAndReturnUser :one +WITH upserted AS ( + INSERT INTO users (name, email) + VALUES (@name, @email) + ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name + RETURNING * +) +SELECT * FROM upserted; + +-- name: DeleteUserAndReturnOrders :many +WITH deleted_user AS ( + DELETE FROM orders WHERE user_id = @user_id RETURNING * +) +SELECT * FROM deleted_user; + +-- name: GetUserSpendingTiers :many +-- Multi-step pipeline: aggregate → percentile → classify into tiers +WITH order_stats AS ( + SELECT user_id, + COUNT(*) as order_count, + COALESCE(SUM(amount), 0) as total_spent, + COALESCE(AVG(amount), 0) as avg_order, + COALESCE(MAX(amount), 0) as max_order, + MIN(o.created_at) as first_order_at, + MAX(o.created_at) as last_order_at + FROM orders o + GROUP BY user_id +), +spending_percentiles AS ( + SELECT user_id, order_count, total_spent, avg_order, max_order, + first_order_at, last_order_at, + PERCENT_RANK() OVER (ORDER BY total_spent) as spend_percentile, + NTILE(4) OVER (ORDER BY total_spent) as spend_quartile + FROM order_stats +), +tiered_users AS ( + SELECT sp.*, + CASE + WHEN spend_quartile = 4 THEN 'platinum' + WHEN spend_quartile = 3 THEN 'gold' + WHEN spend_quartile = 2 THEN 'silver' + ELSE 'bronze' + END as tier + FROM spending_percentiles sp +) +SELECT u.id, u.name, u.email, + tu.order_count, tu.total_spent, tu.avg_order, tu.max_order, + tu.first_order_at, tu.last_order_at, + tu.spend_percentile, tu.tier +FROM users u +INNER JOIN tiered_users tu ON tu.user_id = u.id +ORDER BY tu.total_spent DESC; + +-- name: GetRevenueGrowth :many +-- Generate a date series and compute month-over-month revenue with growth rate +WITH monthly_revenue AS ( + SELECT DATE_TRUNC('month', created_at)::DATE as month, + COUNT(*) as order_count, + SUM(amount) as revenue + FROM orders + WHERE created_at >= @since AND created_at < @until + GROUP BY DATE_TRUNC('month', created_at) +), +with_lag AS ( + SELECT month, order_count, revenue, + LAG(revenue) OVER (ORDER BY month) as prev_revenue, + LAG(order_count) OVER (ORDER BY month) as prev_order_count + FROM monthly_revenue +), +with_growth AS ( + SELECT month, order_count, revenue, + prev_revenue, + CASE + WHEN prev_revenue IS NOT NULL AND prev_revenue > 0 + THEN ROUND(((revenue - prev_revenue) / prev_revenue) * 100, 2) + ELSE NULL + END as growth_pct, + SUM(revenue) OVER (ORDER BY month) as cumulative_revenue + FROM with_lag +) +SELECT month, order_count, revenue, prev_revenue, growth_pct, cumulative_revenue +FROM with_growth +ORDER BY month; + +-- name: GetUserCohortRetention :many +-- Cohort analysis: users grouped by signup month with ordering activity counts +WITH user_cohorts AS ( + SELECT users.id as user_id, + DATE_TRUNC('month', users.created_at)::DATE as cohort_month + FROM users +), +cohort_orders AS ( + SELECT user_cohorts.cohort_month, + COUNT(DISTINCT user_cohorts.user_id) as active_users, + COUNT(orders.id) as total_orders + FROM user_cohorts + INNER JOIN orders ON orders.user_id = user_cohorts.user_id + WHERE orders.created_at >= user_cohorts.cohort_month + AND orders.created_at < user_cohorts.cohort_month + INTERVAL '1 year' + GROUP BY user_cohorts.cohort_month +) +SELECT * FROM cohort_orders +ORDER BY cohort_month; + +-- name: TransferUserOrders :many +-- Writable CTE chain: update orders to new user, then return the transferred orders with both user names +WITH transferred AS ( + UPDATE orders + SET user_id = @to_user_id + WHERE user_id = @from_user_id AND status = @status + RETURNING * +), +from_user AS ( + SELECT name as from_name FROM users WHERE id = @from_user_id +), +to_user AS ( + SELECT name as to_name FROM users WHERE id = @to_user_id +) +SELECT t.id, t.amount, t.status, t.created_at, + fu.from_name, tu.to_name +FROM transferred t +CROSS JOIN from_user fu +CROSS JOIN to_user tu +ORDER BY t.created_at; + +-- name: SearchUsersWithStats :many +-- CTE combined with dynamic filters on base table columns +WITH user_order_stats AS ( + SELECT orders.user_id, + COUNT(*) as order_count, + COALESCE(SUM(orders.amount), 0) as total_spent, + MAX(orders.created_at) as last_order_at + FROM orders + GROUP BY orders.user_id +) +SELECT users.id, users.name, users.email, users.created_at, + COALESCE(user_order_stats.order_count, 0) as order_count, + COALESCE(user_order_stats.total_spent, 0) as total_spent, + user_order_stats.last_order_at +FROM users +LEFT JOIN user_order_stats ON user_order_stats.user_id = users.id +WHERE 1 = 1 + AND users.name = @name -- :if @name + AND users.email = @email -- :if @email +ORDER BY + users.created_at DESC, -- :if @order_by_created + users.name ASC; + +-- name: GetActiveUsersWithSubquery :many +-- CTE with nested subquery in SELECT +WITH user_activity AS ( + SELECT u.id as user_id, u.name, + (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count, + (SELECT COALESCE(SUM(o.amount), 0) FROM orders o WHERE o.user_id = u.id) as total_spent + FROM users u +) +SELECT user_activity.user_id, user_activity.name, + user_activity.order_count, user_activity.total_spent +FROM user_activity +WHERE user_activity.order_count > 0 +ORDER BY user_activity.total_spent DESC; + +-- name: GetAllEntities :many +-- CTE with UNION ALL +WITH all_entities AS ( + SELECT id as entity_id, name as entity_name, 'user' as entity_type, created_at + FROM users + UNION ALL + SELECT id as entity_id, status as entity_name, 'order' as entity_type, created_at + FROM orders +) +SELECT all_entities.entity_id, all_entities.entity_name, + all_entities.entity_type, all_entities.created_at +FROM all_entities +ORDER BY all_entities.created_at DESC; + +-- name: GetUserTiersBySubquery :many +-- CTE with CASE containing subquery +WITH user_tiers AS ( + SELECT u.id as user_id, u.name, + CASE + WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 10 THEN 'platinum' + WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 5 THEN 'gold' + WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) > 0 THEN 'silver' + ELSE 'bronze' + END as tier + FROM users u +) +SELECT user_tiers.user_id, user_tiers.name, user_tiers.tier +FROM user_tiers +ORDER BY user_tiers.user_id; + +-- name: GetUsersWithOrders :many +-- CTE with EXISTS filter +WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1) +) +SELECT active_users.id, active_users.name, active_users.email +FROM active_users +ORDER BY active_users.name; + +-- name: GetExclusiveHighValueUsers :many +-- CTE with EXCEPT +WITH high_value_users AS ( + SELECT DISTINCT user_id FROM orders WHERE amount > 100 + EXCEPT + SELECT DISTINCT user_id FROM orders WHERE status = 'cancelled' +) +SELECT u.id, u.name, u.email +FROM users u +INNER JOIN high_value_users hvu ON hvu.user_id = u.id +ORDER BY u.name; diff --git a/example/sqlc.yaml b/example/sqlc.yaml index d818b5c20..888ddcb90 100644 --- a/example/sqlc.yaml +++ b/example/sqlc.yaml @@ -108,4 +108,6 @@ sql: emit_dynamic_filter: true emit_exported_queries: true disable_result_slice_pointers: true + # Validate CTE/column references before code generation. + enable_validate_cte: true diff --git a/internal/cte_validate.go b/internal/cte_validate.go new file mode 100644 index 000000000..737bbdce3 --- /dev/null +++ b/internal/cte_validate.go @@ -0,0 +1,779 @@ +package golang + +import ( + "fmt" + "regexp" + "strings" + + "github.com/sqlc-dev/plugin-sdk-go/plugin" +) + +type cteDefinition struct { + name string + columns []string +} + +var ( + returningStarRe = regexp.MustCompile(`(?i)\bRETURNING\s*\*`) + selectStarRe = regexp.MustCompile(`(?i)\bSELECT\s+(?:\w+\.)?\*`) +) + +func validateCTEReferences(req *plugin.GenerateRequest) error { + tableColumns := buildTableColumnMap(req) + for _, query := range req.Queries { + if query.Name == "" || query.Cmd == "" { + continue + } + normalized := normalizeSQL(query.Text) + lower := strings.ToLower(normalized) + if strings.HasPrefix(lower, "with ") { + if err := validateQueryCTEs(normalized, query.Name, tableColumns); err != nil { + return err + } + } else { + if err := validateQueryTables(normalized, query.Name, tableColumns); err != nil { + return err + } + } + } + return nil +} + +func buildTableColumnMap(req *plugin.GenerateRequest) map[string]map[string]struct{} { + tables := make(map[string]map[string]struct{}) + for _, schema := range req.Catalog.GetSchemas() { + for _, table := range schema.GetTables() { + cols := make(map[string]struct{}) + for _, col := range table.GetColumns() { + cols[strings.ToLower(col.GetName())] = struct{}{} + } + tables[strings.ToLower(table.GetRel().GetName())] = cols + } + } + return tables +} + +func validateQueryCTEs(sql string, queryName string, tableColumns map[string]map[string]struct{}) error { + sql = normalizeSQL(sql) + ctes, cteBodies, mainQuery := parseCTEs(sql, tableColumns) + + cteMap := make(map[string]*cteDefinition) + for i := range ctes { + cteMap[ctes[i].name] = &ctes[i] + } + + // Validate references inside CTE bodies against earlier CTEs + real tables + earlierCTEs := make(map[string]*cteDefinition) + for i, cte := range ctes { + if err := checkTextReferences(cteBodies[i], queryName, earlierCTEs, tableColumns); err != nil { + return err + } + earlierCTEs[cte.name] = &ctes[i] + } + + return checkTextReferences(mainQuery, queryName, cteMap, tableColumns) +} + +func validateQueryTables(sql string, queryName string, tableColumns map[string]map[string]struct{}) error { + normalized := normalizeSQL(sql) + return checkTextReferences(normalized, queryName, nil, tableColumns) +} + +var ( + lineCommentRe = regexp.MustCompile(`--[^\n]*`) + blockCommentRe = regexp.MustCompile(`/\*[\s\S]*?\*/`) + whitespaceRe = regexp.MustCompile(`\s+`) +) + +func normalizeSQL(sql string) string { + sql = lineCommentRe.ReplaceAllString(sql, "") + sql = blockCommentRe.ReplaceAllString(sql, "") + sql = whitespaceRe.ReplaceAllString(sql, " ") + return strings.TrimSpace(sql) +} + +func parseCTEs(sql string, tableColumns map[string]map[string]struct{}) ([]cteDefinition, []string, string) { + lower := strings.ToLower(sql) + if !strings.HasPrefix(lower, "with ") { + return nil, nil, sql + } + + withBody, mainQuery := splitWithClause(sql) + if withBody == "" { + return nil, nil, sql + } + + var ctes []cteDefinition + var bodies []string + cteMap := make(map[string]*cteDefinition) + + parts := splitCTEDefinitions(withBody) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + cte, body := parseSingleCTE(part, tableColumns, cteMap) + if cte != nil { + ctes = append(ctes, *cte) + bodies = append(bodies, body) + cteMap[cte.name] = cte + } + } + + return ctes, bodies, mainQuery +} + +func splitWithClause(sql string) (string, string) { + // Skip the "WITH " prefix (case-insensitive) + rest := sql[5:] + + // Find the main query by tracking parenthesis depth. + // The main query starts after the last CTE's closing paren, + // followed by the next top-level SQL keyword. + depth := 0 + lastCloseParen := -1 + for i, ch := range rest { + switch ch { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + lastCloseParen = i + } + } + } + + if lastCloseParen == -1 { + return "", sql + } + + withBody := rest[:lastCloseParen+1] + mainQuery := strings.TrimSpace(rest[lastCloseParen+1:]) + + return withBody, mainQuery +} + +func splitCTEDefinitions(withBody string) []string { + var parts []string + depth := 0 + start := 0 + + for i, ch := range withBody { + switch ch { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + parts = append(parts, withBody[start:i+1]) + start = i + 1 + // Skip whitespace and comma to next CTE + for start < len(withBody) && (withBody[start] == ' ' || withBody[start] == '\t' || withBody[start] == '\n' || withBody[start] == '\r' || withBody[start] == ',') { + start++ + } + } + } + } + + return parts +} + +var cteNameRe = regexp.MustCompile(`(?i)^(\w+)\s+AS\s*\(`) + +func parseSingleCTE(part string, tableColumns map[string]map[string]struct{}, knownCTEs map[string]*cteDefinition) (*cteDefinition, string) { + m := cteNameRe.FindStringSubmatchIndex(part) + if m == nil { + return nil, "" + } + + name := strings.ToLower(part[m[2]:m[3]]) + // m[1] is end of full match (position after the opening paren) + body := part[m[1] : len(part)-1] + + columns := extractCTEColumns(body, tableColumns, knownCTEs) + return &cteDefinition{name: name, columns: columns}, body +} + +func extractCTEColumns(body string, tableColumns map[string]map[string]struct{}, knownCTEs map[string]*cteDefinition) []string { + bodyLower := strings.ToLower(strings.TrimSpace(body)) + + // INSERT/UPDATE/DELETE ... RETURNING * + if returningStarRe.MatchString(bodyLower) { + tableName := extractDMLTable(bodyLower) + if tableName != "" { + if cols, ok := tableColumns[tableName]; ok { + var result []string + for col := range cols { + result = append(result, col) + } + return result + } + } + return nil + } + + if !strings.HasPrefix(bodyLower, "select") { + return nil + } + + // SELECT ... FROM ... + if selectStarRe.MatchString(bodyLower) { + return extractSelectStarColumns(bodyLower, tableColumns, knownCTEs) + } + + return extractSelectListColumns(body) +} + +var ( + insertIntoRe = regexp.MustCompile(`(?i)\bINSERT\s+INTO\s+(\w+)`) + updateRe = regexp.MustCompile(`(?i)\bUPDATE\s+(\w+)`) + deleteFromRe = regexp.MustCompile(`(?i)\bDELETE\s+FROM\s+(\w+)`) + fromSourceRe = regexp.MustCompile(`(?i)\bFROM\s+(\w+)`) +) + +func extractDMLTable(bodyLower string) string { + if m := insertIntoRe.FindStringSubmatch(bodyLower); m != nil { + return m[1] + } + if m := updateRe.FindStringSubmatch(bodyLower); m != nil { + return m[1] + } + if m := deleteFromRe.FindStringSubmatch(bodyLower); m != nil { + return m[1] + } + return "" +} + +func extractSelectStarColumns(bodyLower string, tableColumns map[string]map[string]struct{}, knownCTEs map[string]*cteDefinition) []string { + // SELECT * FROM source or SELECT alias.* FROM source alias + m := fromSourceRe.FindStringSubmatch(bodyLower) + if m == nil { + return nil + } + source := strings.ToLower(m[1]) + + if cte, ok := knownCTEs[source]; ok { + cols := make([]string, len(cte.columns)) + copy(cols, cte.columns) + return cols + } + if cols, ok := tableColumns[source]; ok { + var result []string + for col := range cols { + result = append(result, col) + } + return result + } + return nil +} + +func extractSelectListColumns(body string) []string { + selectBody := extractSelectClause(body) + if selectBody == "" { + return nil + } + + cols := splitSelectColumns(selectBody) + var result []string + for _, col := range cols { + name := resolveColumnName(col) + if name != "" { + result = append(result, strings.ToLower(name)) + } + } + return result +} + +func extractSelectClause(body string) string { + lower := strings.ToLower(body) + + // Find SELECT keyword (skip leading spaces) + idx := strings.Index(lower, "select") + if idx == -1 { + return "" + } + rest := body[idx+6:] + restLower := strings.ToLower(rest) + + // Skip DISTINCT/ALL + trimmed := strings.TrimSpace(restLower) + if strings.HasPrefix(trimmed, "distinct ") { + offset := strings.Index(restLower, "distinct") + 8 + rest = rest[offset:] + restLower = restLower[offset:] + } else if strings.HasPrefix(trimmed, "all ") { + offset := strings.Index(restLower, "all") + 3 + rest = rest[offset:] + restLower = restLower[offset:] + } + + // Find FROM at depth 0 + depth := 0 + for i, ch := range restLower { + switch ch { + case '(': + depth++ + case ')': + depth-- + default: + if depth == 0 && i+5 <= len(restLower) { + word := restLower[i : i+5] + if (word == "from " || word == "from\t") && (i == 0 || restLower[i-1] == ' ' || restLower[i-1] == '\t' || restLower[i-1] == ')') { + return strings.TrimSpace(rest[:i]) + } + } + } + } + + return strings.TrimSpace(rest) +} + +func splitSelectColumns(selectBody string) []string { + var cols []string + depth := 0 + start := 0 + + for i, ch := range selectBody { + switch ch { + case '(': + depth++ + case ')': + depth-- + case ',': + if depth == 0 { + cols = append(cols, strings.TrimSpace(selectBody[start:i])) + start = i + 1 + } + } + } + + last := strings.TrimSpace(selectBody[start:]) + if last != "" { + cols = append(cols, last) + } + + return cols +} + +var aliasRe = regexp.MustCompile(`(?i)\bAS\s+(\w+)\s*$`) +var qualifiedColRe = regexp.MustCompile(`(?i)^(\w+)\.(\w+)$`) +var simpleColRe = regexp.MustCompile(`(?i)^(\w+)$`) + +func resolveColumnName(expr string) string { + expr = strings.TrimSpace(expr) + + // Check for "expr AS alias" + if m := aliasRe.FindStringSubmatch(expr); m != nil { + return m[1] + } + + // Check for "table.column" + if m := qualifiedColRe.FindStringSubmatch(expr); m != nil { + return m[2] + } + + // Simple column name + if m := simpleColRe.FindStringSubmatch(expr); m != nil { + return m[1] + } + + return "" +} + +type columnSource struct { + name string + columns map[string]struct{} + isCTE bool +} + +var ( + qualifiedRefRe = regexp.MustCompile(`(?i)\b(\w+)\.(\w+)\b`) + aliasPatternRe = regexp.MustCompile(`(?i)\b(?:FROM|JOIN)\s+(\w+)\s+(?:AS\s+)?(\w+)\b`) +) + +func buildSourceMap(text string, cteMap map[string]*cteDefinition, tableColumns map[string]map[string]struct{}) map[string]*columnSource { + sources := make(map[string]*columnSource) + + for name, cols := range tableColumns { + sources[name] = &columnSource{name: name, columns: cols, isCTE: false} + } + + for name, cte := range cteMap { + colSet := make(map[string]struct{}, len(cte.columns)) + for _, c := range cte.columns { + colSet[c] = struct{}{} + } + sources[name] = &columnSource{name: name, columns: colSet, isCTE: true} + } + + matches := aliasPatternRe.FindAllStringSubmatch(text, -1) + for _, m := range matches { + sourceName := strings.ToLower(m[1]) + aliasName := strings.ToLower(m[2]) + switch aliasName { + case "on", "where", "inner", "left", "right", "outer", "cross", + "full", "natural", "using", "set", "order", "group", "having", + "limit", "offset", "union", "intersect", "except", "returning", + "as", "and", "or", "not", "in", "between", "like", "is", "null", + "true", "false", "case", "when", "then", "else", "end", "select": + continue + } + if src, ok := sources[sourceName]; ok { + sources[aliasName] = src + } + } + return sources +} + +// checkTextReferences validates column references in text, which the caller must +// already have passed through normalizeSQL. +func checkTextReferences(text string, queryName string, cteMap map[string]*cteDefinition, tableColumns map[string]map[string]struct{}) error { + normalized := text + sources := buildSourceMap(normalized, cteMap, tableColumns) + + // Check qualified references (table.column) + matches := qualifiedRefRe.FindAllStringSubmatch(normalized, -1) + for _, match := range matches { + refName := strings.ToLower(match[1]) + colName := strings.ToLower(match[2]) + + src, ok := sources[refName] + if !ok { + continue + } + + if len(src.columns) == 0 { + continue + } + + if _, found := src.columns[colName]; !found { + kind := "table" + if src.isCTE { + kind = "CTE" + } + colList := make([]string, 0, len(src.columns)) + for c := range src.columns { + colList = append(colList, c) + } + return fmt.Errorf( + "query %q: column %q not found in %s %q (available columns: %s)", + queryName, match[2], kind, src.name, strings.Join(colList, ", "), + ) + } + } + + // Check unqualified references in subqueries with a single FROM table + if err := checkSubqueryUnqualifiedRefs(normalized, queryName, sources, tableColumns); err != nil { + return err + } + + return nil +} + +var ( + // Matches FROM table or JOIN table at depth 0 within a clause + fromTableRe = regexp.MustCompile(`(?i)\b(?:FROM|JOIN)\s+(\w+)`) + // Matches unqualified identifiers in WHERE/ON/HAVING conditions + unqualifiedRefRe = regexp.MustCompile(`(?i)\b([a-z_]\w*)\b`) +) + +// sqlKeywords is used to skip SQL keywords when checking unqualified column references +var sqlKeywords = map[string]struct{}{ + "select": {}, "from": {}, "where": {}, "and": {}, "or": {}, "not": {}, + "in": {}, "is": {}, "null": {}, "true": {}, "false": {}, "between": {}, + "like": {}, "ilike": {}, "as": {}, "on": {}, "join": {}, "inner": {}, + "left": {}, "right": {}, "outer": {}, "cross": {}, "full": {}, "natural": {}, + "order": {}, "by": {}, "group": {}, "having": {}, "limit": {}, "offset": {}, + "union": {}, "intersect": {}, "except": {}, "all": {}, "distinct": {}, + "case": {}, "when": {}, "then": {}, "else": {}, "end": {}, "exists": {}, + "any": {}, "some": {}, "asc": {}, "desc": {}, "nulls": {}, "first": {}, + "last": {}, "set": {}, "update": {}, "delete": {}, "insert": {}, "into": {}, + "values": {}, "returning": {}, "conflict": {}, "do": {}, "nothing": {}, + "using": {}, "with": {}, "recursive": {}, "excluded": {}, + "count": {}, "sum": {}, "avg": {}, "min": {}, "max": {}, "coalesce": {}, + "now": {}, "current_timestamp": {}, "current_date": {}, "interval": {}, + "date_trunc": {}, "round": {}, "row_number": {}, "rank": {}, "dense_rank": {}, + "lag": {}, "lead": {}, "over": {}, "partition": {}, "ntile": {}, + "percent_rank": {}, "filter": {}, + "cast": {}, "extract": {}, "trim": {}, "upper": {}, "lower": {}, + "concat": {}, "length": {}, "substring": {}, "replace": {}, "position": {}, + "array_agg": {}, "string_agg": {}, "json_agg": {}, "jsonb_agg": {}, + "array": {}, "unnest": {}, "generate_series": {}, + "int": {}, "integer": {}, "bigint": {}, "smallint": {}, "text": {}, + "varchar": {}, "boolean": {}, "numeric": {}, "decimal": {}, "float": {}, + "double": {}, "date": {}, "timestamp": {}, "timestamptz": {}, + "primary": {}, "key": {}, "references": {}, "default": {}, "check": {}, + "unique": {}, "index": {}, "constraint": {}, "foreign": {}, + "create": {}, "alter": {}, "drop": {}, "table": {}, "column": {}, + "add": {}, "type": {}, "if": {}, "not_null": {}, + "begin": {}, "commit": {}, "rollback": {}, "transaction": {}, + "for": {}, "each": {}, "row": {}, "trigger": {}, "function": {}, + "returns": {}, "language": {}, "plpgsql": {}, "declare": {}, + "raise": {}, "notice": {}, "exception": {}, "perform": {}, + "new": {}, "old": {}, "tg_op": {}, + "year": {}, "month": {}, "day": {}, "hour": {}, "minute": {}, "second": {}, +} + +var ( + // stringLiteralRe matches single-quoted string literals. + stringLiteralRe = regexp.MustCompile(`'[^']*'`) + // castTypeRe matches a "::type" cast, including schema-qualified and array types + // (e.g. ::int, ::order_status, ::pg_catalog.int4, ::text[]). + castTypeRe = regexp.MustCompile(`(?i)::\s*"?[\w.]+"?(?:\s*\[\s*\])*`) + // funcCallRe matches a function-call name (identifier immediately followed by "("). + funcCallRe = regexp.MustCompile(`(?i)\b[a-z_]\w*\s*\(`) +) + +// stripNonColumnTokens removes tokens from a condition clause that contain +// identifiers which are not column references — nested subqueries, string +// literals, "::type" casts, qualified references (already validated separately), +// and function-call names — so the remaining bare identifiers can be checked as +// column names of the current scope. +func stripNonColumnTokens(cond string) string { + cond = stripSubqueries(cond) + cond = stringLiteralRe.ReplaceAllString(cond, "") + cond = castTypeRe.ReplaceAllString(cond, "") + cond = qualifiedRefRe.ReplaceAllString(cond, "") + cond = funcCallRe.ReplaceAllString(cond, "") + return cond +} + +// stripSubqueries removes balanced parenthesized groups whose contents are a +// SELECT (e.g. EXISTS/IN/scalar subqueries). Their identifiers belong to an +// inner scope and are validated separately, so they must not be checked against +// the current scope's columns. Grouping parens (e.g. "(a OR b)") are preserved. +func stripSubqueries(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == '(' { + if end := findMatchingParen(s, i); end != -1 { + if strings.HasPrefix(strings.TrimSpace(s[i+1:end]), "select") { + i = end + 1 + continue + } + } + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +func checkSubqueryUnqualifiedRefs(text string, queryName string, sources map[string]*columnSource, tableColumns map[string]map[string]struct{}) error { + lower := strings.ToLower(text) + + // Validate the top-level statement's own WHERE/ON/HAVING conditions. This + // covers the WHERE clause of a CTE body or main query, which is not wrapped + // in parentheses and so isn't picked up by the subquery scan below. + if trimmed := strings.TrimSpace(lower); strings.HasPrefix(trimmed, "select") { + if err := checkScopeUnqualifiedRefs(trimmed, queryName, sources, tableColumns); err != nil { + return err + } + } + + // Validate each parenthesized subquery in its own scope. + for i := 0; i < len(lower); i++ { + if lower[i] != '(' { + continue + } + + closeIdx := findMatchingParen(lower, i) + if closeIdx == -1 { + break + } + + innerLower := strings.ToLower(strings.TrimSpace(text[i+1 : closeIdx])) + if !strings.HasPrefix(innerLower, "select") { + continue + } + + if err := checkScopeUnqualifiedRefs(innerLower, queryName, sources, tableColumns); err != nil { + return err + } + } + return nil +} + +// checkScopeUnqualifiedRefs validates the unqualified identifiers in the +// WHERE/ON/HAVING clauses of a single SELECT scope against the columns of the +// tables in that scope's FROM/JOIN list. scopeLower must be lower-cased. +func checkScopeUnqualifiedRefs(scopeLower string, queryName string, sources map[string]*columnSource, tableColumns map[string]map[string]struct{}) error { + // Find all tables in this scope's FROM/JOIN (depth 0 only) + scopeTables := extractScopeTables(scopeLower, sources, tableColumns) + if len(scopeTables) == 0 { + return nil + } + + // Build merged column set for all tables in scope + allCols := make(map[string]struct{}) + var scopeTableNames []string + for _, src := range scopeTables { + for col := range src.columns { + allCols[col] = struct{}{} + } + scopeTableNames = append(scopeTableNames, src.name) + } + + // SELECT-list output names/aliases are valid references in GROUP BY, ORDER BY + // and (in some dialects) HAVING, so treat them as in-scope columns too. + for _, out := range extractSelectListColumns(scopeLower) { + allCols[out] = struct{}{} + } + + // Extract the WHERE/ON/HAVING/GROUP BY/ORDER BY condition part + condText := extractConditionClauses(scopeLower) + if condText == "" { + return nil + } + + // Strip everything that contains identifiers which are NOT column + // references, so they aren't mistaken for unknown columns. + condClean := stripNonColumnTokens(condText) + + // Check unqualified identifiers + refs := unqualifiedRefRe.FindAllString(condClean, -1) + for _, ref := range refs { + refLower := strings.ToLower(ref) + + if _, isKeyword := sqlKeywords[refLower]; isKeyword { + continue + } + + // Skip numbers, parameters ($1, @param) + if len(ref) > 0 && (ref[0] >= '0' && ref[0] <= '9') { + continue + } + + // Skip if it's a known table/alias name + if _, isSource := sources[refLower]; isSource { + continue + } + + // Check if this identifier exists in any table in scope + if _, found := allCols[refLower]; !found { + return fmt.Errorf( + "query %q: column %q not found in any table in scope (%s)", + queryName, ref, strings.Join(scopeTableNames, ", "), + ) + } + } + return nil +} + +func findMatchingParen(text string, openIdx int) int { + depth := 0 + for i := openIdx; i < len(text); i++ { + switch text[i] { + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +// parenDepths returns, for each byte index i in s, the parenthesis nesting depth +// after consuming s[i] (count of '(' minus ')' in s[0:i+1]). Identifiers and +// keywords contain no parens, so depths[i] at such a byte equals the depth of the +// clause it belongs to. Computing this once lets the scanners below run in O(n) +// instead of re-running a regex over the tail of the string at every position. +func parenDepths(s string) []int { + depths := make([]int, len(s)) + d := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '(': + d++ + case ')': + d-- + } + depths[i] = d + } + return depths +} + +func extractScopeTables(subqueryLower string, sources map[string]*columnSource, tableColumns map[string]map[string]struct{}) []*columnSource { + // Find FROM/JOIN tables at depth 0 within this subquery. + var result []*columnSource + seen := make(map[string]bool) + depths := parenDepths(subqueryLower) + + for _, m := range fromTableRe.FindAllStringSubmatchIndex(subqueryLower, -1) { + if depths[m[0]] != 0 { + continue + } + tableName := subqueryLower[m[2]:m[3]] + if seen[tableName] { + continue + } + seen[tableName] = true + if src, ok := sources[tableName]; ok { + result = append(result, src) + } else if cols, ok := tableColumns[tableName]; ok { + result = append(result, &columnSource{name: tableName, columns: cols, isCTE: false}) + } + } + + return result +} + +var conditionRe = regexp.MustCompile(`(?i)\b(?:WHERE|ON|HAVING|ORDER\s+BY|GROUP\s+BY)\b`) + +func extractConditionClauses(subqueryLower string) string { + // Find WHERE/ON/HAVING/ORDER BY/GROUP BY clauses at depth 0. + depths := parenDepths(subqueryLower) + clauseEnds := clauseEndRe.FindAllStringIndex(subqueryLower, -1) + + var parts []string + for _, cm := range conditionRe.FindAllStringIndex(subqueryLower, -1) { + if depths[cm[0]] != 0 { + continue + } + clauseStart := cm[1] + clauseEnd := findClauseEnd(subqueryLower, clauseStart, depths, clauseEnds) + parts = append(parts, subqueryLower[clauseStart:clauseEnd]) + } + + return strings.Join(parts, " ") +} + +var clauseEndRe = regexp.MustCompile(`(?i)\b(?:GROUP|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|RETURNING|HAVING|SELECT|FROM|WHERE)\b`) + +// findClauseEnd returns the end index of a clause body that begins at start. The +// clause ends at the first clause-boundary keyword at the same depth, or where a +// ')' closes a paren opened before the clause, whichever comes first. depths and +// clauseEnds are precomputed over the whole string by the caller. +func findClauseEnd(text string, start int, depths []int, clauseEnds [][]int) int { + base := 0 + if start > 0 { + base = depths[start-1] + } + + end := len(text) + // A ')' that drops below the clause's base depth closes the enclosing scope. + for i := start; i < len(text); i++ { + if depths[i] < base { + end = i + break + } + } + // The first clause-boundary keyword at the base depth ends the clause earlier. + for _, m := range clauseEnds { + if m[0] < start { + continue + } + if m[0] >= end { + break + } + if depths[m[0]] == base { + return m[0] + } + } + return end +} diff --git a/internal/cte_validate_falsepos_test.go b/internal/cte_validate_falsepos_test.go new file mode 100644 index 000000000..e54399773 --- /dev/null +++ b/internal/cte_validate_falsepos_test.go @@ -0,0 +1,196 @@ +package golang + +import "testing" + +// TestValidateQueryCTEs_NoFalsePositives guards against the validator rejecting +// valid SQL because of constructs that look like — but are not — column +// references inside subquery conditions (type casts, function calls). +func TestValidateQueryCTEs_NoFalsePositives(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + }{ + { + name: "cast to custom/enum type in subquery WHERE", + sql: `WITH x AS ( + SELECT id, name FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE status = 'active'::order_status AND user_id = u.id) + ) + SELECT x.id FROM x`, + }, + { + name: "custom function call in subquery WHERE", + sql: `WITH x AS ( + SELECT id, name FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE my_func(amount) > 0 AND user_id = u.id) + ) + SELECT x.id FROM x`, + }, + { + name: "column cast keeps the column checked", + sql: `WITH x AS ( + SELECT id, name FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE amount::numeric > 0 AND user_id = u.id) + ) + SELECT x.id FROM x`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateQueryCTEs(tt.sql, tt.name, tables); err != nil { + t.Errorf("false positive: %v", err) + } + }) + } +} + +// TestValidateQueryCTEs_OrderGroupBy covers unqualified column references in +// ORDER BY / GROUP BY clauses, including SELECT-list aliases which must remain +// valid there. +func TestValidateQueryCTEs_OrderGroupBy(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + wantErr bool + errCol string + }{ + { + name: "invalid column in subquery ORDER BY", + sql: `WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id ORDER BY created_at1 DESC LIMIT 1) + ) + SELECT active_users.id FROM active_users`, + wantErr: true, + errCol: "created_at1", + }, + { + name: "valid column in subquery ORDER BY", + sql: `WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1) + ) + SELECT active_users.id FROM active_users`, + wantErr: false, + }, + { + name: "invalid column in CTE GROUP BY", + sql: `WITH stats AS ( + SELECT user_id, COUNT(*) as cnt FROM orders GROUP BY user_id1 + ) + SELECT stats.user_id FROM stats`, + wantErr: true, + errCol: "user_id1", + }, + { + name: "SELECT-list alias is valid in ORDER BY", + sql: `WITH stats AS ( + SELECT user_id, COUNT(*) as cnt FROM orders GROUP BY user_id ORDER BY cnt DESC + ) + SELECT stats.user_id FROM stats`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errCol != "" && !contains(err.Error(), tt.errCol) { + t.Errorf("expected error to mention %q, got: %s", tt.errCol, err.Error()) + } + } else if err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + } +} + +// TestValidateQueryCTEs_TopLevelWhere covers unqualified column references in a +// CTE body's own WHERE clause (not wrapped in a subquery). +func TestValidateQueryCTEs_TopLevelWhere(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + wantErr bool + errCol string + }{ + { + name: "invalid unqualified column in CTE WHERE with EXCEPT", + sql: `WITH high_value_users AS ( + SELECT DISTINCT user_id FROM orders WHERE amount1 > 100 + EXCEPT + SELECT DISTINCT user_id FROM orders WHERE status = 'cancelled' + ) + SELECT u.id, u.name, u.email + FROM users u + INNER JOIN high_value_users hvu ON hvu.user_id = u.id + ORDER BY u.name`, + wantErr: true, + errCol: "amount1", + }, + { + name: "valid unqualified columns in CTE WHERE with EXCEPT", + sql: `WITH high_value_users AS ( + SELECT DISTINCT user_id FROM orders WHERE amount > 100 + EXCEPT + SELECT DISTINCT user_id FROM orders WHERE status = 'cancelled' + ) + SELECT u.id FROM users u + INNER JOIN high_value_users hvu ON hvu.user_id = u.id`, + wantErr: false, + }, + { + name: "EXISTS subquery does not leak into outer scope", + sql: `WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id) + ) + SELECT active_users.id FROM active_users`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errCol != "" && !contains(err.Error(), tt.errCol) { + t.Errorf("expected error to mention %q, got: %s", tt.errCol, err.Error()) + } + } else if err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + } +} + +// TestValidateQueryCTEs_CastDoesNotHideBadColumn ensures stripping a cast still +// leaves the underlying column reference subject to validation. +func TestValidateQueryCTEs_CastDoesNotHideBadColumn(t *testing.T) { + tables := testCatalog() + sql := `WITH x AS ( + SELECT id, name FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE nonexistent::numeric > 0 AND user_id = u.id) + ) + SELECT x.id FROM x` + if err := validateQueryCTEs(sql, "bad", tables); err == nil { + t.Fatal("expected error for nonexistent column, got nil") + } +} diff --git a/internal/cte_validate_test.go b/internal/cte_validate_test.go new file mode 100644 index 000000000..9ebfda72f --- /dev/null +++ b/internal/cte_validate_test.go @@ -0,0 +1,649 @@ +package golang + +import ( + "testing" + + "github.com/sqlc-dev/plugin-sdk-go/plugin" +) + +func testCatalog() map[string]map[string]struct{} { + return map[string]map[string]struct{}{ + "users": { + "id": {}, + "name": {}, + "email": {}, + "created_at": {}, + }, + "orders": { + "id": {}, + "user_id": {}, + "amount": {}, + "status": {}, + "created_at": {}, + }, + } +} + +func TestValidateQueryCTEs_ValidReferences(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + }{ + { + name: "simple CTE with correct reference", + sql: `WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count + FROM orders + GROUP BY user_id + ) + SELECT u.id, ot.order_count + FROM users u + INNER JOIN order_totals ot ON ot.user_id = u.id`, + }, + { + name: "multiple CTEs with cross-references", + sql: `WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent + FROM orders + GROUP BY user_id + ), + user_info AS ( + SELECT u.id, u.name, ot.order_count, ot.total_spent + FROM users u + INNER JOIN order_totals ot ON ot.user_id = u.id + ) + SELECT * FROM user_info + ORDER BY total_spent DESC`, + }, + { + name: "CTE with aliased columns", + sql: `WITH ranked AS ( + SELECT u.id as user_id, u.name, COALESCE(SUM(o.amount), 0) as total_spent + FROM users u + LEFT JOIN orders o ON o.user_id = u.id + GROUP BY u.id, u.name + ) + SELECT ranked.user_id, ranked.name, ranked.total_spent FROM ranked`, + }, + { + name: "writable CTE with RETURNING *", + sql: `WITH deleted AS ( + DELETE FROM orders WHERE user_id = $1 RETURNING * + ) + SELECT deleted.id, deleted.user_id, deleted.amount FROM deleted`, + }, + { + name: "INSERT CTE with RETURNING *", + sql: `WITH upserted AS ( + INSERT INTO users (name, email) + VALUES ($1, $2) + ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name + RETURNING * + ) + SELECT * FROM upserted`, + }, + { + name: "no CTE query", + sql: `SELECT id, name FROM users WHERE id = $1`, + }, + { + name: "CTE with function expressions", + sql: `WITH stats AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent, MAX(created_at) as last_order_at + FROM orders + GROUP BY user_id + ) + SELECT stats.user_id, stats.order_count, stats.total_spent, stats.last_order_at FROM stats`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + } +} + +func TestValidateQueryCTEs_InvalidReferences(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + wantField string + wantCTE string + }{ + { + name: "typo in CTE column reference", + sql: `WITH user_order_stats AS ( + SELECT orders.user_id, COUNT(*) as order_count + FROM orders + GROUP BY orders.user_id + ) + SELECT users.id + FROM users + LEFT JOIN user_order_stats ON user_order_stats.user_id1 = users.id`, + wantField: "user_id1", + wantCTE: "user_order_stats", + }, + { + name: "nonexistent column in CTE", + sql: `WITH totals AS ( + SELECT user_id, SUM(amount) as total + FROM orders + GROUP BY user_id + ) + SELECT totals.nonexistent FROM totals`, + wantField: "nonexistent", + wantCTE: "totals", + }, + { + name: "wrong column in RETURNING * CTE", + sql: `WITH deleted AS ( + DELETE FROM orders WHERE user_id = $1 RETURNING * + ) + SELECT deleted.wrong_col FROM deleted`, + wantField: "wrong_col", + wantCTE: "deleted", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if err == nil { + t.Fatal("expected error, got nil") + } + errStr := err.Error() + if tt.wantField != "" && !contains(errStr, tt.wantField) { + t.Errorf("expected error to mention field %q, got: %s", tt.wantField, errStr) + } + if tt.wantCTE != "" && !contains(errStr, tt.wantCTE) { + t.Errorf("expected error to mention CTE %q, got: %s", tt.wantCTE, errStr) + } + }) + } +} + +func TestValidateQueryCTEs_ChainedCTEsValid(t *testing.T) { + tables := testCatalog() + + sql := `WITH order_stats AS ( + SELECT user_id, COUNT(*) as order_count, COALESCE(SUM(amount), 0) as total_spent + FROM orders + GROUP BY user_id + ), + spending_percentiles AS ( + SELECT user_id, order_count, total_spent, + PERCENT_RANK() OVER (ORDER BY total_spent) as spend_percentile, + NTILE(4) OVER (ORDER BY total_spent) as spend_quartile + FROM order_stats + ), + tiered_users AS ( + SELECT sp.user_id, sp.order_count, sp.total_spent, sp.spend_percentile, + CASE WHEN sp.spend_quartile = 4 THEN 'platinum' ELSE 'bronze' END as tier + FROM spending_percentiles sp + ) + SELECT u.id, tiered_users.order_count, tiered_users.total_spent, tiered_users.tier + FROM users u + INNER JOIN tiered_users ON tiered_users.user_id = u.id` + + err := validateQueryCTEs(sql, "ChainedCTEs", tables) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } +} + +func TestValidateQueryCTEs_ChainedCTEsInvalid(t *testing.T) { + tables := testCatalog() + + sql := `WITH order_stats AS ( + SELECT user_id, COUNT(*) as order_count + FROM orders + GROUP BY user_id + ), + enriched AS ( + SELECT order_stats.user_id, order_stats.order_count, order_stats.total_spent + FROM order_stats + ) + SELECT * FROM enriched` + + err := validateQueryCTEs(sql, "ChainedCTEsInvalid", tables) + if err == nil { + t.Fatal("expected error for reference to nonexistent 'total_spent' in order_stats") + } + if !contains(err.Error(), "total_spent") { + t.Errorf("expected error to mention 'total_spent', got: %s", err.Error()) + } +} + +func TestExtractSelectListColumns(t *testing.T) { + tests := []struct { + body string + want []string + }{ + { + body: "SELECT user_id, COUNT(*) as order_count FROM orders", + want: []string{"user_id", "order_count"}, + }, + { + body: "SELECT u.id as user_id, u.name, COALESCE(SUM(o.amount), 0) as total_spent FROM users u", + want: []string{"user_id", "name", "total_spent"}, + }, + { + body: "SELECT orders.user_id, COUNT(*) as order_count, COALESCE(SUM(orders.amount), 0) as total_spent, MAX(orders.created_at) as last_order_at FROM orders", + want: []string{"user_id", "order_count", "total_spent", "last_order_at"}, + }, + } + + for _, tt := range tests { + t.Run(tt.body[:30], func(t *testing.T) { + got := extractSelectListColumns(tt.body) + if len(got) != len(tt.want) { + t.Fatalf("got %d columns %v, want %d columns %v", len(got), got, len(tt.want), tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("column %d: got %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestValidateCTEReferences_Integration(t *testing.T) { + req := &plugin.GenerateRequest{ + Catalog: &plugin.Catalog{ + DefaultSchema: "public", + Schemas: []*plugin.Schema{ + { + Name: "public", + Tables: []*plugin.Table{ + { + Rel: &plugin.Identifier{Name: "users"}, + Columns: []*plugin.Column{ + {Name: "id"}, + {Name: "name"}, + {Name: "email"}, + {Name: "created_at"}, + }, + }, + { + Rel: &plugin.Identifier{Name: "orders"}, + Columns: []*plugin.Column{ + {Name: "id"}, + {Name: "user_id"}, + {Name: "amount"}, + {Name: "status"}, + {Name: "created_at"}, + }, + }, + }, + }, + }, + }, + Queries: []*plugin.Query{ + { + Name: "GetValid", + Cmd: ":many", + Text: `WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count + FROM orders + GROUP BY user_id + ) + SELECT u.id, ot.order_count + FROM users u + INNER JOIN order_totals ot ON ot.user_id = u.id`, + }, + }, + } + + if err := validateCTEReferences(req); err != nil { + t.Errorf("expected no error, got: %v", err) + } + + // Now test with invalid reference + req.Queries = []*plugin.Query{ + { + Name: "GetInvalid", + Cmd: ":many", + Text: `WITH order_totals AS ( + SELECT user_id, COUNT(*) as order_count + FROM orders + GROUP BY user_id + ) + SELECT u.id, ot.order_count + FROM users u + INNER JOIN order_totals ot ON ot.user_id1 = u.id`, + }, + } + + err := validateCTEReferences(req) + if err == nil { + t.Fatal("expected error for invalid CTE reference") + } + if !contains(err.Error(), "user_id1") { + t.Errorf("expected error to mention 'user_id1', got: %s", err.Error()) + } +} + +func TestValidateQueryCTEs_NestedSubquery(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + wantErr bool + errCol string + }{ + { + name: "CTE with nested subquery in SELECT - valid ref", + sql: `WITH user_stats AS ( + SELECT user_id, + (SELECT COUNT(*) FROM orders o WHERE o.user_id = orders.user_id) as order_count + FROM orders + GROUP BY user_id + ) + SELECT user_stats.user_id, user_stats.order_count FROM user_stats`, + wantErr: false, + }, + { + name: "CTE with nested subquery in SELECT - invalid ref", + sql: `WITH user_stats AS ( + SELECT user_id, + (SELECT COUNT(*) FROM orders o WHERE o.user_id = orders.user_id) as order_count + FROM orders + GROUP BY user_id + ) + SELECT user_stats.user_id, user_stats.wrong_col FROM user_stats`, + wantErr: true, + errCol: "wrong_col", + }, + { + name: "CTE with subquery in FROM", + sql: `WITH totals AS ( + SELECT sq.user_id, sq.total + FROM (SELECT user_id, SUM(amount) as total FROM orders GROUP BY user_id) sq + ) + SELECT totals.user_id, totals.total FROM totals`, + wantErr: false, + }, + { + name: "CTE with subquery in FROM - invalid ref", + sql: `WITH totals AS ( + SELECT sq.user_id, sq.total + FROM (SELECT user_id, SUM(amount) as total FROM orders GROUP BY user_id) sq + ) + SELECT totals.user_id, totals.nonexistent FROM totals`, + wantErr: true, + errCol: "nonexistent", + }, + { + name: "CTE with CASE containing subquery", + sql: `WITH user_tiers AS ( + SELECT user_id, + CASE WHEN (SELECT COUNT(*) FROM orders o WHERE o.user_id = orders.user_id) > 5 THEN 'vip' ELSE 'regular' END as tier + FROM orders + GROUP BY user_id + ) + SELECT user_tiers.user_id, user_tiers.tier FROM user_tiers`, + wantErr: false, + }, + { + name: "CTE with EXISTS in WHERE - valid ref", + sql: `WITH active_users AS ( + SELECT id, name + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id) + ) + SELECT active_users.id, active_users.name FROM active_users`, + wantErr: false, + }, + { + name: "CTE with EXISTS in WHERE - invalid ref", + sql: `WITH active_users AS ( + SELECT id, name + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id) + ) + SELECT active_users.id, active_users.wrong FROM active_users`, + wantErr: true, + errCol: "wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errCol != "" && !contains(err.Error(), tt.errCol) { + t.Errorf("expected error to mention %q, got: %s", tt.errCol, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } + }) + } +} + +func TestValidateQueryCTEs_Union(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + wantErr bool + errCol string + }{ + { + name: "CTE with UNION ALL - valid ref", + sql: `WITH all_names AS ( + SELECT id, name FROM users + UNION ALL + SELECT id, status as name FROM orders + ) + SELECT all_names.id, all_names.name FROM all_names`, + wantErr: false, + }, + { + name: "CTE with UNION ALL - invalid ref", + sql: `WITH all_names AS ( + SELECT id, name FROM users + UNION ALL + SELECT id, status as name FROM orders + ) + SELECT all_names.id, all_names.nonexistent FROM all_names`, + wantErr: true, + errCol: "nonexistent", + }, + { + name: "CTE with UNION (no ALL) - valid ref", + sql: `WITH combined AS ( + SELECT id, name FROM users WHERE id < 100 + UNION + SELECT id, name FROM users WHERE id >= 100 + ) + SELECT combined.id, combined.name FROM combined`, + wantErr: false, + }, + { + name: "CTE with UNION - invalid ref", + sql: `WITH combined AS ( + SELECT id, name FROM users WHERE id < 100 + UNION + SELECT id, name FROM users WHERE id >= 100 + ) + SELECT combined.id, combined.email FROM combined`, + wantErr: true, + errCol: "email", + }, + { + name: "CTE with INTERSECT - valid ref", + sql: `WITH common AS ( + SELECT user_id FROM orders WHERE amount > 100 + INTERSECT + SELECT user_id FROM orders WHERE status = 'completed' + ) + SELECT common.user_id FROM common`, + wantErr: false, + }, + { + name: "CTE with EXCEPT - valid ref", + sql: `WITH excluded AS ( + SELECT user_id FROM orders + EXCEPT + SELECT user_id FROM orders WHERE status = 'cancelled' + ) + SELECT excluded.user_id FROM excluded`, + wantErr: false, + }, + { + name: "CTE with multiple UNIONs - valid ref", + sql: `WITH all_ids AS ( + SELECT id as entity_id, 'user' as entity_type FROM users + UNION ALL + SELECT id as entity_id, 'order' as entity_type FROM orders + ) + SELECT all_ids.entity_id, all_ids.entity_type FROM all_ids`, + wantErr: false, + }, + { + name: "CTE with multiple UNIONs - invalid ref", + sql: `WITH all_ids AS ( + SELECT id as entity_id, 'user' as entity_type FROM users + UNION ALL + SELECT id as entity_id, 'order' as entity_type FROM orders + ) + SELECT all_ids.entity_id, all_ids.wrong_type FROM all_ids`, + wantErr: true, + errCol: "wrong_type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errCol != "" && !contains(err.Error(), tt.errCol) { + t.Errorf("expected error to mention %q, got: %s", tt.errCol, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } + }) + } +} + +func TestValidateQueryCTEs_UnqualifiedReferences(t *testing.T) { + tables := testCatalog() + + tests := []struct { + name string + sql string + wantErr bool + errCol string + }{ + { + name: "EXISTS with valid unqualified column", + sql: `WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id = u.id) + ) + SELECT active_users.id, active_users.name FROM active_users`, + wantErr: false, + }, + { + name: "EXISTS with invalid unqualified column", + sql: `WITH active_users AS ( + SELECT id, name, email + FROM users u + WHERE EXISTS (SELECT 1 FROM orders WHERE user_id1 = u.id) + ) + SELECT active_users.id, active_users.name FROM active_users`, + wantErr: true, + errCol: "user_id1", + }, + { + name: "subquery with valid unqualified WHERE", + sql: `WITH stats AS ( + SELECT user_id, + (SELECT COUNT(*) FROM orders WHERE user_id = stats.user_id) as cnt + FROM orders stats + GROUP BY user_id + ) + SELECT stats.user_id FROM stats`, + wantErr: false, + }, + { + name: "subquery CASE with invalid unqualified column", + sql: `WITH user_tiers AS ( + SELECT u.id as user_id, u.name, + CASE + WHEN (SELECT COUNT(*) FROM orders WHERE user_id1 = u.id) > 0 THEN 'active' + ELSE 'inactive' + END as status + FROM users u + ) + SELECT user_tiers.user_id, user_tiers.status FROM user_tiers`, + wantErr: true, + errCol: "user_id1", + }, + { + name: "JOIN ON with valid unqualified column", + sql: `WITH data AS ( + SELECT u.id as user_id, u.name + FROM users u + INNER JOIN orders ON user_id = u.id + ) + SELECT data.user_id FROM data`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateQueryCTEs(tt.sql, tt.name, tables) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errCol != "" && !contains(err.Error(), tt.errCol) { + t.Errorf("expected error to mention %q, got: %s", tt.errCol, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } + }) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && searchString(s, sub) +} + +func searchString(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/dynfilter.go b/internal/dynfilter.go index 2c355b746..ec8740c1b 100644 --- a/internal/dynfilter.go +++ b/internal/dynfilter.go @@ -9,6 +9,9 @@ import ( "github.com/sqlc-dev/plugin-sdk-go/plugin" ) +// anyParamRe matches ANY($N) where N is a positional param number. +var anyParamRe = regexp.MustCompile(`(?i)\bANY\s*\(\s*\$(\d+)\s*\)`) + // ifAnnotationRe matches "-- :if @p1 [@p2 ...]" or "-- :if $p1 [$p2 ...]" at end of line. var ifAnnotationRe = regexp.MustCompile(`--\s*:if\s+[@$]\w+(?:\s+[@$]\w+)*\s*$`) @@ -76,6 +79,26 @@ func ParseDynFilter(sql string, params []*plugin.Parameter) (*DynFilterInfo, err } } + // Validate ANY($N) usage: the param must have an array type (::type[]). + paramByNumber := make(map[int32]*plugin.Parameter) + for _, p := range params { + paramByNumber[p.Number] = p + } + for _, m := range anyParamRe.FindAllStringSubmatch(sql, -1) { + num := int32(0) + for _, ch := range m[1] { + num = num*10 + int32(ch-'0') + } + p, ok := paramByNumber[num] + if !ok || p.Column == nil { + continue + } + if !p.Column.IsArray && !p.Column.IsSqlcSlice { + name := p.Column.Name + return nil, fmt.Errorf("ANY(@%s) requires an array type cast (e.g. @%s::type[]); without it the query will fail at runtime", name, name) + } + } + lines := strings.Split(sql, "\n") // First pass: collect all :if annotations to find which params are diff --git a/internal/dynfilter_test.go b/internal/dynfilter_test.go index b7ebda165..f8d53c045 100644 --- a/internal/dynfilter_test.go +++ b/internal/dynfilter_test.go @@ -378,3 +378,45 @@ func TestParseDynFilter_OnlyRequiredParams(t *testing.T) { t.Error("expected nil when no :if annotations are present") } } + +func TestParseDynFilter_AnyWithoutArrayType(t *testing.T) { + sql := "SELECT * FROM items\nWHERE id = ANY($1) -- :if @ids" + params := []*plugin.Parameter{ + {Number: 1, Column: &plugin.Column{Name: "ids", NotNull: true, Type: &plugin.Identifier{Name: "bigint"}}}, + } + _, err := ParseDynFilter(sql, params) + if err == nil { + t.Fatal("expected error for ANY(@ids) without array type") + } + if !strings.Contains(err.Error(), "ANY(@ids) requires an array type cast") { + t.Errorf("unexpected error message: %s", err) + } +} + +func TestParseDynFilter_AnyWithArrayType(t *testing.T) { + sql := "SELECT * FROM items\nWHERE id = ANY($1) -- :if @ids" + params := []*plugin.Parameter{ + {Number: 1, Column: &plugin.Column{Name: "ids", IsArray: true, ArrayDims: 1, NotNull: true, Type: &plugin.Identifier{Name: "bigint"}}}, + } + info, err := ParseDynFilter(sql, params) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if info == nil { + t.Fatal("expected non-nil info") + } +} + +func TestParseDynFilter_AnyWithSqlcSlice(t *testing.T) { + sql := "SELECT * FROM items\nWHERE id = ANY($1) -- :if @ids" + params := []*plugin.Parameter{ + {Number: 1, Column: &plugin.Column{Name: "ids", IsSqlcSlice: true, NotNull: true, Type: &plugin.Identifier{Name: "bigint"}}}, + } + info, err := ParseDynFilter(sql, params) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if info == nil { + t.Fatal("expected non-nil info") + } +} diff --git a/internal/gen.go b/internal/gen.go index d791926d2..d7253a813 100644 --- a/internal/gen.go +++ b/internal/gen.go @@ -180,6 +180,12 @@ func Generate(ctx context.Context, req *plugin.GenerateRequest) (*plugin.Generat return nil, err } + if options.EnableValidateCte { + if err := validateCTEReferences(req); err != nil { + return nil, err + } + } + enums := buildEnums(req, options) structs := buildStructs(req, options) queries, err := buildQueries(req, options, structs) diff --git a/internal/opts/options.go b/internal/opts/options.go index 55e9f1330..2735ec924 100644 --- a/internal/opts/options.go +++ b/internal/opts/options.go @@ -51,6 +51,7 @@ type Options struct { GoGenerateMock string `json:"go_generate_mock,omitempty" yaml:"go_generate_mock"` EmitDynamicFilter bool `json:"emit_dynamic_filter,omitempty" yaml:"emit_dynamic_filter"` WrapErrors bool `json:"wrap_errors,omitempty" yaml:"wrap_errors"` + EnableValidateCte bool `json:"enable_validate_cte,omitempty" yaml:"enable_validate_cte"` // nil inherits EmitPointersForNullTypes; non-nil overrides for enums only. EmitPointersForNullEnumTypes *bool `json:"emit_pointers_for_null_enum_types,omitempty" yaml:"emit_pointers_for_null_enum_types"` From 2fcc92f6553d3194d406335068858a14c34ab243 Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Thu, 25 Jun 2026 23:09:54 +0700 Subject: [PATCH 6/8] bump: v3.1.0 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e75761fc4..d56838d29 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ version: '2' plugins: - name: golang wasm: - url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.1/sqlc-gen-go.wasm - sha256: f6a02c8cb363c56bb7a7de1d1012a65e800897696de9d28b938563af02e3ce81 + url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.1.0/sqlc-gen-go.wasm + sha256: fd46f694ad7c9ff2dd13d6dab3291d2cc567e13c1aa63ba082c03aea4ed287d8 sql: - schema: schema.sql queries: query.sql From c13727850cbae278dff654b141a55ca8f7f52afb Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Fri, 26 Jun 2026 08:20:47 +0700 Subject: [PATCH 7/8] docs: update docs --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d56838d29..c71f3679d 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,14 @@ ORDER BY Use `TRUE` as the leading predicate (and trailing `ORDER BY` term) so the clause stays valid when lines are omitted. +All conditions skip when the referenced param is nil/false. + | Style | Syntax | Behaviour | |---|---|---| -| Top-level | `-- :if @param` on its own line | Skip the **next** line if param is nil/false | +| Top-level | `-- :if @param` on its own line | Skip the **next** line | | Top-level (multi-param) | `-- :if @a @b` on its own line | Skip the **next** line if **any** listed param is nil/false | -| Top-level Block `( )` | `-- :if @flag` then a line opening `(` | Skip the **entire block** (until matching `)`) if flag is nil/false | - -Two helpers are emitted into `dynfilter.go`: `dynCompile(query)` (default — pre-compiled once at init, called per request via `.Build(args)`) and `DynamicSQL(query, args)` (one-shot, parses on every call). Both renumber remaining `$N` placeholders and trim args to match. +| Top-level Block `( )` | `-- :if @flag` then a line opening `(` | Skip the **entire block** (until matching `)`) | +| Inline | `expression -- :if @param` | Skip the expression (including trailing comma) | --- From 857253b6241b6666275a7acb549ce00e33936045 Mon Sep 17 00:00:00 2001 From: vtuanjs Date: Fri, 26 Jun 2026 09:05:24 +0700 Subject: [PATCH 8/8] docs: update docs --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index c71f3679d..5f371309f 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,42 @@ query "GetExclusiveHighValueUsers": column "amount1" not found in any table in s --- +## Known Issues + +### Parameters in WHERE against CTE columns need an explicit type cast + +When a query parameter (e.g. `@since`) is compared against a column that comes from a CTE (including `UNION ALL` CTEs), sqlc cannot infer the parameter's type and raises a confusing error: + +``` +table alias "" does not exist +``` + +**Fix:** cast the parameter to the target type at the call site: + +```sql +-- Bad: sqlc cannot infer the type of @since +WITH all_entities AS ( + SELECT id, created_at FROM users + UNION ALL + SELECT id, created_at FROM orders +) +SELECT * FROM all_entities +WHERE all_entities.created_at >= @since; + +-- Good: explicit cast tells sqlc the expected type +WITH all_entities AS ( + SELECT id, created_at FROM users + UNION ALL + SELECT id, created_at FROM orders +) +SELECT * FROM all_entities +WHERE all_entities.created_at >= @since::timestamp; +``` + +This is a core sqlc limitation — the plugin cannot improve the error message. + +--- + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions, how to run tests, and benchmarks.