skills/golang-bob/query-builder.md
Alex Dunmow 7bc233df1a feat(golang-bob): add Bob Go SQL toolkit reference skill
Reference skill for github.com/stephenafamo/bob (v0.46.0): query builder,
generated models/ORM, bobgen code generation, factories, and result scanning.
SKILL.md routes to per-subsystem reference files (query-builder, models,
code-generation, execution, comparisons). Symlinked into ~/.claude/skills and
~/.agents/skills (Codex).

Verified with a RED/GREEN subagent test and cross-checked against v0.46.0
source (flat mod import paths, AppendHooks, plural table var, WhereMod ops).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:51:55 +08:00

252 lines
9.9 KiB
Markdown

# Bob Query Builder (Layer 1)
The query builder is a fluent, **dialect-specific** SQL builder with no knowledge of your schema
(and therefore no type safety on column names — that comes from generated models, Layer 2). Its
strength: because each dialect is hand-crafted, it can build *any* query that dialect supports.
Everything here uses `psql`; substitute `mysql` or `sqlite` and the matching mod packages. Differences
are listed at the bottom.
## Imports
```go
import (
"github.com/stephenafamo/bob/dialect/psql" // Select/Insert/Update/Delete, Raw/RawQuery, and starters
"github.com/stephenafamo/bob/dialect/psql/sm" // SELECT mods
"github.com/stephenafamo/bob/dialect/psql/im" // INSERT mods
"github.com/stephenafamo/bob/dialect/psql/um" // UPDATE mods
"github.com/stephenafamo/bob/dialect/psql/dm" // DELETE mods
"github.com/stephenafamo/bob/dialect/psql/fm" // function mods (window: fm.Over(...))
"github.com/stephenafamo/bob/dialect/psql/wm" // window mods (wm.PartitionBy, wm.OrderBy, wm.BasedOn)
)
```
Paths are **flat** (`dialect/psql/im`, not `dialect/psql/insert/im`).
## Building and running a query
A built query satisfies the `bob.Query` interface (one method, `WriteQuery`). To get the string + args:
```go
type Query interface {
WriteQuery(ctx context.Context, w io.StringWriter, start int) (args []any, err error)
}
```
Use these on any query object:
- `Build(ctx) (query string, args []any, err error)`
- `BuildN(ctx, start int) (...)` — start arg numbering at `start` (for embedding as a subquery)
- `MustBuild(ctx) (query, args)` / `MustBuildN(ctx, start)` — panic on error (good for one-time init)
```go
ctx := context.Background()
q, args, err := psql.Select(
sm.Columns("id", "name"),
sm.From("users"),
sm.Where(psql.Quote("id").In(psql.Arg(100, 200, 300))),
).Build(ctx)
// q: SELECT id, name FROM users WHERE (id IN ($1, $2, $3))
// args: [100 200 300]
rows, err := db.QueryContext(ctx, q, args...) // plain database/sql
```
Or skip the manual step and use the executor (`execution.md`): `bob.All(ctx, exec, query, mapper)`.
## Query mods are the core idea
Each `psql.Select/Insert/Update/Delete` takes a variadic list of **mods**. The mod packages are
distinct per query type, so an INSERT can't take a `FROM` and a SELECT can't take an `INTO` — the
compiler enforces it.
**Conditional building**`Apply()` adds mods to an existing query (it **mutates in place**):
```go
q := psql.Select(sm.From("projects")) // SELECT * FROM projects
if !user.IsAdmin {
q.Apply(sm.Where(psql.Quote("user_id").EQ(psql.Arg(user.ID))))
}
// To reuse a base without mutating it, call q.Clone() first.
```
## Starters (build expressions)
Starter functions live on the dialect package and return a chainable `Expression`. Shared by all dialects:
| Starter | Produces | Example → SQL |
|---------|----------|---------------|
| `Arg(...any)` | bound placeholder(s) | `psql.Arg("a","b")``$1, $2` (args a,b) |
| `ArgGroup(...any)` | parenthesized args (tuples) | `psql.ArgGroup("a","b")``($1, $2)` |
| `Placeholder(n uint)` | n empty placeholders | `psql.Placeholder(3)``$1, $2, $3` (nil args) |
| `Quote(...string)` | quoted identifier | `psql.Quote("t","col")``"t"."col"` |
| `S(string)` | single-quoted string literal | `psql.S("hi")``'hi'` |
| `F(name, args...)` | function call | `psql.F("count", "*")``count(*)` |
| `And(...Expression)` / `Or(...)` | joined with AND/OR | `psql.Or("a","b")``a OR b` |
| `Not(Expression)` | `NOT expr` | |
| `Group(...Expression)` | parenthesized, comma-separated | `psql.Group("a","b")``(a, b)` |
| `Raw(clause, args...)` | raw SQL fragment, `?` placeholders | `psql.Raw("a = ?", 1)``a = $1` |
PSQL adds: `Cast(expr, type)`, `Case()`, `Exists(expr)`, `Concat(...)`, `Any(expr)`, `All(expr)`,
`Minus(expr)`, `TableFunctions(...)`.
## Operators (methods on an Expression)
Chain operators off any expression. Shared across dialects:
`IsNull()` · `IsNotNull()` · `IsDistinctFrom(y)` · `IsNotDistinctFrom(y)` · `EQ(y)` · `NE(y)` ·
`LT(y)` · `LTE(y)` · `GT(y)` · `GTE(y)` · `In(...y)` · `NotIn(...y)` · `Between(y,z)` ·
`NotBetween(y,z)` · `And(y)` · `Or(y)` · `Concat(y)` · `Minus(y)` · `OP(op string, y)` (custom op) ·
`As(alias)` (terminal, for aliasing).
```go
// ($1 >= 50) AND ("name" IS NOT NULL)
psql.Arg("Stephen").GTE(psql.Raw(50)).And(psql.Quote("name").IsNotNull())
// equivalently:
psql.And(psql.Arg("Stephen").GTE(psql.Raw(50)), psql.Quote("name").IsNotNull())
```
## Parameters & quoting — the two footguns
1. **Only `Arg`/`ArgGroup` produce bound parameters.** Anything else (a raw string, a Go int passed
as a literal) is written into the SQL text. Always wrap user values in `Arg`.
```go
sm.Where(psql.Quote("name").EQ(psql.Arg(userInput))) // safe: -> "name" = $1
```
2. **Bare strings passed to `any`-typed args are emitted verbatim (unquoted).** `sm.From("users")`
yields `FROM users`. To quote, pass `psql.Quote("users")`. However, several params that are
*known* to be identifiers auto-quote their `string` arguments:
| API | Auto-quotes |
|-----|-------------|
| `sm/im/um/dm.With(name, cols...)` | CTE name + columns |
| `um/im.SetCol("c")` | the `SET c =` column |
| `im.SetExcluded("c")` | `"c" = EXCLUDED."c"` |
| `im.Into(table, "c1","c2")` | the column list (table is `any` — quote it yourself) |
| `JoinChain.Using("c1")` | USING columns |
| `FromChain.As(alias, cols...)` | alias + renamed columns |
When the left side of a SET is itself an expression (e.g. a qualified column), use `SetExpr`:
`um.SetExpr(psql.Quote("employees", "id")).ToArg(1)`.
## Raw escape hatches
```go
// Whole query:
psql.RawQuery(`SELECT * FROM users WHERE id = ? AND name = ?`, 100, "Stephen")
// Fragment inside a mod (? placeholders, dialect-rewritten):
sm.Where(psql.Raw("id = ? and name = ?", 100, "Stephen"))
```
## SELECT examples (PSQL)
```go
// DISTINCT ON (PSQL-only)
psql.Select(sm.Columns("id","name"), sm.Distinct("id"), sm.From("users"))
// SELECT DISTINCT ON(id) id, name FROM users
// JOIN ... USING + CTE
psql.Select(
sm.With("c", "id", "data").As(psql.Select(
sm.Columns("id"), sm.From("test1"), sm.LeftJoin("test2").Using("id"),
)),
sm.From("c"),
)
// WITH c(id, data) AS (SELECT id FROM test1 LEFT JOIN test2 USING (id)) SELECT * FROM c
// CASE (use .Else(...).As(...), or .End().As(...) when there is no ELSE)
sm.Columns(psql.Case().
When(psql.Quote("id").EQ(psql.S("1")), psql.S("A")).
Else(psql.S("B")).As("C"))
// Window function: F(...) returns a value you CALL with fm/wm mods
sm.Columns(
psql.F("LEAD", "created_date", 1, psql.F("NOW"))(
fm.Over(wm.PartitionBy("presale_id"), wm.OrderBy("created_date")),
).Minus(psql.Quote("created_date")).As("difference"),
)
// Named window:
psql.Select(
sm.Columns(psql.F("avg", "salary")(fm.Over(wm.BasedOn("w")))),
sm.From("c"),
sm.Window("w", wm.PartitionBy("depname"), wm.OrderBy("salary")),
)
// Tuple IN
sm.Where(psql.Group(psql.Quote("id"), psql.Quote("employee_id")).
In(psql.ArgGroup(100, 200), psql.ArgGroup(300, 400)))
// WHERE (id, employee_id) IN (($1, $2), ($3, $4))
// LIMIT/OFFSET as args, FOR UPDATE, subquery FROM, UNION
sm.Limit(psql.Arg(10)); sm.Offset(psql.Arg(15))
sm.ForUpdate("users").SkipLocked()
sm.From(psql.Select(sm.From("clients"), sm.Where(...))).As("c")
sm.Union(psql.Select(sm.Columns("id"), sm.From("admins")))
// For ORDER BY / LIMIT on the whole UNION: sm.OrderCombined("id"), sm.LimitCombined(1000)
```
## INSERT / UPDATE / DELETE examples (PSQL)
```go
// INSERT VALUES (RETURNING is added automatically by the ORM Insert, not here)
psql.Insert(im.Into("films"), im.Values(psql.Arg("UA502","Bananas",105)))
// Multiple rows: repeat im.Values(...). INSERT ... SELECT: im.Query(psql.Select(...)).
// UPSERT
psql.Insert(
im.IntoAs("distributors", "d", "did", "dname"),
im.Values(psql.Arg(8, "Anvil")),
im.OnConflict("did").DoUpdate(
im.SetExcluded("dname"),
im.Where(psql.Quote("d","zipcode").NE(psql.S("21201"))),
),
)
// im.OnConflict().DoNothing() and im.OnConflictOnConstraint("name").DoUpdate(...) also exist.
// UPDATE
psql.Update(
um.Table("films"),
um.SetCol("kind").ToArg("Dramatic"),
um.Where(psql.Quote("kind").EQ(psql.Arg("Drama"))),
)
// um.From("accounts") (PSQL UPDATE...FROM), um.Set(expr1, expr2) for multiple set clauses.
// DELETE
psql.Delete(dm.From("films"), dm.Where(psql.Quote("kind").EQ(psql.Arg("Drama"))))
// dm.Using("accounts") for PSQL DELETE ... USING.
```
## Cross-dialect differences
**Placeholders** (Bob writes the right one; in `Raw` you always type `?`):
| Dialect | Placeholder |
|---------|-------------|
| PostgreSQL | `$1, $2, …` |
| SQLite | `?1, ?2, …` |
| MySQL | `?` (positional) |
**Identifier quoting:** PSQL & SQLite use `"double quotes"`; MySQL uses `` `backticks` ``. String
literals via `.S()` are `'single-quoted'` everywhere.
**Feature availability:**
| Feature | PSQL | MySQL | SQLite |
|---------|------|-------|--------|
| `RETURNING` | ✅ | ❌ | ✅ |
| `DISTINCT ON(col)` (`sm.Distinct("col")`) | ✅ | ❌ | ❌ |
| `ON CONFLICT DO UPDATE` (`im.OnConflict`) | ✅ | ❌ (`ON DUPLICATE KEY`) | ✅ |
| `FOR UPDATE ... SKIP LOCKED` | ✅ | ✅ | ❌ |
| `UPDATE ... FROM` (`um.From`) / `DELETE ... USING` (`dm.Using`) | ✅ | ❌ | ❌ |
| `MERGE` (`mm` package) | ✅ | ❌ | ❌ |
| `ROWS FROM (...)` (`sm.FromFunction`) | ✅ | ❌ | ❌ |
| CTEs, window functions, `CROSS JOIN` subquery | ✅ | ✅ (MySQL 8+) | ✅ |
- `sm.Distinct()` (no args) = `SELECT DISTINCT` in all dialects; only PSQL accepts column args.
- `psql.Concat(...)` is PSQL-only (→ `a || b`); for MySQL/SQLite use `F("CONCAT", ...)`.
- MySQL upsert: `im.OnDuplicateKeyUpdate(im.UpdateWithValues("col"))`.
- Switching dialects is usually just swapping the imports — mods are named to match across dialects;
unsupported combinations fail at compile time rather than producing invalid SQL.