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 SetWireCase(c WireCase)
- func Validate() error
- func WriteManifest(path string) error
- type Action
- type ActionManifest
- type ActionProperty
- type Auto
- type Check
- type ColumnManifest
- type ComputedExpr
- type Default
- type Diagnostic
- type Diagnostics
- type Error
- type Exclusion
- type Field
- func BigInt(name string) *Field
- func BigSerial(name string) *Field
- func Body(specs ...FieldSpec) []*Field
- func Bool(name string) *Field
- func Bytes(name string) *Field
- func Computed(name string, t Type, e ComputedExpr) *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, precision ...int) *Field
- func Real(name string) *Field
- func Ref(name string, target *TableDef) *Field
- func Serial(name string) *Field
- func SmallInt(name string) *Field
- func SmallSerial(name string) *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 Vector(name string, dim int) *Field
- func (f *Field) Array() *Field
- func (f *Field) CheckNamed(name string) *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) Enforced() *Field
- func (f *Field) Expandable() *Field
- func (f *Field) Filterable() *Field
- func (f *Field) Hidden() *Field
- func (f *Field) Identity() *Field
- func (f *Field) IdentityAlways() *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) Needs(keys ...string) *Field
- func (f *Field) Nullable() *Field
- func (f *Field) OfType(t Type) *Field
- func (f *Field) OnDelete(a RefAction) *Field
- func (f *Field) OnUpdate(a RefAction) *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) Serial() *Field
- func (f *Field) Sortable(nulls ...Nulls) *Field
- func (f *Field) Unique() *Field
- type FieldDesc
- type FieldSpec
- type Group
- type Index
- type IndexManifest
- type IndexOrder
- type InverseManifest
- type InverseOption
- type InverseRelation
- type Manifest
- type Nulls
- type Op
- type OperatorDoc
- type ParamDoc
- type REST
- type RESTManifest
- type RefAction
- 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
- func (r *Registry) Wire() WireCase
- func (r *Registry) WireCase(c WireCase) *Registry
- type Severity
- type TableDef
- func (t *TableDef) Action(a Action) *TableDef
- func (t *TableDef) Actions() []Action
- func (t *TableDef) AddExclude(e Exclusion) *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) CompositeKey() []string
- func (t *TableDef) Describe(s string) *TableDef
- func (t *TableDef) Exclusions() []Exclusion
- 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) IndexNamed(name string, 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) PrimaryKeyColumns(columns ...string) *TableDef
- 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) ReplaceExclusion(name, using, elements, where string) bool
- func (t *TableDef) ReplaceIndexWhere(name, expr string) bool
- func (t *TableDef) Rest() *REST
- func (t *TableDef) StoredField(name string) *Field
- func (t *TableDef) StoredFields() []*Field
- func (t *TableDef) Unique(columns ...string) *TableDef
- func (t *TableDef) UniqueIndex(columns ...string) *TableDef
- func (t *TableDef) UniqueIndexNamed(name string, columns ...string) *TableDef
- func (t *TableDef) UniqueNamed(name string, columns ...string) *TableDef
- func (t *TableDef) Uniques() []Unique
- type TableManifest
- type Type
- type Unique
- type WireCase
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.
const Reads = OpRead | OpList
Reads is the read-only exposure: generated reads, hand-written writes.
The peer of CRUD, and the shape an application adopting sqlb into an existing REST surface reaches for — it already has its writes, and the reasons they stay hand-written are domain reasons that do not expire. See [rest.Reads] for the worked version of why (issue #101).
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 SetWireCase ¶ added in v0.8.0
func SetWireCase(c WireCase)
SetWireCase sets the wire case on the default registry, for a schema written with the package-level Table.
Call it before declaring tables — or after, since it is read when a surface is generated rather than when a table is declared. Either works; putting it at the top of the file is how a reader finds it.
func WriteManifest ¶
WriteManifest writes the default registry's manifest to path, creating parent directories as needed.
Types ¶
type Action ¶
type Action struct {
// Name is the verb. It appears in the URL, in the operation ID, and in the
// generated identifiers — "complete" gives POST /tasks/{id}/complete and an
// Actions.CompleteTask field.
Name string
// Path is the sub-path under the collection. It defaults to
// "/{id}/"+Name, which is the item form.
//
// A path that does not contain "{id}" is a *collection* action: there is no
// row to fetch, so the verb receives only the body and answers 204. Note
// what that costs — with no generated fetch there is no BeforeQuery for a
// declared scope to hang off, so a collection action inherits none of
// ADR-0030's closure and is in the same position as a sqlb.Query in
// application code.
Path string
// Body is the request body, declared in the field vocabulary. Build it with
// [Body].
//
// It is declared rather than reflected from an application type for two
// reasons. The value of an action is that the verb reaches the TypeScript,
// Dart, CLI and OpenAPI emitters, and those read this declaration — a body
// sqlb cannot see produces a client method typed `unknown`, which is the
// drift this feature exists to remove. And reflecting an application struct
// would invert the dependency, since models are generated *from* the schema.
//
// Leaving it empty is normal: most verbs carry nothing. The generated input
// type is still emitted, empty, so that adding the first property later
// does not change the shape of the func the application wrote.
Body []*Field
// Writes names the columns the envelope persists after the verb returns,
// and it is enforced rather than documented: exactly these columns are
// written, from the row the verb mutated.
//
// A verb that has to touch anything else has the transaction and can issue
// the statement itself. What this buys is that the blast radius of a route
// is something the OpenAPI document and `sqlb impact` can state — and that
// the envelope knows to take the row lock, since a declared write set is
// exactly the case where a read-modify-write can be lost.
//
// It must be empty on a collection action, which has no row.
Writes []string
// Summary is the one-line description in the OpenAPI document.
//
// Left empty it is filled in downstream, as "Complete a task", rather than
// here: writing that sentence needs the singular of the table name, and a
// singulariser is a thing codegen has and this package deliberately does
// not — a wrong guess in a Go type name is cosmetic, and one baked into a
// declaration is not.
Summary string
// Description documents the operation at length.
Description string
}
Action is a domain verb exposed on a table.
Declare one with TableDef.Action:
Task.Action(schema.Action{
Name: "complete",
Body: schema.Body(schema.Text("note").Nullable()),
Writes: []string{"status", "closed_at"},
})
which serves POST /tasks/{id}/complete and asks the application, at registration, for a func(context.Context, *Task, CompleteTaskInput) error.
func (Action) FullPath ¶ added in v0.5.0
FullPath is the action's route: the resource path with the action's own path appended.
func (Action) IsCollection ¶ added in v0.5.0
IsCollection reports whether the action addresses the collection rather than one row — which is to say, whether its path names no id.
type ActionManifest ¶ added in v0.5.0
type ActionManifest struct {
Name string `json:"name"`
// Path is the full route, resource path included.
Path string `json:"path"`
Method string `json:"method"`
// Summary is the one-line description, as it appears in the OpenAPI
// document.
Summary string `json:"summary,omitempty"`
// Body names the request body's properties. An action that declares none
// carries no body at all, which is not the same as one whose properties
// happen to be optional.
Body []ActionProperty `json:"body,omitempty"`
// Writes names the columns the envelope persists after the verb returns.
// It is what makes the blast radius of a route readable rather than
// something to be inferred from a handler.
Writes []string `json:"writes,omitempty"`
}
ActionManifest documents one declared verb.
type ActionProperty ¶ added in v0.5.0
type ActionProperty struct {
Name string `json:"name"`
Type string `json:"type"`
Nullable bool `json:"nullable,omitempty"`
Enum []string `json:"enum,omitempty"`
}
ActionProperty is one property of an action's request body.
type Auto ¶ added in v0.9.0
type Auto string
Auto is how the database supplies a column's value when an INSERT does not name it: a sequence, or an identity.
It is a property of the column rather than a Type, because it is not one: `bigserial` is a bigint, an identity column is the integer type it is declared as, and reading either back gives the integer. Making it a type would have given int64 two spellings, split the filter and sort machinery, and made a column that stops being auto-incrementing a type change rather than what it is.
A Default is the other way a database supplies a value, and everything downstream of the DDL treats the two the same — see FieldDesc.DatabaseSupplied.
const ( // NotAuto is an ordinary column: whatever is inserted is what is stored. NotAuto Auto = "" // AutoSerial is the pre-identity spelling — `serial`, `bigserial` — which // Postgres expands into a plain integer column, a sequence it owns, and a // nextval default. // // It is supported beside AutoIdentity because it is what existing // databases have. An adoption that could only declare the modern spelling // would propose rewriting the column on its first diff, which is the // permanently-red gate the whole import path exists to avoid. AutoSerial Auto = "serial" // AutoIdentity is GENERATED BY DEFAULT AS IDENTITY: the sequence fills the // column in, and an INSERT may still name it. This is what Postgres // recommends in place of a serial, and it has no separate object to name. AutoIdentity Auto = "identity" // AutoIdentityAlways is GENERATED ALWAYS AS IDENTITY: the sequence is the // only writer, and an INSERT naming the column is an error rather than an // override. Declaring it marks the column ReadOnly, since there is nothing // a caller could put there. AutoIdentityAlways Auto = "identity always" )
func (Auto) Identity ¶ added in v0.9.0
Identity reports whether the value is one of the two identity spellings, as opposed to a serial or nothing. The two differ only in whether an INSERT may name the column, and every DDL site that cares about the distinction between "identity" and "sequence" would otherwise spell out both constants.
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"`
// SortNulls is where NULLs sit when this column is sorted on, present only
// when the column departs from Postgres's direction-following default. It
// is beside Capabilities rather than in it because it is not a capability:
// a request cannot ask for it and cannot decline it, and a reader deciding
// what an endpoint returns wants to know that `?sort=-published_at` puts
// the NULLs at the bottom rather than the top (#88).
SortNulls string `json:"sortNulls,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"`
// Computed reports that the column is an expression rather than storage.
// A consumer reading the manifest to decide what a schema edit costs needs
// the distinction: this column appears in every response and in no
// migration.
Computed bool `json:"computed,omitempty"`
// Needs names the per-request binds the expression takes. It is the
// obligation half — a resource exposing this column does not mount until a
// hook supplies each of them — and it is listed for the same reason Scoped
// is: a reader auditing the boundary wants to see what the server had to
// have done.
Needs []string `json:"needs,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 ComputedExpr ¶ added in v0.5.0
type ComputedExpr struct {
// contains filtered or unexported fields
}
ComputedExpr is how a computed column is produced. Build one with FromSQL.
It is a type rather than a bare string so that the declaration reads as a choice. FromSQL is the only one, and now the only one there will be: ADR-0041 staged a Go-side FromGo as a separate decision and then cut it, on the trigger that record set for itself — the first two applications expressed every derived value in SQL (#17). The type stays because a constructor is still the right shape for the argument, and because reopening it is additive.
func FromSQL ¶ added in v0.5.0
func FromSQL(sql string) ComputedExpr
FromSQL computes a column from a SQL expression over the row's own columns.
The expression is raw SQL and nothing parses it: `sqlb generate` refuses the declarations that are wrong on their face — a volatile Sortable one, a bind count that disagrees with Needs, a Searchable one whose type is not text — but a typo inside the SQL reaches Postgres. That is the cost ADR-0024's bar admits here because there is finally a consumer for the annotation, and [sqlb.Builder.Explain] against a real database is what catches it early.
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 Exclusion ¶ added in v0.8.0
type Exclusion struct {
Name string
// Using is the index method. Empty means Postgres's default, which is
// btree — and which almost no exclusion wants, since the operators that
// make one useful (&&, and = over a range) live in gist.
Using string
// Elements is the body of the constraint: the comma-separated
// `<column-or-expression> WITH <operator>` list, without the surrounding
// parentheses.
Elements string
// Where is the optional predicate that narrows which rows the constraint
// applies to, without the surrounding parentheses.
Where string
}
Exclusion is an EXCLUDE constraint: no two rows may hold values that are pairwise related by the given operators.
It is the one constraint with no near miss. A composite UNIQUE has a unique index; a composite primary key has a surrogate; smallint has integer. Dropping an exclusion has no equivalent at all — the alternatives are enforcing it in application code, where two concurrent requests interleave between the check and the insert, or leaving it as unmanaged DDL and holding a permanent known-difference exception in the drift gate. It is the only construct in either adoption corpus where not declaring it loses a *correctness* property rather than a performance or ergonomic one (issue #121).
AddExclude(schema.Exclusion{
Name: "bookings_no_double_booking",
Using: "gist",
Elements: "coach_id WITH =, tstzrange(starts_at, ends_at) WITH &&",
Where: "status = 'confirmed'",
})
Elements and Where are hand-written SQL, the way TableDef.Check takes hand-written SQL and for the same reason: Postgres stores a parse tree and renders it back in its own spelling, so any structured form here would have to reproduce that spelling exactly or every diff would propose replacing a constraint that had not changed. Both are put through the same probe a check goes through before a diff (shadow.Normalize), which asks Postgres rather than guessing.
An exclusion over a scalar with `=` needs the btree_gist extension, which no generated DDL creates — introspect reports the extensions a database has so the list is knowable before the first bootstrap rather than after 228 errors (issue #115).
func ParseExclusion ¶ added in v0.8.0
ParseExclusion splits what pg_get_constraintdef returns for an EXCLUDE constraint into its parts.
The grammar it accepts is the one Postgres emits, which is fixed:
EXCLUDE USING gist (coach_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (...)
Parsing rather than storing the whole string is what lets the parts be read and edited in a declaration. It is safe to parse because the input is always Postgres's own output — both on the introspect path and on the normalise path, which probes the declared spelling by adding the real constraint and reading it back. A definition this cannot parse returns false rather than a half-filled Exclusion, and the caller reports it as unrepresentable, which is the same contract every other construct here has.
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 BigSerial ¶ added in v0.9.0
BigSerial is the 8-byte auto-incrementing integer, Go int64. See Serial.
func Body ¶ added in v0.5.0
Body builds an action's request body from field declarations.
Body: schema.Body(
schema.Text("note").Nullable(),
schema.Timestamp("completed_at"),
)
The vocabulary is the column vocabulary, deliberately: it is the one the emitters already know how to turn into a TypeScript type, a Dart class, a CLI flag and an OpenAPI schema. Only what describes a *value* applies here — name, type, nullability, enum values, default and comment. The capabilities that describe a column's place in a table (Filterable, PrimaryKey, Ref, Computed, and the rest) have no meaning in a request body and are refused by Validate rather than ignored.
func Computed ¶ added in v0.5.0
func Computed(name string, t Type, e ComputedExpr) *Field
Computed declares a derived column: an expression the query renders in place of a column name, rather than a value the table stores.
schema.Computed("is_overdue", schema.TypeBool,
schema.FromSQL("due_date < current_date AND open_tasks > 0")).
Filterable().Sortable()
It is a column everywhere it matters — it lands in the row type, the JSON, the TypeScript and Dart types and the CLI column set, and Hidden, Filterable and Sortable gate it exactly as they gate a stored one. What it is not is storage: it emits no DDL in either direction, Diff does not see it, no insert names it and no update assigns it. ADR-0041 has the shape and the reasons.
What each form may claim ¶
The expression is rendered as written, so a name in it resolves the way Postgres resolves it. In a statement that joins — one with `?expand` in it — a bare column name shared with the joined table is ambiguous and Postgres says so; qualify it with the table's own name when that is a possibility.
A row-local expression may be Filterable and Sortable, since the compiler can put it in a WHERE and an ORDER BY as readily as in the projection:
schema.Computed("is_overdue", schema.TypeBool,
schema.FromSQL("due_date < current_date AND open_tasks > 0")).Filterable()
A correlated subquery is projection-only unless Filterable is written out, because a subquery in a WHERE runs once per row — the declaration is the acknowledgement that this was considered:
schema.Computed("total_tasks", schema.TypeInt,
schema.FromSQL("(SELECT count(*) FROM tasks t WHERE t.project_id = projects.id)"))
A parameterised expression takes its value from the request. Each `?` binds the key named at the matching position of Needs, and `??` is a literal question mark:
schema.Computed("is_starred", schema.TypeBool,
schema.FromSQL("EXISTS (SELECT 1 FROM stars s "+
"WHERE s.project_id = projects.id AND s.member_id = ?)")).
Needs("viewer").Filterable()
Needs writes no value, exactly as Field.Scoped writes no predicate. What it does is oblige a hook: rest.Resource refuses to mount the resource until a BeforeQuery hook calls Bind for every key it names. Without that check an unbound expression renders `member_id = NULL`, returns false for every row forever, and looks precisely like a feature that works (ADR-0030).
What it will not accept ¶
Sortable over a volatile expression — one reading now() or random() — because a keyset pages on the sort column and an unstable one lets page 1 and page 50 disagree about a row. A default, a primary key, a unique constraint, a reference or an index, all of which are statements about storage. Validate reports each of them.
Searchable used to be on this list and is not any more. The reason given was that "?search fans out over text columns with ILIKE and an expression has no reading there" — which is a claim about *type*, and the rule that Searchable requires a text column already makes it, for stored and computed columns alike. What the blanket refusal actually cost was the only way to search across a relation, since a chat named by its participants has no name column of its own to fan out over (#93). The cost objection — a correlated subquery per candidate row — is answered where cost belongs: a resource searches an expression only if it selected it (#92).
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.
relation, not column ¶
The first argument is the *relation* — the column is named after it, with "_id" appended. So ExternalRef("org", …) declares a column called org_id, and ExternalRef("org_id", …) declares one called org_id_id. Use Field.Named if the column is spelled some other way.
func Numeric ¶
Numeric is an exact decimal. Called with no arguments it is unbounded — `numeric` — which is what a rate, a rating or anything else that wants arbitrary precision should be.
Given a precision and a scale it renders `numeric(p, s)`, which is a *different type* from the unbounded one and the only faithful way to declare a column an existing database already has that way. A schema that could not say so had two bad options: declare it unbounded and hold a permanent `add column` waiver in the drift gate, or leave the column out and have the model, the REST surface and the generated clients silently lack a field the hand-written API carries (issue #81).
schema.Numeric("rating") // numeric
schema.Numeric("contracted_hours", 5, 2) // numeric(5, 2)
A precision alone is legal Postgres — `numeric(5)` means `numeric(5, 0)` — and is accepted here as the one-argument form. More than two arguments is a declaration error, reported when the field is resolved rather than silently truncated.
func Real ¶ added in v0.8.0
Real is the 4-byte float, Go float32.
The peer of SmallInt in the float family, and filed for the same reason: a model-confidence score or any other value never compared for equality is what `real` is for, and widening it to `double precision` to suit the DSL is a schema change the adopter cannot justify on its own merits (issue #120).
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 Serial ¶ added in v0.9.0
Serial, BigSerial and SmallSerial are the auto-incrementing integer columns: `serial`, `bigserial` and `smallserial`, Go int32, int64 and int16.
schema.BigSerial("id").PrimaryKey()
They are siblings of Int, BigInt and SmallInt rather than a modifier on them, for the same reason SmallInt is a sibling of Int: the width is part of what is being declared and reads better in front. The Go type, the filter grammar and the sort machinery are the plain integer's — a serial *is* a bigint, which is why Auto is a property of the column and not a Type.
A serial is what an existing database has, and being unable to declare one left no auto-incrementing integer key expressible at all: an adoption's only options were to widen the key to a UUID — an API change and 16 bytes a row on the highest-volume tables in a system — or to leave the table, and every module holding it, outside the gate (issue #132).
Field.Identity is the modern spelling of the same idea and is what Postgres now recommends. Prefer it for a new column; declare a serial when the database already has one, because changing between them is a migration rather than a rename.
The column is not nullable and takes no Field.Default: the sequence is the default, and Postgres refuses both combinations.
func SmallInt ¶ added in v0.8.0
SmallInt is the 2-byte integer, Go int16.
It is a sibling of Int rather than a width argument to it, which is how BigInt is already spelled. A schema that could not say `smallint` had to widen the column to `integer` to be declarable at all — a schema change whose only justification is the declaration language, which is exactly the change an adopter cannot defend (issue #114).
It filters, sorts and orders exactly as Int does; there are no capability semantics of its own.
func SmallSerial ¶ added in v0.9.0
SmallSerial is the 2-byte auto-incrementing integer, Go int16. See Serial.
func UUIDv7 ¶
UUIDv7 is the conventional primary key column: a UUID defaulting to a generated, time-ordered v7 value.
func Vector ¶ added in v0.4.0
Vector is a pgvector embedding of dim components.
The dimension is an ordinary Go expression, so it can come from wherever the embedder's width comes from:
schema.Vector("embedding", ragcfg.Dim)
That is deliberately stricter than reading it from the environment at startup, which is what a project does when the schema cannot hold it: the dimension is fixed when the code is generated, so one binary can no longer serve a 768- and a 1,536-component embedder. What it buys is that the dimension is in the declaration, so `Diff` proposes a migration when it changes instead of a comment asking someone to remember (ADR-0026).
The column is Hidden and not optionally so. An embedding is twenty kilobytes of float that no client has a use for, and serialising one by accident is the kind of mistake that shows up as a bandwidth bill. Go callers reading through the query engine still get it.
A vector is storable and orderable and nothing else yet: there is no index kind, no metric declaration and no REST search operation, which ADR-0026 stages as a second decision to be taken when a corpus outgrows an exact scan. Until then a similarity search is an exact scan over the rows a filter already selected, which is the shape the module this was designed against actually runs.
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) CheckNamed ¶ added in v0.5.0
CheckNamed pins the name of the CHECK an enum column emits.
schema.Enum("plan", "free", "pro").CheckNamed("chk_org_plan")
It is a second constraint on the same column, so it has a name of its own rather than sharing ConstraintNamed with a unique constraint or a foreign key. Introspection sets it when the database's name is not the one this package would generate; declaring it by hand is for the same case, reached from the other direction.
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) Enforced ¶ added in v0.5.0
Enforced makes an external reference emit a real FOREIGN KEY.
schema.ExternalRef("org", "organizations.id").Enforced().Filterable()
It is for the case an incremental adoption always reaches: the database has a live, enforced foreign key, and the table it points at has not been declared yet. Neither existing spelling covers that — Ref needs the target's *TableDef, and a plain ExternalRef emits no constraint, so a schema-vs-database diff reports the live one as something to drop and, if sqlb owned the DDL, would propose actually dropping it (issue #55).
The target is still not resolved: it is a name, and the constraint is emitted against that name. Two forms are accepted — "organizations.id" names the table and the column, and a bare "organizations" means its "id". A module-qualified target ("platform/users.users.id") cannot be enforced, because a constraint has to name a table in this database, and neither can a schema-qualified one, which this spelling has no room for.
What this gives up ¶
Everything ADR-0015 bought by refusing the constraint. Two modules joined by an enforced reference can no longer be migrated or deployed independently, and neither can be moved to its own database without dropping it. That is the right trade when both tables are in one database and the constraint is already there — which is exactly the adoption case — and the wrong one across a module boundary you intend to keep.
Expansion is still refused. A real constraint says the row exists; it does not give this schema the target's columns, so `?expand` has nothing to build a join from.
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) Identity ¶ added in v0.9.0
Identity makes an integer column GENERATED BY DEFAULT AS IDENTITY: the database fills it in, and an INSERT may still name it.
schema.BigInt("id").PrimaryKey().Identity()
This is what Postgres recommends in place of Serial, and it is the cheaper of the two to own: an identity has no separate sequence object to name, own or drop, which is why Postgres introduced it. Declare a serial instead when the database already has one.
Only an integer column may carry it, it cannot be nullable, and it cannot also have a Field.Default — the sequence is the default. Each is refused when the registry is validated rather than discovered as rejected DDL.
func (*Field) IdentityAlways ¶ added in v0.9.0
IdentityAlways makes an integer column GENERATED ALWAYS AS IDENTITY: the sequence is the only writer, and an INSERT naming the column is an error rather than an override.
It marks the column ReadOnly, for the reason Computed does — nothing can write it, and saying so here is what keeps the generated create and update bodies correct without every write path having to know this exists.
The stricter of the two, and the right one when a key should never be chosen by a caller. Field.Identity is the one to reach for when a data import or a backfill has to supply ids.
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) Needs ¶ added in v0.5.0
Needs names the binds this column's expression takes, in the order its `?` placeholders appear. See Computed.
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.
func (*Field) Serial ¶ added in v0.9.0
Serial makes an integer column draw its value from a sequence Postgres creates and owns — the modifier form of Serial, BigSerial and SmallSerial, which is the same relationship UUIDv7 has to UUID.
Reach for the constructors when writing a schema by hand; this is what an import uses, where the type is decided before the column's auto-ness is read.
func (*Field) Sortable ¶
Sortable allows the column to appear in ?sort.
An optional Nulls fixes where NULLs sit whenever this column is sorted on, in either direction:
Timestamp("published_at").Sortable(schema.NullsLast)
Without it the placement is Postgres's default, which follows the direction — NULLS LAST ascending, NULLS FIRST descending. That default is right for a column whose NULLs are incidental and wrong for one whose NULLs mean something: a NULL `published_at` means "not published", which belongs at the bottom of the feed and not at the top of it, and `?sort=-published_at` puts it at the top (#88).
It is declared here rather than spelled per request because it is a property of what the column *means*, not of what a particular caller wants — which is also why the generated clients need no new syntax to get it right.
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, or a numeric's precision; 0 means unbounded
// Scale is a numeric's scale — the digits after the point. Meaningful only
// beside a Size, since numeric(s) is not a thing: a numeric declares a
// precision or nothing.
Scale int
// Dim is the number of components in a TypeVector column, and is part of
// the type rather than a constraint on it: Postgres will not store a
// 768-component value in a vector(1536). Zero for every other type.
//
// It is a Go expression at the call site, which is the whole answer to the
// substitution sentinel a migration file needs otherwise — the dimension
// wants to be a value and SQL text has nowhere to put one (ADR-0026).
Dim int
Comment string
Nullable bool
PrimaryKey bool
Unique bool
Default *Default
EnumValues []string
// Auto makes the database supply the column's value: a sequence, or an
// identity. Only an integer column may carry one, and never beside a
// Default — see [Auto], and [Serial] and [Field.Identity] for the two
// spellings.
Auto Auto
// 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)
// SortNulls is where NULLs sit whenever ?sort names this column, in either
// direction. Empty leaves Postgres's default, which is NULLS LAST for
// ascending and NULLS FIRST for descending — not one placement but two, so
// a column whose NULLs mean something cannot rely on it (#88).
//
// It is the same Nulls the index orders use, and deliberately so: a
// resource sorted `published_at DESC NULLS LAST` wants the index declared
// the same way, and one vocabulary makes the pair legible as a pair.
SortNulls Nulls
// 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
// CheckName pins the name of the CHECK an enum column emits, which is a
// second constraint on the same column and so cannot share the field above:
// a column may be unique *and* an enum, and one name cannot serve both.
//
// An enum is text plus a CHECK (ADR-0017), and the check's name is the one
// thing about it that introspection cannot recover from the expression. Left
// unpinned, a database whose check is called chk_org_plan is rebuilt with
// one called orgs_plan_check — so a diff against it proposes dropping and
// re-adding that constraint on every run, forever (issue #53).
CheckName 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
// Expr is the SQL a computed column renders as, in place of its name, and
// it is the one thing about a column that no struct tag can carry: a tag is
// a comma-separated list and SQL is not. Codegen writes it into a
// ComputedColumns method instead (ADR-0041).
//
// A column with an expression stores nothing. It emits no DDL in either
// direction, no insert names it and no update sets it — and it is a column
// everywhere else, so Hidden hides it, Filterable gates it, and it lands in
// the row type, the JSON, the client types and the CLI like any other.
Expr string
// Needs names the binds Expr's `?` placeholders take, in order. A computed
// column with none is row-local. One with a bind is answered per request,
// and the value arrives through Builder.Bind — which rest refuses to mount
// a resource without.
Needs []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) Computed ¶ added in v0.5.0
Computed reports whether the column is an expression rather than storage. The DDL emitters read it to skip the column, and Diff reads it to not see it at all.
func (*FieldDesc) DatabaseSupplied ¶ added in v0.9.0
DatabaseSupplied reports whether the database fills this column in when an INSERT does not name it — because it has a default, or because a sequence or an identity supplies it.
The distinction between those matters exactly once, when DDL is rendered. Everywhere after that — whether a create body may omit the column, whether the engine writes a zero or lets the database decide, whether the OpenAPI property is required — the question is only whether the caller has to say, and the answer is the same for all three. Asking it in one place is what let Serial arrive without a write path having to learn what a sequence is.
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.
A nullable bytea is the same argument and stays []byte: nil is already how a slice says NULL, and a pointer would add a second spelling for it.
jsonb used to be excluded alongside it, on the strength of the resemblance — json.RawMessage is a slice of bytes too. The resemblance is where it ends. []byte says NULL by being nil because that is what it *is*; json.RawMessage is a document type whose nullability the model otherwise never states, which left a nullable jsonb as the one column whose generated type did not say it could be NULL.
It was also, until sqlb took pgx as a dependency (ADR-0040), unreadable. database/sql's convertAssign resolves a scan destination by concrete type: it carries a `case *[]byte` that stores NULL as a nil slice, and json.RawMessage is a named type over []byte that matches neither that case nor any other, so a NULL fell out the bottom as "unsupported Scan, storing driver.Value type <nil>". pgx has no such gap and scans NULL into a bare json.RawMessage as nil, so on sqlb's own path this is now consistency rather than a repair — but it is consistency the generated struct keeps when it is read by anything else, database/sql included.
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.
func (*FieldDesc) Placeholders ¶ added in v0.5.0
Placeholders counts the binds a computed expression takes, treating `??` as the escaped literal that Raw does.
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](reg).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
// Opclasses names the operator class each column is indexed under, keyed by
// column name. An absent entry takes the type's default.
//
// For most indexes an operator class is a tuning decision. For some it is
// the whole meaning: pgvector's `hnsw` has *no* default class, because the
// class is what selects the distance function, so an index emitted without
// one is rejected outright —
//
// ERROR: data type vector has no default operator class for access method "hnsw"
//
// — and a schema that could not express it could not describe its own
// database (issue #53).
//
// AddIndex(schema.Index{
// Name: "idx_chunks_embedding",
// Columns: []string{"embedding"},
// Method: "hnsw",
// Opclasses: map[string]string{"embedding": "vector_cosine_ops"},
// With: map[string]string{"m": "16", "ef_construction": "64"},
// })
Opclasses map[string]string
// With is the index's storage parameters — `WITH (m = 16)`. Rendered in
// sorted key order, because a map has none and a migration that reorders
// its own DDL between runs is a diff nobody can read.
With map[string]string
// Orders names the sort order each column is indexed under, keyed by column
// name, in the same shape Opclasses uses and for the same reason: the DDL
// layer renders it without knowing anything about index position.
//
// An absent entry is ascending with Postgres's default null placement,
// which is what almost every index wants. It is here because for the
// indexes that do not, the ordering *is* the index — an index backing
// `ORDER BY position ASC NULLS FIRST, created_at DESC` is unusable in any
// other order — and a declaration that could not say so proposed dropping
// the live index and could not tell "missing" from "differently ordered"
// (issue #64).
//
// AddIndex(schema.Index{
// Name: "idx_tasks_project_position",
// Columns: []string{"project_id", "position", "created_at"},
// Orders: map[string]schema.IndexOrder{
// "position": {Nulls: schema.NullsFirst},
// "created_at": {Desc: true},
// },
// })
Orders map[string]IndexOrder
}
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 IndexOrder ¶ added in v0.6.0
IndexOrder is one column's sort order within an index.
Structured rather than written SQL, because a written suffix would have to reproduce Postgres's normalisation to compare equal — it omits ASC, and omits the null placement that follows from the direction — and that is the failure mode issue #63 is about. A zero IndexOrder means ascending with the default placement, so a map entry is only ever needed for a column that departs from it.
func (IndexOrder) Suffix ¶ added in v0.6.0
func (o IndexOrder) Suffix() string
Suffix renders the order as the DDL fragment that follows the column, empty when the order is the one Postgres assumes.
Normalised the way Postgres normalises: an explicit ASC is dropped, and so is a null placement that already follows from the direction. That is what makes two spellings of the same order compare equal, and it is why Suffix is also what the diff fingerprints — a declaration written `{Desc: true, Nulls: NullsFirst}` and one written `{Desc: true}` are the same index, and reading the second back from the catalog must not propose replacing the first.
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 Nulls ¶ added in v0.6.0
type Nulls string
Nulls is where NULLs sort within one index column. The zero value follows Postgres's own default, which is not a single placement: NULLS LAST for ascending, NULLS FIRST for descending.
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"`
// Actions are the domain verbs the table declares. Without them an agent
// reading this document sees a CRUD-only API and concludes that completing
// a task means PATCHing its status — which is the transition the verb
// exists to own (ADR-0043).
Actions []ActionManifest `json:"actions,omitempty"`
Examples []string `json:"examples,omitempty"`
}
RESTManifest is the queryable surface of an exposed table: the single most useful thing in the document.
type RefAction ¶ added in v0.5.0
type RefAction string
RefAction is a foreign key referential action.
It was spelled Action until a table needed that noun for a domain verb (TableDef.Action, ADR-0043). Two meanings of "action" in one package is the kind of ambiguity that outlives everyone who could explain it, and this is the side almost nobody writes by name — the constants below carry the meaning at every call site.
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 RefAction
OnUpdate RefAction
// 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
// Enforced turns an external reference into a real FOREIGN KEY to the
// table Target names, without resolving that table's declaration. It is
// the case an incremental adoption lives in: the database has a live,
// enforced constraint and the table it points at has not been declared yet
// (issue #55). See Field.Enforced.
Enforced bool
// 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.
func (*Reference) EnforcedTarget ¶ added in v0.5.0
EnforcedTarget resolves an enforced external reference's target into the table and column a FOREIGN KEY names.
"organizations.id" is a table and a column; a bare "organizations" is that table's "id", which is the convention every other part of this DSL already assumes. Anything else — a module-qualified target, an empty one, more than one dot — reports false, and Validate turns that into an error naming the two forms rather than emitting a constraint against a guess.
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.
func (*Registry) Tables ¶
Tables returns every registered table, sorted by name so that generated output is deterministic across runs.
func (*Registry) Validate ¶
Validate checks the registry for authoring mistakes and returns every problem it finds, joined into a single error. Reporting all of them at once rather than stopping at the first keeps the edit-generate loop short.
func (*Registry) WireCase ¶ added in v0.8.0
WireCase sets how this registry's columns are spelled on the wire, and returns the registry so a declaration can chain.
var Module = schema.NewModule("app").WireCase(schema.Camel)
One setting for the whole schema, applied identically at every surface. See WireCase for why it is not per column, and Registry.Validate for what happens to a column the chosen case cannot round-trip.
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) Action ¶ added in v0.5.0
Action declares a domain verb on the table and returns the table, so that declarations chain the way Expose and AddIndex already do.
The table must also be exposed: an action is a route on the resource, and a table with no resource has nowhere to put one.
func (*TableDef) Actions ¶ added in v0.5.0
Actions returns the table's declared verbs, in declaration order.
func (*TableDef) AddExclude ¶ added in v0.8.0
AddExclude adds an EXCLUDE constraint. See Exclusion.
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) CompositeKey ¶ added in v0.8.0
CompositeKey returns the columns of a composite primary key, or nil when the table's key is a single column — which TableDef.PrimaryKey returns — or when it declares none.
Named for what it holds rather than as the getter half of TableDef.PrimaryKeyColumns, because PrimaryKey is already taken by the single-column accessor and Go has no overloading. The asymmetry is worth one odd name: every existing caller of PrimaryKey keeps working and sees nil, which is the behaviour a composite-key table wants from all of them.
func (*TableDef) Exclusions ¶ added in v0.8.0
Exclusions returns the declared EXCLUDE constraints.
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) Fields ¶
Fields returns the table's columns in declaration order, computed ones included: they are columns to every consumer that describes the row — the model, the clients, the CLI, the OpenAPI document.
func (*TableDef) Index ¶
Index adds a secondary index over the given columns, named by convention: posts_org_id_idx. Use TableDef.IndexNamed when the name matters.
func (*TableDef) IndexNamed ¶ added in v0.5.0
IndexNamed adds a secondary index under a name you choose, rather than the one the convention would derive.
t.IndexNamed("idx_projects_org_id", "org_id")
It exists for adopting a database somebody else's tool built. A declared index whose name does not match the live one is a rename, and a schema of any size turns "declare the tables sqlb already agrees with" into "rename every index in the database" — which is a migration nobody asked for, on a database where it is the least welcome (issue #57).
An index name is not always inert ¶
Postgres reports a violated constraint by name, and matching that name is the standard way to tell one unique violation from another:
pgErr.Code == "23505" && pgErr.ConstraintName == "idx_projects_org_code"
So renaming an index can turn a handled collision — retry with the next suffix — into an unhandled 500, without touching the code that handles it. That is the reason this is a declaration rather than a lint: the schema has to be able to say what the name *is*, not merely prefer it.
func (*TableDef) Indexes ¶
Indexes returns the table's secondary indexes: the declared ones, and the implicit index an external reference asks for when nothing else already covers its column.
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) PrimaryKeyColumns ¶ added in v0.8.0
PrimaryKeyColumns declares a composite primary key over two or more columns.
It is the table-level peer of Field.PrimaryKey, and it exists because the alternative was a schema change: a table whose identity is a pair had to grow a surrogate UUID that nothing points at, plus an index to make the real key unique, purely so the declaration language could describe it. On a natural-key cache that is 16 bytes a row and an extra index, identifying something no other table references — a change nobody would defend if sqlb vanished tomorrow, which is the test an adopter applies (issue #109).
schema.Table("llmcatalog_models", ...).PrimaryKey("provider", "model_id")
What a composite key cannot do ¶
TableDef.PrimaryKey returns a *Field and returns nil for a table declared this way, so a composite-key table takes the same path as a keyless one everywhere row identity is assumed:
- it cannot be the target of Ref or ExternalRef, because a reference is single-column here too;
- it cannot be exposed over REST, because /{id} addresses one column;
- it cannot carry a non-collection TableDef.Action, for the same reason.
Those refusals are the point rather than a shortfall. The tables this is for — association tables where the pair *is* the row, and natural-key caches that are re-derivable and referenced by nothing — are not resources, and what they needed was to be *declarable*, so that one of them stops taking its whole module out of the drift gate.
Use TableDef.PrimaryKeyNamed to pin the constraint's name.
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 ¶
func (*TableDef) ReplaceExclusion ¶ added in v0.8.0
ReplaceExclusion rewrites an already-declared exclusion's body and predicate, for the same caller and the same reason as TableDef.ReplaceCheckExpr: shadow.Normalize puts the declared spelling through Postgres and writes back what Postgres stores, so the two sides of a diff are comparable.
func (*TableDef) ReplaceIndexWhere ¶ added in v0.6.0
ReplaceIndexWhere rewrites a partial index's predicate, for the same reason and by the same caller as ReplaceCheckExpr below.
A partial-index predicate is stored the way a CHECK is — as a parse tree, rendered back by pg_get_expr — so `latitude IS NOT NULL` comes back as `(latitude IS NOT NULL)` and a declaration written the obvious way never matches the live index. The diff then proposes creating an index that is already there, with DDL that looks identical to what the database holds (issue #63).
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.Normalize 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) StoredField ¶ added in v0.5.0
StoredField returns the named column if the database holds it, and nil for a computed one — which is what a migration wants: turning a stored column into a computed one means the storage goes away, and a diff that saw the declaration would leave the old column behind forever.
func (*TableDef) StoredFields ¶ added in v0.5.0
StoredFields returns the columns the database actually holds.
It is what the DDL and the diff read, and the only distinction either of them has to make about a computed column: an expression has no type to declare, no default to write and no ALTER to propose, so a migration that saw one would propose creating a column that must not exist and then propose dropping it again on the next run (ADR-0041).
func (*TableDef) Unique ¶ added in v0.8.0
Unique adds a composite UNIQUE constraint, named the way Postgres names one itself: secrets_tenant_kind_tenant_id_name_key.
t.Unique("tenant_kind", "tenant_id", "name")
Why this is not UniqueIndex ¶
TableDef.UniqueIndex renders CREATE UNIQUE INDEX, which enforces the same rule through a different object. Two of those differences are load-bearing: a unique index cannot be the target of FOREIGN KEY … REFERENCES t (a, b), and it cannot be named in ON CONFLICT ON CONSTRAINT. `UNIQUE (a, b)` written inline in CREATE TABLE is also what a hand-written migration reaches for by default, so a database being adopted usually has the constraint.
Declaring the index where the database has the constraint is therefore not a near-miss that diffs to nothing. It diffs to a drop and a rebuild, which is a real migration on live data forced by the declaration language rather than by anything the schema needs (issue #108).
Use TableDef.UniqueNamed when the live name does not follow the convention — which for a constraint an application may be matching on by name, the same way TableDef.IndexNamed describes.
func (*TableDef) UniqueIndex ¶
UniqueIndex adds a composite unique index, named by convention: posts_org_id_slug_uniq. Use TableDef.UniqueIndexNamed when the name matters, which for a unique index it more often does — see below.
func (*TableDef) UniqueIndexNamed ¶ added in v0.5.0
UniqueIndexNamed adds a composite unique index under a name you choose. See TableDef.IndexNamed, and note that a unique index is the kind whose name an application is most likely to be matching on.
func (*TableDef) UniqueNamed ¶ added in v0.8.0
UniqueNamed adds a composite UNIQUE constraint under a name you choose, rather than the one the convention would derive. See TableDef.Unique.
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" TypeSmallInt Type = "smallint" TypeInt Type = "int" TypeBigInt Type = "bigint" // TypeReal is the 4-byte float. It is a distinct type from TypeFloat for // the same reason TypeSmallInt is distinct from TypeInt: importing it as // the wider one would make every later diff propose widening a column the // database is content with (issues #114, #120). TypeReal Type = "real" 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" // TypeVector is a pgvector embedding. Unlike every other type here it // carries a parameter that is part of the type rather than a constraint on // it — a vector(768) and a vector(1536) are different types to Postgres, // and the dimension is FieldDesc.Dim (ADR-0026). TypeVector Type = "vector" )
func Types ¶ added in v0.5.0
func Types() []Type
Types is every logical column type, in declaration order.
It exists so that a consumer which must handle all of them can be checked against the list rather than against its author's memory. That is not hypothetical: `introspect` imported a vector column and `RenderSchema` could not write one back out, so the bootstrap that turns a 69-table database into 69 declarations to review failed on the one type the rest of the toolchain already handled (issue #53). A test walks this list now.
type Unique ¶ added in v0.8.0
Unique is a table-level UNIQUE constraint over one or more columns — the table-level peer of Field.Unique().
It is a different object from a unique index, and the difference is not cosmetic: only a constraint can be the target of FOREIGN KEY … REFERENCES t (a, b) or be named in ON CONFLICT ON CONSTRAINT. Declaring one where the database has the other produces a migration that drops and rebuilds, which on a live table is the expensive kind.
type WireCase ¶ added in v0.8.0
type WireCase string
WireCase is how a column's name is spelled on the wire.
It is a property of the schema, not of a column. ADR-0036's decision is that there is exactly *one* spelling, so that the JSON body, the OpenAPI document, the filter grammar's parameter names and both generated clients cannot disagree — filter parameters are column names by construction, so a second spelling breaks one of the five surfaces. That decision is unchanged. What the 2026-08-02 amendment changed is that the one spelling is a declared *function* of the column name rather than the identity function (issue #116).
There is deliberately no per-field override. A per-column mapping is the part with a reason to drift, and it is what makes a generated client's contents depend on configuration — every guarantee ADR-0028 makes is about the client having no contents that can be wrong.
const ( // Verbatim spells a column on the wire exactly as the database spells it. // The default, and what sqlb has always done. Verbatim WireCase = "" // Camel spells created_at as createdAt across every surface at once. // // For an application whose front end is camelCase throughout, the // alternative was renaming the columns — and camelCase identifiers in // Postgres are reachable only double-quoted, which breaks every // hand-written query, psql session and pg_dump a human reads, permanently. // Choosing the wire spelling is reversible; renaming the columns is not. Camel WireCase = "camel" )
func (WireCase) ColumnName ¶ added in v0.8.0
ColumnName is WireName's inverse: the column a wire name refers to.
It exists so that the round trip can be *checked* rather than assumed — see Registry.Validate, which refuses a schema holding a column this cannot recover. Nothing at runtime calls it: the request path is handed each column's wire name as data and never computes one (ADR-0036's amendment).
func (WireCase) WireName ¶ added in v0.8.0
WireName spells one column name in this case.
Pure, total, and the only place the transformation lives: every surface calls this rather than reproducing it, which is what keeps "there is one spelling" true by construction rather than by five packages agreeing.