skills/golang-bob/code-generation.md
Alex Dunmow 7bc233df1a feat(golang-bob): add Bob Go SQL toolkit reference skill
Reference skill for github.com/stephenafamo/bob (v0.46.0): query builder,
generated models/ORM, bobgen code generation, factories, and result scanning.
SKILL.md routes to per-subsystem reference files (query-builder, models,
code-generation, execution, comparisons). Symlinked into ~/.claude/skills and
~/.agents/skills (Codex).

Verified with a RED/GREEN subagent test and cross-checked against v0.46.0
source (flat mod import paths, AppendHooks, plural table var, WhereMod ops).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:51:55 +08:00

9.1 KiB
Raw Blame History

Bob Code Generation — bobgen (Layers 24)

bobgen introspects a live database (or .sql schema files) and generates: typed models + setters + slices, a factory package for tests, typed query functions from your .sql files, enums, and helper namespaces (SelectWhere, SelectJoins, <T>Columns, error constants). All generated files end in .bob.go and are safe to regenerate. Bob does not manage migrations — the schema must already exist.

The generator binaries

Four drivers under github.com/stephenafamo/bob/gen/ (no atlas/prisma driver in v0.46.0). Run with go run ...@latest or go install. Each reads <DRIVER>_DSN from the env, or a config file via -c.

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

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

-- 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:

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

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:

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:

CREATE TYPE task_status AS ENUM('not_started','in_progress','completed');
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.