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>
252 lines
9.9 KiB
Markdown
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.
|