Documentation
¶
Overview ¶
Package sqlb is a composable, type-parameterised SQL builder for Postgres.
A query is a plain value, not a statement executed at the point of construction. That is the whole point: predicates can be added conditionally, which is what static query generators cannot express.
q := sqlb.Query[User]().Where(sqlb.F("age").Gte(18))
if search != "" {
q = q.Where(sqlb.F("name").Contains(search))
}
users, err := q.OrderBy(sqlb.F("created_at").Desc()).Limit(50).All(ctx, db)
Because the query is a value, hooks and the REST layer can both mutate it before it is compiled, and the same predicate AST is produced by hand-written Go and by parsed URL filter expressions.
Values never reach the SQL text. Every user-supplied value becomes a bind parameter; only identifiers validated against the model are interpolated.
Index ¶
- Variables
- func AfterCommit(ctx context.Context, fn func(context.Context) error) error
- func Array(values ...any) any
- func Collect[R, T any](ctx context.Context, db Executor, b *Builder[T]) ([]R, error)
- func Diagnostics(ds []PlanDiagnostic) string
- func PrincipalFrom[T any](ctx context.Context) (T, bool)
- func RegisterVectorType(ctx context.Context, conn *pgx.Conn) error
- func SetErrorClassifier(fn ErrorClassifier)
- func VectorPoolConfig(dsn string) (*pgxpool.Config, error)
- func WithPrincipal(ctx context.Context, p any) context.Context
- type ArrayCol
- func (c ArrayCol[E]) Column() Column
- func (c ArrayCol[E]) Eq(v []E) Pred
- func (c ArrayCol[E]) Field() Field
- func (c ArrayCol[E]) Has(v E) Pred
- func (c ArrayCol[E]) HasAll(values ...E) Pred
- func (c ArrayCol[E]) HasAny(values ...E) Pred
- func (c ArrayCol[E]) IsNull() Pred
- func (c ArrayCol[E]) Name() string
- func (c ArrayCol[E]) Neq(v []E) Pred
- func (c ArrayCol[E]) NotNull() Pred
- func (c ArrayCol[E]) Qualify(table string) ArrayCol[E]
- type Beginner
- type BetweenExpr
- type Binary
- type Builder
- func (b *Builder[T]) After(c Cursor) *Builder[T]
- func (b *Builder[T]) All(ctx context.Context, db Executor) ([]T, error)
- func (b *Builder[T]) As(alias string) *Builder[T]
- func (b *Builder[T]) Bind(key string, value any) *Builder[T]
- func (b *Builder[T]) Bound() []string
- func (b *Builder[T]) ClearSelect() *Builder[T]
- func (b *Builder[T]) Clone() *Builder[T]
- func (b *Builder[T]) Count(ctx context.Context, db Executor) (int64, error)
- func (b *Builder[T]) CursorFor(row T) (Cursor, error)
- func (b *Builder[T]) Distinct() *Builder[T]
- func (b *Builder[T]) Err() error
- func (b *Builder[T]) Exists(ctx context.Context, db Executor) (bool, error)
- func (b *Builder[T]) Expand(names ...string) *Builder[T]
- func (b *Builder[T]) Expanded() []string
- func (b *Builder[T]) Fail(err error) *Builder[T]
- func (b *Builder[T]) First(ctx context.Context, db Executor) (T, error)
- func (b *Builder[T]) ForShare() *Builder[T]
- func (b *Builder[T]) ForUpdate() *Builder[T]
- func (b *Builder[T]) GroupBy(fields ...Field) *Builder[T]
- func (b *Builder[T]) GroupByExpr(exprs ...Expr) *Builder[T]
- func (b *Builder[T]) Having(preds ...Pred) *Builder[T]
- func (b *Builder[T]) Join(table, alias string, on Pred) *Builder[T]
- func (b *Builder[T]) LeftJoin(table, alias string, on Pred) *Builder[T]
- func (b *Builder[T]) Limit(n int) *Builder[T]
- func (b *Builder[T]) Model() *Model
- func (b *Builder[T]) Offset(n int) *Builder[T]
- func (b *Builder[T]) One(ctx context.Context, db Executor) (T, error)
- func (b *Builder[T]) OrderBy(orders ...Order) *Builder[T]
- func (b *Builder[T]) OrderColumns() []string
- func (b *Builder[T]) Page(number, size int) *Builder[T]
- func (b *Builder[T]) SQL() (string, []any, error)
- func (b *Builder[T]) Select(items ...Selectable) *Builder[T]
- func (b *Builder[T]) SkipLocked() *Builder[T]
- func (b *Builder[T]) Stable() *Builder[T]
- func (b *Builder[T]) UseDialect(d Dialect) *Builder[T]
- func (b *Builder[T]) Where(preds ...Pred) *Builder[T]
- func (b *Builder[T]) WithComputed(names ...string) *Builder[T]
- type Call
- type Cast
- type Col
- func (c Col[T]) Asc() Order
- func (c Col[T]) Between(lo, hi T) Pred
- func (c Col[T]) Column() Column
- func (c Col[T]) Desc() Order
- func (c Col[T]) Eq(v T) Pred
- func (c Col[T]) EqCol(other Col[T]) Pred
- func (c Col[T]) Field() Field
- func (c Col[T]) Gt(v T) Pred
- func (c Col[T]) Gte(v T) Pred
- func (c Col[T]) IsNull() Pred
- func (c Col[T]) Lt(v T) Pred
- func (c Col[T]) Lte(v T) Pred
- func (c Col[T]) Name() string
- func (c Col[T]) Neq(v T) Pred
- func (c Col[T]) NotNull() Pred
- func (c Col[T]) NotOneOf(values ...T) Pred
- func (c Col[T]) OneOf(values ...T) Pred
- func (c Col[T]) Qualify(table string) Col[T]
- type Collection
- type Column
- type ColumnInfo
- type Compilable
- type Computed
- type ConflictRef
- type ConstraintError
- type ConstraintKind
- type Cursor
- type DB
- func (d *DB) AfterCommit(fn func(context.Context) error) error
- func (d *DB) CanBeginTx() bool
- func (d *DB) Exec(ctx context.Context, query string, args ...any) (pgconn.CommandTag, error)
- func (d *DB) Hooks() *Registry
- func (d *DB) InTx() bool
- func (d *DB) Query(ctx context.Context, query string, args ...any) (pgx.Rows, error)
- func (d *DB) Tx() (pgx.Tx, bool)
- func (d *DB) WithHooks(r *Registry) *DB
- func (d *DB) WithTx(ctx context.Context, fn func(ctx context.Context, tx *DB) error) error
- func (d *DB) WithTxOptions(ctx context.Context, opts pgx.TxOptions, ...) error
- type Delete
- func (d *Delete[T]) Clone() *Delete[T]
- func (d *Delete[T]) Everything() *Delete[T]
- func (d *Delete[T]) Exec(ctx context.Context, db Executor) (int64, error)
- func (d *Delete[T]) SQL() (string, []any, error)
- func (d *Delete[T]) UseDialect(dl Dialect) *Delete[T]
- func (d *Delete[T]) Where(preds ...Pred) *Delete[T]
- type Deriver
- type Description
- func (d *Description[T]) Column(field, column string) *Description[T]
- func (d *Description[T]) Computed(column, expr string, needs ...string) *Description[T]
- func (d *Description[T]) Defaulted(columns ...string) *Description[T]
- func (d *Description[T]) Filterable(columns ...string) *Description[T]
- func (d *Description[T]) Hidden(columns ...string) *Description[T]
- func (d *Description[T]) Immutable(columns ...string) *Description[T]
- func (d *Description[T]) Model() *Model
- func (d *Description[T]) PrimaryKey(column string) *Description[T]
- func (d *Description[T]) ReadOnly(columns ...string) *Description[T]
- func (d *Description[T]) Relation(field, fkColumn string, opts ...RelationOption) *Description[T]
- func (d *Description[T]) SQLType(name string, columns ...string) *Description[T]
- func (d *Description[T]) Scoped(column string) *Description[T]
- func (d *Description[T]) Searchable(columns ...string) *Description[T]
- func (d *Description[T]) SoftDeleted(column string) *Description[T]
- func (d *Description[T]) SortNullsFirst(columns ...string) *Description[T]
- func (d *Description[T]) SortNullsLast(columns ...string) *Description[T]
- func (d *Description[T]) Sortable(columns ...string) *Description[T]
- func (d *Description[T]) Table(name string) *Description[T]
- func (d *Description[T]) Timestamps(columns ...string) *Description[T]
- type Dialect
- type ErrorClassifier
- type Executor
- type Expr
- type Field
- func (f Field) Asc() Order
- func (f Field) Between(lo, hi any) Pred
- func (f Field) Cast(typ string) Expr
- func (f Field) Column() Column
- func (f Field) Contains(v string) Pred
- func (f Field) ContainsJSON(doc string) Pred
- func (f Field) Desc() Order
- func (f Field) EndsWith(v string) Pred
- func (f Field) Eq(v any) Pred
- func (f Field) EqField(other Field) Pred
- func (f Field) Gt(v any) Pred
- func (f Field) Gte(v any) Pred
- func (f Field) Has(v any) Pred
- func (f Field) HasAll(values ...any) Pred
- func (f Field) HasAny(values ...any) Pred
- func (f Field) ILike(pattern string) Pred
- func (f Field) IsNull() Pred
- func (f Field) Like(pattern string) Pred
- func (f Field) Lt(v any) Pred
- func (f Field) Lte(v any) Pred
- func (f Field) Name() string
- func (f Field) Neq(v any) Pred
- func (f Field) NotBetween(lo, hi any) Pred
- func (f Field) NotContainsJSON(doc string) Pred
- func (f Field) NotHas(v any) Pred
- func (f Field) NotHasAll(values ...any) Pred
- func (f Field) NotHasAny(values ...any) Pred
- func (f Field) NotNull() Pred
- func (f Field) NotOneOf(values ...any) Pred
- func (f Field) OneOf(values ...any) Pred
- func (f Field) Qualify(table string) Field
- func (f Field) StartsWith(v string) Pred
- func (f Field) Table() string
- type Hooks
- func (h *Hooks[T]) AfterCreate(fn func(context.Context, *T) error) *Hooks[T]
- func (h *Hooks[T]) AfterDelete(fn func(context.Context, int64) error) *Hooks[T]
- func (h *Hooks[T]) AfterUpdate(fn func(context.Context, []T) error) *Hooks[T]
- func (h *Hooks[T]) BeforeCreate(fn func(context.Context, *T) error) *Hooks[T]
- func (h *Hooks[T]) BeforeDelete(fn func(context.Context, *Delete[T]) error) *Hooks[T]
- func (h *Hooks[T]) BeforeQuery(fn func(context.Context, *Builder[T]) error) *Hooks[T]
- func (h *Hooks[T]) BeforeUpdate(fn func(context.Context, *Update[T]) error) *Hooks[T]
- func (h *Hooks[T]) Registered() RegisteredHooks
- func (h *Hooks[T]) Reset()
- type Insert
- func (i *Insert[T]) Exec(ctx context.Context, db Executor) ([]T, error)
- func (i *Insert[T]) Omit(columns ...string) *Insert[T]
- func (i *Insert[T]) OnConflictDoNothing(target ...string) *Insert[T]
- func (i *Insert[T]) OnConflictSet(column string, value Expr) *Insert[T]
- func (i *Insert[T]) OnConflictUpdate(target []string, update ...string) *Insert[T]
- func (i *Insert[T]) One(ctx context.Context, db Executor) (T, error)
- func (i *Insert[T]) Only(columns ...string) *Insert[T]
- func (i *Insert[T]) SQL() (string, []any, error)
- func (i *Insert[T]) UseDialect(d Dialect) *Insert[T]
- type List
- type Model
- type Nearness
- type NullsOrder
- type Order
- type Param
- type Plan
- type PlanDiagnostic
- type PlanNode
- type Postgres
- type Pred
- type Raw
- type RegisteredHooks
- type Registry
- type RelationInfo
- type RelationOption
- type Selectable
- type Selection
- func Avg(f Field) Selection
- func Coalesce(exprs ...Expr) Selection
- func Count() Selection
- func CountDistinct(f Field) Selection
- func CountOf(f Field) Selection
- func Max(f Field) Selection
- func Min(f Field) Selection
- func RawSel(sql string, args ...any) Selection
- func Sel(e Expr) Selection
- func Sum(f Field) Selection
- type Tabler
- type TextCol
- type Unary
- type Update
- func (u *Update[T]) Clone() *Update[T]
- func (u *Update[T]) Everything() *Update[T]
- func (u *Update[T]) Exec(ctx context.Context, db Executor) ([]T, error)
- func (u *Update[T]) One(ctx context.Context, db Executor) (T, error)
- func (u *Update[T]) SQL() (string, []any, error)
- func (u *Update[T]) Set(column string, value any) *Update[T]
- func (u *Update[T]) SetExpr(column string, value Expr) *Update[T]
- func (u *Update[T]) UseDialect(d Dialect) *Update[T]
- func (u *Update[T]) Where(preds ...Pred) *Update[T]
- type Vector
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrAfterCommit = errors.New("sqlb: transaction committed, but an after-commit callback failed")
ErrAfterCommit reports that the transaction committed but an after-commit callback failed. The distinction matters: the write is durable and must not be retried, while the side effect did not happen and may need to be.
if err := db.WithTx(ctx, placeOrder); err != nil {
if errors.Is(err, sqlb.ErrAfterCommit) {
// The order exists. Something downstream of it did not fire.
log.Error("order placed, notification failed", "err", err)
} else {
return err // The order does not exist.
}
}
var ErrBadCursor = errors.New("sqlb: invalid cursor")
ErrBadCursor is the class of every cursor a request cannot be answered with: malformed, or issued for a different ordering. It is a sentinel so the REST layer can map the whole class to 400 without inspecting text; the wrapped error says which case it was and what to do.
var ErrConstraint = errors.New("sqlb: constraint violated")
ErrConstraint is the class of every write a database constraint refused.
It exists so that a caller can tell its own mistake from the library's without depending on a driver:
if errors.Is(err, sqlb.ErrConstraint) { … }
A caller needing to know *which* constraint matches a *ConstraintError with errors.As instead.
var ErrNotFound = errors.New("sqlb: no rows matched")
ErrNotFound is returned by One when the query matches no rows. It is a sentinel so that HTTP handlers can map it to 404 without inspecting text.
var ErrUnscoped = errors.New("sqlb: statement would affect every row; add a Where clause or call Everything to confirm")
ErrUnscoped is returned by Update and Delete when no WHERE clause was given. Rewriting or removing every row is almost never intended, so it must be requested explicitly with Everything.
var SeqScanRowThreshold int64 = 1000
SeqScanRowThreshold is the estimated row count above which a sequential scan is reported. Small tables are legitimately scanned, so flagging every one would be noise that trains readers to ignore the output.
Functions ¶
func AfterCommit ¶
AfterCommit registers fn on the transaction carried by ctx. It is the form to use from a hook, which receives a context rather than a handle:
sqlb.On[Order](reg).AfterCreate(func(ctx context.Context, o *Order) error {
id := o.ID
return sqlb.AfterCommit(ctx, func(ctx context.Context) error {
return events.Publish(ctx, OrderPlaced{ID: id})
})
})
Outside a transaction this is an error rather than an immediate call. "After commit" only means something when sqlb owns the commit; under autocommit the driver has already committed each statement and sqlb cannot say when, so a callback registered from BeforeCreate would fire before the insert and one registered from AfterCreate would fire after it. Running fn at a moment that depends on which hook happened to call it is the kind of quietly-wrong behaviour this codebase refuses elsewhere; the fix is one call, WithTx.
Example ¶
AfterCommit runs work that must not happen if the write does not. AfterCreate and its siblings run inside the transaction, which is right for validation and wrong for anything the outside world can observe: the transaction may still abort after the hook has announced a write that then never happened.
reg := sqlb.NewRegistry()
hooks := sqlb.On[Article](reg)
hooks.AfterCreate(func(ctx context.Context, a *Article) error {
// Runs inside the transaction. Returning an error here rolls the insert
// back, so the event is registered rather than published.
id := a.ID
return sqlb.AfterCommit(ctx, func(context.Context) error {
fmt.Println("published event for", id)
return nil
})
})
db := exampleDB(reg)
err := db.WithTx(context.Background(), func(ctx context.Context, tx *sqlb.DB) error {
a := Article{Title: "Hello", Status: "draft", OrgID: "acme"}
_, err := sqlb.InsertRows(&a).One(ctx, tx)
fmt.Println("insert returned, still inside the transaction")
return err
})
if err != nil {
panic(err)
}
Output: insert returned, still inside the transaction published event for a1
Example (Rollback) ¶
A rollback discards the callbacks by never reaching them, which is the whole point: no event is published for a write that did not land.
reg := sqlb.NewRegistry()
hooks := sqlb.On[Article](reg)
hooks.AfterCreate(func(ctx context.Context, a *Article) error {
return sqlb.AfterCommit(ctx, func(context.Context) error {
fmt.Println("this must not print")
return nil
})
})
db := exampleDB(reg)
errPaymentDeclined := errors.New("payment declined")
err := db.WithTx(context.Background(), func(ctx context.Context, tx *sqlb.DB) error {
a := Article{Title: "Hello", Status: "draft", OrgID: "acme"}
if _, err := sqlb.InsertRows(&a).One(ctx, tx); err != nil {
return err
}
return errPaymentDeclined // something later in the unit of work fails
})
fmt.Println("WithTx:", err)
fmt.Println("last statement:", exampleLog[len(exampleLog)-1])
Output: WithTx: payment declined last statement: ROLLBACK
func Array ¶
Array gathers a value list into one Postgres array parameter rather than a list of them. It is what an array-valued comparand needs:
q.Where(sqlb.F("tags").Eq(sqlb.Array("go", "sql")))
The elements are encoded by their Go types against the column's element type, so a []string, a []int64 or a mixed list of already-coerced values all work. It is a variadic spelling of the slice and nothing more — passing a []string directly binds the same way (ADR-0040).
func Collect ¶
Collect runs a query and scans its rows into R rather than the model type. It is how grouped and aggregated queries are read, where the result shape is not the table shape:
type Revenue struct {
Status string `db:"status"`
Total float64 `db:"revenue"`
}
rows, err := sqlb.Collect[Revenue](ctx, db,
sqlb.Query[Order]().
GroupBy(sqlb.F("status")).
Select(sqlb.F("status"), sqlb.Sum(sqlb.F("total")).As("revenue")))
Query hooks still run, so tenant scoping applies to aggregates too.
func Diagnostics ¶
func Diagnostics(ds []PlanDiagnostic) string
Diagnostics renders a slice of plan diagnostics as text.
func PrincipalFrom ¶ added in v0.7.0
PrincipalFrom returns the principal as a T, and whether one of that type was stored.
The two failure modes are deliberately one answer: no principal stored, and a principal of a different type, both report false. A hook that needs to tell them apart is coupling itself to which middleware ran — the thing this seam exists to prevent.
What a hook must not do is treat false as "no restriction". That turns every path which forgot to authenticate into a read across every tenant, which is the failure the whole arrangement exists to make impossible. Fail closed: return an error, and let the caller see it.
func RegisterVectorType ¶ added in v0.4.0
RegisterVectorType teaches a connection pgvector's binary format. It is the shape pgxpool.Config.AfterConnect wants, so it goes straight on:
cfg.AfterConnect = sqlb.RegisterVectorType
The registration is per connection because the OID is per database: `vector` is an extension type, so it has no fixed number and has to be looked up wherever the connection landed.
A database without the extension installed is not an error. There is no vector type to register, so nothing is registered and the connection is returned as it is — which is what lets one AfterConnect serve a pool that reaches databases with and without it, and keeps this from being a startup failure for an application that declares no vector column.
func SetErrorClassifier ¶
func SetErrorClassifier(fn ErrorClassifier)
SetErrorClassifier installs a classifier that supersedes the built-in one.
It is rarely needed now. This used to be the only way to reach the constraint *name* — the field that lets an application branch on which rule was broken — because that name is a struct field on the driver's error type rather than a method, and sqlb depended on the standard library alone and would not name a driver. ADR-0040 settled which driver, so the built-in classifier reads *pgconn.PgError directly and fills every field. Users who registered a classifier for exactly that reason can delete it.
What remains is a seam for errors that reach sqlb wrapped in something errors.As cannot see through, or for an application that wants its own mapping. Call it once at startup, before serving; passing nil restores the built-in classification.
func VectorPoolConfig ¶ added in v0.4.0
VectorPoolConfig is RegisterVectorType applied to a pool config, for the common case where that is the only AfterConnect a caller wants:
cfg, err := sqlb.VectorPoolConfig(dsn)
if err != nil {
return err
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
It replaces any AfterConnect already set rather than chaining onto it, which is why it takes a DSN rather than a config: a function that silently dropped a hook somebody else installed would be worse than one that cannot.
func WithPrincipal ¶ added in v0.7.0
WithPrincipal returns a context carrying p as the request's principal.
Middleware calls this once, after verifying whatever the request presented. Storing an unverified value here defeats every hook that trusts it: a boundary the caller can name is a convention, not a boundary.
Types ¶
type ArrayCol ¶
type ArrayCol[E any] struct { // contains filtered or unexported fields }
ArrayCol is a typed reference to an array column, carrying the containment operators and none of the ordering ones. Generated model packages declare one per array column:
var PostTags = sqlb.ArrayColumn[string]("tags")
q.Where(gen.PostTags.Has("urgent")) // Has(42) does not compile
Like Col it does not embed Field, so the pattern and ordering operators an array cannot serve are absent rather than present and failing in Postgres.
func ArrayColumn ¶
ArrayColumn declares a typed array column reference.
func (ArrayCol[E]) Field ¶
Field returns the untyped reference, for the operators the typed surface does not cover.
func (ArrayCol[E]) IsNull ¶
IsNull distinguishes a NULL column from an empty array, which are different values and compare differently.
type Beginner ¶
Beginner is the subset of a pgx pool or connection that opens a transaction. It is asserted for rather than required, so Executor stays two methods and every wrapper written against it keeps working.
*pgxpool.Pool and *pgx.Conn satisfy it. A wrapper that wants WithTx to work through it — a tracer, a pool adapter — implements this alongside Executor and returns the underlying pgx.Tx.
type BetweenExpr ¶
BetweenExpr is a range test. It is its own node rather than a Binary because its right-hand side spans two operands and must not be parenthesised.
type Builder ¶
type Builder[T any] struct { // contains filtered or unexported fields }
Builder is a SELECT statement under construction against model T.
Its methods mutate the builder in place and return it, so a query can be assembled across branches without reassignment gymnastics and hooks can amend a query they are handed. Use Clone before sharing a partially built query between goroutines or request scopes.
func Query ¶
Query starts a SELECT against the table mapped by T.
Example ¶
A query is a value. SQL renders the statement and its bind parameters without running anything, which is the inspection point: log it, diff it in a test, or paste it into EXPLAIN.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
q := sqlb.Query[Article]().
Where(sqlb.F("status").Eq("published")).
OrderBy(sqlb.F("view_count").Desc()).
Limit(10)
sql, args, err := q.SQL()
if err != nil {
panic(err)
}
fmt.Println(sql)
fmt.Println(args...)
}
Output: SELECT "articles"."id", "articles"."title", "articles"."status", "articles"."view_count", "articles"."org_id" FROM "articles" WHERE "status" = $1 ORDER BY "view_count" DESC LIMIT 10 published
func (*Builder[T]) After ¶
After restricts the query to the rows following the cursor's position in the query's own ordering. A zero cursor is a no-op, so the first page and every page after it are the same call.
It calls Stable first, because the cursor it was handed was issued against a total order and has to be interpreted against the same one.
The predicate is kept apart from Where rather than folded into it, so that Count still answers "how many rows match" rather than "how many are left" — a total that changed as a client paged would be a worse answer than no total.
func (*Builder[T]) All ¶
All runs the query and returns every matching row.
The builder is cloned first, so query hooks amend a copy and running the same builder twice does not accumulate their predicates.
func (*Builder[T]) Bind ¶ added in v0.5.0
Bind supplies the value a computed column declared it needs.
A computed column whose expression takes a bind is answered per request — is this row starred *by the caller* — so the value cannot be in the schema and has to arrive with the query:
sqlb.On[Project](reg).BeforeQuery(func(ctx context.Context, q *sqlb.Builder[Project]) error {
q.Bind("viewer", memberFrom(ctx))
return nil
})
The value binds once however many times the expression is rendered: the projection, a filter on the column and an ordering by it all resolve to the same placeholder.
Binding a key no computed column names is harmless and does nothing. The failure worth catching is the other one — a column whose bind never arrives — and it is caught twice: the query fails rather than rendering NULL, and [rest.Resource] refuses at startup to mount a resource with no hook to supply it (ADR-0030, ADR-0041).
func (*Builder[T]) Bound ¶ added in v0.5.0
Bound reports the binds this query carries, for a caller inspecting one.
func (*Builder[T]) ClearSelect ¶
ClearSelect discards the projection built so far, so the next Select starts from nothing rather than adding to it.
func (*Builder[T]) Clone ¶
Clone returns an independent copy, so a base query can be reused as the starting point for several derived ones.
func (*Builder[T]) Count ¶
Count returns the number of matching rows, ignoring pagination. For a grouped query it counts groups.
func (*Builder[T]) CursorFor ¶
CursorFor returns the cursor naming row's position in this query's ordering, for a caller handing a client the start of the next page.
The row must have been produced by this query, or by one ordered identically: the cursor is built by reading the ordering columns off it, so a row from a different projection that left one of them zero would encode a position that was never reached.
func (*Builder[T]) Err ¶
Err returns the first error recorded while building, if any. Terminal methods return it too, so checking it explicitly is optional.
func (*Builder[T]) Expand ¶
Expand resolves the named relations inline, one LEFT JOIN each.
Names are relation names, not column names: `Expand("list")`, not `Expand("list_id")`. An unknown name fails the builder rather than being ignored, because a silently dropped expansion answers the request with a 200 and a missing field.
Expanding is additive and idempotent: naming the same relation twice joins it once.
func (*Builder[T]) Fail ¶
Fail records err and returns the builder, so a package outside sqlb can put a query into the same error state the builder uses internally rather than having to break the fluent chain with its own error return. Only the first error is kept, matching fail.
filter.Apply is the motivating caller: it assembles a builder from a parsed request and needs somewhere to put "this request is valid but I cannot express it".
func (*Builder[T]) First ¶
First returns the first matching row, or ErrNotFound. Unlike One it accepts multiple matches, so it should be paired with OrderBy to be deterministic.
func (*Builder[T]) GroupByExpr ¶
GroupByExpr groups by arbitrary expressions.
func (*Builder[T]) Limit ¶
Limit caps the number of rows returned. A negative limit is an error rather than a silent no-op, since it usually means an unchecked computed value.
func (*Builder[T]) One ¶
One runs the query and returns the single matching row. It returns ErrNotFound if nothing matched, and an error if more than one row did, since a caller asking for one row is asserting that only one exists.
func (*Builder[T]) OrderColumns ¶
OrderColumns names the columns the query orders by, in order, skipping any term that orders by an expression rather than a column.
filter.Apply is the motivating caller: it owns the projection and has to cover whatever the ordering ended up being, including the tiebreaker Stable appended, so that a cursor can be read off the last row.
func (*Builder[T]) Page ¶
Page applies offset pagination. Pages are 1-based.
Example ¶
Page is 1-based offset pagination. Limit and offset render as literals rather than bind parameters so the planner can see them; both are validated ints, so there is no injection surface.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
sql, _, _ := sqlb.Query[Article]().
OrderBy(sqlb.F("id").Asc()).
Page(3, 20).
SQL()
fmt.Println(sql)
}
Output: SELECT "articles"."id", "articles"."title", "articles"."status", "articles"."view_count", "articles"."org_id" FROM "articles" ORDER BY "id" ASC LIMIT 20 OFFSET 40
func (*Builder[T]) SQL ¶
SQL compiles the query to SQL text and its bind parameters. It is the inspection point: log it, diff it in tests, or paste it into EXPLAIN.
func (*Builder[T]) Select ¶
func (b *Builder[T]) Select(items ...Selectable) *Builder[T]
Select appends to the projection. Without any call the query selects every mapped column of T. Use ClearSelect to start the projection over.
Example (Aggregate) ¶
Select replaces the default projection of every mapped column, and aggregates carry an alias that the destination type's `db` tag matches. Collect scans such a result into a type other than the model.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
sql, _, _ := sqlb.Query[Article]().
Select(sqlb.F("status"), sqlb.Sum(sqlb.F("view_count")).As("views")).
GroupBy(sqlb.F("status")).
OrderBy(sqlb.F("status").Asc()).
SQL()
fmt.Println(sql)
}
Output: SELECT "status", sum("view_count") AS "views" FROM "articles" GROUP BY "status" ORDER BY "status" ASC
func (*Builder[T]) SkipLocked ¶
SkipLocked skips rows already locked, for queue-style consumers. It has no effect without ForUpdate or ForShare.
func (*Builder[T]) Stable ¶
Stable makes the ordering deterministic by appending the primary key, which is what lets a page boundary be named at all.
Without it `ORDER BY status` leaves rows with equal status in whatever order the plan produced, so page 2 may repeat a row from page 1 or skip one, and no cursor can distinguish the two. This is the same defect schema.Lint reports as list-without-sort; Stable is the fix rather than the warning.
It is idempotent, and a no-op when the ordering already contains the primary key — including when the caller sorted by it explicitly. The appended term takes the direction of the last existing term, so `?sort=-created_at` reads as "newest first, and newest id first among equal timestamps" rather than changing direction halfway through the ORDER BY.
A model with no primary key is left alone rather than failed: such a model can still be listed and paged by offset, and only the cursor calls — After and CursorFor — genuinely cannot work without a key, so they are where the error belongs.
func (*Builder[T]) UseDialect ¶
UseDialect overrides the dialect for this query.
func (*Builder[T]) Where ¶
Where conjoins predicates. Zero predicates are skipped, so conditional filters need no surrounding if statement.
Example (Conditional) ¶
Because the query is a value rather than a statement, a predicate can be added on a branch. This is the case static query generators cannot express, and the reason sqlb exists.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
search := "postgres" // in a handler this came from the request
q := sqlb.Query[Article]().Where(sqlb.F("status").Eq("published"))
if search != "" {
q = q.Where(sqlb.F("title").Contains(search))
}
sql, args, _ := q.SQL()
fmt.Println(sql)
fmt.Println(args...)
}
Output: SELECT "articles"."id", "articles"."title", "articles"."status", "articles"."view_count", "articles"."org_id" FROM "articles" WHERE ("status" = $1) AND ("title" ILIKE $2) published %postgres%
func (*Builder[T]) WithComputed ¶ added in v0.6.0
WithComputed adds the named computed columns to the projection.
Computed columns are opt-in. They are declared on the model, which is shared, and wanted by one caller — so projecting them by default charged every read of the model for a list screen's aggregates, and a column carrying a Needs bind made unrelated reads fail outright:
sqlb.Query[Project]().Where(sqlb.F("id").Eq(id)).One(ctx, db)
// used to answer: computed column "is_starred" needs the "viewer" bind
That query is asking whether a row exists. It has no viewer and should not need one, and before this it had no way to say so (#92).
sqlb.Query[Project]().
WithComputed("total_tasks", "is_starred").
Bind("viewer", actor.ID)
Naming a column the model does not have, or one it stores rather than computes, fails the query — a silent no-op would leave the caller believing they had asked for a value that is about to arrive as the zero value.
Selecting a computed column explicitly with Builder.Select works too and does not need this; WithComputed is for keeping the default projection and adding to it. For a REST resource the equivalent is rest.Options.Computed.
type Call ¶
Call is a function call. Star renders `f(*)`; Distinct renders `f(DISTINCT x)`.
Name is written verbatim and is not validated, like Raw and Field.Cast. It must not come from user input. The helpers in this package — Count, Sum, Lower and the rest — supply their own names, so reaching for the struct literal is what puts a caller on this path.
type Col ¶
type Col[T any] struct { // contains filtered or unexported fields }
Col is a column reference carrying the column's Go type, so that comparands are checked at compile time. Generated model packages declare one per column:
var PostStatus = sqlb.Typed[Status]("status")
q.Where(gen.PostStatus.Eq(StatusDraft)) // Eq(42) does not compile
It deliberately does not embed Field. Embedding would promote every operator onto every column, so Contains would be callable on an integer — which compiles, reaches the database, and fails there. The operators are re-declared here instead, and the text-only ones live on TextCol.
func (Col[T]) Field ¶
Field returns the untyped reference, for the operators the typed surface does not cover.
func (Col[T]) IsNull ¶
IsNull matches rows where the column is NULL. It is available on every typed column, including those whose Go type is not a pointer, because nullability is a property of the column rather than of the comparand.
type Collection ¶
Collection is what an expanded reverse relation arrives as: the children that fit under the relation's cap, and whether there were more.
A bare slice would be the obvious choice and it is deliberately not used. A reverse expansion is capped — an uncapped one makes a single response's size a function of data nobody bounded — and a slice cannot say it was truncated, so a caller reading fifty of an author's two hundred posts would have no way to tell. `HasMore` is the difference between a preview and a wrong answer.
The envelope is the one `rest` already returns for a collection, minus the fields a per-row subquery should not pay for: there is no total, because counting is a second aggregate on every base row and `?count=exact` on the child's own endpoint is where a caller asks for one deliberately.
ADR-0022 records the reasoning, and the trigger that would replace this with an error rather than a bare slice if the envelope proves annoying.
func (Collection[T]) Len ¶
func (c Collection[T]) Len() int
Len reports how many children were returned, which is at most the cap.
type ColumnInfo ¶
type ColumnInfo struct {
// Name is the SQL column name.
Name string
// Field is the Go struct field name.
Field string
// Index is the reflect field index path, which may traverse embedded structs.
Index []int
// Type is the Go type of the struct field.
Type reflect.Type
// Nullable reports whether the Go field is a pointer, and so may hold NULL.
Nullable bool
// HasDefault reports that the column has a database default. Inserts omit
// such a column when its Go value is the zero value, so the database fills
// it rather than being handed a zero.
HasDefault bool
// Capabilities, read back from the `sqlb` struct tag that codegen writes
// from the schema declaration.
PrimaryKey bool
Filterable bool
Sortable bool
Searchable bool
Expandable bool
ReadOnly bool
Immutable bool
Hidden bool
// SortNulls is where NULLs sit whenever this column is sorted on, in either
// direction. The zero value leaves Postgres's own default, which follows
// the direction rather than being one placement. It is declared on the
// column because it is a property of what the column means (#88), so a
// request does not — and cannot — ask for it.
SortNulls NullsOrder
// PGType is the schema's logical type for the column — "date",
// "timestamptz", "text" — carried through the struct tag that codegen
// writes. It is empty for a hand-written model that has not said
// otherwise, so every reader of it must treat "unknown" as a real answer
// rather than a default.
//
// It exists because Type is not enough: timestamptz, date and time are one
// Go type and three different things to Postgres, and an expanded row
// serialises each of them differently. Reading the Go type alone made an
// expansion over a date column answer 500 (#84). Describe.SQLType is the
// hand-written half.
PGType string
// Obligations, from the same tag. Nothing on the request path reads
// either: they are the schema's statement that this model's rows are
// confined by something, and they are checked once, where a resource is
// mounted.
Scoped bool
SoftDelete bool
// Expr is the SQL a computed column renders as, in place of its name. It
// arrives from the model's ComputedColumns method or from Describe, never
// from a struct tag: the expression is SQL, and a tag is a comma-separated
// list (ADR-0041).
Expr string
// Needs names the binds Expr's `?` placeholders take, in order. A computed
// column with none is row-local; one with a bind is answered per request,
// and the value comes from Builder.Bind — which a BeforeQuery hook calls,
// and which rest refuses to mount a resource without.
Needs []string
}
ColumnInfo describes one mapped column of a model.
func (*ColumnInfo) Computed ¶ added in v0.5.0
func (c *ColumnInfo) Computed() bool
Computed reports whether this column is an expression rather than storage. Such a column is projected and may be filtered or sorted on, and it is never written: no insert names it, no update sets it, and no migration creates it.
type Compilable ¶
Compilable is anything that renders to SQL: every builder and every mutation statement in this package.
type Computed ¶ added in v0.5.0
type Computed struct {
// Name is the column name — the key in the JSON, the name a filter or a
// sort spells, and the alias the projection scans back through.
Name string
// Expr is the SQL, written against this table's own columns. Each `?` in
// it takes the bind named at the matching position of Needs; a doubled
// `??` is a literal question mark, as in Raw.
Expr string
// Needs names the binds Expr takes, in order.
Needs []string
}
Computed declares one derived column: a SQL expression the compiler renders wherever the column is named, rather than a value the table stores.
Generated models return these from ComputedColumns; a hand-written one can implement the method itself, or say the same thing through Describe.
type ConflictRef ¶ added in v0.6.0
type ConflictRef struct {
// contains filtered or unexported fields
}
ConflictRef is a column reference inside ON CONFLICT DO UPDATE, qualified to one side of the conflict.
Both sides are in scope there — the row Postgres tried to insert, and the row already stored — and `count = count + 1` reads naturally while meaning nothing definite. So the qualifier is required rather than defaulted, and a bare Field in a conflict assignment is refused (#90). See Excluded and Current.
func Current ¶ added in v0.6.0
func Current(name string) ConflictRef
Current references the *stored* row's value for a column inside an ON CONFLICT DO UPDATE assignment — the one already in the table.
ins.OnConflictUpdate([]string{"key"}).
OnConflictSet("hits", sqlb.Add(sqlb.Current("hits"), sqlb.Val(1)))
func Excluded ¶ added in v0.6.0
func Excluded(name string) ConflictRef
Excluded references the *proposed* row's value for a column inside an ON CONFLICT DO UPDATE assignment — Postgres's EXCLUDED.
ins.OnConflictUpdate([]string{"key"}).
OnConflictSet("payload", sqlb.Excluded("payload"))
func (ConflictRef) IsExcluded ¶ added in v0.6.0
func (r ConflictRef) IsExcluded() bool
IsExcluded reports whether the reference is to the proposed row rather than the stored one.
func (ConflictRef) Name ¶ added in v0.6.0
func (r ConflictRef) Name() string
Name returns the column the reference names.
type ConstraintError ¶
type ConstraintError struct {
// Kind is the integrity rule that was broken.
Kind ConstraintKind
// Constraint is the name of the index or constraint that refused the
// write, where the driver reports one. It is the name the schema declares,
// so a caller can match on it rather than on prose.
Constraint string
// Table is the relation the constraint belongs to, where reported.
Table string
// Column is the column at fault, where the constraint names exactly one —
// which for a NOT NULL violation it does, and for a composite unique index
// it does not.
Column string
// Detail is the driver's own elaboration, where it offers one. It can name
// the conflicting values, so it is a developer-facing string rather than
// something to put in a response.
Detail string
// contains filtered or unexported fields
}
ConstraintError reports a write the database refused because it would have broken a constraint.
This is the caller's mistake far more often than it is an outage: a second signup on a taken email, an order naming a product that was deleted, a balance a CHECK will not let go negative. Without it those arrive as an opaque driver error, and the only way to tell them apart is to match on the text of a message — which no rename survives, and which every application with a unique index otherwise ends up writing.
Every field is filled from what Postgres reported. That is a change: the built-in classification used to recover the kind alone, because reading a constraint name meant naming a driver and sqlb named none. It names pgx now (ADR-0040), so a caller can branch on Constraint without registering anything.
func (*ConstraintError) Error ¶
func (e *ConstraintError) Error() string
Error implements error. The wrapped driver error is included, so a log line carries what the database actually said.
func (*ConstraintError) Is ¶
func (e *ConstraintError) Is(target error) bool
Is reports ErrConstraint, making errors.Is the cheap test for the class.
func (*ConstraintError) Unwrap ¶
func (e *ConstraintError) Unwrap() error
Unwrap returns the driver's error, so a caller that does depend on its driver can still reach the original.
type ConstraintKind ¶
type ConstraintKind string
ConstraintKind names the integrity rule a write broke. It is SQLSTATE class 23, in the terms a schema declares rather than in the terms Postgres numbers them, so that a caller switching on it reads as the schema does.
const ( // ConstraintUnique is a duplicate value in a unique index (23505). ConstraintUnique ConstraintKind = "unique" // ConstraintForeignKey is a reference to a row that is not there, or a // delete of a row still referenced (23503). ConstraintForeignKey ConstraintKind = "foreign_key" // ConstraintCheck is a CHECK expression that evaluated false (23514). ConstraintCheck ConstraintKind = "check" // ConstraintNotNull is a NULL in a column declared NOT NULL (23502). ConstraintNotNull ConstraintKind = "not_null" // ConstraintExclusion is an EXCLUDE constraint (23P01). ConstraintExclusion ConstraintKind = "exclusion" )
func ConstraintKindOf ¶
func ConstraintKindOf(sqlstate string) (ConstraintKind, bool)
ConstraintKindOf maps a SQLSTATE code onto a ConstraintKind, reporting false for codes outside class 23. It is exported for classifiers, which need the same mapping and should not have to restate it.
type Cursor ¶
type Cursor string
Cursor is an opaque position in an ordered result set.
It is opaque by intent rather than by encryption: it decodes to the ordering columns and the values of the row it was taken from, and a client that decodes it learns nothing it could not read off the response. Tampering is equally uninteresting — After checks the columns and directions against the ordering the request actually asked for, so an edited cursor can only move the boundary along a column the caller was already permitted to sort by.
type DB ¶
type DB struct {
// contains filtered or unexported fields
}
DB is a handle carrying an Executor and the hook registry that its queries resolve against.
It is itself an Executor, which is what makes it additive: every terminal call already takes one, so passing a *DB where a *sql.DB used to go changes nothing except which registry the hooks come from.
db := sqlb.New(pool) posts, err := sqlb.Query[Post]().All(ctx, db)
The reason to want one is WithTx. A unit of work needs every statement in it to run on the same connection, and hooks need to be able to tell that they are inside one — neither is expressible when the executor is threaded through call sites individually and the registry is a process global.
Go 1.27 makes the call syntax nicer without changing this object graph: the package-level generic functions gain method forms on *DB, as the README's table describes. The handle is what those methods will hang off, which is why it is worth building now rather than with the toolchain.
func New ¶
New returns a handle over exec with an empty hook registry of its own.
It acquires rules only from WithHooks, and there is no process-wide default for it to inherit (ADR-0047). Two calls to New produce two handles with nothing between them, so a handle cannot pick up rules some other part of the program registered — which also means registering hooks and then calling New is not enough on its own: name the registry.
reg := sqlb.NewRegistry() sqlb.On[Post](reg).BeforeQuery(scopeToOrg) db := sqlb.New(pool).WithHooks(reg)
A pgx.Tx is an Executor like any other, and passing one is how sqlb joins a transaction the application opened itself:
tx, err := pool.Begin(ctx)
defer tx.Rollback(ctx)
if err := legacy.DebitAccount(ctx, tx, id); err != nil {
return err
}
_, err = sqlb.InsertRows(&entry).Exec(ctx, sqlb.New(tx))
...
return tx.Commit(ctx)
The handle knows it is inside one, so InTx reports true and a WithTx on it joins rather than opening a second transaction against the same pool. What it deliberately does not do is take over the boundary: the caller opened the transaction and the caller commits it. So AfterCommit refuses here rather than accumulating callbacks nothing will ever drain — WithTx is what owns a commit, and therefore the only thing that can promise anything after one.
func TxFrom ¶
TxFrom returns the transaction handle a hook is running under, if any.
This is what lets a hook take part in the unit of work it was triggered by. A BeforeQuery that needs to see rows written earlier in the same transaction must read through this handle — reading through the process-wide pool would miss them, because they are not committed yet:
sqlb.On[Post](reg).BeforeCreate(func(ctx context.Context, p *Post) error {
tx, ok := sqlb.TxFrom(ctx)
if !ok {
return errors.New("posts must be created inside a transaction")
}
n, err := sqlb.Query[Post]().Where(sqlb.F("slug").Eq(p.Slug)).Count(ctx, tx)
...
})
func (*DB) AfterCommit ¶
AfterCommit registers fn to run once this transaction commits, and not at all if it rolls back.
This is where publishing an event, enqueuing a job or invalidating a cache belongs. AfterCreate and its siblings run inside the transaction, which is correct for validation — an error there rolls the write back — and wrong for anything the outside world can observe, because the transaction may still abort after the hook has already told the world it succeeded.
Callbacks run in registration order after Commit returns nil, each receiving the context WithTx was called with. That context carries no transaction: there is nothing left to join, and handing back a committed one would be a trap.
A failing callback does not stop the others — these are independent side effects, and abandoning the rest leaves more inconsistency rather than less. The failures are joined under ErrAfterCommit.
func (*DB) CanBeginTx ¶
CanBeginTx reports whether WithTx would be able to open a transaction on this handle.
It exists so that a caller who *requires* transactions can say so at startup rather than on the first write. `rest` uses it for exactly that: a resource that wraps its generated writes refuses to mount over an executor that cannot begin one, because discovering it at request time would mean the first POST is the error report.
It reports true inside a transaction as well, where WithTx joins rather than begins.
func (*DB) InTx ¶
InTx reports whether this handle is inside a transaction. A BeforeQuery hook that must not run its own statements outside the caller's unit of work can check it.
func (*DB) Tx ¶
Tx returns the underlying pgx.Tx, if this handle runs on one.
It exists so that a unit of work can be shared with code that wants more than Executor's two methods — CopyFrom, SendBatch, or a generated query set — so both sides land on one transaction without giving up WithTx's rollback and panic handling:
err := db.WithTx(ctx, func(ctx context.Context, tx *sqlb.DB) error {
post, err := sqlb.InsertRows(&p).One(ctx, tx)
if err != nil {
return err
}
pgTx, ok := tx.Tx()
if !ok {
return errors.New("expected a transaction")
}
return queries.New(pgTx).RecordPublication(ctx, post.ID)
})
It reports false when the executor is a pool, or a wrapper that does not expose the transaction it holds. Committing or rolling back the returned pgx.Tx directly is a mistake: WithTx owns that boundary, and doing it here leaves the after-commit callbacks unrun.
func (*DB) WithHooks ¶
WithHooks returns a copy of the handle resolving hooks against r.
This is how a handle acquires rules at all, since New gives one an empty registry of its own. It is also how two tenants-worth of differing domain rules coexist in one process, and how a test gets isolation.
func (*DB) WithTx ¶
WithTx runs fn inside a transaction, committing if it returns nil and rolling back otherwise. The handle passed to fn executes on the transaction, so every statement in the unit of work lands on one connection:
err := db.WithTx(ctx, func(ctx context.Context, tx *sqlb.DB) error {
order, err := sqlb.InsertRows(&o).One(ctx, tx)
if err != nil {
return err
}
_, err = sqlb.UpdateRows[Stock]().
Set("reserved", true).
Where(sqlb.F("sku").Eq(order.SKU)).
Exec(ctx, tx)
return err
})
fn receives a context carrying the transaction, which is what makes TxFrom work inside hooks — so pass that ctx onward rather than the enclosing one.
A panic in fn rolls back and is re-raised, so a transaction is never left open by one.
Nesting joins rather than nests: calling WithTx on a handle that is already in a transaction runs fn on that same transaction and leaves the commit to the outermost call. Savepoints would be the alternative and are a larger promise — partial rollback changes what "the unit of work succeeded" means, and nothing needs it yet. Joining keeps a function that opens a transaction callable from inside one.
func (*DB) WithTxOptions ¶
func (d *DB) WithTxOptions(ctx context.Context, opts pgx.TxOptions, fn func(ctx context.Context, tx *DB) error) error
WithTxOptions is WithTx with an explicit isolation level or read-only flag.
The options are ignored when joining an outer transaction, since isolation is a property of the transaction and the outer one has already begun. Asking for stricter isolation than the enclosing transaction provides is therefore an error rather than a silent downgrade.
type Delete ¶
type Delete[T any] struct { // contains filtered or unexported fields }
Delete is a DELETE statement over model T.
func (*Delete[T]) Clone ¶
Clone returns an independent copy, so a statement can be reused as the starting point for several derived ones.
func (*Delete[T]) Everything ¶
Everything confirms an intentionally unscoped delete.
func (*Delete[T]) Exec ¶
Exec runs the delete and returns the number of rows removed.
The statement is cloned first, for the reason Update.Exec clones: a BeforeDelete hook narrowing the statement must narrow one execution, not every later one.
func (*Delete[T]) UseDialect ¶
UseDialect overrides the dialect for this statement.
type Deriver ¶ added in v0.5.0
type Deriver interface {
ComputedColumns() []Computed
}
Deriver is a model that declares computed columns. Generated models with a schema.Computed field implement it.
type Description ¶
type Description[T any] struct { // contains filtered or unexported fields }
Description is a set of pending metadata changes to a model.
func Describe ¶
func Describe[T any]() *Description[T]
Describe attaches column metadata to a model at runtime, as an alternative to the `sqlb` struct tags that codegen writes.
It exists for two cases. The first is using sqlb without any code generation at all. The second, and the more common one, is layering sqlb over structs that already exist and that you would rather not edit — the output of another generator, or a package you do not own:
func init() {
sqlb.Describe[Invoice]().
Table("invoices").
PrimaryKey("id").
Defaulted("id", "created_at").
Filterable("customer_id", "paid", "amount_due").
Sortable("created_at", "amount_due").
Hidden("internal_memo")
}
Without either tags or a description, the query builder still works — column names are derived from field names — but no column is filterable, sortable or searchable, so the REST layer rejects every request against it. That is the intended default: capabilities are opt-in, and an undescribed model exposes nothing.
Descriptions merge onto whatever the tags already said, so a partly tagged model can be completed here.
Call it during initialisation, before any query runs. It mutates the cached model in place and does not lock, because doing so would put a mutex on the read path of every query to pay for something that happens once at startup. Calling it after the first statement has been built against the model panics rather than racing. Naming a column that does not exist panics too, listing the ones that do.
func (*Description[T]) Column ¶
func (d *Description[T]) Column(field, column string) *Description[T]
Column overrides the column a Go field maps to, for when the derived snake_case name is not the real one and the struct cannot be given a tag.
func (*Description[T]) Computed ¶ added in v0.5.0
func (d *Description[T]) Computed(column, expr string, needs ...string) *Description[T]
Computed declares that a column is a SQL expression rather than storage: the compiler renders expr wherever the column is named, so the value lands in the projection, and a Filterable or Sortable one reaches WHERE and ORDER BY too.
sqlb.Describe[Task]().
Table("tasks").
PrimaryKey("id").
Computed("is_overdue", "due_date < current_date AND status <> 'done'").
Filterable("is_overdue")
It is the runtime form of schema.Computed, for models sqlb did not generate — the generated ones say the same thing through a ComputedColumns method, which is where the expression goes because a struct tag is a comma-separated list and SQL is not (ADR-0041).
The column must already be mapped: a computed value needs a field to scan into, and the field is what puts it in the JSON and in the Go type.
Each `?` in expr takes the bind named at the matching position of needs, and `??` is a literal question mark. A bind is supplied per query with Builder.Bind, which is how a per-viewer expression gets the viewer:
Computed("is_starred",
"EXISTS (SELECT 1 FROM stars s WHERE s.task_id = tasks.id AND s.member_id = ?)",
"viewer")
Declaring the column writes no value. A query that renders it without the bind fails rather than sending NULL, and [rest.Resource] refuses to mount a resource whose binds no BeforeQuery hook supplies — the same obligation shape Scoped uses, for the same reason: an unbound expression is false for every row forever and looks exactly like a working feature (ADR-0030, ADR-0041).
func (*Description[T]) Defaulted ¶
func (d *Description[T]) Defaulted(columns ...string) *Description[T]
Defaulted marks columns that carry a database default. Inserts omit such a column when its Go value is the zero value, so the database fills it instead of being handed an empty string or a zero timestamp.
func (*Description[T]) Filterable ¶
func (d *Description[T]) Filterable(columns ...string) *Description[T]
Filterable allows the columns to be used in REST filter expressions.
func (*Description[T]) Hidden ¶
func (d *Description[T]) Hidden(columns ...string) *Description[T]
Hidden omits the columns from every REST response, and makes them unreachable from a filter, a sort or a projection.
func (*Description[T]) Immutable ¶
func (d *Description[T]) Immutable(columns ...string) *Description[T]
Immutable allows the columns to be set at create time only.
func (*Description[T]) Model ¶
func (d *Description[T]) Model() *Model
Model returns the model being described, for inspection.
func (*Description[T]) PrimaryKey ¶
func (d *Description[T]) PrimaryKey(column string) *Description[T]
PrimaryKey marks the key column. It implies ReadOnly and Filterable, and is what lets the REST layer address a single row.
func (*Description[T]) ReadOnly ¶
func (d *Description[T]) ReadOnly(columns ...string) *Description[T]
ReadOnly makes the columns unwritable through REST.
func (*Description[T]) Relation ¶
func (d *Description[T]) Relation(field, fkColumn string, opts ...RelationOption) *Description[T]
Relation declares an expandable reference: field is the Go field an expanded row lands in, and fkColumn is the local column joined on.
sqlb.Describe[Task]().
Table("tasks").
PrimaryKey("id").
Relation("List", "list_id")
It is the runtime form of the two-field declaration codegen writes, and it says in one call what the tags say in two:
ListID string `db:"list_id" sqlb:"expand"` List *List `db:"-" sqlb:"expands=list_id"`
Which is the reason it needs no agreement check. Split across two tags the halves can disagree — a field expanding a column that never declared the capability — and the model build refuses that. Here there is one statement of one fact, so declaring the relation is what makes the column expandable.
The relation is named by field's json tag, falling back to the snake-cased field name, because `?expand` names the relation the way the response spells it. The field itself must not be a mapped column: an expanded row is not a value of the row it hangs off, and a field cannot be both.
The target's own model — its columns, and which of them are Hidden — comes from the Go type, and is resolved on first expansion rather than here, so two models expandable to each other do not recurse at startup.
The reverse direction ¶
A field of type *sqlb.Collection[T] declares the other direction, and then fkColumn is a column of T rather than of this model:
sqlb.Describe[List]().
Table("lists").
PrimaryKey("id").
Relation("Tasks", "list_id", sqlb.ExpandOrder("-created_at"), sqlb.ExpandLimit(20))
The options apply to a collection only, because only a collection is capped and only a capped result has to decide which rows it keeps. Passing them to a forward relation is refused rather than ignored.
func (*Description[T]) SQLType ¶ added in v0.6.0
func (d *Description[T]) SQLType(name string, columns ...string) *Description[T]
SQLType names the columns' Postgres type — "date", "timestamptz", "time" — for the cases where the Go type does not determine it.
d.SQLType("date", "due_on", "invoiced_on")
A generated model carries this in its struct tag and needs no call. A hand-written one does, because those three types are all time.Time in Go and an expanded row serialises each of them differently: without it, expanding a relation whose target has a date column answers 500 (#84).
The name is the schema package's logical type, which is also the Postgres one for every type where the two differ only in spelling.
func (*Description[T]) Scoped ¶
func (d *Description[T]) Scoped(column string) *Description[T]
Scoped declares that the column confines the model's rows to one tenant, and so that every operation a resource exposes over it must be constrained by a hook. It is the runtime form of schema.Field.Scoped, for models sqlb did not generate, and it writes no predicate: [rest.Resource] refuses to mount a resource whose obligations no hook satisfies, and that is all it does.
func (*Description[T]) Searchable ¶
func (d *Description[T]) Searchable(columns ...string) *Description[T]
Searchable includes the columns in the ?search fan-out. It implies Filterable, matching the `search` tag.
A computed column cannot be searchable, and saying so panics rather than being ignored — for the reason Computed gives.
func (*Description[T]) SoftDeleted ¶
func (d *Description[T]) SoftDeleted(column string) *Description[T]
SoftDeleted declares the column a soft-delete predicate is expected to filter — the runtime form of schema.SoftDelete's half that is not a column definition. Like Scoped it obliges a BeforeQuery hook and nothing more.
func (*Description[T]) SortNullsFirst ¶ added in v0.6.0
func (d *Description[T]) SortNullsFirst(columns ...string) *Description[T]
SortNullsFirst makes the columns sort NULLs before real values, in either direction, and marks them sortable.
SortNullsLast is the same the other way. Both exist because Postgres's default placement is not one placement but two — NULLS LAST ascending, NULLS FIRST descending — so a column whose NULLs mean something ("not published yet") reverses its intent when the direction flips. Declaring it here is the hand-written half of what `Sortable(schema.NullsLast)` says in a schema.
func (*Description[T]) SortNullsLast ¶ added in v0.6.0
func (d *Description[T]) SortNullsLast(columns ...string) *Description[T]
SortNullsLast makes the columns sort NULLs after real values, in either direction, and marks them sortable. See Description.SortNullsFirst.
func (*Description[T]) Sortable ¶
func (d *Description[T]) Sortable(columns ...string) *Description[T]
Sortable allows the columns to appear in ?sort.
func (*Description[T]) Table ¶
func (d *Description[T]) Table(name string) *Description[T]
Table overrides the table name, which is otherwise derived from the type name or taken from a TableName method.
func (*Description[T]) Timestamps ¶
func (d *Description[T]) Timestamps(columns ...string) *Description[T]
Timestamps is shorthand for the common created_at / updated_at pair: database-defaulted, read-only and sortable.
type Dialect ¶
type Dialect interface {
// Placeholder renders the nth bind parameter, 1-based.
Placeholder(n int) string
// QuoteIdent quotes an identifier.
QuoteIdent(s string) string
// Name identifies the dialect in diagnostics.
Name() string
}
Dialect adapts the compiler to a specific database. Postgres is the only implementation today; the interface exists so that the AST does not have to change when a second one is added.
type ErrorClassifier ¶
type ErrorClassifier func(error) (ConstraintError, bool)
ErrorClassifier turns a driver's error into a ConstraintError. It reports false for anything that is not a constraint violation.
type Executor ¶
type Executor interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
Executor is the subset of pgx that sqlb runs statements through. *pgxpool.Pool, *pgx.Conn and pgx.Tx all satisfy it as they stand, as does any instrumenting wrapper over them.
Taking pgx rather than database/sql is ADR-0040, and the thing it buys that an abstraction could not is that a caller's own pgx.Tx *is* an Executor: sqlb writes join a unit of work the application already opened, rather than opening a second transaction against the same pool.
type Expr ¶
type Expr interface {
// contains filtered or unexported methods
}
Expr is a SQL expression node. The set of implementations is closed apart from Raw, which is the escape hatch for expressions the builder cannot model.
func Add ¶ added in v0.6.0
Add renders `a + b`. Sub renders `a - b`.
They exist for the accumulate-on-conflict case, which is the one arithmetic an upsert needs and cannot express by naming a column:
sqlb.Add(sqlb.Current("hits"), sqlb.Val(1))
func Now ¶ added in v0.6.0
func Now() Expr
Now is the database's clock, `now()`.
It exists so that a value which should come from Postgres does not have to come from the application instead. An upsert whose updated_at was computed in Go put one column on a different clock from every other timestamp on the row, which under clock skew is a disagreement nothing reports (#90).
type Field ¶
type Field struct {
// contains filtered or unexported fields
}
Field is a reference to a column, and the entry point for building predicates against it.
func F ¶
F references a column. A dotted name is split into table and column, so both F("age") and F("users.age") are valid.
func (Field) Cast ¶
Cast returns the field cast to a SQL type. The type name is emitted verbatim, so it must not come from user input.
func (Field) Contains ¶
Contains matches rows whose column contains v, case-insensitively. Wildcards in v are escaped, so it is safe for user input.
Example ¶
Contains escapes LIKE wildcards, so a user typing "100%" searches for that literal string instead of matching every row. Use Like only for patterns your own code wrote.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
sql, args, _ := sqlb.Query[Article]().
Where(sqlb.F("title").Contains("100%")).
SQL()
fmt.Println(sql)
fmt.Println(args...)
}
Output: SELECT "articles"."id", "articles"."title", "articles"."status", "articles"."view_count", "articles"."org_id" FROM "articles" WHERE "title" ILIKE $1 %100\%%
func (Field) ContainsJSON ¶ added in v0.4.0
ContainsJSON matches rows whose jsonb column contains doc — Postgres's `@>`, which asks that every key and value in doc appear in the column. It is the operator a GIN index over the column serves, and the reason a document column can be narrowed without declaring in advance which keys it holds.
sqlb.F("metadata").ContainsJSON(`{"lang":"de"}`)
// "metadata" @> $1::jsonb
doc is JSON text rather than a Go value because a Pred has no error to return, and marshalling has one. A caller holding a value marshals it first and handles the failure where it happens.
The parameter carries an explicit ::jsonb cast. It is not strictly needed — a driver that leaves the parameter type unspecified lets Postgres infer jsonb from the operator, which pgtest confirmed by removing the cast and watching the query still run — but it says what the statement means, and it holds for a driver that types the parameter as text rather than leaving it open.
func (Field) Has ¶
Has matches rows whose array column contains the element. The operand is a single value, not an array: `$1 = ANY(tags)`.
func (Field) HasAll ¶
HasAll matches rows whose array column contains every value — `tags @> $1`. An empty value set matches every row, since every array contains the empty one.
func (Field) HasAny ¶
HasAny matches rows whose array column overlaps the values — `tags && $1`. An empty value set matches nothing, which is what an overlap with nothing is.
func (Field) Like ¶
Like matches a caller-supplied LIKE pattern. The pattern is a bind parameter, but its wildcards are not escaped: prefer Contains, StartsWith or EndsWith for values that came from a user.
func (Field) NotBetween ¶
NotBetween excludes a closed interval.
func (Field) NotContainsJSON ¶ added in v0.6.0
NotContainsJSON matches rows whose jsonb column does not contain doc. Like the negated array operators it is three-valued: a NULL column satisfies neither this nor ContainsJSON.
Worth naming because containment is not equality: this excludes a row whose document holds every key in doc, and keeps a row that holds some of them. It is the negation of "doc is a subset", not "doc is absent".
func (Field) NotHas ¶ added in v0.6.0
NotHas matches rows whose array column does not contain the element.
func (Field) NotHasAll ¶ added in v0.6.0
NotHasAll matches rows whose array column is missing at least one of the values. An empty value set excludes every row.
func (Field) NotHasAny ¶ added in v0.6.0
NotHasAny matches rows whose array column overlaps none of the values. An empty value set excludes nothing.
func (Field) NotOneOf ¶
NotOneOf is the negation of OneOf. An empty value set excludes nothing.
Unlike Field.OneOf, a nil member is not translated: it binds NULL into the list, where three-valued logic makes the whole `NOT IN` unknown and the row is excluded. That is a real asymmetry and it is left standing on purpose, because the alternative reading — that a nil member means "and also keep the NULL rows" — changes which rows come back rather than fixing a case that silently matched none, and it is one of the questions the null-aware negation work settles (IsDistinctFrom and a NULL-inclusive NotOneOf, docs/release-1.0.md). Deciding it here would be guessing twice. Spell the intent today with Or(f.NotOneOf(vals...), f.IsNull()).
func (Field) OneOf ¶
OneOf matches rows whose column equals any of the values. An empty value set yields a predicate that matches nothing, which is what `in ()` means.
A nil member widens the predicate with IS NULL rather than binding NULL into the list, because `IN (NULL)` is never true: without this, a set assembled from nullable values would silently be narrower than the caller wrote. It is the same translation Field.Eq makes for a nil comparand, and it reads the nil the same way — see isNil, which counts a nil pointer and not a nil slice. A set whose every member is nil is therefore just IS NULL.
Field.NotOneOf deliberately does not mirror this; its doc comment says why.
func (Field) StartsWith ¶
StartsWith matches a case-insensitive prefix, with wildcards in v escaped.
type Hooks ¶
type Hooks[T any] struct { // contains filtered or unexported fields }
Hooks are the domain-logic seams around a model's queries and mutations.
The most load-bearing one is BeforeQuery. It receives the query itself, so a single registration applies a constraint to every read of that model — including reads issued by the generated REST handlers, which is how tenant scoping stops being something each call site has to remember:
reg := sqlb.NewRegistry()
sqlb.On[Post](reg).BeforeQuery(func(ctx context.Context, q *sqlb.Builder[Post]) error {
org, ok := auth.OrgFrom(ctx)
if !ok {
return auth.ErrNoTenant
}
q.Where(sqlb.F("org_id").Eq(org))
return nil
})
db := sqlb.New(pool).WithHooks(reg)
Hooks are registered once at startup and run in registration order. A hook returning an error aborts the operation and the error reaches the caller unwrapped.
Registration names the registry it writes to, and the handle names the registry it reads from. Neither reaches process-wide state, which is what makes the set of rules in force a property of how the application was assembled rather than of what happened to run an init function first.
func On ¶
On returns the hook set for model T in r, creating it on first use.
It takes the registry rather than reaching a default because the short, obvious spelling should be the safe one: a registration that does not say where it lands is a registration whose effect depends on what else the process did.
func (*Hooks[T]) AfterCreate ¶
AfterCreate runs on each inserted row, with database defaults populated. It runs inside the caller's transaction, so returning an error rolls the insert back.
That makes it right for validation and wrong for anything the outside world can observe — publishing an event, enqueuing a job, invalidating a cache — because the transaction may still abort after the hook has announced a write that then never happened. Register those with AfterCommit instead.
func (*Hooks[T]) AfterDelete ¶
AfterDelete receives the number of rows removed. Like AfterCreate it runs inside the transaction; side effects the outside world can see belong in AfterCommit.
func (*Hooks[T]) AfterUpdate ¶
AfterUpdate receives the updated rows. Like AfterCreate it runs inside the transaction; side effects the outside world can see belong in AfterCommit.
func (*Hooks[T]) BeforeCreate ¶
BeforeCreate runs on each row before insert, and may modify it: normalising an email, deriving a slug, stamping an owner.
func (*Hooks[T]) BeforeDelete ¶
BeforeDelete runs before a delete executes and receives the statement.
func (*Hooks[T]) BeforeQuery ¶
BeforeQuery runs before every SELECT against T, including those issued by generated REST handlers. The hook may add predicates, joins or ordering.
"Every SELECT against T" means every statement whose subject is T, and also every statement that reaches T as the target of another model's expansion: joining `lists` for `?expand=list` runs List's hooks, requalified onto the join alias, so a scope registered here constrains GET /lists *and* the `list` an expanded task carries.
Two things about the expansion case are worth knowing before relying on it. Only the predicates are read — the hook runs against a throwaway builder, so an ordering or a limit it sets does not follow. And a predicate that cannot be requalified onto the alias, which means RawPred or a column belonging to a table the expansion did not join, fails the query rather than being dropped. See the expansion notes in expand.go.
Example ¶
BeforeQuery is the load-bearing hook: it receives the query itself, so one registration constrains every read of the model — including the reads the generated REST handlers issue. Multi-tenancy and soft deletes stop being something each call site has to remember.
reg := sqlb.NewRegistry()
hooks := sqlb.On[Article](reg)
hooks.BeforeQuery(func(_ context.Context, q *sqlb.Builder[Article]) error {
// In a real application the tenant comes from the request context.
q.Where(sqlb.F("org_id").Eq("acme"))
return nil
})
db := exampleDB(reg)
ctx := context.Background()
// The caller filters on status and knows nothing about tenants.
if _, err := sqlb.Query[Article]().Where(sqlb.F("status").Eq("published")).All(ctx, db); err != nil {
panic(err)
}
fmt.Println("list: ", whereClause())
// A different read, through a different entry point, is scoped too.
if _, err := sqlb.Query[Article]().Count(ctx, db); err != nil {
panic(err)
}
fmt.Println("count:", whereClause())
Output: list: ("status" = $1) AND ("org_id" = $2) count: "org_id" = $1
Example (Reject) ¶
A hook returning an error aborts the operation, and the error reaches the caller unwrapped. This is how "no tenant in this context" becomes impossible to forget rather than merely documented.
reg := sqlb.NewRegistry()
hooks := sqlb.On[Article](reg)
errNoTenant := errors.New("no tenant in context")
hooks.BeforeQuery(func(ctx context.Context, q *sqlb.Builder[Article]) error {
org, ok := ctx.Value(orgKey{}).(string)
if !ok {
return errNoTenant
}
q.Where(sqlb.F("org_id").Eq(org))
return nil
})
db := exampleDB(reg)
_, err := sqlb.Query[Article]().All(context.Background(), db)
fmt.Println("unscoped:", err)
fmt.Println("statements run:", len(exampleLog))
ctx := context.WithValue(context.Background(), orgKey{}, "acme")
if _, err := sqlb.Query[Article]().All(ctx, db); err != nil {
panic(err)
}
fmt.Println("scoped: ", whereClause())
Output: unscoped: no tenant in context statements run: 0 scoped: "org_id" = $1
func (*Hooks[T]) BeforeUpdate ¶
BeforeUpdate runs before an update executes and receives the statement, so it can force columns (an updated_at stamp) or narrow the affected rows.
func (*Hooks[T]) Registered ¶
func (h *Hooks[T]) Registered() RegisteredHooks
Registered reports which kinds of hook are registered for T.
func (*Hooks[T]) Reset ¶
func (h *Hooks[T]) Reset()
Reset removes every registered hook for T.
It existed for tests against the process-default registry, which leaked registrations between cases. That registry is gone, and with it the reason: a test gets isolation by naming its own registry, which costs one line and cannot be forgotten in a teardown. Kept because clearing one model's rules from a registry that outlives a case is still occasionally what a test wants — but if you are reaching for it, a fresh NewRegistry is probably the answer.
type Insert ¶
type Insert[T any] struct { // contains filtered or unexported fields }
Insert is an INSERT statement over model T.
Columns carrying a database default are omitted when their Go value is the zero value, so generated identifiers and timestamps come from the database rather than being overwritten with zeroes. The statement always returns the inserted rows, so those values land back in the caller's structs.
The rule is per row, including in a multi-row insert: a column no row fills in leaves the statement entirely, and in a mixed batch a row that leaves it zero gets the DEFAULT keyword in its own tuple. A row's semantics therefore do not depend on its batch-mates, which they did until #73.
func InsertRows ¶
InsertRows starts an INSERT for one or more rows. The rows are pointers so that hooks and returned database values can be written back into them.
func (*Insert[T]) Exec ¶
Exec runs the insert, returning the stored rows with database defaults applied. The caller's structs are updated in place as well — except when ON CONFLICT DO NOTHING skipped a row, in which case none of them are; see writeBack for why.
func (*Insert[T]) OnConflictDoNothing ¶
OnConflictDoNothing makes a conflict on the given columns skip the row instead of failing. Skipped rows are simply absent from the result.
Because a skipped row cannot be told apart from its neighbours in what comes back, a statement that skips any row leaves every caller struct untouched — the returned slice is then the only account of what was written. See Exec.
func (*Insert[T]) OnConflictSet ¶ added in v0.6.0
OnConflictSet assigns an expression to a column in DO UPDATE, for the upserts that cannot be spelled by naming a column.
ins.OnConflictUpdate([]string{"key"}, "payload").
OnConflictSet("updated_at", sqlb.Now()).
OnConflictSet("hits", sqlb.Add(sqlb.Current("hits"), sqlb.Val(1))).
OnConflictSet("note", sqlb.Coalesce(sqlb.Excluded("note"), sqlb.Current("note")).Expr())
Assignments render after the bare columns, in the order declared, and their bind parameters are numbered in the same sequence as the VALUES list — so a parameterised assignment is an ordinary `$n`, not a separate numbering that happens to line up.
A column reference inside the expression must say which side of the conflict it means, with Excluded or Current. Both rows are in scope in DO UPDATE, so a bare Field is ambiguous, and it is refused rather than resolved to whichever side the compiler would have picked (#90). Raw is exempt for the reason it is always exempt: its contents are not parsed by this package.
Calling it without OnConflictUpdate or OnConflictDoNothing is an error — an assignment with no conflict clause has nowhere to go.
func (*Insert[T]) OnConflictUpdate ¶
OnConflictUpdate upserts: a conflict on target updates the named columns from the proposed row. With no update columns it behaves as do-nothing, unless Insert.OnConflictSet adds an assignment.
Each named column is shorthand for `col = EXCLUDED.col`. For anything else — the database clock, an accumulation, a value kept when the proposed one is null — see OnConflictSet.
func (*Insert[T]) UseDialect ¶
UseDialect overrides the dialect for this statement.
type List ¶
type List struct {
Items []Expr
}
List is a parenthesised expression list, used by IN and row constructors.
type Model ¶
type Model struct {
Type reflect.Type
Table string
Columns []*ColumnInfo
PK *ColumnInfo
// Derived are the computed columns, in declaration order. They are also in
// Columns — a computed column is a column, which is what makes Hidden,
// Filterable and the whole capability vocabulary apply to it unchanged —
// and this is the list for the callers that need only them, the mount check
// among them.
Derived []*ColumnInfo
// Relations are the expandable references this model declares — the
// struct fields carrying an expanded row rather than a column of their
// own. They are not columns: a relation field is `db:"-"`, so nothing
// selects, inserts or updates it.
Relations []*RelationInfo
// Scope and Soft are the columns that declared an obligation, or nil. They
// are resolved here so that the check at mount time is a field read rather
// than a scan, and so that the error can name the column that asked.
Scope *ColumnInfo
Soft *ColumnInfo
// contains filtered or unexported fields
}
Model is the reflected mapping between a Go struct and a table.
func ModelOf ¶
ModelOf returns the model for T, reflecting over it once and caching the result. It panics if T is not a struct, which is a programming error rather than a runtime condition.
func (*Model) Column ¶
func (m *Model) Column(name string) *ColumnInfo
Column returns the named column, or nil.
func (*Model) ColumnNames ¶
ColumnNames returns every mapped column name in declaration order.
func (*Model) Relation ¶
func (m *Model) Relation(name string) *RelationInfo
Relation returns the named relation, or nil.
func (*Model) RelationNames ¶
RelationNames returns every expandable relation name, in declaration order.
func (*Model) Selectable ¶
func (m *Model) Selectable() []*ColumnInfo
Selectable returns the columns a REST response may contain: everything not marked hidden.
type Nearness ¶ added in v0.4.0
type Nearness struct {
// contains filtered or unexported fields
}
Nearness is a similarity comparison between a vector column and a query vector, from which the projection, the threshold and the ordering are all derived. Build one with Near.
func Near ¶ added in v0.4.0
Near compares a vector column against a query vector.
The vector binds as one parameter, cast to `vector` — which is what makes the comparison work against a column whose type Postgres knows and whose parameter type it would otherwise have to guess.
One parameter, not three. The handle names the vector in the projection, the threshold and the ordering, and all three render the same placeholder: an embedding is about twenty kilobytes and sending it once per mention would treble every search's payload for nothing.
func (Nearness) AtLeast ¶ added in v0.4.0
AtLeast keeps rows whose similarity is at or above score.
Note what this does to a shortfall: with a threshold applied, a query returning fewer rows than its limit is the *normal* case, so counting rows cannot tell "nothing was similar enough" from "the search did not look hard enough". That distinction does not matter under an exact scan, where the second cannot happen. It is why ADR-0026 says an under-recall signal must count before the threshold cut rather than after — a thing to remember when the index half is built, and harmless until then.
func (Nearness) Distance ¶ added in v0.4.0
Distance selects the raw distance, aliased `distance`, for a caller that wants the number Postgres computed rather than the one people read.
Offered because re-ranking against another system's scores needs the comparable quantity, and computing `1 - similarity` back is a rounding error nobody should have to think about.
func (Nearness) Nearest ¶ added in v0.4.0
Nearest orders by distance ascending, which is closest first.
It is the distance that is ordered by and not the score, though the two are equivalent orderings: `ORDER BY col <=> $1` is the shape an ANN index can serve, and writing it this way means adding an index later changes the plan rather than the statement.
func (Nearness) Similarity ¶ added in v0.4.0
Similarity selects the score, aliased `similarity`. Larger is closer, and it is in [0, 2] for cosine — 1 for an identical direction, 0 for an orthogonal one, and above 1 only for vectors pointing away from each other.
Rename it with As if the destination struct calls it something else.
type NullsOrder ¶ added in v0.6.0
type NullsOrder uint8
NullsOrder is where NULLs sit relative to real values within one ORDER BY term.
The zero value is Postgres's own default, and the thing worth knowing about that default is that it is not a single placement: NULLS LAST for ascending, NULLS FIRST for descending. So a column whose NULLs carry a meaning — a NULL `published_at` meaning "not published" — cannot rely on it. The placement that is right for the column flips underneath it the moment the direction flips, which is what makes the ordering a property worth declaring rather than leaving to the query (#88).
Exported because it is what a ColumnInfo declares and what the REST sort grammar reads back; Order.NullsFirst and Order.NullsLast remain the way a hand-written query says the same thing.
const ( NullsDefault NullsOrder = iota NullsFirst NullsLast )
type Order ¶
type Order struct {
// contains filtered or unexported fields
}
Order is one ORDER BY term.
func OrderByDesc ¶
OrderByDesc orders by an arbitrary expression, descending.
func (Order) NullsFirst ¶
NullsFirst places NULLs before other values.
type Param ¶
type Param struct {
Value any
}
Param is a bind parameter. Its value is never interpolated into SQL text.
type Plan ¶
type Plan struct {
SQL string
Args []any
Analyzed bool
Raw json.RawMessage
// TotalCost is the planner's estimate for the whole statement, in its
// arbitrary cost units. Useful for comparison against itself over time,
// not as an absolute.
TotalCost float64
// PlanRows is the estimated row count at the root.
PlanRows int64
// ActualRows and ActualMS are populated only by ExplainAnalyze.
ActualRows int64
ActualMS float64
// Nodes is the plan tree flattened depth-first, which is the convenient
// shape for scanning rather than rendering.
Nodes []PlanNode
}
Plan is a parsed Postgres query plan.
func Explain ¶
Explain asks Postgres to plan a query without running it.
It answers two questions that `SQL()` alone cannot. First, whether the statement is actually valid against the live database — a column that no longer exists, or a type that no longer matches, fails here rather than in production. Second, whether the plan is still the one you expect: an index scan that silently became a sequential scan is invisible in the SQL text and obvious in the plan.
Both make it usable as a test assertion, which is the point. A query whose plan regresses can fail a build:
plan, err := sqlb.Explain(ctx, db, q)
if err != nil {
t.Fatal(err)
}
if d := plan.Diagnostics(); len(d) > 0 {
t.Errorf("query plan regressed:\n%s", sqlb.Diagnostics(d))
}
Explain does not execute the statement, so it is safe on mutations. Use ExplainAnalyze only when you mean to run it.
func ExplainAnalyze ¶
ExplainAnalyze plans and *executes* the statement, returning real timings and row counts rather than estimates.
On an INSERT, UPDATE or DELETE this writes to the database. Run it inside a transaction you roll back, or not at all.
func (*Plan) Diagnostics ¶
func (p *Plan) Diagnostics() []PlanDiagnostic
Diagnostics reports plan shapes that usually mean a missing index or a query that will not scale. They are advisory: a sequential scan over a lookup table is correct, and so is a sort of twenty rows.
func (*Plan) String ¶
String renders the plan as an indented tree, in the shape a reader — or an agent comparing two runs — can scan quickly.
func (*Plan) UsesSeqScan ¶
UsesSeqScan reports whether any node sequentially scans the named relation. Pass an empty string to ask about any relation.
type PlanDiagnostic ¶
PlanDiagnostic is an observation about a plan that is worth acting on.
func (PlanDiagnostic) String ¶
func (d PlanDiagnostic) String() string
type PlanNode ¶
type PlanNode struct {
Depth int
Type string
Relation string
Index string
Filter string
TotalCost float64
PlanRows int64
ActualRows int64
ActualMS float64
SortMethod string
SortSpace string
}
PlanNode is one step of the plan.
type Postgres ¶
type Postgres struct{}
Postgres is the Postgres dialect: $N placeholders and double-quoted identifiers.
func (Postgres) Placeholder ¶
func (Postgres) QuoteIdent ¶
type Pred ¶
type Pred struct {
// contains filtered or unexported fields
}
Pred is a boolean expression.
The zero Pred is a no-op: Where, And and Or all skip it. That makes conditional construction read without branches:
q.Where(sqlb.If(minAge > 0, sqlb.F("age").Gte(minAge)))
func And ¶
And conjoins the non-zero predicates. It returns the zero Pred if none are non-zero, and the single predicate unwrapped if exactly one is.
func If ¶
If returns p when cond holds and the zero Pred otherwise.
Example ¶
If drops the predicate when its condition does not hold, so an optional filter needs no surrounding statement. The zero Pred it returns is skipped by Where.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
minViews := int64(0) // not supplied by this request
sql, args, _ := sqlb.Query[Article]().
Where(
sqlb.F("status").Eq("published"),
sqlb.If(minViews > 0, sqlb.F("view_count").Gte(minViews)),
).
SQL()
fmt.Println(sql)
fmt.Println(args...)
}
Output: SELECT "articles"."id", "articles"."title", "articles"."status", "articles"."view_count", "articles"."org_id" FROM "articles" WHERE "status" = $1 published
func Not ¶
Not negates a predicate. Negating the zero Pred yields the zero Pred rather than the always-false predicate, so an absent filter stays absent.
func Or ¶
Or disjoins the non-zero predicates.
Example ¶
Or groups alternatives into a single predicate, which Where then conjoins with the rest. Values never reach the SQL text: every one becomes a bind parameter.
package main
import (
"fmt"
"github.com/jryannel/sqlb"
)
// Article is the model these examples query. The `db` tags name the columns and
// the `sqlb` tags declare what the REST layer may do with them; both are what
// codegen writes from a schema declaration.
type Article struct {
ID string `db:"id" sqlb:"pk,default"`
Title string `db:"title" sqlb:"filter,search,sort"`
Status string `db:"status" sqlb:"filter,sort"`
ViewCount int64 `db:"view_count" sqlb:"filter,sort"`
OrgID string `db:"org_id" sqlb:"filter"`
}
func (Article) TableName() string { return "articles" }
func main() {
sql, args, _ := sqlb.Query[Article]().
Where(
sqlb.F("org_id").Eq("acme"),
sqlb.Or(
sqlb.F("status").Eq("published"),
sqlb.F("status").Eq("review"),
),
).
SQL()
fmt.Println(sql)
fmt.Println(args...)
}
Output: SELECT "articles"."id", "articles"."title", "articles"."status", "articles"."view_count", "articles"."org_id" FROM "articles" WHERE ("org_id" = $1) AND (("status" = $2) OR ("status" = $3)) acme published review
type Raw ¶
Raw is verbatim SQL with its own bind parameters, written as `?` placeholders which the compiler renumbers. Use it only for expressions the builder cannot model: its contents are not validated.
type RegisteredHooks ¶
type RegisteredHooks struct {
BeforeQuery bool
BeforeCreate bool
BeforeUpdate bool
BeforeDelete bool
}
RegisteredHooks reports which kinds of hook a model has, one bool per kind.
It answers "did anyone write this" and deliberately not "does it do the right thing": a hook's body is a closure, and nothing here can tell a tenant predicate from a logging statement. That makes it useful for exactly one thing — refusing to serve a model whose schema declared an obligation that no registration could possibly be meeting, because there is no registration (ADR-0030).
func RegisteredFor ¶
func RegisteredFor[T any](exec Executor) RegisteredHooks
RegisteredFor reports which hooks are registered for T against whichever registry exec resolves to — the same resolution a query would get, so a handle carrying a scoped registry is asked about that registry rather than about the process default.
It reads the registry at the moment it is called, which is why the check it exists for belongs where a resource is mounted: hooks registered afterwards are not visible to it, and a program that mounts before it registers is a program whose first request would have run unscoped anyway.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds the hook sets for a set of models, keyed by type.
Every program names one. There is deliberately no process-wide default: hooks are the rules confining what a query may see, and a set of rules that arrives by ambient state is one nothing in the program is responsible for. Build a registry, register into it, and attach it with DB.WithHooks.
This package used to have a default, and removing it was ADR-0047. The failure it existed to permit — registering hooks before building a handle, so every handle picks them up — is also the failure it caused: two handles in one process shared rules neither had asked for, and a module that stopped registering left the previous module's scoping silently in force.
type RelationInfo ¶
type RelationInfo struct {
// Name is what `?expand` names it, taken from the field's json tag and
// falling back to the snake-cased field name. It is deliberately the JSON
// name: the parameter is part of the wire format, and a client should not
// have to know the Go field is called `List` when the payload says `list`.
Name string
// Field is the Go struct field the expanded row is written to.
Field string
// Index is the reflect path to that field.
Index []int
// Elem is the struct type behind the field, with any pointer removed. For
// a Collection it is the child type, not the Collection itself.
Elem reflect.Type
// FK is the column joined on: a column of this model for a forward
// relation, and a column of the target for a collection. It is resolved
// with the target for a collection, so it is nil until Target has run.
FK *ColumnInfo
// Collection reports that this relation is the reverse direction — many
// rows of the target pointing back at one row of this model.
Collection bool
// Order is the child column a collection is ordered by, with the target's
// primary key appended as a tiebreaker. Empty means the primary key alone.
// Under a cap, a non-total order does not reshuffle the result, it decides
// which children the caller never sees — see ADR-0027 and ADR-0022.
Order string
OrderDesc bool
// Limit caps a collection. Zero means defaultExpandLimit.
Limit int
// contains filtered or unexported fields
}
RelationInfo describes one expandable reference.
func (*RelationInfo) Cap ¶
func (r *RelationInfo) Cap() int
Cap reports how many children this relation returns at most.
func (*RelationInfo) Target ¶
func (r *RelationInfo) Target() (*Model, error)
Target returns the model of the expanded type.
Resolved lazily and once. A cycle — two models expandable to each other — is fine as long as nothing expands both at the same moment, which the SQL could not express anyway.
type RelationOption ¶
type RelationOption func(*relationTag)
RelationOption adjusts a collection expansion declared through Describe. The generated form spells the same two things in the struct tag: `sqlb:"expands=list_id,order=-created_at,limit=20"`.
func ExpandLimit ¶
func ExpandLimit(n int) RelationOption
ExpandLimit caps how many children an expansion returns. The default is 50. Past the cap the collection reports HasMore, and the caller follows the child's own endpoint filtered by the foreign key.
func ExpandOrder ¶
func ExpandOrder(column string) RelationOption
ExpandOrder orders a collection's children, most significant first, with a leading "-" for descending — the spelling ?sort already uses. The target's primary key is appended as a tiebreaker either way, because under a cap a non-total order decides which children the caller never sees.
type Selectable ¶
type Selectable interface {
// contains filtered or unexported methods
}
Selectable is anything that can appear in a SELECT list.
type Selection ¶
type Selection struct {
// contains filtered or unexported fields
}
Selection is one item in a SELECT list: an expression and an optional alias.
type Tabler ¶
type Tabler interface {
TableName() string
}
Tabler lets a model name its own table. Generated models implement it; hand-written ones can too. Without it the table name is derived from the type name, so `type User struct{}` maps to "users".
type TextCol ¶
TextCol is a Col over a string-like column, carrying the pattern operators that only make sense there. Generators emit it for text and varchar columns, including those with a named string type.
func TextColumn ¶
TextColumn declares a typed text column reference.
func (TextCol[T]) Contains ¶
Contains matches rows whose column contains v, case-insensitively, with wildcards in v escaped.
func (TextCol[T]) EndsWith ¶
EndsWith matches a case-insensitive suffix, with wildcards in v escaped.
func (TextCol[T]) StartsWith ¶
StartsWith matches a case-insensitive prefix, with wildcards in v escaped.
type Update ¶
type Update[T any] struct { // contains filtered or unexported fields }
Update is an UPDATE statement over model T.
func (*Update[T]) Clone ¶
Clone returns an independent copy, so a statement can be reused as the starting point for several derived ones.
func (*Update[T]) Everything ¶
Everything confirms an intentionally unscoped update.
func (*Update[T]) Exec ¶
Exec runs the update and returns the updated rows.
The statement is cloned first, for the reason Builder.All clones: a BeforeUpdate hook amends what it is given, and the doc comment's own example is one that calls Set. Amending the caller's statement would make a second Exec assign updated_at twice and narrow a scoping predicate twice.
func (*Update[T]) One ¶
One runs an update expected to touch exactly one row.
The check is on the result, so an update matching several rows has already changed all of them when the error returns. Under autocommit that is durable; inside WithTx the error rolls it back, which is the way to make "expected one" a refusal rather than a report.
func (*Update[T]) SetExpr ¶
SetExpr assigns an expression, for updates computed from the current row such as a counter increment.
func (*Update[T]) UseDialect ¶
UseDialect overrides the dialect for this statement.
type Vector ¶ added in v0.4.0
type Vector []float32
Vector is a pgvector embedding.
It is a plain []float32, so a caller's embedder output goes in without a conversion and comes back out the same way:
chunk.Embedding = sqlb.Vector(embedder.Embed(ctx, text))
Declare the column with schema.Vector(name, dim). The dimension belongs to the column rather than to this type: a vector(1536) column refuses a 768-component value, and the check is Postgres's.
Register the codec ¶
Values move in pgvector's binary format, which is worth about 2.7× the time and 21× the memory of the text form on a page of 1,536-component embeddings — the measurement ADR-0040 was decided on. Binary needs the type's OID, which an extension type only has once it is installed, so it is registered per connection:
cfg, err := pgxpool.ParseConfig(dsn) cfg.AfterConnect = sqlb.RegisterVectorType pool, err := pgxpool.NewWithConfig(ctx, cfg)
Without that registration a Vector still works and moves as text, which is correct and slower. RegisterVectorType says what to do about a database that does not have the extension at all.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
sqlb
command
Command sqlb keeps a project's generated code and migration history in step with its schema declaration, and reports when either has drifted from it.
|
Command sqlb keeps a project's generated code and migration history in step with its schema declaration, and reports when either has drifted from it. |
|
Package codegen renders a schema declaration into Go source.
|
Package codegen renders a schema declaration into Go source. |
|
example
|
|
|
blog/blogschema
Package blogschema is the schema definition for the blog example: the single source of truth that an author, or an agent, edits.
|
Package blogschema is the schema definition for the blog example: the single source of truth that an author, or an agent, edits. |
|
computed
Package computed shows how to get a derived value — one the row does not store — out of Postgres through sqlb.
|
Package computed shows how to get a derived value — one the row does not store — out of Postgres through sqlb. |
|
evolve
Package evolve is a schema that changed five times, and the machinery that kept the changes honest.
|
Package evolve is a schema that changed five times, and the machinery that kept the changes honest. |
|
evolve/evolveschema
Package evolveschema is the schema of a support desk, and the subject of docs/refactoring-a-database.md.
|
Package evolveschema is the schema of a support desk, and the subject of docs/refactoring-a-database.md. |
|
recipes
Package recipes is a collection of small, single-topic examples: one file per aspect of sqlb, each answering a question someone actually has.
|
Package recipes is a collection of small, single-topic examples: one file per aspect of sqlb, each answering a question someone actually has. |
|
withsqlc
Package withsqlc demonstrates sqlb and sqlc over one schema.
|
Package withsqlc demonstrates sqlb and sqlc over one schema. |
|
withsqlc/gen
command
Command gen renders the blog schema as the plain `schema.sql` that sqlc reads, which is the mechanical half of the sqlb/sqlc pairing story: one schema declaration, two consumers.
|
Command gen renders the blog schema as the plain `schema.sql` that sqlc reads, which is the mechanical half of the sqlb/sqlc pairing story: one schema declaration, two consumers. |
|
Package filter compiles URL query parameters into sqlb predicates.
|
Package filter compiles URL query parameters into sqlb predicates. |
|
internal
|
|
|
internalschema
Package internalschema is a schema package that lives under internal/, and exists so that the command is tested against one.
|
Package internalschema is a schema package that lives under internal/, and exists so that the command is tested against one. |
|
pgfake
Package pgfake provides the pgx shapes sqlb's own tests run against, so the engine's suite stays database-free.
|
Package pgfake provides the pgx shapes sqlb's own tests run against, so the engine's suite stays database-free. |
|
Package introspect reads a Postgres schema out of pg_catalog and returns the same *schema.Registry the DSL produces.
|
Package introspect reads a Postgres schema out of pg_catalog and returns the same *schema.Registry the DSL produces. |
|
Package migrate turns a schema change into migration files for an existing migration runner.
|
Package migrate turns a schema change into migration files for an existing migration runner. |
|
Package rest mounts a schema-declared resource on a Huma API.
|
Package rest mounts a schema-declared resource on a Huma API. |
|
Package restcompat diffs the REST contract two schemas generate, and classifies each delta as breaking, additive, or neutral for a deployed client.
|
Package restcompat diffs the REST contract two schemas generate, and classifies each delta as breaking, additive, or neutral for a deployed client. |
|
Package schema is the declarative schema DSL for sqlb.
|
Package schema is the declarative schema DSL for sqlb. |
|
Package shadow builds a schema by replaying a migration history into an empty database, and reading back what the migrations actually produced.
|
Package shadow builds a schema by replaying a migration history into an empty database, and reading back what the migrations actually produced. |
|
sqlbfx
module
|
|
|
Package sqlbtest is a database-free Executor for testing an application built on sqlb.
|
Package sqlbtest is a database-free Executor for testing an application built on sqlb. |