lode/association

Declare named associations on a schema, then preload them by name with repo.preload (Ecto’s preload([:comments, comments: :author]) style) and write them with changeset.put_assoc/cast_assoc.

Gleam has no reflection, so each association bundles the functions the library can’t infer — how to read the join key on each side, and how to attach loaded children. The child type is then erased into closures stored on the schema (a preload loader + write/cascade metadata), so one Schema(parent) can hold associations to many different child types.

fn post_schema() { post_base() |> association.has_many( name: “comments”, related: comment_schema, // a thunk; itself may declare author foreign_key: “post_id”, owner_key: fn(p: Post) { value.VInt(p.id) }, child_key: fn(c: Comment) { value.VInt(c.post_id) }, put: fn(p, cs) { Post(..p, comments: cs) }, opts: association.options() |> association.on_replace(association.ReplaceDelete), ) }

Supported options (see Options): where (extra filter on preload), preload_order, on_replace and defaults (write-side, has_many/has_one), on_delete (cascade on repo.delete, has_many/has_one), and join_defaults/join_row/join_row_checked/join_constraint (many_to_many join-row columns, validation & constraint mapping).

Read-side preloading is supported for every kind (has_many, has_one, belongs_to, has_many_through, many_to_many). Write-side (put_assoc/cast_assoc, on_replace, on_delete) is supported for has_many/has_one/belongs_to/many_to_many; through is read-only (write the underlying association). If any staged child changeset is invalid, repo.insert/repo.update fail before any child row is written, every invalid child reporting at once with errors keyed "<assoc>.<field>" plus association/child_index metadata (the same keying join_row_checked uses for rejected links).

Types

A declared join-table constraint (see join_constraint): the database name to match against a reported violation, and the message the mapped field error carries.

pub type JoinConstraint {
  JoinConstraint(name: String, message: String)
}

Constructors

  • JoinConstraint(name: String, message: String)

What to do with child rows when the parent is deleted via repo.delete.

pub type OnDelete {
  DeleteNothing
  DeleteAll
  NilifyAll
}

Constructors

  • DeleteNothing

    Default: leave child rows untouched (the DB may still enforce its own FK).

  • DeleteAll

    Delete all child rows.

  • NilifyAll

    Set all child rows’ foreign key to NULL.

What to do with previously-associated child rows that are absent from the set supplied to put_assoc/cast_assoc.

pub type OnReplace {
  ReplaceRaise
  ReplaceDelete
  ReplaceNilify
}

Constructors

  • ReplaceRaise

    Default: error if any existing child would be replaced/removed.

  • ReplaceDelete

    Delete the removed child rows.

  • ReplaceNilify

    Set the removed child rows’ foreign key to NULL.

Per-association options. Build with options() and the modifier functions.

pub type Options {
  Options(
    where: option.Option(expr.Expr),
    preload_order: List(query.OrderItem),
    on_replace: OnReplace,
    on_delete: OnDelete,
    defaults: List(#(String, value.Value)),
    owner_column: option.Option(String),
    join_defaults: List(#(String, value.Value)),
    join_row: option.Option(
      fn(dynamic.Dynamic, dynamic.Dynamic) -> Result(
        List(#(String, value.Value)),
        List(#(String, error.FieldError)),
      ),
    ),
    join_constraints: List(JoinConstraint),
    through_parent_column: option.Option(String),
    through_mid_column: option.Option(String),
  )
}

Constructors

Values

pub fn belongs_to(
  schema s: schema.Schema(parent),
  name name: String,
  related related: fn() -> schema.Schema(child),
  foreign_key foreign_key: String,
  owner_key owner_key: fn(parent) -> value.Value,
  child_key child_key: fn(child) -> value.Value,
  put put: fn(parent, option.Option(child)) -> parent,
  opts opts: Options,
) -> schema.Schema(parent)

Declare a many-to-one association (the parent holds the foreign key); put receives at most one child. related is a thunk (see has_many).

Writable: put_assoc/cast_assoc insert the referenced row first, then set the parent’s foreign_key to the referenced row’s child_key. Pass a 0- or 1-element list ([] sets the foreign key to NULL).

pub fn defaults(
  opts opts: Options,
  values values: List(#(String, value.Value)),
) -> Options

Default column values applied to newly-inserted children (overridden by the child changeset’s own changes).

pub fn has_many(
  schema s: schema.Schema(parent),
  name name: String,
  related related: fn() -> schema.Schema(child),
  foreign_key foreign_key: String,
  owner_key owner_key: fn(parent) -> value.Value,
  child_key child_key: fn(child) -> value.Value,
  put put: fn(parent, List(child)) -> parent,
  opts opts: Options,
) -> schema.Schema(parent)

Declare a one-to-many association; put receives every matched child.

related is a thunk (fn() -> Schema(child)), not a Schema, so that mutually-referential schemas don’t recurse forever while being constructed — the related schema is built only when the association is used.

pub fn has_many_through(
  schema s: schema.Schema(parent),
  name name: String,
  via via: fn() -> schema.Schema(mid),
  to to: fn() -> schema.Schema(leaf),
  parent_key parent_key: fn(parent) -> value.Value,
  via_foreign_key via_foreign_key: String,
  mid_owner_key mid_owner_key: fn(mid) -> value.Value,
  mid_key mid_key: fn(mid) -> value.Value,
  to_foreign_key to_foreign_key: String,
  leaf_owner_key leaf_owner_key: fn(leaf) -> value.Value,
  put put: fn(parent, List(leaf)) -> parent,
  opts opts: Options,
) -> schema.Schema(parent)

Declare a has-many-through association: reach leaf rows via an intermediate mid (Ecto’s has_many :x, through: [:mid, :leaf]). Two-hop, read-only.

parent_key/mid_owner_key join the parent to the mid (the mid’s via_foreign_key column equals the parent’s key); mid_key/leaf_owner_key join the mid to the leaf (the leaf’s to_foreign_key column equals the mid’s key). put attaches the collected leaves to the parent.

pub fn has_one(
  schema s: schema.Schema(parent),
  name name: String,
  related related: fn() -> schema.Schema(child),
  foreign_key foreign_key: String,
  owner_key owner_key: fn(parent) -> value.Value,
  child_key child_key: fn(child) -> value.Value,
  put put: fn(parent, option.Option(child)) -> parent,
  opts opts: Options,
) -> schema.Schema(parent)

Declare a one-to-one association owned by the parent; put receives at most one child. related is a thunk (see has_many).

pub fn join(
  query q: query.Query,
  schema s: schema.Schema(parent),
  assoc name: String,
  kind kind: query.JoinKind,
  alias alias: String,
) -> Result(query.Query, error.LodeError)

Add a JOIN derived from a declared association (Ecto’s join: c in assoc(p, :comments)): the child table and the ON child.fk = parent.<key> come from the association on schema, which is the query’s from binding (binding 0). Use it to filter or select by a joined table without spelling the join condition by hand — the joined child sits at the next binding (1 for the first join), so reference its columns with expr.Col(1, "..."):

use q <- result.try( query.from(“posts”, “p”) |> association.join(post_schema(), “comments”, query.InnerJoin, “c”), ) query.where(q, expr.is_nil(expr.Col(1, “deleted_at”)))

Error if the association isn’t joinable: an unknown name, a belongs_to without owner_column, or many_to_many/through (use repo.query_raw).

pub fn join_constraint(
  opts opts: Options,
  name name: String,
  message message: String,
) -> Options

Map a database constraint violation on the many_to_many join table to a field-level changeset error (Ecto’s unique_constraint declared on the join schema). name is the constraint’s database name exactly as the engine reports it; when inserting a new link violates it, repo.insert/repo.update roll the whole write back and return Error(ChangesetInvalid(..)) with one error keyed on the association (e.g. "tags"), carrying message plus constraint/constraint_kind metadata (like changeset.unique_constraint row mapping) and association/join_child_key metadata (like join_row_checked rejections) identifying which child’s link collided. A violation of an undeclared constraint still propagates as the raw ConstraintError.

Engines report names differently, so declare one per engine you run on (calls accumulate):

  • Postgres reports the constraint/index name — UNIQUE (post_id, tag_id) on posts_tags auto-names it "posts_tags_post_id_tag_id_key".

  • SQLite reports the violated columns as "<table>.<column>" joined by ", " — e.g. "posts_tags.post_id, posts_tags.tag_id".

    association.options() |> association.join_constraint( name: “posts_tags_post_id_tag_id_key”, // Postgres message: “has already been taken”, ) |> association.join_constraint( name: “posts_tags.post_id, posts_tags.tag_id”, // SQLite message: “has already been taken”, )

Matching is by name, so any constraint the database enforces on the join table (unique, foreign-key, check) can be declared; the violated kind is reported in constraint_kind metadata. Unlike join_row_checked (which checks every link before any row is written), the database reports the first offending insert, so one link’s error is returned per attempt — the transaction rollback still leaves no partial rows.

pub fn join_defaults(
  opts opts: Options,
  values values: List(#(String, value.Value)),
) -> Options

Extra column values written on each inserted many_to_many join-table row — for a join table with more than the two foreign keys (Ecto’s :join_through with a schema). Columns with a database default can be omitted (the database fills them); supply the rest here. Static values only — use join_row for per-link computed columns, or join_row_checked when a link can be rejected with field errors.

pub fn join_row(
  opts opts: Options,
  with compute: fn(parent, child) -> List(#(String, value.Value)),
) -> Options

Compute extra many_to_many join-table columns per link, from the parent and the specific child being linked (Ecto’s :join_through with a schema, where the join row carries its own data). The returned columns are merged over join_defaults (so a computed value overrides a static one). Use it for per-membership data — a role, a position, an app-computed timestamp. Only applied to newly-inserted links. Declaring both join_row and join_row_checked keeps whichever was applied last.

association.options() |> association.join_row(with: fn(team: Team, user: User) { [#(“role”, value.VString(default_role(team, user)))] })

pub fn join_row_checked(
  opts opts: Options,
  with compute: fn(parent, child) -> Result(
    List(#(String, value.Value)),
    List(#(String, error.FieldError)),
  ),
) -> Options

Like join_row, but the per-link computation can reject the link with field-level errors (Ecto’s join-through-a-schema changeset). Return Ok(columns) to accept — merged over join_defaults exactly like join_row — or Error([#("column", FieldError(..))]) to reject. Every new link is checked before any join row is inserted; if any link is rejected the whole repo.insert/repo.update transaction rolls back and the caller gets Error(ChangesetInvalid(errors)), each error keyed "<assoc>.<column>" (e.g. "tags.role") with association and join_child_key metadata identifying which child’s link failed. Invalid child changesets are reported with the same key shape, tagged child_index instead of join_child_key (see changeset.put_assoc). Declaring both join_row and join_row_checked keeps whichever was applied last. For constraints the database enforces on the join table (e.g. a unique (owner, related) index), declare join_constraint instead.

association.options() |> association.join_row_checked(with: fn(team: Team, user: User) { case role_for(team, user) { Ok(role) -> Ok([#(“role”, value.VString(role))]) Error(_) -> Error([ #(“role”, error.FieldError(“is invalid”, [#(“validation”, “inclusion”)])), ]) } })

pub fn many_to_many(
  schema s: schema.Schema(parent),
  name name: String,
  related related: fn() -> schema.Schema(child),
  join_through join_through: String,
  join_owner_key join_owner_key: String,
  join_related_key join_related_key: String,
  owner_key owner_key: fn(parent) -> value.Value,
  child_key child_key: fn(child) -> value.Value,
  put put: fn(parent, List(child)) -> parent,
  opts opts: Options,
) -> schema.Schema(parent)

Declare a many-to-many association through a join table (Ecto’s many_to_many :x, join_through: "..."). Writable.

join_owner_key/join_related_key are the join-table column names that reference the parent and the child respectively; owner_key reads the parent’s key (matched against join_owner_key); child_key reads the child’s key (matched against join_related_key).

put_assoc/cast_assoc upsert the children, then add/remove join rows to match (honoring on_replace); on_delete(DeleteAll) removes this owner’s join rows when the parent is deleted (the children themselves are untouched). A database constraint violated by a new join row maps to a field error on the association when declared with join_constraint; otherwise it surfaces as the raw ConstraintError.

pub fn on_delete(
  opts opts: Options,
  policy policy: OnDelete,
) -> Options

Set the on_delete policy (has_many/has_one cascade on repo.delete).

pub fn on_replace(
  opts opts: Options,
  policy policy: OnReplace,
) -> Options

Set the on_replace policy (has_many/has_one writes).

pub fn options() -> Options

Default options: no extra filter, no ordering, ReplaceRaise, DeleteNothing, no defaults, no explicit join column.

pub fn owner_column(
  opts opts: Options,
  column column: String,
) -> Options

The parent-side column the join preload matches on (preload.join). Only needed for belongs_to, where the join keys off the parent’s foreign key (owner_column("author_id") to pair with owner_key: row.author_id); has_many/has_one default to the parent’s primary key. Without it, a belongs_to is not join-preloadable and preload.join falls back to an error pointing at preload.one.

pub fn preload_order(
  opts opts: Options,
  by by: List(query.OrderItem),
) -> Options

Order the preloaded children.

pub fn run(
  adapter adapter: adapter.Adapter,
  schema s: schema.Schema(row),
  parents parents: List(row),
  preloads preloads: List(preload.Preload),
) -> Result(List(row), error.LodeError)

Apply a list of named preloads to parents, resolving each against the schema’s registered associations. Used by repo.preload.

pub fn run_join_preloads(
  adapter adapter: adapter.Adapter,
  schema pschema: schema.Schema(parent),
  query base_q: query.Query,
  preloads joined: List(preload.Preload),
) -> Result(List(parent), error.LodeError)

Run one or more preload.joins through a single combined query. Each association’s contributor appends its JOIN(s) and prefixed child columns to a shared query — binding ranges allocate sequentially because every contributor reads query.last_binding of the query so far — so N join preloads become one query with N (or more) joins. The query runs once, the parents are deduplicated by primary key, and each association’s collector splits its own children out of the shared rows (deduping the cartesian repetition the sibling joins produce) and attaches them. With a single join preload this is the same one-join query as before.

pub fn through_columns(
  opts opts: Options,
  parent_column parent_column: String,
  mid_column mid_column: String,
) -> Options

The hop columns for a has_many :through join preload, when the hops don’t go via primary keys. parent_column is the parent column the intermediate’s via_foreign_key references (default: the parent’s primary key); mid_column is the intermediate column the leaf’s to_foreign_key references (default: the intermediate’s primary key). The batched through loader already keys off the value extractors, so this only affects the JOIN’s ON clauses. Pass the actual hop columns (a primary key is fine).

pub fn where(
  opts opts: Options,
  condition condition: expr.Expr,
) -> Options

Add an extra filter applied when preloading this association. The expression references the child source at binding 0.

Search Document