Compare commits
3 Commits
b8535ff7d9
...
7bc233df1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc233df1a | ||
|
|
d7c7607058 | ||
|
|
182d64403e |
@ -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:
|
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
|
~/.claude/commands/<name>.md -> ../../src/skills/commands/<name>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -13,7 +14,9 @@ Each skill directory (and command file under `commands/`) is symlinked back into
|
|||||||
|
|
||||||
- **developing-blockninja-plugins** — creating, building, and publishing BlockNinja CMS plugins/themes
|
- **developing-blockninja-plugins** — creating, building, and publishing BlockNinja CMS plugins/themes
|
||||||
- **fleet** — apply a change across many BlockNinja repos with per-repo verification
|
- **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
|
- **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
|
- **shipit** — full BlockNinja plugin/theme pre-publish pipeline
|
||||||
- **star-response-builder** — turn experience/achievements into STAR-style responses
|
- **star-response-builder** — turn experience/achievements into STAR-style responses
|
||||||
- **workorder** — generate numbered work-order documents (WO-NNN)
|
- **workorder** — generate numbered work-order documents (WO-NNN)
|
||||||
@ -26,5 +29,6 @@ Each skill directory (and command file under `commands/`) is symlinked back into
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir ~/src/skills/<name> # write SKILL.md inside
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@ -21,6 +21,9 @@ Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own
|
|||||||
| Working on | Read first (under `cms/docs/`) |
|
| Working on | Read first (under `cms/docs/`) |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Scaffold, registration, blocks, `CoreServices` | PLUGIN_DEVELOPMENT.md |
|
| Scaffold, registration, blocks, `CoreServices` | PLUGIN_DEVELOPMENT.md |
|
||||||
|
| Depositing media into the library (seed `EnsureMedia` / runtime `CoreServices.Media`) | PLUGIN_DEVELOPMENT.md §Depositing Media |
|
||||||
|
| Plugin migrations / DB tables — each plugin owns a Postgres schema (named after it), never `public` | PLUGIN_DEVELOPMENT.md §Migrations |
|
||||||
|
| **Platform data tables** — provision via `Provisioner.EnsureDataTable` (`RegisterWithProvisioner`); the row-as-JSONB store the admin Data Platform / buckets / **data Views** read, idempotent + **View-rootable**. NOT the plugin's own Postgres schema above | PLUGIN_DEVELOPMENT.md §Provisioning → Data tables |
|
||||||
| Public HTTP routes (webhooks, widgets) | PLUGIN_HTTP_HANDLERS.md |
|
| Public HTTP routes (webhooks, widgets) | PLUGIN_HTTP_HANDLERS.md |
|
||||||
| Load/Unload, runtime state, goroutines | PLUGIN_LIFECYCLE_HOOKS.md |
|
| Load/Unload, runtime state, goroutines | PLUGIN_LIFECYCLE_HOOKS.md |
|
||||||
| Themes, templates, master pages, CSS | TEMPLATE_PLUGINS.md |
|
| Themes, templates, master pages, CSS | TEMPLATE_PLUGINS.md |
|
||||||
|
|||||||
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.
|
||||||
480
revenuecat-api-v2/SKILL.md
Normal file
480
revenuecat-api-v2/SKILL.md
Normal file
@ -0,0 +1,480 @@
|
|||||||
|
---
|
||||||
|
name: revenuecat-api-v2
|
||||||
|
description: Use when integrating, inspecting, automating, or debugging the RevenueCat REST API v2 server-side — reading customer subscription/entitlement/access state, creating or updating customers and attributes, granting or revoking promotional access, managing products, entitlements, offerings, packages, paywalls, apps, or webhooks, and handling refunds, cancellations, metrics, audit logs, or receipt/state migrations. Covers the v2 endpoint map, sk_/atk_ auth, pagination, expandables, rate limits, error types, and write-safety. Server-side REST, not the client SDK.
|
||||||
|
---
|
||||||
|
|
||||||
|
# RevenueCat API v2 Skill
|
||||||
|
|
||||||
|
Use this skill when integrating, inspecting, automating, or debugging RevenueCat REST API v2.
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
Use the current official RevenueCat API v2 docs before implementing. API v2 is still under development and does not cover every v1 use case. Use v1 only when v2 lacks the required endpoint.
|
||||||
|
|
||||||
|
Base URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://api.revenuecat.com/v2
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to use SDK vs REST
|
||||||
|
|
||||||
|
Use the RevenueCat SDK for normal client purchase flows.
|
||||||
|
|
||||||
|
Use REST API v2 for server-side work:
|
||||||
|
- reading customer subscription/access state
|
||||||
|
- creating or updating customers and attributes
|
||||||
|
- granting or revoking promotional access
|
||||||
|
- managing products, entitlements, offerings, packages, paywalls, apps, webhooks
|
||||||
|
- refunds, cancellations, subscription management URLs
|
||||||
|
- metrics, audit logs, invoices, virtual currencies
|
||||||
|
- migrations where the backend already has receipts or user state
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Every request uses:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid tokens:
|
||||||
|
- `sk_...` secret API key: server-to-server, project-scoped.
|
||||||
|
- `atk_...` OAuth access token: third-party tools, developer-scoped.
|
||||||
|
- Public SDK keys are for SDK/client configuration only. Never use secret keys in an app, website, repo, or frontend.
|
||||||
|
|
||||||
|
API v1 keys do not work with API v2. Create a v2 secret key and assign only the endpoint permissions needed.
|
||||||
|
|
||||||
|
## Required request habits
|
||||||
|
|
||||||
|
For `POST`, `PUT`, and `PATCH`:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Always URL-encode path params such as `customer_id`.
|
||||||
|
|
||||||
|
Use environment filters where relevant:
|
||||||
|
|
||||||
|
```text
|
||||||
|
environment=sandbox
|
||||||
|
environment=production
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not infer production state from sandbox data.
|
||||||
|
|
||||||
|
## Core objects
|
||||||
|
|
||||||
|
RevenueCat v2 is project-centred.
|
||||||
|
|
||||||
|
Common object hierarchy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project
|
||||||
|
Apps
|
||||||
|
Products
|
||||||
|
Entitlements
|
||||||
|
Offerings
|
||||||
|
Packages
|
||||||
|
Products
|
||||||
|
Customers
|
||||||
|
Attributes
|
||||||
|
Aliases
|
||||||
|
Active entitlements
|
||||||
|
Subscriptions
|
||||||
|
Purchases
|
||||||
|
Invoices
|
||||||
|
Virtual currencies
|
||||||
|
Paywalls
|
||||||
|
Webhook integrations
|
||||||
|
Metrics / audit logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `project_id` for almost every v2 endpoint.
|
||||||
|
|
||||||
|
## Endpoint map
|
||||||
|
|
||||||
|
### Projects and apps
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects
|
||||||
|
POST /projects
|
||||||
|
GET /projects/{project_id}/apps
|
||||||
|
POST /projects/{project_id}/apps
|
||||||
|
GET /projects/{project_id}/apps/{app_id}
|
||||||
|
POST /projects/{project_id}/apps/{app_id}
|
||||||
|
DEL /projects/{project_id}/apps/{app_id}
|
||||||
|
GET /projects/{project_id}/apps/{app_id}/api_keys
|
||||||
|
GET /projects/{project_id}/apps/{app_id}/store_kit_config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customers
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/customers
|
||||||
|
POST /projects/{project_id}/customers
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}
|
||||||
|
DEL /projects/{project_id}/customers/{customer_id}
|
||||||
|
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/active_entitlements
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/subscriptions
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/purchases
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/aliases
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/attributes
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
Customer search currently supports exact email matching via `$email`.
|
||||||
|
|
||||||
|
### Customer actions
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/transfer
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/grant_entitlement
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/revoke_granted_entitlement
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/assign_offering
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/restore_purchase_by_order_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Granting an entitlement creates a promotional subscription. Revoking it expires that promotional subscription. Treat both as user-impacting writes.
|
||||||
|
|
||||||
|
### Subscriptions
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/subscriptions/{subscription_id}
|
||||||
|
GET /projects/{project_id}/subscriptions
|
||||||
|
GET /projects/{project_id}/subscriptions/{subscription_id}/transactions
|
||||||
|
GET /projects/{project_id}/subscriptions/{subscription_id}/entitlements
|
||||||
|
GET /projects/{project_id}/subscriptions/{subscription_id}/authenticated_management_url
|
||||||
|
|
||||||
|
POST /projects/{project_id}/subscriptions/{subscription_id}/actions/cancel
|
||||||
|
POST /projects/{project_id}/subscriptions/{subscription_id}/actions/extend
|
||||||
|
POST /projects/{project_id}/subscriptions/{subscription_id}/actions/refund
|
||||||
|
POST /projects/{project_id}/subscriptions/{subscription_id}/transactions/{transaction_id}/actions/refund
|
||||||
|
```
|
||||||
|
|
||||||
|
Use subscription fields such as `gives_access`, `status`, `auto_renewal_status`, `pending_payment`, `current_period_ends_at`, `store_subscription_identifier`, and `total_revenue_in_usd`. Prefer `gives_access` and active entitlements over hand-rolled expiry logic.
|
||||||
|
|
||||||
|
### Purchases
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/purchases/{purchase_id}
|
||||||
|
GET /projects/{project_id}/purchases/{purchase_id}/entitlements
|
||||||
|
GET /projects/{project_id}/purchases
|
||||||
|
POST /projects/{project_id}/purchases/{purchase_id}/actions/refund
|
||||||
|
```
|
||||||
|
|
||||||
|
Use purchase search when starting from a store purchase identifier.
|
||||||
|
|
||||||
|
### Product catalog
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/entitlements
|
||||||
|
POST /projects/{project_id}/entitlements
|
||||||
|
GET /projects/{project_id}/entitlements/{entitlement_id}
|
||||||
|
POST /projects/{project_id}/entitlements/{entitlement_id}
|
||||||
|
DEL /projects/{project_id}/entitlements/{entitlement_id}
|
||||||
|
|
||||||
|
POST /projects/{project_id}/entitlements/{entitlement_id}/actions/archive
|
||||||
|
POST /projects/{project_id}/entitlements/{entitlement_id}/actions/unarchive
|
||||||
|
POST /projects/{project_id}/entitlements/{entitlement_id}/actions/attach_products
|
||||||
|
POST /projects/{project_id}/entitlements/{entitlement_id}/actions/detach_products
|
||||||
|
|
||||||
|
GET /projects/{project_id}/products
|
||||||
|
POST /projects/{project_id}/products
|
||||||
|
GET /projects/{project_id}/products/{product_id}
|
||||||
|
POST /projects/{project_id}/products/{product_id}
|
||||||
|
DEL /projects/{project_id}/products/{product_id}
|
||||||
|
POST /projects/{project_id}/products/{product_id}/actions/archive
|
||||||
|
POST /projects/{project_id}/products/{product_id}/actions/unarchive
|
||||||
|
POST /projects/{project_id}/products/{product_id}/create_in_store
|
||||||
|
```
|
||||||
|
|
||||||
|
Product creation does not create Web Billing products. Store push has store-specific requirements.
|
||||||
|
|
||||||
|
### Offerings and packages
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/offerings
|
||||||
|
POST /projects/{project_id}/offerings
|
||||||
|
GET /projects/{project_id}/offerings/{offering_id}
|
||||||
|
POST /projects/{project_id}/offerings/{offering_id}
|
||||||
|
DEL /projects/{project_id}/offerings/{offering_id}
|
||||||
|
|
||||||
|
GET /projects/{project_id}/offerings/{offering_id}/packages
|
||||||
|
POST /projects/{project_id}/offerings/{offering_id}/packages
|
||||||
|
GET /projects/{project_id}/offerings/{offering_id}/packages/{package_id}
|
||||||
|
POST /projects/{project_id}/offerings/{offering_id}/packages/{package_id}
|
||||||
|
DEL /projects/{project_id}/offerings/{offering_id}/packages/{package_id}
|
||||||
|
|
||||||
|
POST /projects/{project_id}/offerings/{offering_id}/packages/{package_id}/actions/attach_products
|
||||||
|
POST /projects/{project_id}/offerings/{offering_id}/packages/{package_id}/actions/detach_products
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting an offering also deletes attached packages.
|
||||||
|
|
||||||
|
### Paywalls
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /projects/{project_id}/paywalls/media
|
||||||
|
GET /projects/{project_id}/paywalls
|
||||||
|
POST /projects/{project_id}/paywalls
|
||||||
|
GET /projects/{project_id}/paywalls/{paywall_id}
|
||||||
|
PATCH /projects/{project_id}/paywalls/{paywall_id}
|
||||||
|
DEL /projects/{project_id}/paywalls/{paywall_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Paywall updates affect drafts.
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/integrations/webhooks
|
||||||
|
POST /projects/{project_id}/integrations/webhooks
|
||||||
|
GET /projects/{project_id}/integrations/webhooks/{webhook_integration_id}
|
||||||
|
POST /projects/{project_id}/integrations/webhooks/{webhook_integration_id}
|
||||||
|
DEL /projects/{project_id}/integrations/webhooks/{webhook_integration_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook fields include name, URL, optional authorization header, environment, event types, and optional app scope.
|
||||||
|
|
||||||
|
### Metrics and audit
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/metrics/overview
|
||||||
|
GET /projects/{project_id}/metrics/revenue
|
||||||
|
GET /projects/{project_id}/metrics/charts/{chart_id}
|
||||||
|
GET /projects/{project_id}/metrics/charts/{chart_id}/options
|
||||||
|
GET /projects/{project_id}/audit_logs
|
||||||
|
GET /projects/{project_id}/collaborators
|
||||||
|
```
|
||||||
|
|
||||||
|
Metrics have stricter rate limits than customer reads.
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
List responses use:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"items": [],
|
||||||
|
"next_page": "...",
|
||||||
|
"url": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use:
|
||||||
|
- `limit`, default `20`
|
||||||
|
- `starting_after`
|
||||||
|
- `next_page` until absent
|
||||||
|
|
||||||
|
Pagination is forward-only.
|
||||||
|
|
||||||
|
## Expandables
|
||||||
|
|
||||||
|
Use `expand` when an endpoint supports it to fetch related objects in one request.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}?expand=attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
Only request expansions the API key has permission to read.
|
||||||
|
|
||||||
|
## Rate limits
|
||||||
|
|
||||||
|
Default domains:
|
||||||
|
- Customer Information: 480 requests/minute
|
||||||
|
- Charts & Metrics: 25 requests/minute
|
||||||
|
- Project Configuration: 60 requests/minute
|
||||||
|
- Virtual Currency transaction creation: 480 requests/minute
|
||||||
|
|
||||||
|
Read these headers:
|
||||||
|
- `RevenueCat-Rate-Limit-Current-Usage`
|
||||||
|
- `RevenueCat-Rate-Limit-Current-Limit`
|
||||||
|
- `Retry-After` on `429`
|
||||||
|
|
||||||
|
Back off on `429`, `423`, retryable `5xx`, and any response with `retryable: true` or `backoff_ms`.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
RevenueCat uses normal HTTP status classes:
|
||||||
|
- `2xx`: success
|
||||||
|
- `4xx`: client/auth/validation/conflict/rate-limit issue
|
||||||
|
- `5xx`: RevenueCat/upstream issue
|
||||||
|
|
||||||
|
Error bodies include fields like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "parameter_error",
|
||||||
|
"param": "customer_id",
|
||||||
|
"message": "id is too long",
|
||||||
|
"retryable": false,
|
||||||
|
"doc_url": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Common error types:
|
||||||
|
- `authentication_error`: bad token
|
||||||
|
- `authorization_error`: token lacks access to project/permission
|
||||||
|
- `invalid_request`: wrong method or missing JSON content type
|
||||||
|
- `parameter_error`: invalid path/query/body value
|
||||||
|
- `rate_limit_error`: wait using `backoff_ms` / `Retry-After`
|
||||||
|
- `resource_missing`: bad ID or deleted resource
|
||||||
|
- `resource_already_exists`: duplicate ID
|
||||||
|
- `resource_locked_error`: concurrent modification
|
||||||
|
- `store_error`: upstream app store problem
|
||||||
|
- `unprocessable_entity_error`: valid syntax, impossible operation
|
||||||
|
- `entity_references_archived_entities`: unarchive referenced objects first
|
||||||
|
|
||||||
|
## Write safety
|
||||||
|
|
||||||
|
Before any write, state:
|
||||||
|
- method and endpoint
|
||||||
|
- required permission
|
||||||
|
- affected object IDs
|
||||||
|
- side effects
|
||||||
|
- whether it touches production or sandbox
|
||||||
|
|
||||||
|
Do not perform destructive/user-impacting writes unless explicitly authorized.
|
||||||
|
|
||||||
|
Treat these as high-risk:
|
||||||
|
- deleting customers, apps, products, offerings, paywalls, webhooks
|
||||||
|
- granting/revoking entitlements
|
||||||
|
- transferring purchases/subscriptions
|
||||||
|
- refunding purchases/subscriptions
|
||||||
|
- cancelling/extending subscriptions
|
||||||
|
- changing production product catalog
|
||||||
|
- changing production webhooks
|
||||||
|
|
||||||
|
For uncertain writes, provide a dry-run request and explain expected result.
|
||||||
|
|
||||||
|
## Common workflows
|
||||||
|
|
||||||
|
### Check whether a customer has access
|
||||||
|
|
||||||
|
1. Fetch the customer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Fetch active entitlements:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/active_entitlements
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Fetch subscriptions if detail is needed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/customers/{customer_id}/subscriptions?environment=production
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `active_entitlements` and subscription `gives_access`. Do not invent access rules from expiry dates alone.
|
||||||
|
|
||||||
|
### Find a customer by email
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /projects/{project_id}/customers?search=user@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
This searches exact `$email` attribute matches.
|
||||||
|
|
||||||
|
### Set customer attributes
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/attributes
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use reserved attribute names like `$email` only according to RevenueCat’s rules. Keep custom attribute names short and stable.
|
||||||
|
|
||||||
|
### Grant temporary access
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/grant_entitlement
|
||||||
|
```
|
||||||
|
|
||||||
|
Explain that this creates a promotional subscription.
|
||||||
|
|
||||||
|
### Revoke granted access
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /projects/{project_id}/customers/{customer_id}/actions/revoke_granted_entitlement
|
||||||
|
```
|
||||||
|
|
||||||
|
Explain that this expires the promotional subscription.
|
||||||
|
|
||||||
|
### Build a product catalogue view
|
||||||
|
|
||||||
|
Read in this order:
|
||||||
|
1. apps
|
||||||
|
2. products
|
||||||
|
3. entitlements
|
||||||
|
4. entitlement product attachments
|
||||||
|
5. offerings
|
||||||
|
6. packages
|
||||||
|
7. package product attachments
|
||||||
|
8. paywalls
|
||||||
|
|
||||||
|
Use `expand` where supported.
|
||||||
|
|
||||||
|
### Configure webhooks
|
||||||
|
|
||||||
|
Create or update:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /projects/{project_id}/integrations/webhooks
|
||||||
|
POST /projects/{project_id}/integrations/webhooks/{webhook_integration_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always specify environment intentionally. Use an authorization header for webhook verification. Do not log webhook secrets.
|
||||||
|
|
||||||
|
### Refund or cancel
|
||||||
|
|
||||||
|
Use the endpoint that matches the store and object:
|
||||||
|
- Web Billing purchase refund
|
||||||
|
- Web Billing subscription cancel/refund/extend
|
||||||
|
- Play Store or Galaxy subscription transaction refund
|
||||||
|
- subscription management URL for customer-managed changes
|
||||||
|
|
||||||
|
Always confirm production environment and affected subscription/purchase ID.
|
||||||
|
|
||||||
|
## Response standards
|
||||||
|
|
||||||
|
When answering RevenueCat API questions:
|
||||||
|
- identify SDK vs REST
|
||||||
|
- identify v1 vs v2
|
||||||
|
- state endpoint, method, and required token type
|
||||||
|
- state required permission scope when relevant
|
||||||
|
- show minimal `curl` or code using env vars
|
||||||
|
- never expose real tokens
|
||||||
|
- call out side effects
|
||||||
|
- explain retry/backoff behaviour
|
||||||
|
- convert timestamps to readable ISO dates when shown
|
||||||
|
- distinguish sandbox from production
|
||||||
|
- mention when v1 is still required
|
||||||
|
|
||||||
|
## Minimal curl pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: Bearer $REVENUECAT_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"https://api.revenuecat.com/v2/projects/$REVENUECAT_PROJECT_ID/customers/$CUSTOMER_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
For writes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer $REVENUECAT_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"key":"value"}' \
|
||||||
|
"https://api.revenuecat.com/v2/projects/$REVENUECAT_PROJECT_ID/..."
|
||||||
|
```
|
||||||
Loading…
x
Reference in New Issue
Block a user