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

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 at start (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 buildingApply() 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

  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.

    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

// 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 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.