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:
parent
d7c7607058
commit
7bc233df1a
@ -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
126
golang-bob/SKILL.md
Normal 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`.
|
||||
216
golang-bob/code-generation.md
Normal file
216
golang-bob/code-generation.md
Normal file
@ -0,0 +1,216 @@
|
||||
# Bob Code Generation — bobgen (Layers 2–4)
|
||||
|
||||
`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
36
golang-bob/comparisons.md
Normal 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
126
golang-bob/execution.md
Normal 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
294
golang-bob/models.md
Normal 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
251
golang-bob/query-builder.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user