lode/repo

The repository: ties a schema to an adapter and runs operations.

Unlike Ecto’s use Ecto.Repo (which generates a module backed by the process dictionary), a Repo here is a plain value you pass explicitly. Multi-tenant / multiple databases = multiple Repo values. Every operation returns Result(_, LodeError); there are no bang variants.

Types

pub type Repo {
  Repo(
    adapter: adapter.Adapter,
    prepare: fn(query.Query) -> query.Query,
  )
}

Constructors

Values

pub fn aggregate(
  repo repo: Repo,
  query q: query.Query,
  by agg: expr.Expr,
) -> Result(option.Option(value.Value), error.LodeError)

Compute one aggregate over a query — Ecto’s Repo.aggregate. Pass the aggregate expression (expr.sum(field), expr.avg(field), expr.min, expr.max, expr.count, or expr.count_all); returns the scalar result, or None when it is SQL NULL (e.g. sum/avg/min/max over zero rows). Like count, this does not wrap the query in a subquery, so any limit/offset/group_by on q is not honored.

pub fn all(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  query q: query.Query,
) -> Result(List(row), error.LodeError)

Run a query and load every row into a typed struct. Preloads attached with query.preload are applied to the results: preload.one/nest run as a batched query per association, while preload.joins (any mix of has_many/has_one/belongs_to/many_to_many/through) load together in this query via LEFT JOINs — one combined query however many there are.

pub fn count(
  repo repo: Repo,
  query q: query.Query,
) -> Result(Int, error.LodeError)

Count rows matching a query. A plain query pushes SELECT count(*) down to the database; when the query carries limit/offset/group_by/distinct (which count(*) cannot honor without a subquery — see the subqueries divergence) it falls back to materializing the rows and counting them.

pub const default_stream_batch_size: Int

Default batch size for stream_fold/stream_for_each.

pub fn delete(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  row row: row,
) -> Result(Nil, error.LodeError)

Delete a struct by primary key. Associations with an on_delete policy (DeleteAll/NilifyAll) are cascaded first, in the same transaction.

pub fn delete_all(
  repo repo: Repo,
  query q: query.Query,
) -> Result(Int, error.LodeError)

Bulk delete rows matching a query; returns the affected count.

pub fn delete_changeset(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
) -> Result(Nil, error.LodeError)

Delete a changeset’s data by primary key — delete for a changeset, so changeset-recorded markers apply (Ecto’s Repo.delete(changeset)): an optimistic_lock makes the DELETE filter on the current version and return Error(error.StaleEntry) when another writer got there first, and declared constraints (e.g. no_assoc_constraint) map violations to field errors. Pending changes are ignored — this is a delete. Associations with an on_delete policy are cascaded first, in the same transaction.

pub fn exists(
  repo repo: Repo,
  query q: query.Query,
) -> Result(Bool, error.LodeError)

Whether any row matches.

pub fn get(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  id id: value.Value,
) -> Result(option.Option(row), error.LodeError)

Fetch one row by primary key value.

pub fn get_by(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  filters filters: List(#(String, value.Value)),
) -> Result(option.Option(row), error.LodeError)

Fetch one row matching all equality filters.

pub fn insert(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
) -> Result(row, error.LodeError)

Insert a changeset. Autogenerated fields are omitted so the database assigns them; the stored row (with generated keys) is loaded back into a struct.

If the changeset stages association writes (changeset.put_assoc/ cast_assoc), the parent and its children are inserted in one transaction and the returned struct has the children attached.

pub fn insert_all(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  rows rows: List(row),
) -> Result(Int, error.LodeError)

Bulk-insert typed rows in a single statement (Ecto’s Repo.insert_all), returning the inserted count. Each row is dumped through the schema; database-generated columns and virtual fields are omitted, so the database assigns generated keys. This is the raw bulk path: no changesets, no validations, no association writes. Ok(0) for an empty list.

Note: one statement means one parameter per column per row — for very large batches, chunk the rows yourself (Postgres caps a statement at 65535 parameters).

pub fn insert_all_raw(
  repo repo: Repo,
  source source: String,
  columns columns: List(String),
  rows rows: List(List(value.Value)),
) -> Result(Int, error.LodeError)

Schemaless bulk insert (Ecto’s Repo.insert_all("table", rows)): insert rows — each a value list aligned with columns — into source in one statement, returning the inserted count. No schema, changesets, or associations. Ok(0) for empty input. Pair every column with a value yourself; database-generated columns you omit are filled by the database.

pub fn insert_all_raw_returning(
  repo repo: Repo,
  source source: String,
  columns columns: List(String),
  rows rows: List(List(value.Value)),
) -> Result(List(dict.Dict(String, value.Value)), error.LodeError)

Like insert_all_raw, but returns the inserted rows as name-keyed Rows (with any database-generated columns filled in) rather than a count.

pub fn insert_all_returning(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  rows rows: List(row),
) -> Result(List(row), error.LodeError)

Like insert_all, but loads and returns the stored rows (with any database-generated keys filled in), in insertion order.

pub fn insert_or_update(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
) -> Result(row, error.LodeError)

Insert or update a changeset depending on whether its data is already persisted (Ecto’s Repo.insert_or_update). Delegates to insert/update, so association writes, prefixes, and constraint-error mapping all apply.

DIVERGENCE: Ecto chooses from the struct’s __meta__.state (:built -> insert, :loaded -> update). lode has no struct metadata, so it decides from the primary key via schema.primary_key_present: if every primary-key field on the changeset’s data is set (not NULL/0/""), the row is updated; otherwise it is inserted. This is the same persisted-vs-new rule cast_assoc/put_assoc use for child rows. The practical limit is the same as theirs: a brand-new row with a natural (non-generated) primary key already filled in is seen as persisted and updated. See the “Divergences from Ecto” guide (insert-or-update).

pub fn insert_prefixed(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
  prefix prefix: String,
) -> Result(row, error.LodeError)

insert into a specific Postgres schema prefix — a call-level override of the changeset’s own prefix (Ecto’s Repo.insert(.., prefix:)). Equivalent to insert(repo, schema, changeset.put_prefix(cs, prefix)).

pub fn list(
  repo repo: Repo,
  schema schema: schema.Schema(row),
) -> Result(List(row), error.LodeError)

Fetch every row of a schema — all with the table and binding alias taken from the schema itself, for the common unfiltered case. Build a Query and use all when you need where / order_by / joins.

pub fn new(adapter: adapter.Adapter) -> Repo

Build a repo over an adapter.

pub fn one(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  query q: query.Query,
) -> Result(option.Option(row), error.LodeError)

Fetch at most one row. Ok(None) for zero, Error(MultipleResults) for >1.

pub fn preload(
  repo r: Repo,
  schema schema: schema.Schema(row),
  parents parents: List(row),
  preloads preloads: List(preload.Preload),
) -> Result(List(row), error.LodeError)

Preload associations by name onto already-loaded rows (Ecto’s Repo.preload). Each named association on the schema is loaded in a single batched query (no N+1); nest with preload.nest to load deeper:

repo.preload(repo: r, schema: post_schema(), parents: posts, preloads: [ preload.one(“tags”), preload.nest(“comments”, [preload.one(“author”)]), ])

pub fn query_raw(
  repo repo: Repo,
  sql sql: String,
  params params: List(value.Value),
) -> Result(List(dict.Dict(String, value.Value)), error.LodeError)

Run raw SQL with positional parameters ($1, $2, …) and return the result rows as name-keyed Dict(String, Value) — the same DB-canonical shape every adapter speaks. Pass [] for a parameterless statement.

This is the escape hatch for queries the typed query model doesn’t cover (joins, CTEs, fragment-style SQL). Because rows come back keyed by column name, decoding is order-independent: select columns in any order, or a subset, and load fields by name with schema.load_field (absent columns load as VNull, so a projection that omits nullable columns still loads).

Returns the adapter’s error for backends without a SQL engine (the in-memory adapter).

pub fn query_raw_as(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  sql sql: String,
  params params: List(value.Value),
) -> Result(List(row), error.LodeError)

Run raw SQL and load each result row into a typed struct via the schema’s loadquery_raw plus the same per-row decoding all applies. Use it for SQL the typed query builder can’t express (joins, CTEs, subqueries, UNION) when you still want structs back rather than raw Row dicts.

Columns are matched by name, so select them in any order; a column the schema expects but the projection omits loads as VNull (see schema.load_field). Not supported by the in-memory adapter (no SQL engine).

pub fn stream_fold(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  query q: query.Query,
  batch_size batch_size: Int,
  from initial: acc,
  with reducer: fn(acc, row) -> acc,
) -> Result(acc, error.LodeError)

Fold a query’s full result set in batches, holding at most one batch of loaded rows in memory on the SQL adapters — for walking results too large to materialize with all (Ecto’s Repo.stream).

reducer is applied to every row in order, threading an accumulator; batch_size bounds how many rows are fetched (and loaded) per round trip. A row that fails to load aborts the stream with that error.

On Postgres this drives a real server-side cursor inside a transaction it opens itself: peak memory is one batch no matter how large the result, and every row is read from a single MVCC snapshot, so concurrent writes never duplicate or skip a row (an O(n) read — no OFFSET re-scans). On SQLite the prepared statement is stepped row by row — also bounded memory (one row; batch_size is irrelevant with no server round trips) — and the stream owns the adapter’s connection lock for its whole walk, like a transaction. The in-memory adapter has no cursor engine, so it folds its matched rows directly (a test convenience, not bounded-memory).

DIVERGENCE (shape, not capability): Ecto’s Repo.stream returns a lazy Enumerable you compose with Enum/Flow and must wrap in Repo.transaction yourself. Gleam has no lazy stream type and no macros, so this is a fold instead, and the transaction is managed for you. Rows arrive in the query’s order; if the query declares none, this orders by the schema’s primary key so the stream is deterministic by default. See the “Divergences from Ecto” guide (repo-stream).

pub fn stream_for_each(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  query q: query.Query,
  batch_size batch_size: Int,
  with effect: fn(row) -> Nil,
) -> Result(Nil, error.LodeError)

Run effect on every row of a query, streamed in batches so the whole result set is never materialized — for side-effecting passes over a large table. Built on stream_fold.

pub fn transaction(
  repo repo: Repo,
  body body: fn(Repo) -> Result(a, error.LodeError),
) -> Result(a, error.LodeError)

Run body in a transaction. If it returns Error, the transaction rolls back. Nested transaction calls run as savepoints (Postgres and SQLite), so an inner Error rolls back only the inner work — unless the outer body propagates it.

body receives a transaction-scoped Repo — use it (not the outer repo) for every operation inside the transaction so the work runs on the transaction’s connection:

repo.transaction(repo, fn(tx) { use a <- result.try(repo.insert(tx, schema, cs_a)) use b <- result.try(repo.insert(tx, schema, cs_b)) Ok(#(a, b)) })

pub fn update(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
) -> Result(row, error.LodeError)

Update a changeset, filtering by primary key. Returns the updated struct. Staged association writes run in the same transaction (see insert).

pub fn update_all(
  repo repo: Repo,
  query q: query.Query,
  set sets: List(#(String, value.Value)),
) -> Result(Int, error.LodeError)

Bulk update rows matching a query with literal column values; returns the affected count. For expression-valued sets or inc/push/pull, use update_all_set.

pub fn update_all_set(
  repo repo: Repo,
  query q: query.Query,
  set ops: List(query.Assignment),
) -> Result(Int, error.LodeError)

Bulk update with SetOps — literal (query.set), expression-valued (query.set_expr), or the query.inc/push/pull operators (Ecto’s update_all(.., set:/inc:/push:/pull:)). Returns the affected count.

repo.update_all_set(repo: r, query: q, set: [ query.inc(“views”, by: VInt(1)), query.set_expr(“slug”, to: expr.Call(“lower”, [expr.Col(0, “title”)])), ])

pub fn update_prefixed(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
  prefix prefix: String,
) -> Result(row, error.LodeError)

update into a specific Postgres schema prefix — a call-level override of the changeset’s own prefix.

pub fn upsert(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  changeset cs: changeset.Changeset(row),
  on_conflict oc: on_conflict.OnConflict(row),
) -> Result(option.Option(row), error.LodeError)

Insert a changeset with an upsert policy (Ecto’s Repo.insert with :on_conflict / :conflict_target — see lode/on_conflict).

Returns Ok(Some(row)) with the stored row — for on_conflict.Update that is the row after the conflicting update, read back with RETURNING, so the result reflects what the database holds. Returns Ok(None) when an on_conflict.Nothing policy skipped the insert because a conflicting row already existed.

Unlike insert, staged association writes (put_assoc/cast_assoc) are rejected: under a conflict the parent row’s identity is ambiguous, so write associations separately instead.

pub fn upsert_all(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  rows rows: List(row),
  on_conflict oc: on_conflict.OnConflict(row),
) -> Result(Int, error.LodeError)

insert_all with an upsert policy (Ecto’s Repo.insert_all with :on_conflict — see lode/on_conflict). Returns the number of rows actually written: rows an on_conflict.Nothing policy skipped are not counted.

pub fn upsert_all_returning(
  repo repo: Repo,
  schema schema: schema.Schema(row),
  rows rows: List(row),
  on_conflict oc: on_conflict.OnConflict(row),
) -> Result(List(row), error.LodeError)

Like upsert_all, but loads and returns the rows actually written (with database-generated keys filled in, and on_conflict.Update rows as stored after their update). Rows an on_conflict.Nothing policy skipped are absent.

pub fn with_prepare(
  repo repo: Repo,
  prepare prepare: fn(query.Query) -> query.Query,
) -> Repo

Install a query-preparation hook (Ecto’s prepare_query/default_options): every query run through this repo’s reads and bulk writes (all/one/ get/count/aggregate/exists/update_all(_set)/delete_all/ stream_fold) is passed through prepare first. Use it for automatic scoping — soft-delete filters, tenant scoping — without threading the condition through every call site. The hook is inherited by transaction-scoped repos.

let scoped = repo.with_prepare(r, fn(q) { query.where(q, expr.is_nil(expr.Col(0, “deleted_at”))) })

Search Document