lode/schema/spec
The declarative schema spec — the single source of truth for codegen and drift checking (DESIGN §14).
A spec is pure data: no closures, no type erasure. Unlike the runtime
Schema(row) (whose codecs are opaque functions), a spec is rich enough to
be lossless over the type system — enum variants, embeds, decimal,
temporal kinds, uuid-vs-string, jsonb — so the record type, the
Schema(row) value, and the typed accessors can all be generated from it
with no live database, and a drift checker can compare it against one.
pub fn tables() -> List(spec.TableSpec) { [ spec.table(name: “products”, record: “Product”) |> spec.fields([ spec.primary_key(name: “id”, of: spec.Integer), spec.field(name: “name”, of: spec.Text), spec.field(name: “price”, of: spec.Decimal), spec.field(name: “description”, of: spec.Text) |> spec.nullable, spec.virtual(name: “tagline”, of: spec.Text, default: “""”), ]) |> spec.assocs([ spec.has_many(name: “reviews”, table: “reviews”, fk_column: “product_id”), ]), ] }
Two projections come out of one spec (DESIGN §14.6): the DDL/drift
projection (stored_fields — stored columns only, what the database must
look like) and the schema projection (schema_fields — all fields,
stored + virtual, what the generated record and codecs carry). Virtual
fields are invisible to DDL and drift by construction.
Types
Per-association options, mirroring association.Options (DESIGN §14.5).
The policy options are plain data; where/preload_order/defaults carry
Gleam source expressions (the same code-as-data convention as as_custom
and virtual defaults), with imports naming any modules those snippets
need. Unset options fall back to association.options() defaults.
pub type AssocOpts {
AssocOpts(
where: option.Option(String),
preload_order: option.Option(String),
on_replace: option.Option(OnReplaceOpt),
on_delete: option.Option(OnDeleteOpt),
defaults: option.Option(String),
imports: List(String),
)
}
Constructors
-
AssocOpts( where: option.Option(String), preload_order: option.Option(String), on_replace: option.Option(OnReplaceOpt), on_delete: option.Option(OnDeleteOpt), defaults: option.Option(String), imports: List(String), )Arguments
- where
-
Source of an
expr.Exprfiltering preloads (child at binding 0). - preload_order
-
Source of a
List(query.OrderItem)ordering preloaded children. - defaults
-
Source of a
List(#(String, value.Value))applied to new children. - imports
-
Import lines the source snippets above need.
A named association, declared on the owning table. table references
another TableSpec by table name; references (where present) overrides
the key column on the one side, defaulting to that table’s primary key.
pub type AssocSpec {
BelongsTo(
name: String,
table: String,
fk_column: String,
references: option.Option(String),
opts: AssocOpts,
)
HasMany(
name: String,
table: String,
fk_column: String,
references: option.Option(String),
opts: AssocOpts,
)
HasOne(
name: String,
table: String,
fk_column: String,
references: option.Option(String),
opts: AssocOpts,
)
ManyToMany(
name: String,
table: String,
join_through: String,
join_owner_key: String,
join_related_key: String,
opts: AssocOpts,
)
HasManyThrough(
name: String,
via: String,
to: String,
opts: AssocOpts,
)
}
Constructors
-
BelongsTo( name: String, table: String, fk_column: String, references: option.Option(String), opts: AssocOpts, )This table holds
fk_columnreferencingtable. -
HasMany( name: String, table: String, fk_column: String, references: option.Option(String), opts: AssocOpts, )Rows of
tableholdfk_columnreferencing this table. -
HasOne( name: String, table: String, fk_column: String, references: option.Option(String), opts: AssocOpts, )Like
HasMany, but at most one child. -
ManyToMany( name: String, table: String, join_through: String, join_owner_key: String, join_related_key: String, opts: AssocOpts, )Linked through a join table. The join table itself is usually declared as a
manual_schematable so drift checking covers it. -
HasManyThrough( name: String, via: String, to: String, opts: AssocOpts, )Two-hop, read-only: reach rows via the
viaassociation on this table, then thetoassociation on the intermediate table (Ecto’shas_many :x, through: [:via, :to]). Options apply to the leaf load.
The type intent of a column (or virtual field). Carries everything the database representation erases: enum variants, embedded specs, decimal versus float, temporal kind, uuid versus plain text.
pub type ColumnType {
Integer
BigInt
Float
Boolean
Text
VarChar(Int)
Binary
Decimal
Date
Time
NaiveDatetime
UtcDatetime
Uuid
Jsonb
StringEnum(record: String, variants: List(#(String, String)))
IntEnum(record: String, variants: List(#(String, Int)))
EmbedsOne(embed: EmbedSpec)
EmbedsMany(embed: EmbedSpec)
}
Constructors
-
IntegerINTEGER(GleamInt). -
BigIntBIGINT(GleamInt). -
FloatDOUBLE PRECISION(GleamFloat). -
BooleanBOOLEAN(GleamBool). -
TextTEXT(GleamString). -
VarChar(Int)VARCHAR(n)(GleamString). -
BinaryBYTEA(GleamBitArray). -
DecimalNUMERIC(dee-backedDecimal). -
DateDATE(calendar.Date). -
TimeTIME(calendar.TimeOfDay). -
NaiveDatetimeTIMESTAMP(#(calendar.Date, calendar.TimeOfDay)). -
UtcDatetimeTIMESTAMPTZ(timestamp.Timestamp). -
UuidUUID(canonical lowercase string). -
JsonbJSONB(Gleam-sidevalue.Valuetree). -
StringEnum(record: String, variants: List(#(String, String)))A string-backed enum stored as
TEXT.recordis the Gleam type to generate;variantspairs each constructor name with its DB string. -
IntEnum(record: String, variants: List(#(String, Int)))An integer-backed enum stored as
INTEGER. -
EmbedsOne(embed: EmbedSpec)A single embedded record stored inline as
JSONB. -
EmbedsMany(embed: EmbedSpec)A list of embedded records stored inline as
JSONB.
A representation override: keep the column’s DB type for DDL and drift, but
map it to a different Gleam type via a custom LodeType. Because generation
happens at the source level, the override is expressed as source text: the
Gleam type name, the expression building the LodeType, and the import
lines both need.
pub type CustomType {
CustomType(
gleam_type: String,
lode_expr: String,
imports: List(String),
)
}
Constructors
-
CustomType( gleam_type: String, lode_expr: String, imports: List(String), )
A nested schema stored inline (no table of its own). Only name, type_,
nullable, and as_type are meaningful on embedded fields — embeds have no
keys, no autogeneration, and no virtual fields.
pub type EmbedSpec {
EmbedSpec(record: String, fields: List(FieldSpec))
}
Constructors
-
EmbedSpec(record: String, fields: List(FieldSpec))
One field of a table (or of an embed). Build with field / primary_key /
natural_key / virtual and refine with the modifier functions; the
constructor is public so the spec stays inspectable pure data.
pub type FieldSpec {
FieldSpec(
name: String,
type_: ColumnType,
source: option.Option(String),
nullable: Bool,
primary_key: Bool,
autogenerate: Bool,
virtual: Bool,
virtual_default: option.Option(String),
as_type: option.Option(CustomType),
redact: Bool,
)
}
Constructors
-
FieldSpec( name: String, type_: ColumnType, source: option.Option(String), nullable: Bool, primary_key: Bool, autogenerate: Bool, virtual: Bool, virtual_default: option.Option(String), as_type: option.Option(CustomType), redact: Bool, )Arguments
- name
-
Field name on the record and in changesets.
- type_
-
Type intent. For stored fields this drives DDL/drift and the codecs; for virtual fields only the codecs.
- source
-
Database column name when it differs from
name. - nullable
-
Whether the column may be NULL. Nullable non-key fields are generated as
primitive.NullableValue(_). - autogenerate
-
The database generates the value on insert (serial / identity / DEFAULT); omitted from insert payloads.
- virtual
-
App-only field (DESIGN §14.6): on the record and cast in changesets, never in DDL or drift;
dumpomits it andloadseeds it fromvirtual_default. - virtual_default
-
Gleam source expression seeding a virtual field on load. Required for virtual fields — Gleam records are closed and have no nil.
- as_type
-
Optional representation override (see
CustomType). - redact
-
Mark sensitive fields; generated code carries a
(redacted)marker so readers know not to log them.
Mirror of association.OnDelete as pure spec data.
pub type OnDeleteOpt {
DeleteNothing
DeleteAll
NilifyAll
}
Constructors
-
DeleteNothing -
DeleteAll -
NilifyAll
Mirror of association.OnReplace as pure spec data.
pub type OnReplaceOpt {
ReplaceRaise
ReplaceDelete
ReplaceNilify
}
Constructors
-
ReplaceRaise -
ReplaceDelete -
ReplaceNilify
One table: its name, the Gleam record type to generate, fields, and associations. Authored explicitly — no name derivation, no pluralization guessing.
pub type TableSpec {
TableSpec(
name: String,
record: String,
fields: List(FieldSpec),
assocs: List(AssocSpec),
manual_schema: Bool,
)
}
Constructors
-
TableSpec( name: String, record: String, fields: List(FieldSpec), assocs: List(AssocSpec), manual_schema: Bool, )Arguments
- name
-
Table name in the database.
- record
-
Gleam record type name to generate (or, under
manual_schema, the type the user hand-writes). - manual_schema
-
Whole-table opt-out (DESIGN §14.6 rung 3): no record/schema/accessors are generated — only the DDL/drift descriptor remains in force, and the user hand-writes the record +
Schema(row).
Values
pub fn as_custom(
field f: FieldSpec,
gleam_type gleam_type: String,
lode_expr lode_expr: String,
imports imports: List(String),
) -> FieldSpec
Map the column to a different Gleam type via a custom LodeType while the
column type still drives DDL and drift.
pub fn assoc_defaults(
assoc a: AssocSpec,
values source: String,
imports imports: List(String),
) -> AssocSpec
Default column values for newly-inserted children: values is the Gleam
source of a List(#(String, value.Value)).
pub fn assoc_name(assoc a: AssocSpec) -> String
The association’s name (its record field).
pub fn assoc_on_delete(
assoc a: AssocSpec,
policy policy: OnDeleteOpt,
) -> AssocSpec
Set the on_delete cascade policy.
pub fn assoc_on_replace(
assoc a: AssocSpec,
policy policy: OnReplaceOpt,
) -> AssocSpec
Set the on_replace policy (write-side).
pub fn assoc_opts() -> AssocOpts
No options: every behavior at its association.options() default.
pub fn assoc_preload_order(
assoc a: AssocSpec,
order source: String,
imports imports: List(String),
) -> AssocSpec
Order preloaded children: order is the Gleam source of a
List(query.OrderItem); imports lists the modules it needs.
pub fn assoc_where(
assoc a: AssocSpec,
expr source: String,
imports imports: List(String),
) -> AssocSpec
Filter preloads of this association: expr is the Gleam source of an
expr.Expr (child at binding 0); imports lists the modules it needs.
pub fn assocs(
table t: TableSpec,
assocs as_: List(AssocSpec),
) -> TableSpec
Set the table’s associations.
pub fn belongs_to(
name name: String,
table table: String,
fk_column fk_column: String,
) -> AssocSpec
Declare a belongs_to (this table holds the foreign key).
pub fn column_name(field f: FieldSpec) -> String
The database column name backing a field (source override or name).
pub fn embed(
record record: String,
fields fields: List(FieldSpec),
) -> EmbedSpec
Build an embedded-schema spec.
pub fn field(
name name: String,
of type_: ColumnType,
) -> FieldSpec
A plain stored field (NOT NULL, not a key).
pub fn fields(
table t: TableSpec,
fields fs: List(FieldSpec),
) -> TableSpec
Set the table’s fields.
pub fn find_field(
table t: TableSpec,
name name: String,
) -> Result(FieldSpec, Nil)
Find a field by name on a table.
pub fn find_table(
tables tables: List(TableSpec),
name name: String,
) -> Result(TableSpec, Nil)
Find a table spec by table name.
pub fn has_many(
name name: String,
table table: String,
fk_column fk_column: String,
) -> AssocSpec
Declare a has_many (the related table holds the foreign key).
pub fn has_many_through(
name name: String,
via via: String,
to to: String,
) -> AssocSpec
Declare a has_many ... through two existing associations.
pub fn has_one(
name name: String,
table table: String,
fk_column fk_column: String,
) -> AssocSpec
Declare a has_one (the related table holds the foreign key).
pub fn manual_schema(table t: TableSpec) -> TableSpec
Opt the whole table out of generation: keep it in the spec for DDL/drift,
hand-write the record + Schema(row).
pub fn many_to_many(
name name: String,
table table: String,
join_through join_through: String,
join_owner_key join_owner_key: String,
join_related_key join_related_key: String,
) -> AssocSpec
Declare a many_to_many through join_through.
pub fn natural_key(
name name: String,
of type_: ColumnType,
) -> FieldSpec
An application-supplied (natural) primary key.
pub fn nullable(field f: FieldSpec) -> FieldSpec
Allow NULL. Non-key nullable fields generate as NullableValue(_).
pub fn primary_key(
name name: String,
of type_: ColumnType,
) -> FieldSpec
A database-generated primary key (serial / identity).
pub fn primary_key_columns(table t: TableSpec) -> List(String)
Column names of the primary key (stored fields only), in declared order.
pub fn references(
assoc a: AssocSpec,
column column: String,
) -> AssocSpec
Override the referenced column on the one side (defaults to that table’s
primary key). No-op for many_to_many/through.
pub fn resolve_through(
tables tables: List(TableSpec),
table t: TableSpec,
via via: String,
to to: String,
context here: String,
) -> Result(#(AssocSpec, TableSpec, AssocSpec, TableSpec), String)
Resolve a through chain: the via association on t names the
intermediate table; the to association on that table names the leaf.
Returns #(via_assoc, mid_table, to_assoc, leaf_table).
pub fn schema_fields(table t: TableSpec) -> List(FieldSpec)
The schema projection: every field (stored + virtual) with its app representation. What the generated record and codecs carry.
pub fn snake(record record: String) -> String
Snake-case a PascalCase record name (for generated function prefixes:
"ProductReview" -> "product_review").
pub fn stored_fields(table t: TableSpec) -> List(FieldSpec)
The DDL/drift projection: stored columns only. What the live database is expected to look like; virtual fields are excluded by construction.
pub fn table(
name name: String,
record record: String,
) -> TableSpec
Start a table spec.
pub fn validate(
tables tables: List(TableSpec),
) -> Result(Nil, String)
Check a spec set for the structural mistakes the type system can’t catch:
duplicate names, dangling association references, missing virtual defaults,
virtual fields posing as keys or columns, unresolvable through chains.
Generators and the drift checker call this first.
pub fn virtual(
name name: String,
of type_: ColumnType,
default default: String,
) -> FieldSpec
An app-only field: on the record and cast in changesets, never stored.
default is the Gleam source expression that seeds the field when a row is
loaded (e.g. "\"\"" for an empty string, "0", "primitive.Absent").
pub fn with_autogenerate(
field f: FieldSpec,
value value: Bool,
) -> FieldSpec
Override whether the database generates the value on insert (for non-key
generated columns: a DEFAULT now() timestamp, a non-key identity).