lode

lode n. — a rich vein of ore; an abundant store.

A database toolkit for Gleam — schemas, changesets, a typed query builder, a repo, Multi, association preloading, and PostgreSQL + SQLite adapters. Ported from Elixir’s Ecto.

Because Gleam has no macros, no runtime reflection, and no behaviours, this is a functional re-expression of Ecto rather than a literal translation. The design rationale and a full Ecto-feature mapping live in DESIGN.md.

Packages

Mirroring Elixir’s split, lode ships as two sibling packages in two repos:

Modules in both packages live under the lode/ namespace (so you still import lode/migrator), just as Elixir namespaces Ecto.Migration under Ecto. despite it shipping in ecto_sql.

Status — v0.1

All six build phases are complete and tested (419 tests across both packages, including live PostgreSQL round-trips):

AreaModule(s)Notes
Value boundary & errorslode/value, lode/errortagged DB Value; Result-based errors (no exceptions)
Type systemlode/type_, lode/types/*int/float/bool/string/binary/uuid/enum/decimal/date-time/array/map; type-erased FieldType
Schemalode/schemaexplicit Schema(row) metadata + load/dump codecs + field builders
Changesetlode/changesetcast, validations, constraints, optimistic_lock, apply_action
Querylode/query, lode/query/expr, lode/query/sqltyped field accessors; builders; parameterized SQL renderer
Repolode/repoall/one/get/get_by/insert(_prefixed)/insert_all(_returning)/upsert(_all)(_returning)/insert_or_update/update(_prefixed)/delete(_changeset)/*_all/count/exists/aggregate/stream_fold/stream_for_each/query_raw(_as)/transaction (transactions nest via savepoints)
Multilode/multiEcto’s core step set: insert/update/delete/run, bulk (insert_all/update_all/delete_all), query (one/all/exists), put; merge/append/prepend composition with duplicate-step-name rejection (no insert_or_update/error/inspect steps — a run step covers each)
Associationslode/association, lode/preload, lode/repohas_many/has_one/belongs_to/has_many_through/many_to_many declared by name; by-name repo.preload with nesting (batched, N+1-free); preload.join loads a has_many/has_one/belongs_to/many_to_many in the parent query via a LEFT JOIN (with the association’s where/preload_order); association.join derives a JOIN from a declared association (Ecto’s assoc(..)); per-assoc where/preload_order; write-side put_assoc/cast_assoc with on_replace/on_delete/defaults (has_many/has_one); invalid staged children fail the write with errors keyed "<assoc>.<field>" (+ association/child_index metadata); declared join-table constraints map violations to field errors (association.join_constraint)
Adapterslode/adapter, lode/adapters/memory, lode/adapters/postgres, lode/adapters/sqlitein-memory (tests) + Postgres via pog + SQLite via sqlight (serverless :memory: or file); the adapter carries an engine tag so the migrator picks the DDL dialect
Migrationslode/migration, lode/migration/ddl, lode/migratortyped DDL builder w/ auto-reversible up/down; dialect-aware (Postgres + SQLite, chosen from the adapter’s engine); migrate/rollback/status over schema_migrations
Schema speclode/schema/specdeclarative, pure-data schema spec — the single source of truth; lossless type intent (enums, embeds, decimal, temporal, uuid, jsonb), stored/virtual field kinds, source/as/redact overrides, all five association kinds, manual_schema opt-out
Codegenlode/codegengenerate_from_spec: spec → records (with association fields), Schemas with associations registered, typed FieldRef accessors, typed preload constructors, enum/embed definitions — no live DB; grouped by association connected component to keep mutually-recursive code intra-module. Introspection retained as bootstrap_spec (one-shot DB → spec importer) and generate (DB → modules via the same emitters), engine-aware (information_schema on Postgres; sqlite_master + PRAGMA on SQLite)
Drift checklode/driftverify a live database against the spec’s DDL projection: missing/extra tables & columns, type/nullability/PK mismatches, association-implied foreign keys; virtual fields invisible by construction; engine-aware (Postgres + SQLite — SQLite compares at type-affinity level, see lode/drift docs)

Note: migrations live in Elixir’s separate ecto_sql package, not in ecto itself; lode_sql is the equivalent layer for lode.

A taste

import lode/changeset
import lode/query
import lode/query/expr
import lode/repo
import lode/adapters/postgres
import lode/types/primitive
import lode/value.{VInt, VString}
import gleam/dict
import gleam/option.{Some}

// 1. Your own record + a Schema value describing it (see test/ for the full schema).
pub type User { User(id: Int, name: String, age: Int) }

// 2. Typed field accessors make query expressions compile-checked:
fn user_age() {
  expr.FieldRef(binding: 0, name: "age", lode_type: primitive.integer())
}

pub fn example(conn) {
  let r = repo.new(postgres.new(conn))   // or adapters/memory.new() for tests
  let s = user_schema()

  // INSERT ... RETURNING * (the DB assigns the serial id).
  // Public functions take labelled arguments (positional still works too).
  let changeset =
    changeset.cast(
      data: User(0, "", 0),
      schema: s,
      params: dict.from_list([#("name", VString("Alice")), #("age", VInt(30))]),
      permitted: ["name", "age"],
    )
  let assert Ok(alice) = repo.insert(repo: r, schema: s, changeset: changeset)

  // SELECT with a typed WHERE — expr.gte(field: user_age(), to: "thirty") would
  // not compile.
  let adults =
    query.from(source: "users", alias: "u")
    |> query.where(expr.gte(field: user_age(), to: 18))
  let assert Ok(found) = repo.all(repo: r, schema: s, query: adults)

  let assert Ok(Some(_)) = repo.get(repo: r, schema: s, id: VInt(alice.id))
}

Associations & preloading

Declare associations by name on the schema (Gleam has no reflection, so you supply how to read the join keys and how to attach loaded rows), then preload them by name — Ecto’s Repo.preload(posts, [comments: :author]) style. Each association loads in one batched query (no N+1); nesting composes to any depth.

fn post_schema() -> Schema(Post) {
  post_base()
  |> association.has_many(
    name: "comments",
    related: comment_schema(),      // may itself declare `author`, etc.
    foreign_key: "post_id",
    owner_key: fn(p: Post) { VInt(p.id) },
    child_key: fn(c: Comment) { VInt(c.post_id) },
    put: fn(p, cs) { Post(..p, comments: cs) },
  )
}

// preload by name:
let assert Ok(posts) =
  repo.preload(repo: r, schema: post_schema(), parents: posts, preloads: [
    preload.one("comments"),
  ])

// nested — posts -> comments -> author (like [comments: :author]):
let assert Ok(posts) =
  repo.preload(repo: r, schema: post_schema(), parents: posts, preloads: [
    preload.nest("comments", [preload.one("author")]),
  ])

There is no lazy loading — like Ecto, associations are loaded explicitly.

Association options

Every association takes an opts: built from association.options():

|> association.has_many(
  name: "comments",
  // ...keys + put...
  opts: association.options()
    |> association.where(expr.neq(field: comment_body(), to: "spam"))   // extra preload filter
    |> association.preload_order([query.desc(expr.col(comment_body()))]) // order children
    |> association.on_replace(association.ReplaceDelete)  // write-side (below)
    |> association.on_delete(association.DeleteAll)       // cascade on repo.delete
    |> association.defaults([#("flagged", value.VBool(False))]),
)

through & many_to_many

// has_many :comment_authors, through: [:comments, :author]
|> association.has_many_through(
  name: "comment_authors",
  via: comment_schema, to: user_schema,
  parent_key: fn(p: Post) { value.VInt(p.id) }, via_foreign_key: "post_id",
  mid_owner_key: fn(c: Comment) { value.VInt(c.post_id) },
  mid_key: fn(c: Comment) { value.VInt(c.user_id) },
  to_foreign_key: "id", leaf_owner_key: fn(u: User) { value.VInt(u.id) },
  put: fn(p, us) { Post(..p, authors: us) }, opts: association.options(),
)

// many_to_many :tags, join_through: "posts_tags"
|> association.many_to_many(
  name: "tags", related: tag_schema,
  join_through: "posts_tags", join_owner_key: "post_id", join_related_key: "tag_id",
  owner_key: fn(p: Post) { value.VInt(p.id) }, child_key: fn(t: Tag) { value.VInt(t.id) },
  put: fn(p, ts) { Post(..p, tags: ts) }, opts: association.options(),
)

Writing associations

For has_many/has_one, stage child rows on the parent changeset and they are written in one transaction when you repo.insert/repo.update — honoring the association’s on_replace policy, and cascaded on repo.delete per on_delete:

let cs =
  changeset.change(author, author_schema())
  |> changeset.put_assoc("books", [           // typed child changesets
    changeset.change(Book(0, 0, "Dune"), book_schema()),
  ])
let assert Ok(saved) = repo.insert(repo: r, schema: author_schema(), changeset: cs)
// saved.books are inserted with the foreign key set and attached to `saved`.

// or build children from params (Ecto's cast_assoc):
changeset.change(author, author_schema())
|> changeset.cast_assoc(
  name: "books",
  data: Book(0, 0, ""),
  params: book_param_maps,
  with: fn(b, p) { changeset.cast(b, book_schema(), p, ["title"]) },
)

through is read/preload-only by design; belongs_to and many_to_many are writable via put_assoc/cast_assoc (see “Known limitations” for the details).

lode/codegen generates all of this from the schema spec (lode/schema/spec) — a hand-authored, pure-data description of your tables that is the single source of truth (see DESIGN.md §14). With no live database, codegen.generate_from_spec(tables) emits the records (association fields included), schema() functions with associations registered, typed FieldRef accessors, and typed preload-name constructors so post_comments([...]) is compile-checked (a typo’d name won’t compile). The spec also carries virtual fields (on the record and cast in changesets, never written to the database), column renames (source), custom type overrides (as_custom), enums, and embedded schemas. To keep the mutually-recursive records and registrations legal under Gleam’s no-circular-imports rule, codegen groups tables by association connected component — isolated tables get their own clean module, and a cluster of associated tables shares one module with record-prefixed names.

Migrations stay hand-authored, and lode/drift keeps the two honest: drift.check(repo:, schema:, tables:) compares the spec’s stored columns against the engine’s catalog (information_schema on Postgres; sqlite_master + PRAGMA on SQLite) and reports missing/extra tables and columns, type/nullability/primary-key mismatches, and missing association-implied foreign keys. To adopt the workflow on an existing database, run codegen.bootstrap_spec(repo:, schema:) once: it introspects and emits a spec module you refine by hand (the database can’t express enums, embeds, or virtuals).

Running the tests

The core’s tests use the in-memory adapter and need nothing extra:

gleam test

The lode_sql repo’s Postgres and migration tests need a live database — see its README. The workflow in .github/workflows/test.yml runs this package’s tests and format check on the Erlang target and builds the JavaScript target.

Publishing

Both packages carry Hex-ready metadata (v0.1.0, descriptions, Apache-2.0 — the licence Elixir’s Ecto uses), and the names lode and lode_sql are free on Hex.

Releases are driven by version_bump (a dev dependency): gleam run -m version_bump -- --dry-run previews the next version and release notes from the conventional commits; the real run (needs HEXPM_API_KEY) tags v${version}, publishes to Hex, and commits the version bump. Config lives under [tools.version_bump] in gleam.toml.

Publish lode before lode_sql (whose path dependency on ../lode must become a Hex version dependency at its own release time). There is also no GitHub remote yet — add one and uncomment the repository line in gleam.toml so Hexdocs links back to the source.

Known limitations & not-yet-implemented

What works today is listed in the status table above. The items below are genuine gaps versus Elixir’s Ecto — tracked here so they’re visible rather than tribal knowledge. (Features Ecto has that this port intentionally drops — process-dictionary repo, __schema__ reflection, bang/exception variants, telemetry, macro keyword syntax — are recorded in DESIGN.md §11.)

Types & values

Schema & changeset

Associations

Query

Repo

Migrations & tooling

Codegen & spec

Adapters & targets

Search Document