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_delete policy 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

  • 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 for put_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; load codecs 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

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_names(schema: Schema(row)) -> List(String)

All field names.

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.

pub fn with_source(
  def def: FieldDef,
  source source: String,
) -> FieldDef

Override the database column name for a field.

Search Document