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:

Search Document