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>
9.9 KiB
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
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:
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 atstart(for embedding as a subquery)MustBuild(ctx) (query, args)/MustBuildN(ctx, start)— panic on error (good for one-time init)
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):
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).
// ($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
-
Only
Arg/ArgGroupproduce bound parameters. Anything else (a raw string, a Go int passed as a literal) is written into the SQL text. Always wrap user values inArg.sm.Where(psql.Quote("name").EQ(psql.Arg(userInput))) // safe: -> "name" = $1 -
Bare strings passed to
any-typed args are emitted verbatim (unquoted).sm.From("users")yieldsFROM users. To quote, passpsql.Quote("users"). However, several params that are known to be identifiers auto-quote theirstringarguments:API Auto-quotes sm/im/um/dm.With(name, cols...)CTE name + columns um/im.SetCol("c")the SET c =columnim.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
// 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)
// 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)
// 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 DISTINCTin all dialects; only PSQL accepts column args.psql.Concat(...)is PSQL-only (→a || b); for MySQL/SQLite useF("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.