diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index c8037411a..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,44 +0,0 @@ -# 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 deleted file mode 100644 index 93dd187eb..000000000 --- a/MIGRATION.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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 5f371309f..3565546dc 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,6 @@ # sqlc-gen-go -> Write SQL. Generate Go. Let the compiler be your AI's feedback loop. - -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. - -## Why this matters for AI-driven development - -When AI writes database code, the most valuable thing you can give it is **fast, deterministic feedback**. sqlc-gen-go provides exactly that: - -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 - -The loop is: **SQL → generate → compile → test → repeat.** No ORM guessing, no runtime surprises, no hand-written boilerplate for AI to get wrong. - -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. +A sqlc plugin that generates type-safe Go database access code from SQL. Runs as a WASM plugin (recommended) or standalone binary. ## Usage @@ -27,8 +9,8 @@ version: '2' plugins: - name: golang wasm: - url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.1.0/sqlc-gen-go.wasm - sha256: fd46f694ad7c9ff2dd13d6dab3291d2cc567e13c1aa63ba082c03aea4ed287d8 + url: https://github.com/vtuanjs/sqlc-gen-go/releases/download/v3.0.0/sqlc-gen-go.wasm + sha256: a652e2c2c25d2b0332b4d8a8d0476e6674cda615f74090a8ead91aacba9704f3 sql: - schema: schema.sql queries: query.sql @@ -39,29 +21,75 @@ sql: 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). +## 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 +``` ## Migrating from sqlc's built-in Go codegen -See [MIGRATION.md](MIGRATION.md) for step-by-step migration instructions. +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.0/sqlc-gen-go.wasm + sha256: a652e2c2c25d2b0332b4d8a8d0476e6674cda615f74090a8ead91aacba9704f3 +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" +``` ## Advanced Options @@ -121,40 +149,82 @@ options: ### `emit_dynamic_filter` -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. +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 ```yaml options: emit_dynamic_filter: true ``` +**SQL annotations** + ```sql -- name: SearchUsers :many SELECT * FROM users WHERE - TRUE - -- :if @phone - AND phone = @phone - -- :if @has_orders - AND EXISTS ( - SELECT 1 FROM orders WHERE orders.user_id = users.id + 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 + 1 = 1 + AND email = @email -- :if @email ORDER BY - id ASC, -- :if @id_asc - id DESC, -- :if @id_desc - TRUE; + id ASC, -- :if @id_asc + id DESC -- :if @id_desc ``` -Use `TRUE` as the leading predicate (and trailing `ORDER BY` term) so the clause stays valid when lines are 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...) +... +} +``` -All conditions skip when the referenced param is nil/false. +**Annotation rules** | Style | Syntax | Behaviour | |---|---|---| -| 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 `)`) | -| Inline | `expression -- :if @param` | Skip the expression (including trailing comma) | +| 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. + +After filtering, remaining `$N` placeholders are renumbered sequentially and the args slice is trimmed to match, preventing "expected N arguments, got M" errors. --- @@ -198,61 +268,112 @@ Running `go generate ./...` produces a mock per SQL file (with `emit_per_file_qu --- -### `enable_validate_cte` +## Test Coverage -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. +### Plugin internals (`internal/`) -Disabled by default (no validation overhead). Enable it per codegen output: - -```yaml -options: - enable_validate_cte: true -``` - -Example failure for a query referencing a non-existent `amount1` column: +Run with: -``` -query "GetExclusiveHighValueUsers": column "amount1" not found in any table in scope (orders) +```sh +go test ./internal/... -coverprofile=coverage.out -covermode=atomic +go tool cover -func=coverage.out ``` ---- +| Package | Coverage | +|---|---| +| `internal` | 88.0% | +| `internal/opts` | 89.4% | +| `internal/inflection` | 100.0% | +| **Total** | **88.2%** | -## Known Issues +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). -### Parameters in WHERE against CTE columns need an explicit type cast +### Generated code (`example/test/`) -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: +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. -``` -table alias "" does not exist +```sh +cd example +go test ./test/... -v ``` -**Fix:** cast the parameter to the target type at the call site: +**51 passing test cases** across: -```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; +| 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 ``` -This is a core sqlc limitation — the plugin cannot improve the error message. +| 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 | --- -## Contributing +## Benchmarks + +Benchmarks compare three approaches for dynamic SQL construction (`emit_dynamic_filter`). Source: [`example/bench/`](example/bench/). -See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions, how to run tests, and benchmarks. +```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. diff --git a/example/db/cte.sql.go b/example/db/cte.sql.go deleted file mode 100644 index 9f584fa20..000000000 --- a/example/db/cte.sql.go +++ /dev/null @@ -1,865 +0,0 @@ -//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/dynfilter.go b/example/db/dynfilter.go index 671ccd21a..aba7539ed 100644 --- a/example/db/dynfilter.go +++ b/example/db/dynfilter.go @@ -103,8 +103,7 @@ func dynCompile(annotatedSQL string) *dynCompiledQuery { } // Unconditional line: accumulate into the static buffer. - staticBuf.WriteString(sep) - staticBuf.WriteString(line) + staticBuf.WriteString(sep + line) } flushStatic() diff --git a/example/db/mock/cte.sql.go b/example/db/mock/cte.sql.go deleted file mode 100644 index b5ad550cd..000000000 --- a/example/db/mock/cte.sql.go +++ /dev/null @@ -1,267 +0,0 @@ -// 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 deleted file mode 100644 index 8faae7e35..000000000 --- a/example/queries/cte.sql +++ /dev/null @@ -1,253 +0,0 @@ --- 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 888ddcb90..d818b5c20 100644 --- a/example/sqlc.yaml +++ b/example/sqlc.yaml @@ -108,6 +108,4 @@ 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 deleted file mode 100644 index 737bbdce3..000000000 --- a/internal/cte_validate.go +++ /dev/null @@ -1,779 +0,0 @@ -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 deleted file mode 100644 index e54399773..000000000 --- a/internal/cte_validate_falsepos_test.go +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index 9ebfda72f..000000000 --- a/internal/cte_validate_test.go +++ /dev/null @@ -1,649 +0,0 @@ -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 ec8740c1b..2c355b746 100644 --- a/internal/dynfilter.go +++ b/internal/dynfilter.go @@ -9,9 +9,6 @@ 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*$`) @@ -79,26 +76,6 @@ 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 f8d53c045..b7ebda165 100644 --- a/internal/dynfilter_test.go +++ b/internal/dynfilter_test.go @@ -378,45 +378,3 @@ 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 d7253a813..d791926d2 100644 --- a/internal/gen.go +++ b/internal/gen.go @@ -180,12 +180,6 @@ 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 2735ec924..55e9f1330 100644 --- a/internal/opts/options.go +++ b/internal/opts/options.go @@ -51,7 +51,6 @@ 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"` diff --git a/internal/templates/dynfilterCode.tmpl b/internal/templates/dynfilterCode.tmpl index ce3782c15..bc7204d43 100644 --- a/internal/templates/dynfilterCode.tmpl +++ b/internal/templates/dynfilterCode.tmpl @@ -95,8 +95,7 @@ func dynCompile(annotatedSQL string) *dynCompiledQuery { } // Unconditional line: accumulate into the static buffer. - staticBuf.WriteString(sep) - staticBuf.WriteString(line) + staticBuf.WriteString(sep + line) } flushStatic()