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 EncodeArray(v any) (string, error)
- func SetErrorClassifier(fn ErrorClassifier)
- 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]) 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]
- 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 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) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
- func (d *DB) Hooks() *Registry
- func (d *DB) InTx() bool
- func (d *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
- func (d *DB) Tx() (*sql.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 *sql.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 Description
- func (d *Description[T]) Column(field, column 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]) 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]) 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) 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) 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]) 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 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]
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]().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.
hooks := sqlb.On[Article]()
defer hooks.Reset()
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()
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.
hooks := sqlb.On[Article]()
defer hooks.Reset()
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()
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 wraps a value list so that it binds as one Postgres array parameter rather than as 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, so a []string, a []int64 or a mixed list of already-coerced values all work.
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 EncodeArray ¶
EncodeArray renders a Go slice as a Postgres array literal.
It is exported because the escape hatches need it: a Raw fragment binding an array operand, or a SetExpr writing one, has no other way to produce a value the driver will accept.
It accepts []any as well as a typed slice, because that is the shape the filter parser produces: `?tags=hasall.a,b` arrives as strings and leaves Coerce as the element's Go type, one value at a time.
func SetErrorClassifier ¶
func SetErrorClassifier(fn ErrorClassifier)
SetErrorClassifier installs a driver-aware classifier, which supersedes the built-in one.
The built-in classification is deliberately dependency-free: it reads SQLSTATE through an interface a driver error may satisfy, which recovers the kind and nothing else. The constraint *name* is the field carrying the value — it is what lets an application branch on which rule was broken — and every driver exposes it as a struct field rather than as a method, so reaching it means naming the driver. This library depends on the standard library alone and will not do that, so the seam is here instead:
sqlb.SetErrorClassifier(func(err error) (sqlb.ConstraintError, bool) {
var pg *pgconn.PgError
if !errors.As(err, &pg) {
return sqlb.ConstraintError{}, false
}
kind, ok := sqlb.ConstraintKindOf(pg.SQLState())
if !ok {
return sqlb.ConstraintError{}, false
}
return sqlb.ConstraintError{
Kind: kind,
Constraint: pg.ConstraintName,
Table: pg.TableName,
Column: pg.ColumnName,
Detail: pg.Detail,
}, true
})
Call it once at startup, before serving. Passing nil restores the built-in classification.
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 *sql.DB that opens a transaction. It is asserted for rather than required, so Executor stays two methods and every wrapper written against it keeps working.
A wrapper that wants WithTx to work through it — a tracer, a pool adapter — implements this alongside Executor and returns the underlying *sql.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]) 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%
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
// 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
}
ColumnInfo describes one mapped column of a model.
type Compilable ¶
Compilable is anything that renders to SQL: every builder and every mutation statement in this package.
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.
Kind is always set. The remaining fields are filled only as far as the driver reports them: the standard library defines no way to read a constraint name from an error, so the built-in classification recovers the kind alone. Registering a driver-aware SetErrorClassifier fills in the rest.
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, using the process-default hook registry — so hooks registered with On[T]() apply to it, and an existing program can adopt the handle without moving its registrations.
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]().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) ExecContext ¶
ExecContext satisfies Executor.
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) QueryContext ¶
QueryContext satisfies Executor.
func (*DB) Tx ¶
Tx returns the underlying *sql.Tx, if this handle runs on one.
It exists so that a unit of work can be shared with a library that wants more than Executor's two methods. sqlc's generated DBTX wants four, so this is how 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
}
sqlTx, ok := tx.Tx()
if !ok {
return errors.New("expected a transaction")
}
return sqlcgen.New(sqlTx).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 *sql.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 instead of the process default. It is how a test gets isolation without Reset, and how two tenants-worth of differing domain rules can coexist in one process.
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 *sql.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 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]) 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]) 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.
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]) 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 {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
Executor is the subset of *sql.DB and *sql.Tx that sqlb uses. Anything satisfying it works, including pgx through its stdlib adapter and any instrumenting wrapper.
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.
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) 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) 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.
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:
sqlb.On[Post]().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
})
Hooks are registered once at startup, typically from an init function or main, and run in registration order. A hook returning an error aborts the operation and the error reaches the caller unwrapped.
func On ¶
On returns the hook set for model T in the process-default registry, creating it on first use.
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.
hooks := sqlb.On[Article]()
defer hooks.Reset()
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()
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.
hooks := sqlb.On[Article]()
defer hooks.Reset()
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()
_, 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 exists for tests against the process-default registry, which otherwise leak registrations between cases. A test that can afford to name its own registry — NewRegistry, then DB.WithHooks — gets the same isolation without the teardown.
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.
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]) OnConflictUpdate ¶
OnConflictUpdate upserts: a conflict on target updates the named columns from the proposed row. With no update columns it behaves as do-nothing.
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
// 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 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.
Most programs never name one: On[T]() reaches a process default, and registering at startup is the intended use. A registry becomes worth holding when two of them need to differ — a test that wants isolation without Reset, or a handle whose domain rules are not the process-wide ones. Attach it with DB.WithHooks.
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.
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. |
|
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. |
|
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
|