lode/schema
Schema metadata as an explicit value (no macros, no __schema__ reflection).
The user already writes their own Gleam record for a row; a Schema(row)
supplies the metadata Ecto would otherwise derive at compile time, plus the
load/dump codecs between the typed record and a Dict(String, Value) row.
In the “builders first” phase these codecs are hand-written (or produced by the field-builder helpers here). A future code generator can emit them.
pub type User { User(id: Int, name: String, age: Int) }
pub fn user_schema() -> Schema(User) { schema.new( source: “users”, fields: [ schema.primary_key(“id”, primitive.id()), schema.field(“name”, primitive.string()), schema.field(“age”, primitive.integer()), ], load: fn(row) { use id <- result.try(schema.require_int(row, “id”)) use name <- result.try(schema.require_string(row, “name”)) use age <- result.try(schema.require_int(row, “age”)) Ok(User(id:, name:, age:)) }, dump: fn(u: User) { dict.from_list([ #(“id”, value.VInt(u.id)), #(“name”, value.VString(u.name)), #(“age”, value.VInt(u.age)), ]) }, ) }
Types
Write-side metadata for a named association, used by repo.insert,
repo.update, and repo.delete.
Both closures are erased over the child type (they carry their typed child
schema and key functions captured inside), so a Schema(parent) can hold
write behavior for many different child types while staying a single type —
and crucially so schema need not depend on association (which would be a
cycle). lode/association builds these; the repo layer runs them.
pub type AssocMeta {
AssocMeta(
write: AssocWrite,
cascade: fn(adapter.Adapter, dynamic.Dynamic) -> Result(
Nil,
error.LodeError,
),
)
}
Constructors
-
AssocMeta( write: AssocWrite, cascade: fn(adapter.Adapter, dynamic.Dynamic) -> Result( Nil, error.LodeError, ), )Arguments
- write
-
How (and when) to persist children supplied via
put_assoc/cast_assoc. - cascade
-
Apply this association’s
on_deletepolicy when the parent is deleted.(adapter, parent) -> Nil. Runs before the parent row is deleted.
The write behavior of an association, distinguished by when it runs relative to the owner’s own insert/update.
pub type AssocWrite {
WriteAfter(
fn(adapter.Adapter, dynamic.Dynamic, dynamic.Dynamic) -> Result(
dynamic.Dynamic,
error.LodeError,
),
)
WriteBefore(
fn(adapter.Adapter, dynamic.Dynamic) -> Result(
#(String, value.Value),
error.LodeError,
),
)
WriteReadonly(String)
}
Constructors
-
WriteAfter( fn(adapter.Adapter, dynamic.Dynamic, dynamic.Dynamic) -> Result( dynamic.Dynamic, error.LodeError, ), )has_many/has_one/many_to_many: run after the owner row is written (so the owner’s key is available).(adapter, owner, erased_children) -> updated_owner. -
WriteBefore( fn(adapter.Adapter, dynamic.Dynamic) -> Result( #(String, value.Value), error.LodeError, ), )belongs_to: run before the owner is written — insert the referenced row and yield the foreign-key change to apply to the owner.(adapter, erased_children) -> #(fk_column, fk_value). -
WriteReadonly(String)Not writable (e.g.
through); carries the error message forput_assoc.
A single field’s metadata.
primary_key and autogenerate are independent, mirroring Ecto: a field
can be generated by the database without being a key (a DEFAULT now()
timestamp, a non-key identity/serial column, a read_after_writes-style
column), and a key can be application-supplied rather than generated (a
natural UUID/business key). All four combinations are meaningful.
pub type FieldDef {
FieldDef(
name: String,
source: String,
field_type: type_.FieldType,
primary_key: Bool,
autogenerate: Bool,
virtual: Bool,
)
}
Constructors
-
FieldDef( name: String, source: String, field_type: type_.FieldType, primary_key: Bool, autogenerate: Bool, virtual: Bool, )Arguments
- name
-
Field name as used in changesets/queries.
- source
-
Database column name (defaults to
name). - field_type
-
Type-erased caster/loader for this field.
- primary_key
-
Whether this field is part of the primary key.
- autogenerate
-
Whether the database generates this field’s value on insert (e.g. a serial id). Autogenerated fields are omitted from insert payloads.
- virtual
-
App-only field: no column backs it. Cast in changesets like any other field, but omitted from every write payload;
loadcodecs seed it from a default instead of a column.
Phase 2 of a join preload: given the adapter (for nested preloads), the shared result rows, and the deduplicated parents, extract this association’s children from its column prefix (deduping the cartesian repetition that several joins produce) and attach them, returning the updated parents.
pub type JoinCollector(row) =
fn(
adapter.Adapter,
List(dict.Dict(String, value.Value)),
List(row),
) -> Result(List(row), error.LodeError)
Derive the JOIN clause for a declared association (Ecto’s
join: c in assoc(p, :comments)). Given the parent Schema (for the
owner-side join column), the join kind, the child’s binding index, and its
alias, it returns the Join (source + ON child.fk = parent.<owner>).
Registered by has_many/has_one and by belongs_to once it declares its
owner_column; consumed by association.join.
pub type JoinDeriver(row) =
fn(Schema(row), query.JoinKind, Int, String) -> Result(
query.Join,
error.LodeError,
)
A join preload contributor for one association — the two-phase shape
that lets several join preloads share one query. Phase 1 (this function):
given the parent Schema, the query accumulated so far (parent select +
any prior associations’ joins/selects), and the nested preloads, it appends
this association’s JOIN(s) and prefixed child columns and returns the
augmented query plus a JoinCollector. The repo runs the final combined
query once, then phase 2 runs each collector over the shared rows. With a
single join preload this is just N=1.
pub type JoinPreloadFn(row) =
fn(Schema(row), query.Query, List(preload.Preload)) -> Result(
#(
query.Query,
fn(
adapter.Adapter,
List(dict.Dict(String, value.Value)),
List(row),
) -> Result(List(row), error.LodeError),
),
error.LodeError,
)
An association loader stored on a schema under its name. The child type is
erased into the closure, so a schema can hold associations to many different
child types while staying a single Schema(row). Given the adapter, the
parent rows, and any nested preloads, it returns the parents with the
association attached. Built by lode/association, run by repo.preload.
pub type PreloadFn(row) =
fn(adapter.Adapter, List(row), List(preload.Preload)) -> Result(
List(row),
error.LodeError,
)
Schema metadata + row codecs, parameterized by the user’s row record type.
pub type Schema(row) {
Schema(
source: String,
fields: List(FieldDef),
load: fn(dict.Dict(String, value.Value)) -> Result(
row,
error.LodeError,
),
dump: fn(row) -> dict.Dict(String, value.Value),
assocs: List(
#(
String,
fn(adapter.Adapter, List(row), List(preload.Preload)) -> Result(
List(row),
error.LodeError,
),
),
),
join_preloaders: List(
#(
String,
fn(Schema(row), query.Query, List(preload.Preload)) -> Result(
#(
query.Query,
fn(
adapter.Adapter,
List(dict.Dict(String, value.Value)),
List(row),
) -> Result(List(row), error.LodeError),
),
error.LodeError,
),
),
),
join_derivers: List(
#(
String,
fn(Schema(row), query.JoinKind, Int, String) -> Result(
query.Join,
error.LodeError,
),
),
),
assoc_metas: List(#(String, AssocMeta)),
)
}
Constructors
-
Schema( source: String, fields: List(FieldDef), load: fn(dict.Dict(String, value.Value)) -> Result( row, error.LodeError, ), dump: fn(row) -> dict.Dict(String, value.Value), assocs: List( #( String, fn(adapter.Adapter, List(row), List(preload.Preload)) -> Result( List(row), error.LodeError, ), ), ), join_preloaders: List( #( String, fn(Schema(row), query.Query, List(preload.Preload)) -> Result( #( query.Query, fn( adapter.Adapter, List(dict.Dict(String, value.Value)), List(row), ) -> Result(List(row), error.LodeError), ), error.LodeError, ), ), ), join_derivers: List( #( String, fn(Schema(row), query.JoinKind, Int, String) -> Result( query.Join, error.LodeError, ), ), ), assoc_metas: List(#(String, AssocMeta)), )Arguments
- source
-
Table / collection name.
- load
-
Build a typed row from a column map (DB-canonical values).
- dump
-
Render a typed row into a column map (DB-canonical values).
- assocs
-
Named associations, for
repo.preload. - join_preloaders
-
Named join preloaders (
has_many/has_one/belongs_to), forrepo.all’spreload.joinpath. - join_derivers
-
Named join derivers, for
association.join(Ecto’sassoc(..)join). - assoc_metas
-
Named write-side association metadata, for
repoinsert/update/delete.
Values
pub fn autogenerate_names(schema: Schema(row)) -> List(String)
Names of fields the database generates on insert.
pub fn autogenerate_sources(schema: Schema(row)) -> List(String)
Column names of fields the database generates on insert (the source of
each autogenerated field). These are dropped from insert payloads.
pub fn dump_field(
of t: type_.LodeType(a),
value value: a,
) -> value.Value
Dump one typed value to a column Value using the field’s LodeType.
(A well-typed value always dumps; falls back to VNull defensively.)
pub fn field(
name name: String,
of t: type_.LodeType(a),
) -> FieldDef
Declare a non-key, non-generated field from a typed LodeType.
pub fn field_def(
schema schema: Schema(row),
name name: String,
) -> Result(FieldDef, Nil)
Look up a field definition by name.
pub fn field_type(
schema schema: Schema(row),
name name: String,
) -> Result(type_.FieldType, Nil)
Look up the (erased) type of a field by name.
pub fn from(
schema s: Schema(row),
alias alias: String,
) -> query.Query
Start a query from this schema’s table — query.from with the table name
taken from the schema, so it can’t be typo’d or drift from the schema you
load with. (It lives here, not on query: query is the lower layer
schema is built on, so query can’t import schema without a cycle — only
this side can see both.)
let smiths = schema.from(person_schema(), alias: “p”) |> query.where(expr.eq(field: person_last_name(), to: “Smith”))
pub fn get(
row row: dict.Dict(String, value.Value),
name name: String,
) -> Result(value.Value, Nil)
Fetch a raw column value if present.
pub fn get_assoc(
schema schema: Schema(row),
name name: String,
) -> Result(
fn(adapter.Adapter, List(row), List(preload.Preload)) -> Result(
List(row),
error.LodeError,
),
Nil,
)
Look up a named association loader.
pub fn get_assoc_meta(
schema schema: Schema(row),
name name: String,
) -> Result(AssocMeta, Nil)
Look up write-side metadata for a named association.
pub fn get_join_deriver(
schema schema: Schema(row),
name name: String,
) -> Result(
fn(Schema(row), query.JoinKind, Int, String) -> Result(
query.Join,
error.LodeError,
),
Nil,
)
Look up a named join deriver (absent when the association isn’t joinable —
e.g. a belongs_to without owner_column, or many_to_many/through).
pub fn get_join_preloader(
schema schema: Schema(row),
name name: String,
) -> Result(
fn(Schema(row), query.Query, List(preload.Preload)) -> Result(
#(
query.Query,
fn(
adapter.Adapter,
List(dict.Dict(String, value.Value)),
List(row),
) -> Result(List(row), error.LodeError),
),
error.LodeError,
),
Nil,
)
Look up a named join preloader (absent when the association doesn’t support
preload.join — e.g. belongs_to, many_to_many, or one carrying
per-association where/preload_order options).
pub fn load_field(
row row: dict.Dict(String, value.Value),
name name: String,
of t: type_.LodeType(a),
) -> Result(a, error.LodeError)
Load one column into its typed value using the field’s LodeType. A missing
column is treated as VNull (so nullable types load as their empty value).
This is the building block generated load codecs use.
pub fn load_field_prefixed(
row row: dict.Dict(String, value.Value),
prefix prefix: String,
name name: String,
of t: type_.LodeType(a),
) -> Result(a, error.LodeError)
Load one column out of a joined row whose keys are prefixed (e.g. u__id).
Looks up the key prefix <> "__" <> name; a missing column is treated as
VNull (same tolerance as load_field). This is the per-field building
block for splitting a joined row built with expr.col_as.
pub fn load_prefixed(
schema schema: Schema(row),
prefix prefix: String,
row row: dict.Dict(String, value.Value),
) -> Result(row, error.LodeError)
Split one side of a joined row out of a prefix-aliased result. Re-keys row
by stripping the leading prefix <> "__" from each matching key, then runs
the schema’s existing load. Pair with expr.col_as(field, prefix), which
produces the prefix__name aliases. Only the leading prefix segment is
stripped, so a real column name that itself contains __ is preserved.
pub fn load_virtual(
row row: dict.Dict(String, value.Value),
name name: String,
of t: type_.LodeType(a),
default default: a,
) -> a
Load a virtual field out of a row map, falling back to default. Rows read
from the database never carry virtual fields (no column exists), but rows
built by changeset.apply_changes may — a cast virtual change must survive
the load round-trip. Infallible: anything unreadable becomes default.
pub fn natural_key(
name name: String,
of t: type_.LodeType(a),
) -> FieldDef
Declare a non-autogenerated (natural) primary key, e.g. a UUID or business key supplied by the application.
pub fn new(
source source: String,
fields fields: List(FieldDef),
load load: fn(dict.Dict(String, value.Value)) -> Result(
row,
error.LodeError,
),
dump dump: fn(row) -> dict.Dict(String, value.Value),
) -> Schema(row)
Construct a schema. Labels keep call sites readable.
pub fn primary_key(
name name: String,
of t: type_.LodeType(a),
) -> FieldDef
Declare an autogenerated primary key (e.g. a serial id). Omitted from insert payloads so the database assigns the value.
pub fn primary_key_names(schema: Schema(row)) -> List(String)
Names of the primary-key fields.
pub fn primary_key_present(
schema schema: Schema(row),
row row: row,
) -> Bool
Whether every primary-key field on row carries a value — the signal
lode uses to tell a persisted row (update) from a freshly built one
(insert), standing in for Ecto’s __meta__.state. A key column counts as
absent when it is NULL, integer 0, or the empty string — the zero values
a just-built struct holds before the database assigns them. A schema with
no primary key is never considered persisted.
pub fn put_assoc(
schema schema: Schema(row),
name name: String,
loader loader: fn(
adapter.Adapter,
List(row),
List(preload.Preload),
) -> Result(List(row), error.LodeError),
) -> Schema(row)
Register a named association loader (used by lode/association).
pub fn put_assoc_meta(
schema schema: Schema(row),
name name: String,
meta meta: AssocMeta,
) -> Schema(row)
Register write-side metadata for a named association (used by
lode/association for writable associations).
pub fn put_join_deriver(
schema schema: Schema(row),
name name: String,
deriver deriver: fn(Schema(row), query.JoinKind, Int, String) -> Result(
query.Join,
error.LodeError,
),
) -> Schema(row)
Register a join deriver for a named association (used by lode/association).
pub fn put_join_preloader(
schema schema: Schema(row),
name name: String,
loader loader: fn(
Schema(row),
query.Query,
List(preload.Preload),
) -> Result(
#(
query.Query,
fn(
adapter.Adapter,
List(dict.Dict(String, value.Value)),
List(row),
) -> Result(List(row), error.LodeError),
),
error.LodeError,
),
) -> Schema(row)
Register a join preloader for a named association (has_many/has_one).
pub fn require_int(
row row: dict.Dict(String, value.Value),
name name: String,
) -> Result(Int, error.LodeError)
Require an integer column.
pub fn require_string(
row row: dict.Dict(String, value.Value),
name name: String,
) -> Result(String, error.LodeError)
Require a string column.
pub fn schemaless(
fields fields: List(FieldDef),
) -> Schema(dict.Dict(String, value.Value))
Build a schemaless schema over a bare Dict(String, Value) row (Ecto’s
cast({data, types}, ...)): the fields supply the types for casting and
validation, load/dump are the identity, and source is empty (the data
is typically validated, not persisted). Cast against it like any schema:
let types = schema.schemaless([ schema.field(“name”, primitive.string()), schema.field(“age”, primitive.integer()), ]) let cs = changeset.cast(dict.new(), types, params, [“name”, “age”]) // changeset.apply_changes(cs) -> Ok(Dict(String, Value))
pub fn stored_sources(schema: Schema(row)) -> List(String)
The column sources present on a loaded row: every non-virtual field’s
source (including database-generated ones like id). The projection a
SELECT needs to reconstruct a struct — used by join-preload to alias each
side’s columns.
pub fn value_present(value v: value.Value) -> Bool
Whether a value counts as “set” for persisted-vs-new detection: anything
but the zero values a freshly built struct carries (NULL / 0 / "").
The single source of truth for this rule, shared by primary_key_present
and the association write path’s insert-vs-update inference.
pub fn virtual_field(
name name: String,
of t: type_.LodeType(a),
) -> FieldDef
Declare an app-only (virtual) field. It is cast by changesets like any
stored field but never written to the database; pair it with a load codec
that seeds it via load_virtual.
pub fn virtual_names(schema: Schema(row)) -> List(String)
Names of app-only (virtual) fields. No column backs them, so they are dropped from every write payload.
pub fn with_autogenerate(
def def: FieldDef,
value value: Bool,
) -> FieldDef
Override whether the database generates this field’s value on insert.
Independent of whether the field is a primary key: use it for non-key
database-generated columns (a DEFAULT-backed timestamp, a non-key identity
column). Autogenerated fields are dropped from insert payloads.