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>
This commit is contained in:
Alex Dunmow 2026-06-22 20:51:25 +08:00
parent d7c7607058
commit 7bc233df1a
7 changed files with 1054 additions and 2 deletions

View File

@ -5,7 +5,8 @@ Personal Claude Code skills and commands, source of truth for `~/.claude/skills/
Each skill directory (and command file under `commands/`) is symlinked back into place:
```
~/.claude/skills/<name> -> ../../src/skills/<name>
~/.claude/skills/<name> -> ../../src/skills/<name> # Claude Code
~/.agents/skills/<name> -> ../../src/skills/<name> # Codex / cross-runtime
~/.claude/commands/<name>.md -> ../../src/skills/commands/<name>.md
```
@ -13,6 +14,7 @@ Each skill directory (and command file under `commands/`) is symlinked back into
- **developing-blockninja-plugins** — creating, building, and publishing BlockNinja CMS plugins/themes
- **fleet** — apply a change across many BlockNinja repos with per-repo verification
- **golang-bob** — reference for the Bob Go SQL toolkit (query builder, generated models/ORM, bobgen code generation, factories, scanning)
- **grill-with-docs** — stress-test a plan against the domain model and update docs inline
- **revenuecat-api-v2** — server-side reference for the RevenueCat REST API v2 (endpoints, auth, write-safety)
- **shipit** — full BlockNinja plugin/theme pre-publish pipeline
@ -27,5 +29,6 @@ Each skill directory (and command file under `commands/`) is symlinked back into
```sh
mkdir ~/src/skills/<name> # write SKILL.md inside
ln -s ../../src/skills/<name> ~/.claude/skills/<name>
ln -s ../../src/skills/<name> ~/.claude/skills/<name> # Claude Code
ln -s ../../src/skills/<name> ~/.agents/skills/<name> # Codex
```

126
golang-bob/SKILL.md Normal file
View File

@ -0,0 +1,126 @@
---
name: golang-bob
description: Use when writing Go against the Bob SQL toolkit (github.com/stephenafamo/bob) — building dialect SQL with query mods (sm/im/um/dm), generated models and setters, bobgen code generation, test factories, typed queries from .sql files, or scanning rows with bob.One/All/Cursor. Covers PostgreSQL, MySQL, and SQLite.
---
# Bob — Go SQL Access Toolkit
## Overview
Bob (`github.com/stephenafamo/bob`) is a Go SQL toolkit by Stephen Afamo — the author of
SQLBoiler, who started Bob as "an experiment for how v5 of SQLBoiler could look." It is
**database-first** and spans a fluent query builder up to a fully generated, type-safe ORM
with test factories. This skill targets **v0.46.0**.
Three principles drive every design decision:
1. **Correctness** — each dialect is hand-crafted to match its SQL spec; you cannot build a
query the dialect doesn't support (it won't compile).
2. **Convenience, not magic** — no hidden abstraction; queries map to the SQL you expect.
3. **Cooperation** — built on `database/sql` and `github.com/stephenafamo/scan`, not around them.
**Progressive adoption is the whole point.** Use one layer or all four:
| Layer | What you get | Analogous to | Type-safe |
|-------|--------------|--------------|-----------|
| 1. Query builder | Fluent, dialect-specific SQL builder (no DB knowledge) | squirrel | No (string-based) |
| 2. Models + ORM gen | Generated structs, CRUD, typed WHERE/JOIN mods, relationships, eager loading | SQLBoiler | **Yes** |
| 3. Factory gen | Generated test fixtures that auto-create required relations | Ruby FactoryBot | Yes |
| 4. Query gen | Type-safe Go funcs from hand-written `.sql` files | sqlc | Yes |
Cutting across all layers, the **SQL executor** (`bob.One/All/Cursor/Each/Exec`) builds and runs
a query and scans rows into structs in one step.
All three of PostgreSQL, MySQL/MariaDB and SQLite support every layer.
## When to use Bob (and when not)
**Reach for Bob when** you want database-first type safety without writing scan boilerplate;
you need to build *arbitrary* dialect-specific SQL (window functions, CTEs, `DISTINCT ON`,
`FOR UPDATE SKIP LOCKED`, upserts) that a lowest-common-denominator builder can't express;
or you want generated factories for relationship-heavy tests.
**The query builder alone** (Layer 1) is a fine, dependency-light replacement for squirrel even
if you never generate models.
**Consider alternatives when:** you want code-first/migrations-owned schema (Ent, GORM); you
only ever write raw SQL and just need typed funcs (plain sqlc); or you don't want a DB to exist
before generating code (Bob introspects a live DB or `.sql` schema files — it does **not** manage
migrations). See `comparisons.md` for a faithful Bob-vs-{GORM,Ent,SQLBoiler,Jet} table.
**Deliberately NOT in Bob:** automatic `created_at`/`updated_at` timestamps and soft-deletes.
Do timestamps at the DB level (defaults/triggers); soft-deletes are left to you.
## Critical conventions (read before writing code)
These are the things that trip people up — verified against v0.46.0 source:
- **Queries are built from "query mods."** `psql.Select(sm.From("users"), sm.Where(...))`
every argument is a mod. Each query type in each dialect has its own mod package, so wrong
mods fail at compile time.
- **Import paths are flat per dialect:** `dialect/psql`, `dialect/psql/sm`, `.../im`, `.../um`,
`.../dm`. Older docs/examples sometimes show `dialect/psql/insert/im` — that nesting does **not**
exist. (`sm`=select, `im`=insert, `um`=update, `dm`=delete mods; also `fm` function, `wm` window,
`vm` view, `mm` merge.)
- **`Arg()` makes a placeholder; a bare string is literal SQL.** `psql.Quote("id").EQ(psql.Arg(5))`
parameterizes `5`. Passing a Go value directly as a literal interpolates it as text — only `Arg`
(or `ArgGroup`) produces a bound parameter. This is the #1 injection footgun.
- **Bare strings in `any`-typed mod args are emitted verbatim, NOT quoted.** `sm.From("users")`
`FROM users` (unquoted). For a quoted identifier pass `sm.From(psql.Quote("users"))`. Some
string params (CTE names, `SetCol`, join `Using`) auto-quote; see `query-builder.md`.
- **`Raw` uses `?` placeholders in every dialect.** `psql.Raw("id = ?", 5)` — Bob rewrites `?`
to `$1`/`?1`/`?` per dialect on build. Escape a literal `?` as `\?`.
- **`.Apply()` mutates the query in place.** To reuse a base query, `.Clone()` first.
- **Hooks register with `AppendHooks`, not `Add`.** The hooks doc page shows `.Add(...)`; the real
method is `table.BeforeInsertHooks.AppendHooks(fn)`. Skip hooks per-call with `bob.SkipHooks(ctx)`.
## Quick reference
**Dialect + mod packages** (swap `psql` for `mysql`/`sqlite`):
```go
import (
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/psql" // Select/Insert/Update/Delete + starters (Arg, Quote, F, S, And...)
"github.com/stephenafamo/bob/dialect/psql/sm" // SELECT mods (From, Where, Join, GroupBy, Limit...)
"github.com/stephenafamo/bob/dialect/psql/im" // INSERT mods (Into, Values, OnConflict...)
"github.com/stephenafamo/bob/dialect/psql/um" // UPDATE mods (Table, SetCol, Where...)
"github.com/stephenafamo/bob/dialect/psql/dm" // DELETE mods (From, Where, Using...)
)
```
**Build → run** (two styles):
```go
// A) Build the string yourself, run with database/sql
q, args, err := psql.Select(sm.From("users"), sm.Where(psql.Quote("id").EQ(psql.Arg(1)))).Build(ctx)
rows, err := db.QueryContext(ctx, q, args...)
// B) Build + run + scan in one step with the executor
exec := bob.NewDB(sqlDB) // wrap *sql.DB
user, err := bob.One(ctx, exec, q, scan.StructMapper[User]()) // -> User
users, err := bob.All(ctx, exec, q, scan.StructMapper[User]()) // -> []User
```
**Generated-model usage** (after `bobgen`; table `jets``models.Jets`):
```go
jet, err := models.FindJet(ctx, db, 10) // by PK
jets, err := models.Jets.Query(models.SelectWhere.Jets.ID.EQ(100)).All(ctx, db)
jet, err = models.Jets.Insert(&models.JetSetter{Name: omit.From("x")}).One(ctx, db)
```
## Where to look next
Load the reference file for the layer you're working in — each has verbatim, dialect-correct examples:
| You are... | Read |
|------------|------|
| Building SQL by hand (any layer 1 work, mods, expressions, dialect differences) | `query-builder.md` |
| Using generated models — CRUD, setters, typed WHERE/JOIN, relationships, eager loading, hooks | `models.md` |
| Setting up `bobgen` — config, drivers, typed `.sql` queries, factories, enums, relationships config | `code-generation.md` |
| Running queries — executor, `One/All/Each/Cursor`, transactions, prepared statements, scanning | `execution.md` |
| Choosing Bob vs GORM/Ent/SQLBoiler/Jet | `comparisons.md` |
Hand-written ORM models (without codegen) and the `orm.NewTable`/`NewView` primitives are covered
at the end of `models.md`.

View File

@ -0,0 +1,216 @@
# Bob Code Generation — bobgen (Layers 24)
`bobgen` introspects a **live database** (or `.sql` schema files) and generates: typed models +
setters + slices, a `factory` package for tests, typed query functions from your `.sql` files,
`enums`, and helper namespaces (`SelectWhere`, `SelectJoins`, `<T>Columns`, error constants). All
generated files end in `.bob.go` and are safe to regenerate. Bob does **not** manage migrations —
the schema must already exist.
## The generator binaries
Four drivers under `github.com/stephenafamo/bob/gen/` (no atlas/prisma driver in v0.46.0). Run with
`go run ...@latest` or `go install`. Each reads `<DRIVER>_DSN` from the env, or a config file via `-c`.
```sh
# PostgreSQL
PSQL_DSN='postgres://user:pass@host:5432/db?sslmode=disable' \
go run github.com/stephenafamo/bob/gen/bobgen-psql@latest
go run github.com/stephenafamo/bob/gen/bobgen-psql@latest -c ./bobgen.yaml
# MySQL
MYSQL_DSN='user:pass@tcp(host:3306)/db' go run github.com/stephenafamo/bob/gen/bobgen-mysql@latest
# SQLite
SQLITE_DSN='test.db' go run github.com/stephenafamo/bob/gen/bobgen-sqlite@latest
# SQL schema files (no live DB) — dialect is REQUIRED
SQL_DIALECT=psql go run github.com/stephenafamo/bob/gen/bobgen-sql@latest
```
Default config path is `./bobgen.yaml`. Flag: `-c FILE` / `--config FILE`. A typical project wires
this into `go:generate` or a Makefile target.
> **Pin the generator to your runtime Bob version.** `@latest` drifts; the generated code targets
> the `github.com/stephenafamo/bob` API and a generator newer/older than the version in your
> `go.mod` can emit code that doesn't compile. Use `@v0.46.0` (or whatever your `go.mod` pins).
## Configuration (`bobgen.yaml`)
Driver-specific keys are nested under the driver name; general keys sit at the top level.
```yaml
psql: # driver block (mysql:/sqlite:/sql: for others)
dsn: "postgres://user:pass@host:5432/db?sslmode=disable"
driver: "github.com/jackc/pgx/v5" # default: github.com/lib/pq
schemas: ["public"]
shared_schema: "public" # this schema is omitted from generated names
uuid_pkg: "gofrs" # "gofrs" | "google"
queries: ["./queries"] # folders of .sql files -> typed query funcs (Layer 4)
concurrency: 10
column_order: "ordinal" # "ordinal" (DB order) | "name" (alphabetical)
only: # allow-list; value = optional column subset
"/^public\\./": # keys can be regexes (case-insensitive)
except: # deny-list
public.migrations:
public.addresses: [ updated_at ] # drop just these columns
"*": [ secret_col ] # from every table
# ---- general (top-level) ----
type_system: "github.com/aarondl/opt" # default; or "database/sql"
struct_tag_casing: "snake" # snake | camel | title
tags: [] # extra struct tags to emit
relation_loaded_name: "Loaded" # name of model.R.<this> ("Loaded" is reserved)
enum_format: "title_case" # title_case | screaming_snake_case
no_tests: false
# ---- which plugins run (each writes one package) ----
plugins_preset: "all" # all | none
plugins:
models: { pkgname: models, destination: models }
factory: { pkgname: factory, destination: factory }
enums: { pkgname: enums, destination: enums }
dbinfo: { disabled: false }
dberrors: { disabled: false }
where: { disabled: false }
joins: { disabled: false }
loaders: { disabled: false }
counts: { disabled: false }
```
Notes:
- `type_system: github.com/aarondl/opt` (default) → `null.Val[T]` for nullable row fields and
`omit.Val[T]`/`omitnull.Val[T]` in setters. `database/sql``sql.Null[T]` and pointers.
- `plugins_preset: none` + selectively enabling plugins generates only what you need.
- Setting a plugin `disabled: true` **deletes** the `.bob.go` files in its destination.
**Per-driver extras:** SQLite adds `driver` (`modernc.org/sqlite` or `github.com/mattn/go-sqlite3`)
and `attach: { name: path.db }`. The `sql` driver needs `dialect:` (`psql`/`mysql`/`sqlite`) and a
`pattern:` glob for the schema files (default `*.sql`).
**Other config sections** (see the configuration doc when needed): `aliases` (rename tables/columns/
relationships), `constraints` (declare PK/unique/FK that aren't in the DB), `relationships` (manual
relations — below), `types` (custom Go types + random/compare expressions for factories),
`replacements` (swap a column's generated type), `inflections` (pluralization overrides).
## Typed queries from SQL (Layer 4 — the sqlc analog)
Point a driver's `queries:` key at folders of `.sql` files. Each file `foo.sql` generates
`foo.bob.go` (+ a test file). Name each query with a leading comment:
```sql
-- AllUsers
SELECT * FROM users WHERE id = ?;
```
`SELECT *` is expanded to explicit columns at generation time, so a schema change won't silently
break the result struct. The generated function takes typed params and returns a query you finish
the usual way:
```go
row, err := AllUsers(1).One(ctx, db) // -> AllUsersRow
rows, err := AllUsers(1).All(ctx, db) // -> []AllUsersRow
// Add more mods without re-wrapping:
rows, err = AllUsers(1).With(sm.Where(psql.Quote("name").EQ(psql.Arg("Bob"))), sm.Limit(10)).All(ctx, db)
```
`With()` semantics: `Where` ANDs, `OrderBy` appends; `Limit`/`Offset` **replace** (psql/sqlite) but
**append** on MySQL — on MySQL only add them if the base query lacks them. For combined
UNION/INTERSECT queries use `sm.OrderCombined`/`sm.LimitCombined`.
**Annotations** override inferred names/types/nullability. On the query line:
`-- Name *OneType:AllType:Transformer`. Inline per column/param: `/* name:type:notnull */` (any part
omittable, e.g. `/* username */`, `/* ::notnull */`, `/* :big.Int: */`).
**Nested results from joins** via column naming + `--prefix:` comments:
- `related.col` (dot) → a to-many slice in the result struct.
- `related__col` (double underscore) → a to-one pointer.
```sql
-- Nested
SELECT users.*,
--prefix:videos.
videos.*,
--prefix:videos.sponsor__
sponsors.*
FROM users
LEFT JOIN videos ON videos.user_id = users.id
INNER JOIN sponsors ON videos.sponsor_id = sponsors.id;
-- -> NestedRow_{ ...users; Videos []NestedRow_Videos{ ...; Sponsor *NestedRow_Videos_Sponsor } }
```
## Factories (Layer 3 — for tests)
A `factory` package is generated next to `models` (disable with `plugins.factory.disabled: true`).
Factories build or insert rows and **auto-create required (non-nullable FK) relations**. Random
values come from `github.com/jaswdr/faker`.
```go
f := factory.New()
// A template = a recipe for one row. Mods set columns / relations.
tmpl := f.NewJet(
factory.JetMods.Name("Concorde"), // set a column
factory.JetMods.RandomAirportID(nil), // randomize a column (nil = default faker)
factory.JetMods.WithNewPilot( // create + attach a related pilot
factory.PilotMods.RandomizeAllColumns(nil),
),
)
// Base mods apply to every template from this factory:
f.AddBaseJetMods(factory.JetMods.RandomID(nil))
// Build (no DB):
setter := tmpl.BuildSetter() // a *JetSetter (ignores relations)
jet := tmpl.Build() // a *Jet with R populated from the template
// Create (inserts; required relations auto-created):
jet, err := tmpl.Create(ctx, db)
jets, err := tmpl.CreateMany(ctx, db, 10)
jet := tmpl.MustCreate(ctx, db) // panics on err
jet = tmpl.CreateOrFail(t, db) // calls t.Fatal on err (also CreateManyOrFail)
```
To-many relationship mods come in `With*`/`WithNew*` (overwrite) and `Add*`/`AddNew*` (append)
forms, e.g. `factory.PilotMods.WithNewJets(5, mods...)`. Mark a relation `never_required: true` in
config to stop factories auto-creating it even when the FK is non-nullable.
## Relationships config (when there's no FK, or it's complex)
Relationships are auto-detected from foreign keys (multi-column supported). Declare extra ones under
`relationships:` keyed by the "from" table:
```yaml
relationships:
users:
- name: "users_to_videos_through_teams" # has-many-through
sides:
- { from: users, to: teams, columns: [[team_id, id]] }
- { from: teams, to: videos, columns: [[id, team_id]] }
- name: "verified_members" # static-value filter
sides:
- from: teams
to: users
columns: [[id, team_id]]
to_where:
- { column: verified, sql_value: "true", go_value: "true" }
```
## Enums
A DB enum becomes a typed string constant set in the `enums` package:
```sql
CREATE TYPE task_status AS ENUM('not_started','in_progress','completed');
```
```go
type TaskStatus string
const (
TaskStatusNotStarted TaskStatus = "not_started" // title_case (default)
TaskStatusInProgress TaskStatus = "in_progress"
TaskStatusCompleted TaskStatus = "completed"
)
func AllTaskStatus() []TaskStatus { /* ... */ }
```
`enum_format: screaming_snake_case` yields `TaskStatusNOT_STARTED` etc. The enum type is used
directly as the model field type for compile-time safety.

36
golang-bob/comparisons.md Normal file
View File

@ -0,0 +1,36 @@
# Bob vs other Go SQL libraries
> These comparisons are distilled from Bob's own `vs/` docs, which are written by Bob's author.
> Treat the framing as informed but partial; the factual axes (codegen direction, migrations,
> dialect model) are accurate and what you'll usually decide on.
Bob's author also wrote **SQLBoiler**, and started Bob as "an experiment for how v5 of SQLBoiler
could look." Bob is effectively SQLBoiler's successor with a clean foundation.
| Tool | Kind | Schema direction | Migrations | Type safety | Notes |
|------|------|------------------|-----------|-------------|-------|
| **Bob** | Query builder + DB-first ORM | Database-first (introspect DB or `.sql`) | Not included (use your own) | Full, compile-time | Per-dialect mods → can't build invalid SQL; factories; incremental adoption |
| **GORM** | Code-first ORM | Code → DB | Auto-migrate built in | Low (lots of `interface{}`, magic strings → runtime panics) | Big ecosystem/plugins; weaker query builder; all-or-nothing adoption |
| **Ent** | Code-first ORM | Schema-as-Go-code → DB | Owns migrations (via Atlas) | Good | Mature ecosystem (gqlgen, gRPC); all-or-nothing; predicates less flexible than Bob for complex SQL |
| **SQLBoiler** | Query builder + DB-first ORM | Database-first | Not included | Good | Shares one query/mod type across all dialects → *can* assemble invalid queries; Bob is its descendant |
| **Jet** | Query builder only (explicitly not an ORM) | Database-first | Not included | Good | Similar build experience to Bob, but no relationship loading, no factories — every mapping is manual |
## How to choose
- **Want database-first + type safety + the ability to build any dialect-specific SQL, and you
manage migrations yourself** → Bob.
- **Want the schema defined in Go and the tool to own migrations** → Ent (typed) or GORM (looser).
- **Only ever write raw SQL and just want typed functions** → plain sqlc is simpler; Bob's Layer 4
does the same but pairs it with models/factories if you later want them.
- **Want just a query builder, no ORM** → Bob's Layer 1 or Jet. Bob adds an upgrade path to models.
## What Bob deliberately omits
- **Automatic `created_at` / `updated_at`.** Set these at the DB level (column defaults / triggers).
- **Soft deletes.** Left out on purpose — cascading soft-deletes through relationships have too many
edge cases. Implement explicitly if you need them.
## What Bob added over SQLBoiler
Cross-schema generation, preloading via `LEFT JOIN`s, multi-key relationships, has-one-through /
has-many-through, and context chaining through hooks.

126
golang-bob/execution.md Normal file
View File

@ -0,0 +1,126 @@
# Bob SQL Executor — running queries & scanning (cross-cutting)
The executor builds a query, runs it, and scans rows into Go values in one step. It's built on
`github.com/stephenafamo/scan`; the scanning functions (`bob.One`, `bob.All`, …) are thin wrappers
over `scan.One`/`scan.All`/etc.
## Getting an Executor
`bob.Executor` is `scan.Queryer` (a `QueryContext` that returns `scan.Rows`) plus `ExecContext`.
A plain `*sql.DB` does **not** satisfy it directly (its `QueryContext` returns `*sql.Rows`), so wrap it:
```go
import "github.com/stephenafamo/bob"
db := bob.NewDB(sqlDB) // wrap an existing *sql.DB -> bob.DB (embeds *sql.DB)
db, err := bob.Open("pgx", dsn) // or open directly (wraps sql.Open)
// bob.OpenDB(connector), bob.NewConn(*sql.Conn) also exist.
```
`bob.Open`/`sql.Open` need the `database/sql` driver registered by a blank import — same as plain
`database/sql`. For Postgres that's `_ "github.com/lib/pq"` (driver name `"postgres"`) or
`_ "github.com/jackc/pgx/v5/stdlib"` (driver name `"pgx"`); for SQLite, `_ "modernc.org/sqlite"`.
`bob.DB` embeds `*sql.DB`, so all standard methods remain. Pass `db` (or a `bob.Tx`) as the
`Executor` to any scanning function. Returning `scan.Rows` (an interface) rather than `*sql.Rows`
is what makes executors mockable in tests.
## Transactions
```go
tx, err := db.BeginTx(ctx, nil) // bob.Tx (embeds *sql.Tx)
// ... use tx as the Executor ...
err = tx.Commit(ctx) // or tx.Rollback(ctx)
// Or the managed helper — commits on nil error, rolls back on error/panic:
err = db.RunInTx(ctx, nil, func(ctx context.Context, exec bob.Executor) error {
_, err := bob.Exec(ctx, exec, psql.Insert(/* ... */))
return err
})
```
## Mappers
A scanning function needs a `scan.Mapper[T]` that maps result columns to `T`:
- `scan.StructMapper[T]()` — maps columns to struct fields (by `db` tag / name). The default for rows.
- Single-column results map to primitives directly (e.g. `scan.SingleColumnMapper[int]`).
```go
import "github.com/stephenafamo/scan"
type User struct {
ID int `db:"id"`
Name string `db:"name"`
}
mapper := scan.StructMapper[User]()
```
## Scanning functions (package `bob`)
All take `(ctx, exec Executor, q Query, m scan.Mapper[T])` unless noted. `q` is any built query
(Layer 1 builder, or `models.Jets.Query(...)` — generated queries also expose their own `.One/.All`).
| Function | Returns | Use for |
|----------|---------|---------|
| `bob.Exec(ctx, exec, q)` | `(sql.Result, error)` | INSERT/UPDATE/DELETE with no rows back (no mapper) |
| `bob.One(..., m)` | `(T, error)` | exactly one row (`sql.ErrNoRows` if none) |
| `bob.All(..., m)` | `([]T, error)` | all rows into a slice |
| `bob.Allx[Tr](..., m)` | `(V, error)` | all rows into a **custom** slice type via a transformer |
| `bob.Cursor(..., m)` | `(scan.ICursor[T], error)` | stream row-by-row; `Next()/Get()/Close()` |
| `bob.Each(..., m)` | range-over-func | stream via a Go 1.23 `for ... range` iterator |
```go
q := psql.Select(sm.From("users"), sm.Where(psql.Quote("id").EQ(psql.Arg(1))))
user, err := bob.One(ctx, db, q, scan.StructMapper[User]())
users, err := bob.All(ctx, db, q, scan.StructMapper[User]())
// Stream a large result set without loading it all:
for user, err := range bob.Each(ctx, db, q, scan.StructMapper[User]()) {
if err != nil { /* ... */ }
// use user
}
// Or a cursor:
c, err := bob.Cursor(ctx, db, q, scan.StructMapper[User]())
defer c.Close()
for c.Next() {
user, err := c.Get()
_ = user; _ = err
}
// Custom slice type:
type Users []User
us, err := bob.Allx[bob.SliceTransformer[User, Users]](ctx, db, q, scan.StructMapper[User]())
```
## Prepared statements
`bob.Prepare` (exec-only) and `bob.PrepareQuery` (returns rows) build a reusable statement. `Arg` is
the type of the bound argument bundle:
```go
// Query statement:
stmt, err := bob.PrepareQuery(ctx, db, q, scan.StructMapper[User]())
defer stmt.Close()
users, err := stmt.All(ctx) // also .One(ctx), .Cursor(ctx)
// Exec statement:
estmt, err := bob.Prepare(ctx, db, psql.Update(/* ... */))
_, err = estmt.Exec(ctx)
```
`PrepareQueryx` returns a custom slice type, mirroring `Allx`.
## Two-step alternative (no executor)
If you'd rather run the query with plain `database/sql`, build the string yourself and execute it:
```go
q, args, err := psql.Insert(im.Into("films"), im.Values(psql.Arg("UA502"))).Build(ctx)
_, err = sqlDB.ExecContext(ctx, q, args...)
```
This cooperates fully with anything that takes a query string + args (pgx, sqlx, your own pool
wrapper) — you only need the executor when you want Bob to do the scanning.

294
golang-bob/models.md Normal file
View File

@ -0,0 +1,294 @@
# Bob Models & ORM (Layer 2)
After running `bobgen` (see `code-generation.md`) you get a typed, database-first ORM. This file
covers **using** those generated models. Hand-written models (without codegen) via `orm.NewTable`/
`NewView` are at the end.
## What gets generated (per table `jets`)
```go
type Jet struct { // the row type
ID int `db:"id,pk" json:"id"`
Name string `db:"name"`
Color null.Val[string] `db:"color"` // nullable col -> null.Val[T]
// ...
}
type JetSetter struct { // for insert/update; every field optional
ID omit.Val[int] `db:"id,pk"`
Name omit.Val[string] `db:"name"`
Color omitnull.Val[string] `db:"color"` // nullable col -> omitnull.Val[T]
}
type JetSlice []*Jet // use this instead of []*Jet
var Jets = psql.NewTablex[*Jet, JetSlice, *JetSetter]("public", "jets", /* columns */)
```
`models.Jets` is the entrypoint object (the plural table var). It exposes `Query`, `Insert`,
`Update`, `Delete`, `NameExpr()`, `Columns`, and the hooks. Alongside it Bob generates the helper
namespaces `SelectWhere.Jets`, `SelectJoins.Jets`, `JetColumns`, `FindJet`, `JetExists`, and
`JetErrors`.
> Some doc pages show a `JetsTable` variable name; in v0.46.0 the generated var is the plural form
> (`models.Jets`). Use that.
## Setters: omit.Val and omitnull.Val
Setters express "which columns to write." Field types come from `github.com/aarondl/opt`:
- `omit.Val[T]` — two states: **set** or **unset**. Unset fields are excluded from the SQL entirely.
- `omitnull.Val[T]` — three states: **value**, **NULL**, or **unset**. Used for nullable columns.
```go
import "github.com/aarondl/opt/omit"
import "github.com/aarondl/opt/omitnull"
s := &models.JetSetter{
Name: omit.From("Concorde"), // write this column
Color: omitnull.FromPtr(ptr), // nil ptr -> unset; non-nil -> value
// ID left zero -> unset -> not in the INSERT/UPDATE
}
```
Useful constructors/methods: `omit.From(v)`, `omit.FromPtr(*v)`, `omit.FromCond(v, ok)`;
`.Get() (T, bool)`, `.GetOr(fallback)`, `.GetOrZero()`, `.IsValue()`, `.IsUnset()`, `.Set(v)`,
`.Unset()`. `omitnull` adds `.IsNull()` and `.Null()`.
## Querying
`models.Jets.Query(mods...)` returns a query whose finishers run + scan:
```go
jet, err := models.Jets.Query(mods...).One(ctx, db) // T, sql.ErrNoRows if none
jets, err := models.Jets.Query(mods...).All(ctx, db) // JetSlice
count, err := models.Jets.Query(mods...).Count(ctx, db) // int64 (rewrites to count(1))
exists, err := models.Jets.Query(mods...).Exists(ctx, db) // bool
cursor, err := models.Jets.Query(mods...).Cursor(ctx, db) // scan.ICursor[T]; stream large sets
// Each(ctx, db) returns a Go 1.23 range-over-func iterator.
```
Shorthands by primary key:
```go
jet, err := models.FindJet(ctx, db, 10) // SELECT * ... WHERE id = 10
jet, err := models.FindJet(ctx, db, 10, "id", "cargo") // only those columns
has, err := models.JetExists(ctx, db, 10)
```
### Typed WHERE filters
Generated per column; one mod namespace per query type (`SelectWhere`, `UpdateWhere`, `DeleteWhere`):
```go
models.Jets.Query(models.SelectWhere.Jets.ID.EQ(100)) // type-checked value
models.SelectWhere.Jets.Name.IsNull()
models.SelectWhere.Jets.Age.GTE(21)
```
Every generated column exposes the full comparison set (type `WhereMod[Q,C]`):
`EQ`, `NE`, `LT`, `LTE`, `GT`, `GTE`, `In(...vals)`, `NotIn(...vals)`, `Like(v)`, `ILike(v)`.
**Nullable** columns additionally get `IsNull()` and `IsNotNull()`. Values are type-checked against
the column's Go type.
Combine with `psql.WhereOr` / `psql.WhereAnd` (nestable):
```go
users, err := models.Users.Query(
psql.WhereOr(
models.SelectWhere.Users.Name.IsNull(),
models.SelectWhere.Users.Email.IsNotNull(),
psql.WhereAnd(
models.SelectWhere.Users.Age.GT(21),
models.SelectWhere.Users.Location.IsNotNull(),
),
),
).All(ctx, db)
```
For an aliased table: `models.SelectWhere.Users.AliasedAs("u").Name.IsNull()`.
### Typed JOIN helpers
Generated from the table's relationships (`SelectJoins`/`InsertJoins`/`UpdateJoins`/`DeleteJoins`):
```go
models.Jets.Query(
models.SelectJoins.Jets.InnerJoin.Pilots,
models.SelectJoins.Jets.InnerJoin.Airports,
).All(ctx, db)
// AliasedAs works here too: SelectJoins.Jets.AliasedAs("j").InnerJoin.Airports.AliasedAs("a")
```
### Column expressions
`models.JetColumns.X` are expressions usable anywhere in a hand-built query (mixing Layer 1 + 2):
```go
psql.Select(
sm.Columns(models.JetColumns.Name, "count(1)"),
sm.From(models.Jets.NameExpr()),
sm.Where(models.JetColumns.ID.Between(50, 5000)),
sm.OrderBy(models.JetColumns.PilotID),
)
```
## CRUD
```go
// INSERT (RETURNING added automatically -> finish with .One()/.All())
jet, err := models.Jets.Insert(&models.JetSetter{Name: omit.From("x")}).One(ctx, db)
jets, err := models.Jets.Insert(s1, s2, s3).All(ctx, db) // bulk
jets, err := models.Jets.Insert(bob.ToMods(setterSlice...)).All(ctx, db) // from a []*Setter
// UPSERT (PSQL/SQLite)
models.Jets.Insert(setter, im.OnConflict("id").DoUpdate(im.SetExcluded("name"))).One(ctx, db)
// MySQL: im.OnDuplicateKeyUpdate(im.UpdateWithValues("name"))
// UPDATE via the table + typed where
jet, err := models.Jets.Update(
models.UpdateWhere.Jets.ID.EQ(jetID),
setter.UpdateMod(),
).One(ctx, db)
// Instance methods on a fetched row / slice
err := jet.Update(ctx, db, &models.JetSetter{Name: omit.From("new")}) // by PK
err = jets.UpdateAll(ctx, db, models.JetSetter{AirportID: omit.From(100)})
_, err = jet.Delete(ctx, db)
_, err = jets.DeleteAll(ctx, db)
_, err = jet.Reload(ctx, db) // re-read all columns; jets.ReloadAll for slices
```
## Error constants (unique-constraint matching)
Bob generates `<Table>Errors` plus a generic `ErrUniqueConstraint`:
```go
pilot, err := models.Pilots.Insert(setter).One(ctx, db)
if errors.Is(models.PilotErrors.ErrUniqueFirstNameAndLastName, err) { /* handle */ }
if models.ErrUniqueConstraint.Is(err) { /* any unique violation */ }
```
> With `errors.Is`, order matters: the **constant goes first**, the DB error second. Flipping them
> silently fails to match.
## Relationships
Related rows live in `model.R`; relationship counts (when loaded) live in `model.C`.
```go
jet.R.Pilot // *Pilot (to-one)
pilot.R.Jets // JetSlice (to-many)
```
**Two eager-loading strategies:**
- **`Preload`** — one `LEFT JOIN` in the same SELECT. To-one relationships only.
- **`ThenLoad`** — a separate follow-up query. Works for any relationship type, including to-many.
```go
// Preload (to-one, single round-trip)
jet, err := models.Jets(
models.Preload.Jet.Pilot(
psql.OnlyColumns("id"),
psql.PreloadAs("pilot"),
psql.SelectThenLoad.Pilot.Licences(), // nest a further load
),
).One(ctx, db)
// ThenLoad (any type; can filter the loaded side)
pilots, err := models.Pilots(
models.ThenLoad.Pilots.Jets(models.SelectWhere.Jet.AirportID.EQ(100)),
).All(ctx, db)
```
There are query-type variants: `SelectThenLoad`, `InsertThenLoad`, `UpdateThenLoad` (and the
`*ThenLoadCount` / `PreloadCount` forms that populate `model.C` without loading rows).
**On an existing instance:**
```go
err := jet.LoadPilot(ctx, db) // fills jet.R.Pilot
err = pilot.LoadJets(ctx, db) // fills pilot.R.Jets
err = pilot.LoadCountJets(ctx, db) // fills *pilot.C.Jets
```
**Mutating relationships:**
```go
jet.InsertPilot(ctx, db, &models.PilotSetter{...}) // create + link (to-one)
pilot.InsertJets(ctx, db, &models.JetSetter{...}) // create + link (to-many)
jet.AttachPilot(ctx, db, existingPilot) // link an existing row
```
**Was it loaded?** Each `R` carries a `Loaded` sub-struct (rename via `relation_loaded_name` config):
```go
if jet.R.Loaded.Pilot && jet.R.Pilot == nil {
// definitively loaded and there is no pilot (null FK)
}
```
`Lazy` relationship querying (no preload) is also generated: `jet.Pilots(ctx, db, mods...)` returns
a query with `One/All/Count/Exists/...`.
## Hooks
Models expose typed hook sets you register onto:
```go
// signature: func(ctx, exec, T) (context.Context, error); returned ctx threads forward.
models.Jets.BeforeInsertHooks.AppendHooks(func(ctx context.Context, exec bob.Executor, s *models.JetSetter) (context.Context, error) {
return ctx, nil
})
```
Hook points on a Table: `BeforeInsertHooks` (receives the **setter**), `AfterInsertHooks`,
`Before/AfterUpdateHooks`, `Before/AfterDeleteHooks`, `Before/AfterMergeHooks` (all receive the
**slice**), plus `AfterSelectHooks` on the View. There are also query-level hooks
(`InsertQueryHooks`, etc.).
> **The method is `AppendHooks`, not `Add`.** The hooks doc page is wrong about this.
Skip hooks for a single call by threading a marked context:
```go
users, err := models.Jets.Query().All(bob.SkipHooks(ctx), db)
// bob.SkipModelHooks(ctx) and bob.SkipQueryHooks(ctx) skip only one kind.
```
---
## Hand-written models (no codegen)
You can build the same `View`/`Table` objects by hand. A **View** is read-only; a **Table** embeds
a View and adds writes. Construct via the dialect package:
```go
import (
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/psql"
"github.com/stephenafamo/bob/expr"
)
type User struct {
ID int `db:"id,pk"`
Name string `db:"name"`
Email string `db:"email"`
}
type UserSetter struct { // must satisfy orm.Setter
ID omit.Val[int] `db:"id,pk"`
Name omit.Val[string] `db:"name"`
Email omit.Val[string] `db:"email"`
}
// psql/sqlite: (schema, table, columns). mysql: (table, columns, uniques...) — no schema arg.
var userView = psql.NewView[*User, bob.Expression]("public", "users", expr.ColsForStruct[User]("users"))
var userTable = psql.NewTable[User, *UserSetter, bob.Expression]("public", "users", expr.ColsForStruct[User]("users"))
```
- `expr.ColsForStruct[T](alias)` reflects `db` tags into the column list.
- `NewViewx` / `NewTablex` let you choose the slice type (e.g. a named `[]*User`).
- Query/CRUD on these behaves exactly like the generated models: `userTable.Query(...).All(ctx, db)`,
`userTable.Insert(&UserSetter{...}).One(ctx, db)`, `user.Update(ctx, db, setter)`, etc.
- `Columns` field supports `.Only("a")`, `.Except("b")`, `.WithParent("schema","t")`,
`.WithPrefix("t.")` for projection control.
- `bob.UseSchema(ctx, "tenant")` overrides the schema at runtime for views built with an empty schema.

251
golang-bob/query-builder.md Normal file
View File

@ -0,0 +1,251 @@
# 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.