Injection-safe SQL: q.SQL, q.PgSQL, q.NamedSQL¶
A specialised cousin of q.F: same {expr} interpolation surface, but rewrites to placeholder-style parameterised SQL. User-supplied values are never inlined into the query string — the rewriter physically can't produce that shape, so the safety guarantee is structural, not advisory.
The format string MUST be a Go string literal. Allowing a dynamic format would re-open the SQL-injection hole the helper exists to close.
Signatures¶
type SQLQuery struct {
Query string
Args []any
}
func SQL(format string) SQLQuery // ?, ?, ? — SQLite, MySQL, plain database/sql
func PgSQL(format string) SQLQuery // $1, $2, $3 — lib/pq, pgx
func NamedSQL(format string) SQLQuery // :name1, :name2, :name3 — sqlx, named-param drivers
At a glance¶
id, status := 42, "active"
name := "alice'; DROP TABLE users; --" // injection attempt
// q.SQL — `?` placeholders (most portable)
s := q.SQL("SELECT * FROM users WHERE id = {id} AND status = {status}")
// s.Query → "SELECT * FROM users WHERE id = ? AND status = ?"
// s.Args → []any{42, "active"}
db.QueryRowContext(ctx, s.Query, s.Args...)
// q.PgSQL — Postgres-style `$N`
s2 := q.PgSQL("SELECT * FROM users WHERE id = {id} AND status = {status}")
// s2.Query → "SELECT * FROM users WHERE id = $1 AND status = $2"
// q.NamedSQL — named-param style
s3 := q.NamedSQL("SELECT * FROM users WHERE id = {id} AND status = {status}")
// s3.Query → "SELECT * FROM users WHERE id = :name1 AND status = :name2"
// Crucial: injection attempts stay parameterised. The user's value
// goes into Args, never inlined into Query.
s4 := q.SQL("DELETE FROM cache WHERE key = {name}")
// s4.Query → "DELETE FROM cache WHERE key = ?"
// s4.Args → []any{"alice'; DROP TABLE users; --"} (single arg, never SQL)
What you can put in {expr}¶
Anything that parses as a Go expression: identifiers, selectors, function calls, arithmetic, indexing. Same rules as q.F:
q.SQL("SELECT * FROM events WHERE user_id = {user.ID}")
q.SQL("SELECT * FROM logs WHERE created_at > NOW() - INTERVAL '{minutes} minutes'")
q.SQL("INSERT INTO audit (event, payload) VALUES ({event}, {json.RawMessage(payload)})")
The expression's runtime value lands in Args as any. Drivers handle the type-specific bind (string, int, time.Time, json.RawMessage, …) the same way they would for hand-written placeholders.
What q.SQL is (and isn't)¶
q.SQL produces a (string, []any) pair — the parameterised query text and the corresponding values. It does not execute anything, does not create a *sql.Stmt, and does not touch a database. What happens next is your driver's call:
s := q.SQL("SELECT * FROM users WHERE id = {id}")
// s.Query = "SELECT * FROM users WHERE id = ?"
// s.Args = []any{42}
// Most drivers prepare/cache/bind/execute internally:
row := db.QueryRowContext(ctx, s.Query, s.Args...)
// Or you prepare explicitly and reuse:
stmt, _ := db.PrepareContext(ctx, s.Query)
defer stmt.Close()
row := stmt.QueryRowContext(ctx, s.Args...)
The injection-safety guarantee comes from the parameterised form — your values flow through the driver's bind path, never through string concatenation. Whether the driver uses a real prepared statement, a one-shot bind, statement-cache reuse, or something else is a driver-level concern that q stays out of.
Why a struct return?¶
A (string, []any) tuple return would pair nicely with Go's f(g()) rule (db.QueryRow(q.SQL("...")) would auto-spread)... except db.QueryRow takes (string, ...any), and the variadic spread doesn't compose with multi-value return — Go's spec restricts f(g()) to single-arg-position uses. So an explicit struct + s.Args... spread is the cleanest shape:
It's one extra symbol per query, in exchange for type safety and a clear name for the parameterised query handle.
Brace escapes¶
{{ is a literal {, }} is a literal }. Rare in SQL but supported:
q.SQL("INSERT INTO docs (data) VALUES ('{{json}}') WHERE id = {id}")
// s.Query → "INSERT INTO docs (data) VALUES ('{json}') WHERE id = ?"
% is NOT escaped — unlike q.F, the SQL helpers don't pipe through fmt.Sprintf, so % is a plain character (handy for LIKE patterns).
Composing with the rest of q¶
Standard q composition rules apply:
s := q.Try(loadQuery()) // q.Try with a (SQLQuery, error)-returning loader
row := db.QueryRowContext(ctx, s.Query, s.Args...)
// Or built inline:
return q.Try(db.ExecContext(ctx,
q.SQL("UPDATE cache SET hit_count = hit_count + 1 WHERE key = {key}").Query,
q.SQL("UPDATE cache SET hit_count = hit_count + 1 WHERE key = {key}").Args...,
))
The latter shape is awkward — for clarity, bind to a local first.
Stretch ideas (not yet implemented)¶
{values...}for IN-list expansion. A bare{xs}for a slice is ambiguous (one placeholder vs N), so a dedicated form is clearer. Tracked as a stretch goal in TODO #77.- Compile-time SQL syntax check. Could lint the rewritten Query for obvious issues. Out of scope for the current pass; users should run their own SQL linter on the output.
Tradeoffs¶
- Identifiers inside the literal aren't IDE-visible. Same as
q.F: rename / go-to-def don't see{name}. Compiler still catches typos. - Driver compatibility is your responsibility.
q.SQLproduces?placeholders — if you're on a Postgres-only stack, preferq.PgSQLso the query reads natively. - Named placeholders auto-generate names.
q.NamedSQLemits:name1,:name2, … in source order. There's no facility yet to use the source identifier as the placeholder name (:idfor{id}); add a stretch ticket if you need that.
See also¶
q.F/q.Ferr/q.Fln— the same{expr}syntax, but rewriting tofmt.Sprintffor free-form string formatting.