Associations
A lode port of Ecto’s associations cheatsheet. “Internal data” below means
values your own Gleam code constructs; “external data” means user-supplied
params (forms, APIs) that should be normalized and validated through
lode/changeset. Migration examples use lode/migration from the lode_sql
package.
Design note: Gleam has no reflection or macros, so associations are declared
by name on the schema, and each declaration supplies what Ecto would infer —
key extractors (owner_key:/child_key:) and a put: function that attaches
loaded children to a field on your own record. Like Ecto, there is no lazy
loading: associations load only when you ask.
See also the CRUD cheatsheet, Polymorphic associations with many to many, Self-referencing many to many, and divergences from Ecto.
Imports used throughout:
import lode/association
import lode/changeset
import lode/preload
import lode/query
import lode/query/expr
import lode/repo
import lode/schema.{type Schema}
import lode/types/primitive
import lode/types/temporal
import lode/value.{VDate, VInt, VString}
import gleam/dict
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/time/calendar.{type Date}
Has many / belongs to
The has many association
The association field (characters) lives on your own record; put: is how
loaded children get attached to it. related: is a thunk (fn() -> Schema(child)), so mutually-referential schemas don’t recurse while being
built.
pub type Movie {
Movie(
id: Int,
title: String,
release_date: Date,
characters: List(Character),
)
}
fn movie_schema() -> Schema(Movie) {
schema.new(
source: "movies",
fields: [
schema.primary_key("id", primitive.id()),
schema.field("title", primitive.string()),
schema.field("release_date", temporal.date()),
],
load: fn(row) {
use id <- result.try(schema.require_int(row, "id"))
use title <- result.try(schema.require_string(row, "title"))
use release_date <- result.try(schema.load_field(
row,
"release_date",
temporal.date(),
))
Ok(Movie(id:, title:, release_date:, characters: []))
},
dump: fn(m: Movie) {
dict.from_list([
#("id", VInt(m.id)),
#("title", VString(m.title)),
#("release_date", VDate(m.release_date)),
])
},
)
|> association.has_many(
name: "characters",
related: character_schema,
foreign_key: "movie_id",
owner_key: fn(m: Movie) { VInt(m.id) },
child_key: fn(c: Character) { VInt(c.movie_id) },
put: fn(m, cs) { Movie(..m, characters: cs) },
opts: association.options(),
)
}
Per-association options are built by piping association.options():
// A typed column reference on the child source (binding 0 in the preload query).
fn character_name() -> expr.FieldRef(Character, String) {
expr.FieldRef(binding: 0, name: "name", lode_type: primitive.string())
}
association.options()
|> association.where(expr.neq(field: character_name(), to: ""))
|> association.preload_order([query.asc(expr.col(character_name()))])
|> association.on_replace(association.ReplaceDelete)
|> association.on_delete(association.DeleteAll)
|> association.defaults([#("age", VInt(0))])
on_replace is one of ReplaceRaise (default), ReplaceDelete,
ReplaceNilify; on_delete is one of DeleteNothing (default), DeleteAll,
NilifyAll and is applied by repo.delete in the same transaction.
The belongs to association
pub type Character {
Character(
id: Int,
name: String,
age: Int,
movie_id: Int,
movie: Option(Movie),
)
}
fn character_schema() -> Schema(Character) {
schema.new(
source: "characters",
fields: [
schema.primary_key("id", primitive.id()),
schema.field("name", primitive.string()),
schema.field("age", primitive.integer()),
schema.field("movie_id", 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"))
use movie_id <- result.try(schema.require_int(row, "movie_id"))
Ok(Character(id:, name:, age:, movie_id:, movie: None))
},
dump: fn(c: Character) {
dict.from_list([
#("id", VInt(c.id)),
#("name", VString(c.name)),
#("age", VInt(c.age)),
#("movie_id", VInt(c.movie_id)),
])
},
)
|> association.belongs_to(
name: "movie",
related: movie_schema,
foreign_key: "movie_id",
owner_key: fn(c: Character) { VInt(c.movie_id) },
child_key: fn(m: Movie) { VInt(m.id) },
put: fn(c, m) { Character(..c, movie: m) },
opts: association.options(),
)
}
For belongs_to, owner_key: reads the owner’s foreign key and child_key:
reads the referenced row’s key; put: receives Option(child).
The migration
import lode/migration
import lode/migration/ddl
pub fn create_movies_and_characters() -> migration.Migration {
migration.new(20_260_612_120_000)
|> migration.create_table("movies", [
ddl.column("id", ddl.Serial) |> ddl.primary_key,
ddl.column("title", ddl.Text) |> ddl.not_null,
ddl.column("release_date", ddl.Date),
])
|> migration.create_table("characters", [
ddl.column("id", ddl.Serial) |> ddl.primary_key,
ddl.column("name", ddl.Text) |> ddl.not_null,
ddl.column("age", ddl.Integer),
ddl.column("movie_id", ddl.Integer)
|> ddl.not_null
|> ddl.references(ddl.reference("movies") |> ddl.on_delete(ddl.Cascade)),
])
}
ddl.references(column, ddl.reference("movies")) makes movie_id a foreign
key, and ddl.on_delete(ddl.Cascade) adds ON DELETE CASCADE (use
ddl.on_update / ddl.references_column for the other options). For the
conventional inserted_at/updated_at columns, splice ..ddl.timestamps()
into a table’s column list.
For an application-level cascade instead of (or alongside) the database one,
declare association.on_delete(association.DeleteAll) on the has_many.
Has one / belongs to
The has one association
has_one takes the same arguments as has_many, but put: receives
Option(child).
pub type Movie {
Movie(id: Int, title: String, screenplay: Option(Screenplay))
}
// movie_base() is the schema.new(source: "movies", ...) pattern shown above.
fn movie_schema() -> Schema(Movie) {
movie_base()
|> association.has_one(
name: "screenplay",
related: screenplay_schema,
foreign_key: "movie_id",
owner_key: fn(m: Movie) { VInt(m.id) },
child_key: fn(s: Screenplay) { VInt(s.movie_id) },
put: fn(m, s) { Movie(..m, screenplay: s) },
opts: association.options(),
)
}
The belongs to association
pub type Screenplay {
Screenplay(id: Int, lead_writer: String, movie_id: Int, movie: Option(Movie))
}
// screenplay_base() is the schema.new(source: "screenplays", ...) pattern.
fn screenplay_schema() -> Schema(Screenplay) {
screenplay_base()
|> association.belongs_to(
name: "movie",
related: movie_schema,
foreign_key: "movie_id",
owner_key: fn(s: Screenplay) { VInt(s.movie_id) },
child_key: fn(m: Movie) { VInt(m.id) },
put: fn(s, m) { Screenplay(..s, movie: m) },
opts: association.options(),
)
}
The migration
pub fn create_movies_and_screenplays() -> migration.Migration {
migration.new(20_260_612_121_000)
|> migration.create_table("movies", [
ddl.column("id", ddl.Serial) |> ddl.primary_key,
ddl.column("title", ddl.Text) |> ddl.not_null,
ddl.column("release_date", ddl.Date),
])
|> migration.create_table("screenplays", [
ddl.column("id", ddl.Serial) |> ddl.primary_key,
ddl.column("lead_writer", ddl.Text) |> ddl.not_null,
ddl.column("movie_id", ddl.Integer) |> ddl.not_null,
])
}
Many to many
Through a join table
join_through: names the join table; join_owner_key:/join_related_key: are
its columns referencing each side.
The first schema
pub type Movie {
Movie(id: Int, title: String, actors: List(Actor))
}
fn movie_schema() -> Schema(Movie) {
movie_base()
|> association.many_to_many(
name: "actors",
related: actor_schema,
join_through: "movies_actors",
join_owner_key: "movie_id",
join_related_key: "actor_id",
owner_key: fn(m: Movie) { VInt(m.id) },
child_key: fn(a: Actor) { VInt(a.id) },
put: fn(m, actors) { Movie(..m, actors: actors) },
opts: association.options(),
)
}
The second schema
pub type Actor {
Actor(id: Int, name: String, movies: List(Movie))
}
fn actor_schema() -> Schema(Actor) {
actor_base()
|> association.many_to_many(
name: "movies",
related: movie_schema,
join_through: "movies_actors",
join_owner_key: "actor_id",
join_related_key: "movie_id",
owner_key: fn(a: Actor) { VInt(a.id) },
child_key: fn(m: Movie) { VInt(m.id) },
put: fn(a, movies) { Actor(..a, movies: movies) },
opts: association.options(),
)
}
Through a join schema
join_through: is a table-name string. association.join_defaults([#("col", value)]) sets static extra columns on each inserted link, and
association.join_row(with: fn(parent, child) { [#("col", value)] }) computes
columns per link from the parent and child (role, position, timestamp) — so a
data-carrying join table works. join_row_checked is the fallible form — a
join-row changeset that can reject the link itself, with errors arriving as
ChangesetInvalid keyed "assoc.column" (the whole write rolls back) — the
same key shape an invalid child changeset produces, tagged child_index
instead of join_child_key:
opts: association.options()
|> association.join_row_checked(with: fn(_actor: Actor, m: Movie) {
case m.title {
"" -> Error([#("billing", FieldError("is invalid", []))])
_ -> Ok([#("billing", VString("top"))])
}
}),
join_constraint(name:, message:) maps a database constraint violated by a
new link (e.g. a unique (owner, related) index) to the same kind of field
error — keyed "<assoc>", with constraint/constraint_kind and
association/join_child_key metadata; the whole write rolls back. Engines
report names differently, so declare one per engine (Postgres: the constraint
name, "movies_actors_actor_id_movie_id_key"; SQLite: the column list,
"movies_actors.actor_id, movies_actors.movie_id"):
opts: association.options()
|> association.join_constraint(
name: "movies_actors_actor_id_movie_id_key", // Postgres
message: "has already been taken",
)
|> association.join_constraint(
name: "movies_actors.actor_id, movies_actors.movie_id", // SQLite
message: "has already been taken",
),
You can still declare an ordinary schema over the join table and read or write its rows directly:
pub type MovieActor {
MovieActor(movie_id: Int, actor_id: Int)
}
fn movie_actor_schema() -> Schema(MovieActor) {
schema.new(
source: "movies_actors",
fields: [
schema.field("movie_id", primitive.integer()),
schema.field("actor_id", primitive.integer()),
],
load: fn(row) {
use movie_id <- result.try(schema.require_int(row, "movie_id"))
use actor_id <- result.try(schema.require_int(row, "actor_id"))
Ok(MovieActor(movie_id:, actor_id:))
},
dump: fn(ma: MovieActor) {
dict.from_list([
#("movie_id", VInt(ma.movie_id)),
#("actor_id", VInt(ma.actor_id)),
])
},
)
}
The migration
pub fn create_movies_and_actors() -> migration.Migration {
migration.new(20_260_612_122_000)
|> migration.create_table("movies", [
ddl.column("id", ddl.Serial) |> ddl.primary_key,
ddl.column("title", ddl.Text) |> ddl.not_null,
])
|> migration.create_table("actors", [
ddl.column("id", ddl.Serial) |> ddl.primary_key,
ddl.column("name", ddl.Text) |> ddl.not_null,
])
|> migration.create_table("movies_actors", [
ddl.column("movie_id", ddl.Integer) |> ddl.not_null,
ddl.column("actor_id", ddl.Integer) |> ddl.not_null,
])
|> migration.create_index(
"movies_actors",
["movie_id", "actor_id"],
unique: True,
)
}
As in Ecto, foreign-key constraints on the join table are recommended — add a
ddl.references(ddl.reference(...)) to each join column (see above).
Querying associated records
Preloading in the parent record query
Attach preloads to the query itself; repo.all (and repo.one) apply them to
the results.
let q =
query.from(source: "movies", alias: "m")
|> query.preload([preload.one("characters")])
let assert Ok(movies) = repo.all(r, movie_schema(), q)
Preloading when parent records are already loaded
let assert Ok(movies) =
repo.all(r, movie_schema(), query.from("movies", "m"))
let assert Ok(movies) =
repo.preload(repo: r, schema: movie_schema(), parents: movies, preloads: [
preload.one("characters"),
])
Nest with preload.nest for Ecto’s [characters: :movie] style:
repo.preload(repo: r, schema: movie_schema(), parents: movies, preloads: [
preload.nest("characters", [preload.one("movie")]),
])
Preloading with a join (single query)
preload.join loads a has_many/has_one association in the parent’s own
query via a LEFT JOIN — one query instead of two (Ecto’s preload through a
join binding):
let q =
query.from(source: "movies", alias: "m")
|> query.preload([preload.join("characters")])
let assert Ok(movies) = repo.all(r, movie_schema(), q)
// each movie comes back with its characters attached, loaded in one query
It works for has_many, has_one, belongs_to, many_to_many (joins through
the join table), and has_many :through (two LEFT JOINs, parent → mid → leaf;
association.through_columns(parent_column:, mid_column:) names the hop columns
when they aren’t primary keys). A belongs_to just has to declare which parent
column it joins on, with association.owner_column("movie_id") (codegen emits
this for you). A per-association where/preload_order is honored in the join. More than one
preload.join per query runs as a single combined query — N joins, parents
deduped by primary key, each association’s children split out of the shared rows.
It’s SQL-adapter only, and avoid it with a parent limit (the join counts
character rows, not movies).
[TASK:join-preload](divergences-from-ecto.html#join-preload)
Regular join
You can still write the join yourself and split the rows manually with
expr.col_as + schema.load_prefixed (via lode/query/sql and
repo.query_raw from lode_sql):
import lode/query/sql
fn movie_id() -> expr.FieldRef(Movie, Int) {
expr.FieldRef(binding: 0, name: "id", lode_type: primitive.id())
}
fn char_movie_id() -> expr.FieldRef(Character, Int) {
expr.FieldRef(binding: 1, name: "movie_id", lode_type: primitive.integer())
}
let q =
query.from("movies", "m")
|> query.join(
query.InnerJoin,
"characters",
"c",
expr.eq_col(movie_id(), char_movie_id()),
)
|> query.select([
expr.col_as(movie_id(), "m"),
// ...the rest of both schemas' columns, prefixed "m" / "c"...
])
let #(sql_text, params) = sql.to_sql(q)
let assert Ok(rows) = repo.query_raw(r, sql_text, params)
// Per row: schema.load_prefixed(movie_schema(), "m", row) and
// schema.load_prefixed(character_schema(), "c", row), then group yourself.
Join using assoc
There is no assoc(m, :characters) join helper — association internals are
erased into closures, so the ON condition is written explicitly with
expr.eq_col as above (design difference, same outcome).
Inserting associated records
Inserting a child record to an existing parent
Using internal data
There is no Ecto.build_assoc — records are closed, so construct the child
yourself and set its foreign key directly (design difference):
let assert Ok(Some(movie)) =
repo.get_by(r, movie_schema(), [
#("title", VString("The Shawshank Redemption")),
])
let cs =
changeset.change(Character(0, "", 0, 0, None), character_schema())
|> changeset.put_change(field.field("name"), VString("Red"))
|> changeset.put_change(field.field("age"), VInt(60))
|> changeset.put_change(field.field("movie_id"), VInt(movie.id))
let assert Ok(_red) = repo.insert(r, character_schema(), cs)
Using external data
let params =
dict.from_list([#("name", VString("Red")), #("age", VInt(60))])
let cs =
changeset.cast(Character(0, "", 0, 0, None), character_schema(), params, field.fields([
"name", "age",
]))
|> changeset.put_change(field.field("movie_id"), VInt(movie.id))
let assert Ok(_red) = repo.insert(r, character_schema(), cs)
Inserting parent and child records together
Setting the association field on the record itself (Ecto’s
%Movie{characters: [...]}) does nothing — repo.insert persists only schema
fields. Stage children on the changeset with put_assoc/cast_assoc instead;
parent and children are then written in one transaction, with foreign keys set
and the saved children attached to the returned parent.
Using internal data
fn blank_movie() -> Movie {
Movie(0, "", calendar.Date(1970, calendar.January, 1), [])
}
fn new_character(name: String, age: Int) -> changeset.Changeset(Character) {
changeset.change(Character(0, "", 0, 0, None), character_schema())
|> changeset.put_change(field.field("name"), VString(name))
|> changeset.put_change(field.field("age"), VInt(age))
}
let cs =
changeset.change(blank_movie(), movie_schema())
|> changeset.put_change(field.field("title"), VString("The Shawshank Redemption"))
|> changeset.put_assoc("characters", [
new_character("Andy Dufresne", 50),
new_character("Red", 60),
])
let assert Ok(movie) = repo.insert(r, movie_schema(), cs)
// movie.characters holds both inserted rows, movie_id set.
belongs_to and many_to_many are also writable via put_assoc/cast_assoc:
belongs_to inserts the referenced row first, then sets the owner’s foreign
key (pass a 0- or 1-element list; [] sets it to NULL); many_to_many upserts
the children and reconciles join rows. has_many_through is read/preload-only
by design — put_assoc on it marks the changeset invalid; write the underlying
association instead.
Staging an invalid child changeset makes repo.insert/repo.update fail
before any child row is written: every invalid child reports at once, each
error keyed "<assoc>.<field>" with association/child_index metadata
(the child’s 0-based position in the staged list), and the whole write rolls
back.
Using external data
let params = [
dict.from_list([#("name", VString("Andy Dufresne")), #("age", VInt(50))]),
dict.from_list([#("name", VString("Red")), #("age", VInt(60))]),
]
let cs =
changeset.cast(
blank_movie(),
movie_schema(),
dict.from_list([#("title", VString("Shawshank Redemption"))]),
field.fields(["title"]),
)
|> changeset.cast_assoc(
name: "characters",
data: Character(0, "", 0, 0, None),
params: params,
with: fn(c, p) {
changeset.cast(c, character_schema(), p, field.fields(["name", "age"]))
},
)
let assert Ok(movie) = repo.insert(r, movie_schema(), cs)
Updating associated records
Updating records individually
For individual updates, preload, then update the child with its own schema:
let assert Ok([movie]) =
repo.preload(repo: r, schema: movie_schema(), parents: [movie], preloads: [
preload.one("screenplay"),
])
let assert Some(screenplay) = movie.screenplay
let assert Ok(_) =
repo.update(
r,
screenplay_schema(),
changeset.change(screenplay, screenplay_schema())
|> changeset.put_change(field.field("lead_writer"), VString("Frank Darabont")),
)
Updating all associated records, using internal data
Using changeset.put_assoc
Child changesets built from loaded children carry their primary key in data,
so they are updated in place. Existing children left out of the list follow the
association’s on_replace policy (ReplaceRaise by default — declare
ReplaceDelete/ReplaceNilify to allow removal).
let assert Ok([movie]) =
repo.preload(repo: r, schema: movie_schema(), parents: [movie], preloads: [
preload.one("characters"),
])
let older =
list.map(movie.characters, fn(c) {
changeset.change(c, character_schema())
|> changeset.put_change(field.field("age"), VInt(c.age + 1))
})
let assert Ok(movie) =
repo.update(
r,
movie_schema(),
changeset.change(movie, movie_schema())
|> changeset.put_assoc("characters", older),
)
Note: as in Ecto, when the same change applies to every row, prefer a query — it avoids loading the rows into memory. See the next example.
Using repo.update_all
There is no Ecto.assoc(movie, :characters) query builder — filter on the
foreign key yourself (design difference):
fn char_movie_id() -> expr.FieldRef(Character, Int) {
expr.FieldRef(binding: 0, name: "movie_id", lode_type: primitive.integer())
}
let q =
query.from("characters", "c")
|> query.where(expr.eq(field: char_movie_id(), to: movie.id))
let assert Ok(_count) =
repo.update_all(repo: r, query: q, set: [#("age", VInt(61))])
repo.update_all’s set: takes literal values; for expression updates use
repo.update_all_set with query.set_expr/inc/push/pull — e.g.
repo.update_all_set(r, q, [query.inc("age", by: VInt(1))]) for Ecto’s
inc: [age: 1]. [TASK:update-all-set-expressions](divergences-from-ecto.html#update-all-set-expressions)
Updating all associated records, using external data
Using changeset.cast_assoc
let params = [
dict.from_list([#("name", VString("Red")), #("age", VInt(60))]),
]
let assert Ok([movie]) =
repo.preload(repo: r, schema: movie_schema(), parents: [movie], preloads: [
preload.one("characters"),
])
let assert Ok(movie) =
repo.update(
r,
movie_schema(),
changeset.change(movie, movie_schema())
|> changeset.cast_assoc(
name: "characters",
data: Character(0, "", 0, 0, None),
params: params,
with: fn(c, p) {
changeset.cast(c, character_schema(), p, field.fields(["name", "age"]))
},
),
)
When using changeset.cast_assoc, honestly compared with Ecto’s behavior:
- Plain
cast_assocbuilds every child from the blankdata:template (so it inserts as a new row). For id-based matching,changeset.cast_assoc_by_keymatches each param to an existing child bykey_param— a match updates that child in place, a non-match inserts.[TASK:cast-assoc-id-matching](divergences-from-ecto.html#cast-assoc-id-matching) - Existing children missing from the new set follow
on_replace(ReplaceRaiseerrors by default). - If any cast child changeset is invalid, the parent changeset gains an
"is invalid"error on the association plus each child’s own errors keyed"<assoc>.<field>"(withassociation/child_indexmetadata), and the write fails. changeset.sort_drop_params(params, key_param, sort, drop)reorders/removes child params before casting — Ecto’s:sort_param/:drop_param.[TASK:cast-assoc-sort-drop-params](divergences-from-ecto.html#cast-assoc-sort-drop-params)