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
-
DeleteNothingDefault: leave child rows untouched (the DB may still enforce its own FK).
-
DeleteAllDelete all child rows.
-
NilifyAllSet 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
-
ReplaceRaiseDefault: error if any existing child would be replaced/removed.
-
ReplaceDeleteDelete the removed child rows.
-
ReplaceNilifySet 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
-
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), )
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)onposts_tagsauto-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).