From c42104eb2ffb3a6904625e123c46cfcce7674e51 Mon Sep 17 00:00:00 2001 From: xen0l <1426772+xen0l@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:57:51 +0000 Subject: [PATCH] fix(duckdb): rewrite sqlc.arg and @name to native placeholders The preprocess dialect table had no entry for DuckDB, and an engine with no entry gets its source back unchanged. sqlc.arg(), sqlc.narg() and @name then reached the analyzer as function calls and column names. Add a DuckDB entry that uses $1 placeholders. Reject sqlc.slice for this dialect. With $1 placeholders the generated Go still calls strings.Replace on a "?" marker that the SQL does not contain, so it binds only the first element of the list and returns wrong rows without an error. A list parameter (list_contains or ANY) is the DuckDB way to do this. Adds the duckdb_named_params and analyze_slice_rejected cases. --- .../analyze_slice_rejected/duckdb/exec.json | 5 + .../analyze_slice_rejected/duckdb/query.sql | 2 + .../analyze_slice_rejected/duckdb/schema.sql | 1 + .../analyze_slice_rejected/duckdb/stderr.txt | 1 + .../duckdb_named_params/duckdb/exec.json | 1 + .../duckdb_named_params/duckdb/go/db.go | 31 +++++ .../duckdb_named_params/duckdb/go/models.go | 10 ++ .../duckdb/go/query.sql.go | 124 ++++++++++++++++++ .../duckdb_named_params/duckdb/query.sql | 11 ++ .../duckdb_named_params/duckdb/schema.sql | 1 + .../duckdb_named_params/duckdb/sqlc.yaml | 10 ++ internal/sql/preprocess/CLAUDE.md | 1 + internal/sql/preprocess/dialect.go | 15 +++ internal/sql/preprocess/scan.go | 7 + 14 files changed, 220 insertions(+) create mode 100644 internal/endtoend/testdata/analyze_slice_rejected/duckdb/exec.json create mode 100644 internal/endtoend/testdata/analyze_slice_rejected/duckdb/query.sql create mode 100644 internal/endtoend/testdata/analyze_slice_rejected/duckdb/schema.sql create mode 100644 internal/endtoend/testdata/analyze_slice_rejected/duckdb/stderr.txt create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/exec.json create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/go/db.go create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/go/models.go create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/go/query.sql.go create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/query.sql create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/schema.sql create mode 100644 internal/endtoend/testdata/duckdb_named_params/duckdb/sqlc.yaml diff --git a/internal/endtoend/testdata/analyze_slice_rejected/duckdb/exec.json b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/exec.json new file mode 100644 index 0000000000..56cb4b3fff --- /dev/null +++ b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/exec.json @@ -0,0 +1,5 @@ +{ + "command": "analyze", + "args": ["--dialect", "duckdb", "--schema", "schema.sql", "query.sql"], + "contexts": ["base"] +} diff --git a/internal/endtoend/testdata/analyze_slice_rejected/duckdb/query.sql b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/query.sql new file mode 100644 index 0000000000..d89f625b0a --- /dev/null +++ b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/query.sql @@ -0,0 +1,2 @@ +-- name: ByIDs :many +SELECT id, name FROM items WHERE id IN (sqlc.slice(ids)); diff --git a/internal/endtoend/testdata/analyze_slice_rejected/duckdb/schema.sql b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/schema.sql new file mode 100644 index 0000000000..3a1848aa23 --- /dev/null +++ b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/schema.sql @@ -0,0 +1 @@ +CREATE TABLE items (id INTEGER NOT NULL, name VARCHAR NOT NULL); diff --git a/internal/endtoend/testdata/analyze_slice_rejected/duckdb/stderr.txt b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/stderr.txt new file mode 100644 index 0000000000..ab1167afc9 --- /dev/null +++ b/internal/endtoend/testdata/analyze_slice_rejected/duckdb/stderr.txt @@ -0,0 +1 @@ +Error: error parsing queries: query.sql:2:41: sqlc.slice is not supported by this engine; bind a list parameter instead diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/exec.json b/internal/endtoend/testdata/duckdb_named_params/duckdb/exec.json new file mode 100644 index 0000000000..f4e21983b6 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/exec.json @@ -0,0 +1 @@ +{"contexts": ["base"]} diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/go/db.go b/internal/endtoend/testdata/duckdb_named_params/duckdb/go/db.go new file mode 100644 index 0000000000..32faa9f2b8 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/go/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/go/models.go b/internal/endtoend/testdata/duckdb_named_params/duckdb/go/models.go new file mode 100644 index 0000000000..66124a32e6 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/go/models.go @@ -0,0 +1,10 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package querytest + +type Item struct { + ID int32 + Name string +} diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/go/query.sql.go b/internal/endtoend/testdata/duckdb_named_params/duckdb/go/query.sql.go new file mode 100644 index 0000000000..cc8b35bb19 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/go/query.sql.go @@ -0,0 +1,124 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: query.sql + +package querytest + +import ( + "context" + "database/sql" +) + +const byAt = `-- name: ByAt :many +SELECT id, name FROM items WHERE id = $1; +` + +func (q *Queries) ByAt(ctx context.Context, wantID int32) ([]Item, error) { + rows, err := q.db.QueryContext(ctx, byAt, wantID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Item + for rows.Next() { + var i Item + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const byID = `-- name: ByID :many +SELECT id, name FROM items WHERE id = $1; +` + +func (q *Queries) ByID(ctx context.Context, wantID int32) ([]Item, error) { + rows, err := q.db.QueryContext(ctx, byID, wantID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Item + for rows.Next() { + var i Item + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const byNullableID = `-- name: ByNullableID :many +SELECT id, name FROM items WHERE id = $1; +` + +func (q *Queries) ByNullableID(ctx context.Context, wantID sql.NullInt32) ([]Item, error) { + rows, err := q.db.QueryContext(ctx, byNullableID, wantID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Item + for rows.Next() { + var i Item + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const range_ = `-- name: Range :many +SELECT id, name FROM items WHERE id > $1 AND id < $2; +` + +type RangeParams struct { + MinID int32 + MaxID int32 +} + +func (q *Queries) Range(ctx context.Context, arg RangeParams) ([]Item, error) { + rows, err := q.db.QueryContext(ctx, range_, arg.MinID, arg.MaxID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Item + for rows.Next() { + var i Item + if err := rows.Scan(&i.ID, &i.Name); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/query.sql b/internal/endtoend/testdata/duckdb_named_params/duckdb/query.sql new file mode 100644 index 0000000000..b455352ab6 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/query.sql @@ -0,0 +1,11 @@ +-- name: ByID :many +SELECT id, name FROM items WHERE id = sqlc.arg(want_id); + +-- name: ByNullableID :many +SELECT id, name FROM items WHERE id = sqlc.narg(want_id); + +-- name: ByAt :many +SELECT id, name FROM items WHERE id = @want_id; + +-- name: Range :many +SELECT id, name FROM items WHERE id > sqlc.arg(min_id) AND id < sqlc.arg(max_id); diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/schema.sql b/internal/endtoend/testdata/duckdb_named_params/duckdb/schema.sql new file mode 100644 index 0000000000..3a1848aa23 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/schema.sql @@ -0,0 +1 @@ +CREATE TABLE items (id INTEGER NOT NULL, name VARCHAR NOT NULL); diff --git a/internal/endtoend/testdata/duckdb_named_params/duckdb/sqlc.yaml b/internal/endtoend/testdata/duckdb_named_params/duckdb/sqlc.yaml new file mode 100644 index 0000000000..07d6d9d3b8 --- /dev/null +++ b/internal/endtoend/testdata/duckdb_named_params/duckdb/sqlc.yaml @@ -0,0 +1,10 @@ +version: "2" +sql: + - engine: "duckdb" + schema: "schema.sql" + queries: "query.sql" + gen: + go: + package: "querytest" + out: "go" + sql_package: "database/sql" diff --git a/internal/sql/preprocess/CLAUDE.md b/internal/sql/preprocess/CLAUDE.md index a7f4d9aafe..4b15992dba 100644 --- a/internal/sql/preprocess/CLAUDE.md +++ b/internal/sql/preprocess/CLAUDE.md @@ -20,6 +20,7 @@ Native placeholders by dialect: | postgresql | `$1` | sqlc syntax | | mysql | `?` | user variable, left alone | | sqlite | `?1` | sqlc syntax | +| duckdb | `$1` | sqlc syntax; `sqlc.slice` is rejected | GoogleSQL and ClickHouse are deliberately absent. They handle their own parameter syntax, so `File` returns their source unchanged and sqlc syntax is diff --git a/internal/sql/preprocess/dialect.go b/internal/sql/preprocess/dialect.go index 4d8d4ae85b..af7fa514d0 100644 --- a/internal/sql/preprocess/dialect.go +++ b/internal/sql/preprocess/dialect.go @@ -61,6 +61,12 @@ type Dialect struct { // identifiers. It decides the case of a parameter named by a bare // reference, e.g. sqlc.arg(FooBar). FoldIdentifier bool + + // NoSlice reports whether sqlc.slice is rejected. A dialect that numbers + // its parameters ($1) binds a list as one value, and the Go code that + // sqlc.slice generates only expands "?" placeholders, so it would bind + // the first element and return wrong rows without an error. + NoSlice bool } var dialects = map[config.Engine]Dialect{ @@ -88,6 +94,15 @@ var dialects = map[config.Engine]Dialect{ Backtick: true, Backslash: true, }, + // DuckDB binds with $1 like PostgreSQL and accepts $$ ... $$ strings. A + // bare ? is left to the engine, as it was before DuckDB had an entry. + config.EngineDuckDB: { + Style: StyleDollar, + AtSign: true, + DollarQuote: true, + DollarNumber: true, + NoSlice: true, + }, // ClickHouse binds with an unnumbered ?, like MySQL, and its identifiers // keep their case. config.EngineClickHouse: { diff --git a/internal/sql/preprocess/scan.go b/internal/sql/preprocess/scan.go index b2b4b1f534..da36627fdd 100644 --- a/internal/sql/preprocess/scan.go +++ b/internal/sql/preprocess/scan.go @@ -112,6 +112,13 @@ func (l *lexer) sqlcCall(start, afterSqlc int) (occurrence, int, bool) { case "narg": occ.kind = kindNarg case "slice": + if l.d.NoSlice { + occ.err = &sqlerr.Error{ + Message: "sqlc.slice is not supported by this engine; bind a list parameter instead", + Location: start, + } + return occ, occ.end, true + } occ.kind = kindSlice case "embed": occ.kind = kindEmbed