Declaring tables
A schema is ordinary Go values. That is what lets one declaration be the source of truth for migrations, models, REST handlers, generated clients and the OpenAPI document — there is no separate schema language to keep in sync, and no reflection over a database at startup.
package blogschema
import "github.com/jryannel/sqlb/schema"
var Post = schema.Table("posts", schema.UUIDv7("id").PrimaryKey(), schema.Ref("author", Author).OnDelete(schema.Restrict), schema.Text("title").Searchable().Sortable(), schema.Enum("status", "draft", "review", "published"). Default(schema.Value("draft")). Filterable(). Sortable(), schema.Timestamps(), schema.SoftDelete(),). Index("author_id"). Check("published_posts_have_a_date", "status <> 'published' OR published_at IS NOT NULL"). Describe("A blog post."). Expose(schema.REST{Ops: schema.CRUD | schema.OpList, MaxPageSize: 100})schema.Table registers into the default registry as a side effect of
declaration, which is why a codegen program only has to import the package.
This page covers the column vocabulary and the table-level constructs. Capabilities is what each column opts in to, and References and relations is how tables point at each other.
Column types
Section titled “Column types”| Constructor | SQL type | Go type |
|---|---|---|
Text(name) |
text |
string |
Varchar(name, n) |
varchar(n) |
string |
SmallInt(name) |
smallint |
int16 |
Int(name) |
int |
int32 |
BigInt(name) |
bigint |
int64 |
Real(name) |
real |
float32 |
Float(name) / Numeric(name) |
float / numeric |
float64 |
Numeric(name, p, s) |
numeric(p, s) |
float64 |
Bool(name) |
bool |
bool |
UUID(name) |
uuid |
string |
Timestamp(name) |
timestamptz |
time.Time |
Date(name) / Time(name) |
date / time |
time.Time |
JSON(name) |
jsonb |
json.RawMessage |
Bytes(name) |
bytea |
[]byte |
Enum(name, values...) |
text + a check constraint |
a named string type |
SmallSerial(name) / Serial(name) / BigSerial(name) |
smallserial / serial / bigserial |
int16 / int32 / int64 |
Two shorthands cover the conventional cases. UUIDv7(name) is a UUID column
defaulting to a generated, time-ordered v7 value — the usual primary key.
Enum emits a Go string type with one constant per value, so
blog.PostStatusPublished exists and a typo does not compile.
Auto-incrementing keys
Section titled “Auto-incrementing keys”A serial is an integer whose value comes from a sequence, and it has two
spellings in Postgres. The serial constructors above are the older one;
.Identity() and .IdentityAlways() are the modern one, which is what Postgres
now recommends because it has no separate sequence object to name:
schema.BigSerial("id").PrimaryKey() // bigserialschema.BigInt("id").Identity().PrimaryKey() // GENERATED BY DEFAULT AS IDENTITYschema.Int("attempt").IdentityAlways() // GENERATED ALWAYS AS IDENTITYPrefer an identity for a new column; declare a serial when the database already
has one, because moving between them is a migration rather than a rename — and
adopting an existing database is most of why these exist. The two identity forms
differ in one thing: BY DEFAULT still lets an INSERT name the column, which is
what a data import or a backfill needs, while ALWAYS refuses it and is
therefore marked read-only.
None of this is a type. A bigserial column is a bigint — the Go type, the
filter operators and the sort machinery are the plain integer’s, and it reads
back as one. What the declaration adds is that the database supplies the value,
so an insert that does not name the column is not missing one.
The column cannot be nullable and cannot carry a Default(): the sequence is
the default, and Postgres refuses both combinations. So does Validate(),
before any DDL is written.
One thing a generated migration cannot do for you: when a column becomes a
serial on a table that already has rows, the sequence starts at 1. The change
says so in its hazard and names the setval to run first — the row count is not
in the schema, so nothing here can size the scan that would compute it.
Nullable() allows SQL NULL and makes codegen emit the Go field as a pointer —
so a nullable JSON column is a *json.RawMessage, and a nullable Timestamp
a *time.Time.
The exceptions are the two types that can already express absence on their own.
A Bytes column stays []byte and an Array() column stays a slice, because
nil is what a slice is when the value is absent; a pointer would add a second
spelling for the same thing. json.RawMessage is a slice too, but a document
type rather than a bag of bytes, so it takes the pointer like everything else.
The full table, including which filter operators each type admits, is in the column type reference.
Arrays
Section titled “Arrays”Array() is a modifier on any of the scalar constructors above, so a text[]
is a text column that says so:
schema.Text("labels").Array().Filterable().Default(schema.Value("{}")),schema.Enum("channels", "web", "email").Array().Nullable(),The Go field is the plain slice — []string, not a wrapper type — and the
generated TypeScript and Dart clients get string[] and List<String>, which
is precisely what the same column declared as JSON could not give them.
The constructor keeps naming the element, and that is load-bearing rather than
cosmetic: ?labels=has.urgent binds one text, so the enum’s value set and the
varchar’s length stay attached to the thing that has them.
Nullability is about the column, not the elements. A NULL column and an empty
array are different values, and the Go side spells them nil and []string{};
there is no way to declare an array whose elements may be NULL, because
{a,NULL,b} and NULL are two absences no generated client could tell a UI
apart.
Three rules, all reported by schema.Validate:
- an array column cannot be
Sortable— the keyset cursor encodes the ordering columns, and an array has no spelling in it; - it cannot be
Searchable— search is a text operation; - a
Filterableone must carry a GIN index, or every filter over it is a sequential scan that returns the right rows and reports nothing.
).AddIndex(schema.Index{Columns: []string{"labels"}, Method: "gin"})Elements are the scalar types; not JSON, not Bytes, and one dimension only.
Filtering one is has / hasany / hasall, and their n-prefixed negations.
Computed columns
Section titled “Computed columns”A Computed column is an expression rather than storage. It emits no DDL — the
table Postgres holds does not have it — and it is a column to everything above
Postgres: it is in the row type, the JSON, the TypeScript and Dart types, the
CLI’s columns and the OpenAPI document, and Filterable and Sortable gate it
exactly as they gate a stored one.
schema.Computed("is_overdue", schema.TypeBool, schema.FromSQL("due_date < current_date AND open_tasks > 0")). Filterable(),
schema.Computed("total_tasks", schema.TypeInt, schema.FromSQL("(SELECT count(*) FROM tasks t WHERE t.project_id = projects.id)")),
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(),The compiler substitutes the expression wherever the column is named, so one
declaration reaches the projection, ?filter=is_overdue.eq.true and
?sort=-progress at once. The projection aliases it back to the column name,
which is what lets the row scan into the field.
A computed column is nullable unless it says otherwise, which is the
opposite of a stored one and the same as SQL. A correlated subquery that matches
nothing is NULL, arithmetic over a nullable column is NULL, and a comparison
against one is NULL — and there is no NOT NULL in any DDL for the generator
to read the answer off, because there is no DDL. So the three declarations above
generate *bool, *int32 and *bool, and NotNull() is how an expression
that cannot produce one says so:
schema.Computed("total_tasks", schema.TypeInt, schema.FromSQL("(SELECT count(*) FROM tasks t WHERE t.project_id = projects.id)")). NotNull(), // count(*) is 0, never NULLIt is a claim rather than a check — nothing parses the SQL — so it belongs on
the count(*), the EXISTS, and the comparison already guarded against its own
nulls. The default runs the other way because that is the direction that fails
safely: a pointer scans a non-null value fine, and the reverse is a 500 saying
cannot scan NULL into *string, on rows a fixture is unlikely to contain
(#147).
A subquery is projection-only unless you say otherwise. Writing
Filterable() on one is the acknowledgement that a subquery in a WHERE runs
once per candidate row. Searchable() says the same thing about ?search, and
is allowed on a computed column whose declared type is text — which is the only
way to search across a relation:
schema.Computed("participant_names", schema.TypeText, schema.FromSQL("(SELECT string_agg(m.display_name, ' ') FROM members m "+ "WHERE m.id = ANY(chats.participant_ids))")). Searchable(),A chat is named in the UI by whoever is in it — a direct message has no name
at all — so fanning out over the chat’s own columns finds nothing for exactly
the rows a search is for, and answers 200 while doing it
(#93).
Reading one is opt-in, because the model is shared. A computed column is declared on the model and usually wanted by one screen, so nothing projects it unless it asks:
sqlb.Query[Project]().WithComputed("total_tasks", "is_starred") // a queryrest.Options{Computed: []string{"total_tasks", "is_starred"}} // a resourceA generated resource opts into every computed column its table declares, so generated endpoints are unaffected — the opt-in exists for everything else reading the same model. Without it, three aggregates declared for a list screen attached a correlated subquery each to every read of the model, including this:
sqlb.Query[Project]().Where(sqlb.F("id").Eq(id)).One(ctx, db)which is asking whether a row exists. Worse, a column declaring Needs made
that query fail — it wanted a viewer bind the caller had no business
supplying (#92).
For a resource the opt-in is a boundary rather than a projection setting: a
computed column a resource does not select is not filterable, sortable or
nameable in ?select there either. A filter on a correlated subquery costs what
the projection would have, so being merely unprojected would not have made it
cheap. The obligation follows the same line — a resource that selects a Needs
column still refuses to mount without a hook to supply the bind, and one that
does not select it never has to care.
Needs supplies what the request knows. Each ? takes the bind named at the
matching position, and the value arrives with the query:
sqlb.On[Project](reg).BeforeQuery(func(ctx context.Context, q *sqlb.Builder[Project]) error { q.Bind("viewer", memberFrom(ctx)) return nil})Like Scoped, the declaration writes no value — it obliges a hook. A resource
whose binds nothing supplies does not mount, because an unbound expression would
render member_id = NULL, answer false for every row forever, and look exactly
like a feature that works. The bind is sent once however many times the
expression appears in the statement.
Four rules, reported by schema.Validate:
- a computed column cannot be
Searchable—?searchfans out over text columns withILIKE, and an expression has no reading there; - it cannot be
Sortableif its expression is volatile — one readingnow()orcurrent_dateis a different value on the next page, and the keyset cursor pages on the sort column; - it cannot be a primary key, unique, defaulted, a reference, indexed or an enum — each is a statement about storage;
Needsmust name exactly as many binds as the expression takes.
Nothing parses the SQL: sqlb generate refuses the four rules above, but a typo
inside the expression reaches Postgres, and Explain against a real database is
what catches it early.
Nothing writes one: it is absent from the generated create and update bodies and
from every INSERT. A write’s RETURNING can read one back, so a POST
response carries it without a second read — but only the ones the caller asked
for, with WithComputed on the statement or Computed on the resource. That is
the same opt-in a read takes, and it is opt-in for the same reasons plus one: an
aggregate evaluated by a create counts the rows that create has not written yet
(#164). A parameterised one can
never be read back — a write has no viewer to bind — so it is absent from the
statement and from the write’s response, and arrives on the next read.
An index can never serve one, which is why a trigger-maintained counter or a
GENERATED ALWAYS AS … STORED column is still the better answer when the value
allows it; schema.Lint says so once per filterable computed column.
example/computed is the five techniques side by
side, and ADR-0041 is why the tiers are drawn
where they are.
Whose table does it name
Section titled “Whose table does it name”Nothing parses the expression, so nothing can refuse a subquery that reaches into another module — and the question to ask before writing one is not “is this a subquery” but whose table does it name.
A subquery over this module’s own tables is what the feature is for: a chat’s
participant_ids over its own chat_members is correct and deletes an N+1.
A subquery naming another module’s table is the coupling ExternalRef refuses
to expand for, arriving through a door nothing guards.
It is tempting to reason that the coupling is the same as the LEFT JOIN being
replaced. It is not, and the difference is the footprint. A join lives in one
query behind one handler. A computed column travels with the model: it is
selectable by every mount that opts into it, and it is in the RETURNING of
every write that asks for it. A module that turned LEFT JOIN projects into
Computed("project_name", FromSQL("(SELECT name FROM projects …)")) found the
subquery in the RETURNING of every insert, so the table could not be written
at all unless projects existed in the same database, and its isolation boot
test failed on its own seed with relation "projects" does not exist. The
column had to come back out of the declaration.
The answer is ExternalRef’s own: fetch the other side through that module’s
API. sqlb cannot check this — resolving a table name out of raw SQL is exactly
the dependency ExternalRef’s free-text target exists to avoid — which is why
it is written down rather than enforced.
Groups
Section titled “Groups”Timestamps() and SoftDelete() insert several columns as a unit:
schema.Timestamps() // created_at, updated_at — both default now(), read-only, sortableschema.SoftDelete() // deleted_at — nullable, read-onlyFactor your own recurring column sets the same way by returning a
schema.Group.
SoftDelete adds a column and stops. Nothing writes deleted_at, nothing
filters it out of reads, and the generated DELETE issues a real DELETE.
Making it mean something is two pieces of your own — a BeforeQuery hook that
adds the predicate, and an endpoint that stamps the column — and the schema
knows to ask for the first: a resource over a soft-deleting model does not
mount until a hook confines it. example/blog is that
pair written out.
The wire spelling
Section titled “The wire spelling”Every column has one spelling on the wire — the JSON body, the filter grammar’s
parameter names, ?sort, the OpenAPI document and both generated clients. By
default it is the column’s own name, so created_at is created_at everywhere.
A schema whose front end is camelCase says so once:
var Module = schema.NewModule("app").WireCase(schema.Camel)
// or, for a schema using the package-level Table():func init() { schema.SetWireCase(schema.Camel) }created_at is then createdAt in the body, in ?createdAt=gte.…, in
?sort=-createdAt, in the OpenAPI document and in both clients — and still
created_at in the database, in every hand-written query and in pg_dump.
There is deliberately no per-column override. One setting, applied by one pure function, is what keeps the five surfaces from disagreeing; a per-column mapping is the part with a reason to drift (ADR-0036).
A case that cannot round-trip is refused at build time. snake → camel is
not invertible over every name: pos_x_2 becomes posX2, which reads back as
pos_x2. Validate computes both directions for every column and fails the
schema naming the column and both spellings, so an ambiguity is a build error on
a schema nobody has deployed rather than a wrong parameter name in a shipped
client. Rename the column, or leave the schema Verbatim.
A CLI flag keeps its kebab-cased spelling either way — --created-at under
both — because a flag is a local affordance rather than a wire format. What
moves is the query parameter it sends.
Indexes and constraints
Section titled “Indexes and constraints”). Index("org_id", "status"). // composite UniqueIndex("org_id", "slug"). IndexNamed("idx_posts_author", "author_id"). // the name the database has AddIndex(schema.Index{Columns: []string{"body"}, Method: "gin"}). Check("name", "status <> 'published' OR published_at IS NOT NULL")Exclusion constraints
Section titled “Exclusion constraints”An EXCLUDE constraint says no two rows may hold values pairwise related by the
given operators. The canonical use is the one no application-level check can
make safe:
AddExclude(schema.Exclusion{ Name: "bookings_no_double_booking", Using: "gist", Elements: "coach_id WITH =, tstzrange(starts_at, ends_at) WITH &&", Where: "status = 'confirmed'",})One coach cannot hold two confirmed bookings whose time ranges overlap — enforced by the database, across concurrent transactions.
This is the one constraint with no near miss. A composite UNIQUE has a
unique index; a composite primary key has a surrogate; smallint still
round-trips at the wrong width. Dropping an exclusion has no equivalent at all:
either the invariant moves into application code, where two concurrent requests
interleave between the check and the insert, or the table stays outside the
declaration and the drift gate holds a permanent known-difference exception.
Elements and Where are hand-written SQL, exactly as Check is, and for the
same reason — Postgres stores a parse tree and renders it back in its own
spelling, so a structured form would have to reproduce that spelling or every
diff would propose replacing a constraint that had not changed. Both go through
the probe in shadow.Normalize, which adds the real constraint to the shadow
database and reads back what Postgres stored. Asking beats guessing.
Using is the index method, and it is almost always gist: the operators that
make an exclusion useful — && over a range, and = over a scalar beside it —
are gist’s. Pairing a scalar = with a range needs the btree_gist
extension, which no generated DDL creates; sqlb introspect lists the
extensions a database has so that is knowable before the first bootstrap rather
than after it fails.
A composite primary key
Section titled “A composite primary key”Field.PrimaryKey() declares a single-column key. When the key is a pair —
an association table where the pair is the row, a natural-key cache keyed by
what it describes — declare it on the table:
schema.Table("llmcatalog_models", schema.Text("provider"), schema.Text("model_id"), schema.Text("display_name"),).PrimaryKeyColumns("provider", "model_id")The alternative was a schema change: a surrogate UUID that nothing points at,
plus a unique index to make the real key unique — 16 bytes and an extra index
per row, identifying something no other table references. That is a change
nobody would defend if sqlb vanished tomorrow, which is the test an adopter
applies to every line of a migration.
A composite-key table is not a resource, and the schema refuses to make it
one. TableDef.PrimaryKey() returns nil for it, so it takes the keyless path
everywhere row identity is assumed, and Validate refuses three things by name:
| Why | |
|---|---|
| REST exposure | /{id} addresses one column, and so do the cursor and the generated cache key — each is a wire format (ADR-0034) |
Being the target of Ref/ExternalRef |
A reference is single-column here too |
A non-collection Action |
An id is one column |
Those refusals are the design rather than a shortfall. What these tables needed was to be declarable, so that one of them stops taking its whole module out of the drift gate — the gate is per registry, and it is all-or-nothing. If such a table does need to be a resource, give it a surrogate key deliberately, which is then a decision rather than a tax.
UniqueIndex and Unique are different objects
Section titled “UniqueIndex and Unique are different objects”Both enforce the same rule and they are not interchangeable. UNIQUE (a, b, c)
written inline in CREATE TABLE produces a constraint; CREATE UNIQUE INDEX
produces an index. Postgres builds an index either way, but only a constraint
can be
- the target of
FOREIGN KEY … REFERENCES t (a, b), and - named in
ON CONFLICT ON CONSTRAINT.
So when a table already has a constraint, declaring the index instead is not a
spelling difference: it diffs as a different object, and adopting that table
would propose dropping the constraint and building an index in its place — a
real migration on live data, forced by the declaration language rather than by
anything the schema needed. Unique and UniqueNamed are the table-level peers
of Field.Unique(), and they are what an adoption reaches for, because the
inline form is how a composite natural key is ordinarily written.
Unique names the constraint the way Postgres names one it generated itself —
secrets_tenant_kind_tenant_id_name_key — rather than the way UniqueIndex
names an index. Two conventions, deliberately: an index sqlb declares is one
sqlb named, while a constraint is usually one that already exists under a name
the application may be matching on.
When a unique constraint is checked
Section titled “When a unique constraint is checked”By default, at the end of each statement. Deferrable moves it to COMMIT:
AddUnique(schema.Unique{ Columns: []string{"product_id", "option_signature"}, Deferrable: schema.DeferredCheck,})The case is a rule about the committed state that no single statement can
satisfy. A product variant is identified by the combination of its option
values; the values live in a child table and reference the variant, so the
variant row is written first and passes through a state where its denormalised
signature is still the default — and two variants of one product collide on that
default at INSERT time. INITIALLY DEFERRED says exactly what is meant, and
the alternatives weaken the rule (a partial index excluding the placeholder) or
move it into application code, where two concurrent writers interleave between
the check and the insert.
schema.DeferrableCheck is the third setting: deferrable, but immediate unless a
transaction says SET CONSTRAINTS … DEFERRED. Field.Deferred() is the
column-level spelling of DeferredCheck for a single-column constraint.
Deferral is declarable on UNIQUE and nothing else. On the other constraint
kinds it is read and reported rather than silently dropped: introspection
lists a deferred foreign key, primary key, check or exclusion as a construct the
declaration cannot express, with its definition attached, so an adoption knows to
keep the hand-written migration that put it there. That reporting is the point of
#154 — before it, a deferred constraint was invisible to both sides of the
round trip, so the fixpoint held because the declaration and the database were
blind to the same property, and a migration that recreated the constraint without
its clause would have passed the drift gate on its way to breaking every write.
ADR-0051 is the general rule that came out of it: where a layer below the declaration can say something the declaration cannot, the gap is reported rather than left to be found.
AddIndex takes a fully specified Index for what the shorthands do not cover
— GIN indexes, partial indexes via Where, and per-column sort order via
Orders. A partial unique index is often the cleanest way to state a domain
rule the type system cannot:
UNIQUE (book_id, borrower_id) WHERE returned_at IS NULL makes borrowing a book
you already have out impossible, and borrowing it again next year ordinary.
Orders is what an index backing a specific ORDER BY needs, because there the
ordering is the index — one built for
ORDER BY position ASC NULLS FIRST, created_at DESC serves nothing else:
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}, },})An absent entry is ascending with Postgres’s default null placement, and a
placement that already follows from the direction is dropped when the DDL is
rendered — so a declaration and what pg_get_indexdef hands back agree, and two
spellings of the same order do not propose replacing each other.
A partial index’s Where is hand-written SQL, and Postgres stores it as a parse
tree rather than as text: latitude IS NOT NULL comes back as
(latitude IS NOT NULL). sqlb migrate puts the declared predicate through the
same normalisation before diffing (shadow.Normalize), so you write it the way
you would say it rather than the way Postgres would print it.
Index and UniqueIndex name the index by convention — posts_org_id_idx,
posts_org_id_slug_uniq. IndexNamed and UniqueIndexNamed take the name
instead, which is what describing a database somebody else’s tool built needs: a
declared index whose name differs from the live one is a rename, and across a
schema of any size that turns adoption into renaming every index in the
database.
An index name is not always inert, which is the sharper half. 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 a unique index turns a handled collision into an unhandled 500 without touching the code that handled it. The generated migration says so when it proposes one.
An external reference gets an index whether or not you asked for one. It is
resolved when the index set is read, so an index you declare on the same column
replaces it rather than colliding with it, and it shows up in Indexes(), the
manifest and the generated DDL like any other.
Check is the floor under everything else. A hook is a convention; a check
constraint cannot be bypassed by code that has not been written yet — see
where domain logic goes.
Exposure
Section titled “Exposure”Expose is what publishes a table over HTTP. Without it, the table is reachable
from Go and has no REST surface at all.
Expose(schema.REST{ Path: "/posts", Ops: schema.OpCreate | schema.OpRead | schema.OpUpdate | schema.OpList, DefaultPageSize: 20, MaxPageSize: 100, MaxFilters: 12, MaxSortTerms: 4, MaxOffset: 10_000, DefaultSort: []string{"-pinned", "-published_at"},})schema.CRUD is create, read, update and delete together; OpList is separate
because a table can be readable by id without being listable. Leaving an
operation out means the endpoint does not exist — not that it answers 405.
Five of these are the per-request cost ceilings, and each is worth setting per
resource: they are the bounds on what one request may ask the database to do,
and the numbers that justify them — the row count, the width of the table — are
known here. MaxPageSize is a hard ceiling rather than a hint; MaxFilters and
MaxSortTerms bound how many predicates and sort terms one request may carry;
MaxOffset bounds how deep ?page= may reach. A zero takes the package default.
See Pagination.
DefaultSort is not a ceiling. It says what a list request that names no ?sort
returns — the ordering the collection means, rather than the primary-key order
that is what silence used to fall back to. Terms are column names, most
significant first, with a leading - for descending, and each must declare
Sortable. ?sort replaces it; the primary-key tiebreak is appended either way,
so cursors are unaffected. It reaches the OpenAPI description, the manifest, the
generated skill and the ejected handlers, which is the point: the alternative is
a constant in one hand-maintained SDK facade that no other client and no agent
reading the spec ever sees.
Modules
Section titled “Modules”A registry is the unit of isolation. Independent modules each declare into their
own, so two of them may both own a table called events:
var Billing = schema.NewModule("billing")var Invoice = Billing.Table("invoices", …) // → billing_invoicesThe prefix is applied by the registry rather than written into each declaration, which is the point: a convention repeated at every call site is one that drifts. Declarations still use the local name, so moving a table between modules changes one line. The URL keeps the local name too — a module prefix is a storage concern, and leaking it into the API would make that move a breaking change.
Across a module boundary, use ExternalRef; see
References.
Checking your work
Section titled “Checking your work”Two different questions, two different calls.
Validate() — is this schema well-formed? Every authoring mistake is
reported at once rather than one per run. Call it from a test, or let codegen
fail.
Lint() — will this schema behave badly in production? Problems that
compile fine and produce a bad database or a bad API:
for _, d := range reg.Lint() { fmt.Println(d)}[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 serveThat output is from ExampleRegistry_Lint in schema/example_test.go, so it is
what the linter actually says. Both are worth running from a test — the loop
here is go test, not a CLI.
- Capabilities — what each column lets the outside world do
- References and relations — foreign keys, and both directions of one
- Schema practices — which opinions the DSL enforces, which it only recommends, and which refusals are merely unbuilt
- Migrations — turning a schema edit into DDL
- Queries — using the models this produces