diff --git a/README.md b/README.md index 78bfc9f..d536f99 100644 --- a/README.md +++ b/README.md @@ -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/ -> ../../src/skills/ +~/.claude/skills/ -> ../../src/skills/ # Claude Code +~/.agents/skills/ -> ../../src/skills/ # Codex / cross-runtime ~/.claude/commands/.md -> ../../src/skills/commands/.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/ # write SKILL.md inside -ln -s ../../src/skills/ ~/.claude/skills/ +ln -s ../../src/skills/ ~/.claude/skills/ # Claude Code +ln -s ../../src/skills/ ~/.agents/skills/ # Codex ``` diff --git a/golang-bob/SKILL.md b/golang-bob/SKILL.md new file mode 100644 index 0000000..005f48a --- /dev/null +++ b/golang-bob/SKILL.md @@ -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`. diff --git a/golang-bob/code-generation.md b/golang-bob/code-generation.md new file mode 100644 index 0000000..d11d8ba --- /dev/null +++ b/golang-bob/code-generation.md @@ -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`, `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 `_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. ("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. diff --git a/golang-bob/comparisons.md b/golang-bob/comparisons.md new file mode 100644 index 0000000..fd6ca5e --- /dev/null +++ b/golang-bob/comparisons.md @@ -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. diff --git a/golang-bob/execution.md b/golang-bob/execution.md new file mode 100644 index 0000000..a740443 --- /dev/null +++ b/golang-bob/execution.md @@ -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. diff --git a/golang-bob/models.md b/golang-bob/models.md new file mode 100644 index 0000000..e22e595 --- /dev/null +++ b/golang-bob/models.md @@ -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 `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. diff --git a/golang-bob/query-builder.md b/golang-bob/query-builder.md new file mode 100644 index 0000000..7f9ed7e --- /dev/null +++ b/golang-bob/query-builder.md @@ -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.