skills/golang-bob/models.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

10 KiB

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)

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

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:

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

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

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

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

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

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

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.

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

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:

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

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:

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

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:

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.