Documentation
¶
Overview ¶
Package schema is the declarative schema DSL for sqlb.
A schema is written as ordinary Go values, which makes it the single source of truth for migrations, models, REST handlers and the OpenAPI document:
var User = schema.Table("users",
schema.UUIDv7("id").PrimaryKey(),
schema.Text("email").Unique().Searchable(),
schema.Int("age").Nullable().Filterable(),
schema.Ref("org", Org).OnDelete(schema.Cascade),
schema.Timestamps(),
).Expose(schema.REST{Path: "/users", Ops: schema.CRUD | schema.List})
Capabilities such as Filterable and Sortable are opt-in per column. A column that does not declare a capability can never be reached through it from the REST layer, which is what separates sqlb from exposing the database directly.
Index ¶
- Constants
- func CheckIdent(name string) error
- func IsArrayElement(t Type) bool
- func Register(t *TableDef)
- func Validate() error
- func WriteManifest(path string) error
- type Action
- type Check
- type ColumnManifest
- type Default
- type Diagnostic
- type Diagnostics
- type Error
- type Field
- func BigInt(name string) *Field
- func Bool(name string) *Field
- func Bytes(name string) *Field
- func Date(name string) *Field
- func Enum(name string, values ...string) *Field
- func ExternalRef(relation, target string) *Field
- func Float(name string) *Field
- func Int(name string) *Field
- func JSON(name string) *Field
- func Numeric(name string) *Field
- func Ref(name string, target *TableDef) *Field
- func Text(name string) *Field
- func Time(name string) *Field
- func Timestamp(name string) *Field
- func UUID(name string) *Field
- func UUIDv7(name string) *Field
- func Varchar(name string, size int) *Field
- func (f *Field) Array() *Field
- func (f *Field) Comment(s string) *Field
- func (f *Field) ConstraintNamed(name string) *Field
- func (f *Field) Default(d *Default) *Field
- func (f *Field) Desc() *FieldDesc
- func (f *Field) Expandable() *Field
- func (f *Field) Filterable() *Field
- func (f *Field) Hidden() *Field
- func (f *Field) Immutable() *Field
- func (f *Field) Inverse(name string) *Field
- func (f *Field) InverseExpandable(opts ...InverseOption) *Field
- func (f *Field) Name() string
- func (f *Field) Named(column string) *Field
- func (f *Field) Nullable() *Field
- func (f *Field) OfType(t Type) *Field
- func (f *Field) OnDelete(a Action) *Field
- func (f *Field) OnUpdate(a Action) *Field
- func (f *Field) PrimaryKey() *Field
- func (f *Field) ReadOnly() *Field
- func (f *Field) RenamedFrom(old string) *Field
- func (f *Field) Scoped() *Field
- func (f *Field) Searchable() *Field
- func (f *Field) Sortable() *Field
- func (f *Field) Unique() *Field
- type FieldDesc
- type FieldSpec
- type Group
- type Index
- type IndexManifest
- type InverseManifest
- type InverseOption
- type InverseRelation
- type Manifest
- type Op
- type OperatorDoc
- type ParamDoc
- type REST
- type RESTManifest
- type RefManifest
- type Reference
- type Registry
- func (r *Registry) Add(t *TableDef)
- func (r *Registry) BuildManifest() *Manifest
- func (r *Registry) Exposed() []*TableDef
- func (r *Registry) Get(name string) *TableDef
- func (r *Registry) Inverses(t *TableDef) []InverseRelation
- func (r *Registry) Lint() Diagnostics
- func (r *Registry) Module() string
- func (r *Registry) Qualify(local string) string
- func (r *Registry) Table(name string, specs ...FieldSpec) *TableDef
- func (r *Registry) Tables() []*TableDef
- func (r *Registry) Validate() error
- type Severity
- type TableDef
- func (t *TableDef) AddIndex(idx Index) *TableDef
- func (t *TableDef) Check(name, expr string) *TableDef
- func (t *TableDef) Checks() []Check
- func (t *TableDef) Comment() string
- func (t *TableDef) Describe(s string) *TableDef
- func (t *TableDef) Expose(r REST) *TableDef
- func (t *TableDef) Field(name string) *Field
- func (t *TableDef) Fields() []*Field
- func (t *TableDef) Index(columns ...string) *TableDef
- func (t *TableDef) Indexes() []Index
- func (t *TableDef) LocalName() string
- func (t *TableDef) Module() string
- func (t *TableDef) Name() string
- func (t *TableDef) PrimaryKey() *Field
- func (t *TableDef) PrimaryKeyName() string
- func (t *TableDef) PrimaryKeyNamed(name string) *TableDef
- func (t *TableDef) Relations() []*Field
- func (t *TableDef) RenamedFrom(local string) *TableDef
- func (t *TableDef) RenamedFromName() string
- func (t *TableDef) ReplaceCheckExpr(name, expr string) bool
- func (t *TableDef) Rest() *REST
- func (t *TableDef) UniqueIndex(columns ...string) *TableDef
- type TableManifest
- type Type
Examples ¶
Constants ¶
const CRUD = OpCreate | OpRead | OpUpdate | OpDelete
CRUD is the conventional single-row operation set. Combine it with OpList for a fully exposed collection.
const DefaultExpandLimit = 50
DefaultExpandLimit is the cap an expanded collection takes when it declares none. It mirrors the engine's own default, and sqlb's model test asserts the two agree — a schema package that disagreed with the runtime would publish a number the responses do not honour.
const ManifestVersion = "1"
ManifestVersion is bumped when the manifest shape changes incompatibly.
Variables ¶
This section is empty.
Functions ¶
func CheckIdent ¶
CheckIdent reports why the DSL cannot declare a table or column called name, or nil when it can.
It is exported for introspection, which reads names a database already has rather than names an author chose. Those two are not the same set — a camelCase column is legal in Postgres and undeclarable here — and an importer needs to say which construct it had to skip, not fail the whole import with a message about what the DSL considers impossible.
func IsArrayElement ¶
IsArrayElement reports whether t may be the element type of an array column.
jsonb and bytea are excluded: both already hold a composite value, and an array of either is a shape no generated client can narrow past `unknown`.
func Register ¶
func Register(t *TableDef)
Register adds a table to the default registry. Table calls this for you.
func WriteManifest ¶
WriteManifest writes the default registry's manifest to path, creating parent directories as needed.
Types ¶
type ColumnManifest ¶
type ColumnManifest struct {
Name string `json:"name"`
// Type names the element type of an array column, with Array set beside
// it — the same split the declaration uses, so a consumer reading the
// manifest sees the enum values and the varchar length attached to the
// thing that has them.
Type string `json:"type"`
Array bool `json:"array,omitempty"`
GoType string `json:"goType"`
Nullable bool `json:"nullable,omitempty"`
Comment string `json:"comment,omitempty"`
Enum []string `json:"enum,omitempty"`
HasDefault bool `json:"hasDefault,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Immutable bool `json:"immutable,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
References *RefManifest `json:"references,omitempty"`
// Obligations, kept out of Capabilities because a capability is something
// a request may reach and these are things the server must have done. A
// client generator has no use for either; a reader auditing the boundary
// has.
Scoped bool `json:"scoped,omitempty"`
SoftDelete bool `json:"softDelete,omitempty"`
}
ColumnManifest describes one column. Hidden columns are omitted entirely rather than listed as hidden: the manifest is publishable, and a name is itself information.
type Default ¶
Default describes a column default. Raw is emitted verbatim into DDL; Value is emitted as a literal.
func GenUUIDv4 ¶
func GenUUIDv4() *Default
GenUUIDv4 defaults the column to a random UUID using pgcrypto.
func GenUUIDv7 ¶
func GenUUIDv7() *Default
GenUUIDv7 defaults the column to a freshly generated UUIDv7.
How this renders depends on the Postgres it is generated for. By default it emits uuid_generate_v7(), which needs the pg_uuidv7 extension — so the generated DDL does not apply to a stock install. Postgres 18 has uuidv7() built in, and migrate.MinPostgres(18) emits that instead, which needs nothing. On an older server without the extension, use GenUUIDv4.
type Diagnostic ¶
type Diagnostic struct {
Rule string
Table string
Column string
Severity Severity
Message string
// Fix is the concrete change that would resolve it, where there is one.
Fix string
}
Lint reports schemas that are correct but operationally unwise.
Validate answers "is this schema well-formed?" and returns errors. Lint answers "will this schema behave badly in production?" and returns advice. The distinction matters: a table can pass validation completely and still expose an unindexed filter that sequential-scans a large table on every request, which is the kind of mistake that is invisible in review and obvious at three in the morning.
Diagnostics are advisory. Nothing fails because of them, and a schema may have good reasons to keep one — a filterable column on a table of twenty rows does not need an index.
func (Diagnostic) String ¶
func (d Diagnostic) String() string
type Diagnostics ¶
type Diagnostics []Diagnostic
Diagnostics is an ordered set of lint results.
func (Diagnostics) String ¶
func (ds Diagnostics) String() string
func (Diagnostics) Warnings ¶
func (ds Diagnostics) Warnings() Diagnostics
Warnings returns only the warn-level diagnostics, for callers that want to fail a build on those but tolerate info.
type Error ¶
Error is a single schema validation failure, located at a table and optionally a column.
type Field ¶
type Field struct {
// contains filtered or unexported fields
}
Field is a column under construction. Its methods are chainable setters, so the DSL reads as a declaration:
schema.Text("email").Unique().Searchable()
Code generators read the result through Desc.
func Enum ¶
Enum is a text column constrained to a fixed set of values. Codegen emits a Go string type with one constant per value.
func ExternalRef ¶
ExternalRef declares a reference to a table this module does not own.
It produces a column named relation+"_id" holding the other side's identifier, and an index to join on — but no FOREIGN KEY, so the two modules can be migrated and deployed independently, and either can be moved to its own database without dropping a constraint:
// in the billing module, with no import of the tenants module
schema.ExternalRef("tenant", "tenants.id").Filterable()
The target is free text. Resolving it to a real table would require exactly the dependency this exists to avoid, so it is recorded for the manifest and for whoever reads the schema, and not checked.
Such a reference cannot be Expandable: expanding it would join a table this module does not own. Fetch the other side through that module's own API.
func Ref ¶
Ref declares a foreign key to target. The column is named name+"_id" and the relation is named name, so Ref("org", Org) yields column "org_id" reachable as ?expand=org once marked Expandable.
func UUIDv7 ¶
UUIDv7 is the conventional primary key column: a UUID defaulting to a generated, time-ordered v7 value.
func (*Field) Array ¶
Array makes the column a one-dimensional Postgres array of the type the constructor named:
schema.Text("tags").Array().Filterable()
schema.Enum("labels", "red", "green").Array()
The Go field is the plain slice — []string, not a named wrapper — so a model described over an existing sqlc struct can carry one (ADR-0033). Nullable still refers to the column: a NULL array and an empty array are different values, and the Go side spells them nil and []string{}.
Only the scalar element types are permitted, and only one dimension. An array column may not be Sortable or Searchable, and a Filterable one must carry a GIN index; Validate reports each of those.
func (*Field) ConstraintNamed ¶
ConstraintNamed pins the name of the constraint this column declares. Use it when adopting an existing database whose constraint names do not match the ones this package would generate.
func (*Field) Desc ¶
Desc returns the column description. The pointer aliases the field's own state, so generators must treat it as read-only.
func (*Field) Expandable ¶
Expandable allows a reference to be resolved inline via ?expand.
func (*Field) Filterable ¶
Filterable allows the column to be used in REST filter expressions.
func (*Field) Hidden ¶
Hidden omits the column from every REST response. Use it for password hashes and similar values that must never leave the process.
func (*Field) Inverse ¶
Inverse names the relation from the target's side: the name an author knows its posts by. Declaring it is what makes the reverse relation exist.
schema.Ref("list", List).Expandable().Inverse("tasks").InverseExpandable()
Read as: a task has a list; a list has tasks; both directions may be expanded. Absent Inverse there is no reverse relation, which is not an error — most references never need one.
One side declares, as it already does for the column, the constraint and the delete action. What the target does gain is a field on its generated struct, because the expanded rows need somewhere to land.
func (*Field) InverseExpandable ¶
func (f *Field) InverseExpandable(opts ...InverseOption) *Field
InverseExpandable exposes the reverse relation through ?expand on the target's endpoint, and takes the options that decide which children a capped expansion returns:
schema.Ref("list", List).
Expandable().
Inverse("tasks").
InverseExpandable(schema.ExpandOrder("-created_at"), schema.ExpandLimit(20))
It requires Inverse: a relation with no name cannot be asked for. Exposure is a separate decision from Expandable in the forward direction, because the two are about different endpoints.
func (*Field) Named ¶
Named overrides the column name.
Most columns are named where they are declared, so this is only needed where the name was derived: Ref("org", Org) produces "org_id", and a database that calls it "organisation_uuid" needs
schema.Ref("org", Org).Named("organisation_uuid")
The relation keeps its own name, so ?expand=org still works.
func (*Field) OfType ¶
OfType overrides the column type, for an external reference whose target is not the conventional UUID.
func (*Field) OnDelete ¶
OnDelete sets the foreign key delete action. It panics if the field is not a reference: that is a schema authoring bug, and failing at init is more useful than failing at request time.
func (*Field) PrimaryKey ¶
PrimaryKey marks the column as the table's primary key. Primary keys are implicitly read-only and filterable.
func (*Field) RenamedFrom ¶
RenamedFrom declares that this column used to be called old, so that a generated migration renames it rather than dropping it and adding a new one:
schema.Text("email_address").RenamedFrom("email")
A rename is indistinguishable from a drop and an add when only the before and after states are known, and inferring one from a similar name and type would destroy data whenever the inference was wrong. So it is declared, never inferred (ADR-0014).
The hint is needed for exactly one release: the migration it produces is generated once, and after that the old name is gone from every database the migration has been applied to. A hint whose old column no longer exists is ignored, so leaving one behind is harmless — but delete it at the next edit, because a stale hint reads as a claim about the current schema that is no longer true.
func (*Field) Scoped ¶
Scoped declares that this column confines the table's rows to one tenant, and that every operation the table exposes must be constrained by a hook.
schema.Ref("workspace", Workspace).Filterable().ReadOnly().Scoped()
Like SoftDelete, it writes no predicate and changes no query. What it changes is what happens when the predicate is missing: [rest.Resource] refuses to mount the resource at startup rather than serving every tenant's rows with a 200 next to them (ADR-0030).
The obligation follows the operations the table exposes, because a BeforeQuery hook constrains what a request can see and says nothing about what it can overwrite by id — a list needs BeforeQuery, an update needs BeforeUpdate, a delete needs BeforeDelete, and a create needs BeforeCreate when the column is ReadOnly and so has no other source than the hook.
The row itself is the tenant on the table the others point at, so there the declaration goes on the primary key:
schema.UUIDv7("id").PrimaryKey().Scoped()
A table may declare one scope column. Where the confinement cannot be written as a column of this table at all — a membership join, say — declare it on the column the hook does constrain, which is the key it narrows.
func (*Field) Searchable ¶
Searchable includes the column in the ?search fan-out. Implies Filterable, since search is a filter over the same column.
type FieldDesc ¶
type FieldDesc struct {
Name string
// Type names the *element* type when Array is set, and the column type
// otherwise. Keeping the element rather than fusing the two is what lets
// the filter parser bind `?tags=has.urgent` as a text value, and what keeps
// EnumValues and Size attached to something that has them (ADR-0033).
Type Type
// Array makes the column a one-dimensional Postgres array of Type.
Array bool
Size int // varchar length; 0 means unbounded
Comment string
Nullable bool
PrimaryKey bool
Unique bool
Default *Default
EnumValues []string
// Capabilities. Each is opt-in and gates one specific REST affordance.
Filterable bool // may be used in a REST filter expression
Sortable bool // may appear in ?sort
Searchable bool // included in the ?search fan-out
Expandable bool // relation may be pulled in via ?expand (references only)
// Write protection, enforced by the REST layer. Go code going through the
// query engine directly is trusted and bypasses these.
ReadOnly bool // never settable through REST
Immutable bool // settable at create, rejected on update
Hidden bool // never serialised into a REST response
// Obligations. Neither of these changes a query. They are read once, at
// startup, where rest refuses to mount a resource whose declarations have
// no hook behind them; nothing on the request path reads either one.
Scoped bool // every exposed operation must be constrained by a hook
SoftDelete bool // the column a soft-delete predicate is expected to filter
// ConstraintName pins the name of the constraint this column declares —
// its unique constraint, or its foreign key if it is a reference. Set it
// when adopting an existing database, so that a generated migration
// recognises the constraint already there instead of dropping and
// recreating it under a name of its own choosing.
ConstraintName string
// RenamedFrom is the column's previous name, declared for one release so
// that a migration renames the column instead of dropping and re-adding
// it. Nothing else reads it.
RenamedFrom string
Ref *Reference
// contains filtered or unexported fields
}
FieldDesc is the resolved description of a column: everything a generator or the runtime needs to know about it.
func (*FieldDesc) Capabilities ¶
Capabilities renders the column's capabilities as the comma-separated body of the `sqlb` struct tag that the runtime engine reads back.
Example ¶
The capabilities render into the `sqlb` struct tag that codegen writes onto the model, which is how the runtime engine reads them back without importing this package. That import direction is what keeps the engine usable without the DSL.
package main
import (
"fmt"
"github.com/jryannel/sqlb/schema"
)
func main() {
fmt.Println(schema.Text("email").Unique().Searchable().Desc().Capabilities())
fmt.Println(schema.Text("secret").Hidden().Desc().Capabilities())
fmt.Println(schema.BigInt("views").Filterable().Sortable().ReadOnly().Desc().Capabilities())
}
Output: filter,search hidden filter,sort,readonly
func (*FieldDesc) GoType ¶
GoType is the Go type codegen emits for this column.
An array is the plain slice of its element type, nullable or not: a nil slice already says NULL and an empty one says {}, so a pointer would add a third spelling for a distinction that only has two.
func (*FieldDesc) IndexWanted ¶
IndexWanted reports whether this column implicitly asked for an index. An external reference does, since a soft foreign key exists to be joined on and one without an index scans the table.
type FieldSpec ¶
type FieldSpec interface {
// contains filtered or unexported methods
}
FieldSpec is anything contributing columns to a table. Both *Field and the grouped helpers (Timestamps, SoftDelete) implement it, so they mix freely in a single Table call.
type Group ¶
type Group []*Field
Group is an ordered set of fields inserted into a table as a unit. Use it to factor recurring column sets out of a schema.
func SoftDelete ¶
func SoftDelete() Group
SoftDelete adds a nullable deleted_at column, and nothing else. Nothing on the request path reads the column: the name is not load-bearing anywhere below this line, and declaring the group changes no query.
What it does do is oblige the table to have a BeforeQuery hook. A table that declares a soft delete and filters nothing returns deleted rows from every list endpoint, so [rest.Resource] refuses to mount one whose reads no hook constrains (ADR-0030). The refusal is at startup and it checks only that a hook exists — writing the predicate is still the caller's, exactly as below.
Filtering the deleted rows out is a BeforeQuery registration, which is the seam that reaches generated REST handlers as well as queries written by hand (ADR-0008):
sqlb.On[Post]().BeforeQuery(func(_ context.Context, q *sqlb.Builder[Post]) error {
q.Where(sqlb.F("deleted_at").IsNull())
return nil
})
Serving DELETE as an update to the column is the caller's too, and BeforeDelete cannot do it — that hook receives a *Delete and can abort or amend the statement, not turn it into an UPDATE. A table that means deletes to be soft should leave OpDelete out of its Expose and route the endpoint itself.
func Timestamps ¶
func Timestamps() Group
Timestamps is the created_at / updated_at pair, both defaulting to now().
type Index ¶
type Index struct {
Name string
Columns []string
Unique bool
Method string // "btree", "gin", ...; empty means the dialect default
Where string // optional partial-index predicate
}
Index is a secondary index.
type IndexManifest ¶
type IndexManifest struct {
Name string `json:"name"`
Columns []string `json:"columns"`
Unique bool `json:"unique,omitempty"`
Method string `json:"method,omitempty"`
}
IndexManifest describes a secondary index.
type InverseManifest ¶
type InverseManifest struct {
Name string `json:"name"`
Table string `json:"table"`
Column string `json:"column"`
// Order is the column an expansion sorts the collected rows by, with a
// leading "-" for descending. Empty means the primary key.
Order string `json:"order,omitempty"`
// Limit is how many rows one expansion returns at most, with the default
// already resolved: a client reading this is never left to guess the cap.
// Past it the response reports has_more and the caller pages the collected
// table's own endpoint by Column.
Limit int `json:"limit,omitempty"`
// Expandable reports whether ?expand on this table may ask for it. A
// relation that is named but not expandable is still described here,
// because the relationship exists whether or not this endpoint serves it.
Expandable bool `json:"expandable"`
}
InverseManifest describes one reverse relation from the target's side.
type InverseOption ¶
type InverseOption func(*Reference)
InverseOption adjusts an expanded collection.
func ExpandLimit ¶
func ExpandLimit(n int) InverseOption
ExpandLimit caps how many children an expansion returns; the default is 50. Past the cap the response reports has_more and the caller follows the child's own endpoint, filtered by this foreign key — which is why that column wants to be Filterable, and why Lint says so when it is not.
func ExpandOrder ¶
func ExpandOrder(column string) InverseOption
ExpandOrder orders an expanded collection by a column of the referencing table, with a leading "-" for descending — the spelling ?sort already uses. The primary key is appended as a tiebreaker, because under a cap a non-total order decides which children the caller never sees.
type InverseRelation ¶
type InverseRelation struct {
Name string // the name ?expand uses on the target
Table *TableDef // the table whose rows are collected
Column string // that table's foreign key column
Order string // ordering column, with a leading "-" for descending
Limit int // cap as declared; zero means DefaultExpandLimit
Expandable bool // reachable through ?expand on the target
}
InverseRelation is a reverse relation seen from the target's side: the rows of another table that point at this one, and the name this table knows them by.
It is derived rather than declared here — the declaration lives on the referencing column, which is the side that already owns the constraint. What the target gains is a field on its generated struct and, if the reference exposed it, a name in its ?expand vocabulary. ADR-0022.
func (InverseRelation) Cap ¶
func (i InverseRelation) Cap() int
Cap is how many rows one expansion returns at most, with the default resolved. Anything published — the manifest, a generated tag — uses this rather than Limit, so a caller is never left to guess the number.
type Manifest ¶
type Manifest struct {
Version string `json:"version"`
Module string `json:"module,omitempty"`
Tables []TableManifest `json:"tables"`
Operators []OperatorDoc `json:"filterOperators"`
Params []ParamDoc `json:"reservedParams"`
}
Manifest is a machine-readable description of a schema: every table, every column, and — the part that matters most — exactly what a client may filter, sort, search and expand on each exposed resource.
It reports capabilities that work, not capabilities that are declared. The two coincide today; where they ever diverge again, this file is what has to keep telling the truth.
It exists because reading a Go DSL to answer "what can I query here?" is a poor interface for a program. The manifest answers it directly, in one file, with worked example requests. Emit it next to the generated code and point tooling at it.
type OperatorDoc ¶
type OperatorDoc struct {
Name string `json:"name"`
Form string `json:"form"`
Applies string `json:"applies"`
}
OperatorDoc documents one filter operator.
type REST ¶
type REST struct {
// Path is the collection path, e.g. "/users". Defaults to "/"+table name.
Path string
// Ops is the set of exposed operations.
Ops Op
// DefaultPageSize applies when the request omits a page size. Zero means
// the package default.
DefaultPageSize int
// MaxPageSize caps the page size a client may request. Zero means the
// package default. This is a hard ceiling, not a hint.
MaxPageSize int
// MaxFilters caps how many filter predicates one request may carry, which
// bounds the cost of a single query. Zero means the package default.
MaxFilters int
// Tag groups the resource's operations in the OpenAPI document. Defaults
// to the table name.
Tag string
}
REST describes how a table is exposed over HTTP.
type RESTManifest ¶
type RESTManifest struct {
Path string `json:"path"`
Operations []string `json:"operations"`
DefaultPageSize int `json:"defaultPageSize"`
MaxPageSize int `json:"maxPageSize"`
MaxFilters int `json:"maxFilters,omitempty"`
Filterable []string `json:"filterable"`
Sortable []string `json:"sortable"`
Searchable []string `json:"searchable"`
// Expandable names the relations ?expand may pull in. Each is the relation
// name, not the foreign key column: ?expand=list, not ?expand=list_id.
//
// Only internal references appear. An ExternalRef crosses a module
// boundary, which is exactly the join this schema will not perform.
Expandable []string `json:"expandable,omitempty"`
Examples []string `json:"examples,omitempty"`
}
RESTManifest is the queryable surface of an exposed table: the single most useful thing in the document.
type RefManifest ¶
type RefManifest struct {
Relation string `json:"relation"`
Table string `json:"table,omitempty"`
Column string `json:"column,omitempty"`
OnDelete string `json:"onDelete,omitempty"`
External bool `json:"external,omitempty"`
Target string `json:"target,omitempty"`
Enforced bool `json:"enforced"`
}
RefManifest describes a relationship. External references carry a target string and no enforced constraint, so a reader can see the relationship even though the database does not.
type Reference ¶
type Reference struct {
Name string // relation name, e.g. "org" for column "org_id"
Table *TableDef
Column string // referenced column; defaults to the target primary key
OnDelete Action
OnUpdate Action
// External marks a reference across a module boundary. No FOREIGN KEY is
// emitted for one, so the modules stay independently deployable and
// independently migratable. Referential integrity becomes the
// application's responsibility, which is the trade a module architecture
// is already making everywhere else.
External bool
// Target names what is referenced, for documentation and for the
// manifest: "tenants.id", or "platform/users.users.id". It is free text,
// deliberately — resolving it would require the dependency this is
// designed to avoid.
Target string
// Inverse is the name the target knows this relation by, and declaring it
// is what makes the reverse relation exist at all.
//
// It cannot be derived. Two references from posts to authors — the writer
// and the reviewer — would both derive to "posts" on the far side, and an
// author's posts are not the posts an author reviewed. The distinction
// exists only in the head of whoever wrote the schema, so the schema is
// where it has to be written. ADR-0022.
Inverse string
// InverseExpandable exposes the reverse relation through ?expand on the
// target's endpoint. It is a separate decision from Expandable, about a
// different endpoint, and neither implies the other — ADR-0006.
InverseExpandable bool
// InverseOrder is the column an expanded collection is ordered by, with a
// leading "-" for descending. It names a column of *this* table, since
// these are the rows being collected. Empty means the primary key.
InverseOrder string
// InverseLimit caps an expanded collection. Zero takes sqlb's default.
InverseLimit int
}
Reference describes a relationship to another table.
A reference is either internal — a real foreign key to a table in the same registry — or external, which is a column holding another module's identifier with no database-level constraint behind it.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds a set of table declarations.
A registry is also the unit of module isolation. Independent modules — fx modules, or any other arrangement where one package must not import another — each declare into their own registry, so two modules may both own a table called "events" without colliding.
func DefaultRegistry ¶
func DefaultRegistry() *Registry
DefaultRegistry returns the registry that Table populates.
func NewModule ¶
NewModule returns a registry whose tables are all prefixed with the module name, so that table ownership is visible in the database and cannot be forgotten:
var Billing = schema.NewModule("billing")
var Invoice = Billing.Table("invoices", …) // → billing_invoices
The prefix is applied by the registry rather than written into each declaration, which is the point: a convention that has to be repeated at every call site is a convention that drifts.
Declarations still use the local name, so a table moving between modules changes one line.
func NewRegistry ¶
func NewRegistry() *Registry
NewRegistry returns an empty registry. Most schemas use the default one via the package-level functions.
func (*Registry) Add ¶
Add registers a table. A duplicate name panics: two tables with the same name is an authoring error that would otherwise surface as confusing DDL.
func (*Registry) BuildManifest ¶
BuildManifest describes every table in the registry.
func (*Registry) Inverses ¶
func (r *Registry) Inverses(t *TableDef) []InverseRelation
Inverses returns the reverse relations pointing at t, in a deterministic order: by referencing table, then by declaration order within it.
func (*Registry) Lint ¶
func (r *Registry) Lint() Diagnostics
Lint checks the registry.
Example ¶
Lint reports schema problems that compile fine but produce a bad database or a bad API — an unindexed filterable column, a table exposed without a primary key. It is worth running from a test, so the schema is checked in CI.
package main
import (
"fmt"
"github.com/jryannel/sqlb/schema"
)
func main() {
reg := schema.NewRegistry()
reg.Table("events",
schema.UUIDv7("id").PrimaryKey(),
// Filterable, but nothing indexes it: every filtered request is a
// sequential scan.
schema.Text("kind").Filterable(),
).
Expose(schema.REST{Ops: schema.OpList})
for _, d := range reg.Lint() {
fmt.Println(d)
}
}
Output: [warn] unindexed-filter: events.kind: column is filterable but is not the leading column of any index, so filtering on it scans the table fix: add .Index("kind") to the table, or drop .Filterable() from the column [info] list-without-sort: events: list endpoint has no sortable column, so every client gets the same primary-key order and none can ask for another fix: mark at least one column .Sortable(), conventionally created_at [info] no-max-page-size: events: no MaxPageSize, so the package default applies as the hard ceiling fix: set MaxPageSize on the REST exposure to a value this table can serve
func (*Registry) Module ¶
Module returns the module name, or "" for a registry that is not a module.
func (*Registry) Table ¶
Table declares a table in a specific registry. Use it to keep a schema isolated from the default one, which is mainly what tests want.
type TableDef ¶
type TableDef struct {
// contains filtered or unexported fields
}
TableDef is a table declaration. Build one with Table, which also registers it in the default registry.
func Table ¶
Table declares a table and registers it in the default registry. This is the form a schema file uses.
Example ¶
A schema is written as ordinary Go values, which is what lets one declaration be the source of truth for migrations, models, REST handlers and OpenAPI.
Capabilities are opt-in per column: a column that does not declare one cannot be reached through it. That is the difference between this and exposing the database — the failure is a 400 naming the allowed columns, not a leak.
package main
import (
"fmt"
"github.com/jryannel/sqlb/schema"
)
func main() {
// A registry of its own keeps this example out of the default one. A schema
// file would call schema.Table, which registers into the default registry.
reg := schema.NewRegistry()
posts := reg.Table("posts",
schema.UUIDv7("id").PrimaryKey(),
schema.Text("title").Searchable().Sortable(),
schema.Enum("status", "draft", "review", "published").
Default(schema.Value("draft")).
Filterable().
Sortable(),
// Readable by Go code, but never serialised into a response — and not
// filterable either, since a filterable secret can be recovered by
// probing it one value at a time.
schema.Text("password_hash").Hidden(),
schema.Timestamps(),
).
Index("status").
Expose(schema.REST{Ops: schema.CRUD | schema.OpList, MaxPageSize: 100})
for _, f := range posts.Fields() {
d := f.Desc()
fmt.Printf("%-13s %-11s %s\n", d.Name, d.Type, d.Capabilities())
}
fmt.Println("path:", posts.Rest().Path)
fmt.Println("ops: ", posts.Rest().Ops)
}
Output: id uuid pk,default,filter,readonly title text filter,sort,search status enum default,filter,sort password_hash text hidden created_at timestamptz default,sort,readonly updated_at timestamptz default,sort,readonly path: /posts ops: create|read|update|delete|list
func (*TableDef) AddIndex ¶
AddIndex adds a fully specified index, for cases the shorthands do not cover such as GIN indexes or partial indexes.
func (*TableDef) Expose ¶
Expose publishes the table over HTTP. Without this call the table is reachable from Go code but has no REST surface at all.
func (*TableDef) Name ¶
Name is the table's storage name, including any module prefix. This is the name that reaches SQL.
func (*TableDef) PrimaryKey ¶
PrimaryKey returns the primary key column, or nil if the table declares none.
func (*TableDef) PrimaryKeyName ¶
PrimaryKeyName returns the pinned primary key constraint name, if any.
func (*TableDef) PrimaryKeyNamed ¶
PrimaryKeyNamed pins the primary key constraint name, for adopting an existing database whose constraint is not called <table>_pkey.
func (*TableDef) RenamedFrom ¶
RenamedFrom declares that this table used to be called local, so that a generated migration renames it rather than dropping it and creating a new one. See Field.RenamedFrom for why a rename is declared rather than inferred, and for how long the hint is needed.
The old name is local, without the module prefix, and is qualified with the same prefix as the current one — so this renames a table within a module, not between modules. Moving a table between modules changes which registry declares it, and is a drop and a create until something asks for otherwise.
func (*TableDef) RenamedFromName ¶
RenamedFromName returns the table's previous storage name, or "".
func (*TableDef) ReplaceCheckExpr ¶
ReplaceCheckExpr rewrites the expression of an already-declared check, and reports whether there was one by that name.
This exists for one caller and it is worth naming, because a setter on a declaration is otherwise a smell. Postgres does not store a CHECK expression as it was written: it stores a parse tree, and hands back a normalised spelling — fully parenthesised, with explicit casts on literals. So a registry read back by introspect and a registry declared here disagree about every check they have in common, and a diff between them proposes dropping and re-adding each one forever (issue #24).
The only reliable way to compare them is to put the declared expression through the same normalisation, which means asking a Postgres. That is what shadow.NormalizeChecks does, and this is how it writes the answer back. Comparing the two spellings textually instead was rejected: stripping parentheses can make two genuinely different expressions look equal, and a diff that reports "unchanged" for a changed constraint is silently wrong, where churn is merely loud.
func (*TableDef) UniqueIndex ¶
UniqueIndex adds a composite unique index.
type TableManifest ¶
type TableManifest struct {
Name string `json:"name"`
Module string `json:"module,omitempty"`
LocalName string `json:"localName,omitempty"`
Comment string `json:"comment,omitempty"`
PrimaryKey string `json:"primaryKey,omitempty"`
Columns []ColumnManifest `json:"columns"`
Indexes []IndexManifest `json:"indexes,omitempty"`
// CollectedBy describes the reverse relations pointing at this table: the
// rows of another table that this one collects, and the name it knows them
// by. Declared on the referencing side, which is where the column and the
// constraint already live, so reading this table alone would otherwise not
// show that its endpoint has them. ADR-0022.
CollectedBy []InverseManifest `json:"collectedBy,omitempty"`
REST *RESTManifest `json:"rest,omitempty"`
}
TableManifest describes one table.
type Type ¶
type Type string
Type is the logical column type. Dialects map these onto concrete SQL types.
const ( TypeText Type = "text" TypeVarchar Type = "varchar" TypeInt Type = "int" TypeBigInt Type = "bigint" TypeFloat Type = "float" TypeNumeric Type = "numeric" TypeBool Type = "bool" TypeUUID Type = "uuid" TypeTimestamp Type = "timestamptz" TypeDate Type = "date" TypeTime Type = "time" TypeJSON Type = "jsonb" TypeBytes Type = "bytea" TypeEnum Type = "enum" )