Skip to content

Repository files navigation

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:

  • lode (this repo) — the database-agnostic core: types, schema, changeset, query builder, repo, Multi, associations, the adapter interface, and the in-memory adapter. Depends only on gleam_stdlib. Compiles on both the Erlang and JavaScript targets.
  • lode_sql — the SQL renderer (dialect-parameterized), the PostgreSQL and SQLite adapters, migrations, codegen, and the drift checker. Depends on lode + pog + sqlight. Lives in its own repository alongside this one (checked out as a sibling directory, ../lode_sql), together with the runnable examples (examples/codegen_demo, examples/lustre_form).

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):

Area Module(s) Notes
Value boundary & errors lode/value, lode/error tagged DB Value; Result-based errors (no exceptions)
Type system lode/type_, lode/types/* int/float/bool/string/binary/uuid/enum/decimal/date-time/array/map; type-erased FieldType
Schema lode/schema explicit Schema(row) metadata + load/dump codecs + field builders
Changeset lode/changeset cast, validations, constraints, optimistic_lock, apply_action
Query lode/query, lode/query/expr, lode/query/sql typed field accessors; builders; parameterized SQL renderer
Repo lode/repo all/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)
Multi lode/multi Ecto'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)
Associations lode/association, lode/preload, lode/repo has_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)
Adapters lode/adapter, lode/adapters/memory, lode/adapters/postgres, lode/adapters/sqlite in-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
Migrations lode/migration, lode/migration/ddl, lode/migrator typed DDL builder w/ auto-reversible up/down; dialect-aware (Postgres + SQLite, chosen from the adapter's engine); migrate/rollback/status over schema_migrations
Schema spec lode/schema/spec declarative, 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
Codegen lode/codegen generate_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 check lode/drift verify 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

  • decimal and the date/time types are real. decimal is backed by dee (a port of Elixir's Decimal, so equality and rounding match Ecto); the temporal types are backed by gleam/time (calendar.Date, calendar.TimeOfDay, timestamp.Timestamp). They compare, order, and round-trip natively through pog. Reads are lossless: the Postgres adapter installs a custom numeric decoder (lode_pg_numeric) that returns the wire digits as a decimal string instead of letting pgo decode numeric to a 64-bit Float, so high-precision values keep every digit on read (writes were already lossless — decimal is sent as text).
  • JSON / jsonb columns are supported via a json type (lode/types/json) backed by a Value↔JSON codec, round-tripping through a Postgres jsonb column (sent as text, parsed back on load). WHERE clauses can be operator-aware: expr.json_get/json_get_text render ->/->> member access and expr.json_contains renders @> containment (all built on expr.fragment).

Schema & changeset

  • Embedded schemas are supportedembeds_one/embeds_many (lode/embed) store a nested schema inline as jsonb, with cast_embed_one/cast_embed_many mirroring cast_assoc. Embedded decimal/ temporal fields round-trip (serialized as strings, parsed by the field's lenient load).
  • Validator coverage matches Ecto's common setrequired, length, number, inclusion/exclusion/subset, format (via gleam_regexp), confirmation, acceptance, and custom validate_change. Confirmation and acceptance fields are typically declared as schema.virtual_fields so they cast and validate without ever being stored.
  • Constraint violations map to changeset errors — a unique_constraint / foreign_key_constraint / check_constraint declared on the changeset turns a matching database violation into Error(ChangesetInvalid(..)) with the field error (e.g. "has already been taken"), instead of a raw ConstraintError. An undeclared violation still surfaces as ConstraintError.

Associations

  • Write-side is supported for has_many/has_one, belongs_to, and many_to_many via put_assoc/cast_assoc (with on_replace/on_delete). belongs_to inserts the referenced row first and sets the owner's foreign key; many_to_many upserts children and reconciles join rows. has_many :through is read/preload-only by design (write the underlying association instead).
  • Composite primary keys are honored in association writes (child identity for on_replace matching and updates uses the full key). Note: insert-vs-update for natural composite keys is still inferred from key presence — pre-existing rows are matched, but brand-new natural-key children supplied to put_assoc are treated as updates. Relationship links (foreign keys, join keys) remain single-column.

Query

  • The in-memory adapter evaluates single-source queries, aggregate selects (count/sum/avg/min/max), and subquery sources / IN (subquery); it does not do joins, group-by, or fragments — those go through the SQL renderer + Postgres.
  • Subqueries are supported as a FROM source (query.from_subquery) and as WHERE x IN/NOT IN (subquery) (query.where_in_subquery), with the subquery's parameters threaded into the outer $n sequence.
  • Window functionsexpr.over(call, partition_by:, order_by:) in select (ranking expr.row_number/rank/dense_rank or any aggregate), plus named windows via query.window + expr.over_named; identical standard SQL on Postgres and SQLite. Frames (ROWS BETWEEN ...) drop to repo.query_raw.
  • CTEsquery.with_cte(q, name:, as_:, recursive:) renders WITH [RECURSIVE] "name" AS (...); reference the CTE by name as an ordinary from/join source, build recursive bodies with query.union_all, and the CTE's parameters thread first into the $n/?n sequence. Plain and recursive CTEs work on Postgres and SQLite; CTEs on update_all/delete_all drop to repo.query_raw.
  • Row locks, first/last, and set combinationsquery.lock(q, "FOR UPDATE"), query.first/query.last (order + limit 1), and query.union/query.union_all (compose with from_subquery to order a union).
  • Schema prefixes for Postgres-schema multi-tenancy — query.prefix(q, schema) qualifies reads (and update_all/delete_all), and changeset.put_prefix (plus repo.insert_prefixed/update_prefixed) qualifies writes, so tables render as "schema"."table".
  • expr.fragment(sql, args) is the raw-SQL splice (Ecto's fragment): each ? is replaced by an expression, literals still become $n parameters. It covers most one-off SQL (json operators ship as fragment helpers).

Repo

  • Bulk insert is supportedrepo.insert_all(repo:, schema:, rows:) inserts typed rows in a single statement (count back), and insert_all_returning loads the stored rows with generated keys filled in. It is the raw bulk path (no changesets, no association writes); autogenerated and virtual columns are omitted automatically. One statement means one parameter per column per row, so chunk very large batches yourself (Postgres caps a statement at 65535 parameters).
  • Upsert is supported (Ecto's :on_conflict/:conflict_target) — repo.upsert(repo:, schema:, changeset:, on_conflict:) and upsert_all/upsert_all_returning take a lode/on_conflict policy: Nothing (DO NOTHING; a skipped insert comes back as Ok(None)) or Update (DO UPDATE SET) with a Columns/Constraint target and a Replace(cols)/ReplaceAll/ReplaceAllExcept(cols)/Set(values) action. DO UPDATE results are read back with RETURNING, so the returned struct reflects the stored row. The in-memory adapter detects conflicts only for Columns targets (it has no constraint catalog). Upserts don't run staged association writes (put_assoc/cast_assoc) — under a conflict the parent row's identity is ambiguous, so that combination is rejected.
  • Aggregatesrepo.count pushes SELECT count(*) to the database (falling back to materializing only when a limit/offset/group_by/ distinct would make count(*) wrong), and repo.aggregate(query:, by:) computes sum/avg/min/max/count server-side, returning the scalar.
  • Transactions nest — a repo.transaction opened on the transaction-scoped repo inside another runs as a SAVEPOINT (Postgres) or snapshot (memory), so an inner rollback is independent of the outer.
  • Test sandboxlode/sandbox's sandbox.run(repo, body) isolates a test in a transaction that always rolls back (no truncation); nested transactions inside become savepoints. There is no process-ownership registry by design — concurrency is own-Repo-per-async-test (see DESIGN.md §11).
  • Insert-or-updaterepo.insert_or_update(repo:, schema:, changeset:) inserts or updates depending on whether the changeset's data already has a primary key (lode has no struct __meta__.state, so PK presence is the signal — same rule as cast_assoc). See the "Divergences" guide for the natural-key caveat.
  • Streaming readsrepo.stream_fold/stream_for_each walk a result set in batch_size chunks rather than materializing it like all. On Postgres this is a real server-side cursor (DECLARE/FETCH/CLOSE) in a transaction the adapter manages: constant memory and one MVCC snapshot. The shape differs from Ecto — a fold, not a lazy Enumerable, since Gleam has no lazy stream type (see the "Divergences" guide).
  • Raw-SQL escape hatchrepo.query_raw(sql, params) runs arbitrary parameterized SQL (positional $1, $2, ... like Ecto's Repo.query/3), returning name-keyed Dict(String, Value) rows: order-independent and projection-tolerant (load by name with schema.load_field, absent columns as VNull). repo.query_raw_as(schema:, sql:, params:) loads each row into a typed struct via schema.load in one call.
  • Query-integrated preload is supported — attach preloads to the query with query.preload(q, [preload.nest("comments", [...])]) and repo.all/repo.one load them onto the results (batched per association, same machinery as the separate repo.preload step, which remains for already-loaded rows).

Migrations & tooling

  • Migration CLImigrator.run(repo, migrations, args) dispatches migrate / rollback [n] / status. Wire it into a gleam run entrypoint (see examples/codegen_demo/src/migrate.gleam): gleam run -m migrate migrate|rollback [n]|status. Each migration runs in its own transaction, so a failed migration rolls back cleanly.
  • Foreign keys and timestamps have buildersddl.references(column, ddl.reference("table")) (with ddl.on_delete/ddl.on_update taking a NoAction/Restrict/Cascade/SetNull action, and ddl.references_column for a non-id target), and ddl.timestamps() for the conventional inserted_at/updated_at pair. Still partial elsewhere: no migration.create_schema (CREATE SCHEMA), alter-column-type, or richer index options — use the migration.execute escape hatch.

Codegen & spec

  • Schema-first generation is implemented (DESIGN §14): the spec is the source of truth, generate_from_spec needs no database, introspection is demoted to a drift verifier (lode/drift) plus a one-shot importer (bootstrap_spec). Association options live in the spec too: assoc_on_replace/assoc_on_delete as plain data, and assoc_where/assoc_preload_order/assoc_defaults as source snippets (the same code-as-data convention as as_custom), each rendered onto the generated registration as association.options() builder pipes. Residual gaps:
    • references assumes a single-column key on the one side (composite relationship links remain single-column, matching the association layer).
    • Spec as_custom/virtual defaults/assoc-option snippets are source-text expressions — they are compile-checked only once the generated module is compiled.
  • Type-name singular/pluralization is naive — but it only applies to the introspection bridge (generate/bootstrap_spec); authored specs name their record types explicitly.

Adapters & targets

  • Postgres (via pog), SQLite (via sqlight), and the in-memory adapter exist; no MySQL. The SQL/DDL renderer is dialect-parameterized, so the query builder, changesets, repo, and migrations work unchanged across Postgres and SQLite — the adapter is the only line that names the engine (postgres.new / sqlite.new / memory.new). SQLite is embedded, so its tests need no server (sqlight.open(":memory:")), and the adapter serializes its single connection across processes — concurrent transactions queue rather than interleave.
  • Erlang/BEAM target only; the JavaScript target is untested.

About

A database toolkit for Gleam: typed schemas, changesets, a composable query builder, repos, associations, and a declarative schema spec. An Ecto-shaped design — no macros, no reflection.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages