Divergences from Ecto
These guides are a 1:1 port of Elixir’s Ecto v3.14 guides to lode. Most of Ecto translates directly. Where it doesn’t, the guides flag it in place, and this page is the consolidated map.
There are two distinct kinds of difference, and only one of them is a gap:
-
Design re-expressions. Gleam has no macros, no runtime reflection, no behaviours/protocols, and no process dictionary, and it models failure with
Resultrather than exceptions. So Ecto’s macro query syntax becomes builder functions,__schema__reflection becomes explicitSchema(row)values, the implicit global/dynamic repo becomes aRepovalue you pass, and bang functions (insert!) and telemetry are dropped. These are deliberate and are not tracked as tasks — they are explained inline in the guides and inDESIGN.mdin the repository (see especially §1 and §11). They are not listed below. -
Not-yet-implemented gaps. Real Ecto features with no lode equivalent yet. Each is marked in the guides with a
[TASK:<slug>]token and has a matching entry below (same slug, same anchor) plus a tracked issue in the project’sbdtracker. This is that list.
Every [TASK:<slug>] you see in a guide links to the #<slug> anchor on this
page. Each entry records what Ecto does, what lode does today (the
workaround), the tracking issue, and which guides reference it.
Query builder
Subqueries — resolved
Resolved (lode-s3o, lode-qqw, lode-bx4). A subquery as a FROM source
(query.from_subquery) and WHERE x IN/NOT IN (subquery)
(query.where_in_subquery / where_not_in_subquery) are expressible in the
typed builder, with the subquery’s parameters threaded into the outer $n
sequence. Correlated / EXISTS subqueries and subquery-as-JOIN-source are
covered too — see
Correlated / EXISTS subqueries. Referenced by:
Aggregates and subqueries.
Correlated / EXISTS subqueries — resolved
Resolved (lode-qqw). query.where_exists / query.where_not_exists render
WHERE EXISTS (...) / NOT EXISTS (...), and a subquery references the
enclosing query’s bindings with expr.outer(alias, name) (Ecto’s parent_as).
The outer alias is rendered verbatim — the subquery is lexically nested, so the
binding is already in scope — which sidesteps the positional-binding model
without any cross-query alias plumbing. The subquery’s $n parameters are
threaded into the outer statement’s sequence. Correlated EXISTS needs a SQL
adapter; the in-memory adapter evaluates only uncorrelated EXISTS (see the
in-memory coverage note).
A subquery as a JOIN source is also covered now (lode-bx4):
query.join_subquery(q, kind, subquery, alias, on) renders <kind> JOIN (<subquery>) AS alias ON <on>, threading the subquery’s params — so subqueries
work as a FROM source, in IN/EXISTS, and as a JOIN source.
Referenced by:
Aggregates and subqueries.
Association-derived joins — resolved
Resolved (lode-epe). association.join(query, schema, name, kind, alias)
derives a join’s source and ON from a declared association (Ecto’s
join: c in assoc(p, :comments)): the child table and ON child.fk = parent.<key> come from the association on schema, which is the query’s from
binding. The owner-side column is the parent’s primary key for
has_many/has_one, or a belongs_to’s owner_column. It returns a
Result — Error for an unknown or non-joinable association
(many_to_many/through, which still need repo.query_raw). The joined child
takes the next binding, so reference its columns with expr.Col(1, "..."); as
with any join, project explicitly (query.select) rather than relying on
SELECT *, which clobbers same-named columns. Referenced by:
Dynamic queries.
Preload via JOIN — resolved
Resolved for has_many/has_one/belongs_to/many_to_many (lode-crv,
lode-v21, lode-qig).
preload.join("comments") (through query.preload + repo.all/repo.one)
loads the association in the parent’s own query via a LEFT JOIN, projecting
each side’s columns under a distinct prefix and splitting them back with
schema.load_prefixed — Ecto’s preload-through-a-join, expressed as a strategy
on the preload rather than a named join binding (Gleam has no query-macro
binding syntax). The join’s parent column is the primary key for
has_many/has_one; a belongs_to declares its parent foreign key with
association.owner_column("author_id") (codegen emits it automatically). A
per-association where/preload_order is honored — rebound to the joined
binding and pushed into the ON clause, so the LEFT JOIN still keeps childless
parents. repo.one dedups the joined rows to the single parent (by the parent’s
primary key, so distinct parents sharing a foreign key don’t collapse); nested
preloads on the joined children still run batched. many_to_many works too — it
joins through the join table (two LEFT JOINs) and groups the leaves by the
parent row rather than a key match.
has_many :through and multiple join preloads (lode-pdt, lode-m5w). A
has_many :through loads via a join preload too: two LEFT JOINs (parent → mid
→ leaf), grouping leaves by the parent row and deduping by the leaf primary key.
The hops default to primary keys; when a hop goes via a non-key column,
association.through_columns(parent_column:, mid_column:) names the columns for
the ON clauses (the batched through loader already keys off the value
extractors, so only the join form needed this). More than one preload.join runs as a single
combined query: each association contributes its JOIN(s) and prefixed child
columns to one query (binding ranges allocate sequentially), the query runs
once, parents are deduped by primary key, and each association’s children are
split back out of the shared rows — deduping the cartesian repetition that
sibling joins produce. So loading two associations is one round-trip, not two.
Two further notes: join preload is SQL-adapter only (the in-memory adapter
doesn’t evaluate joins), and a parent limit/offset combined with a
has_many join counts joined rows, not parents — paginate with a batched
preload instead, and note that several to-many joins multiply the intermediate
row count before dedup. Referenced by: Associations.
UNION / UNION ALL — resolved
Resolved (lode-a0b). query.union(q, other) and query.union_all(q, other)
combine result sets (the second operand is parenthesized; an outer limit
applies to the whole). To order a union, wrap it with query.from_subquery and
order the outer query. Referenced by:
Self-referencing many to many.
Row locking (FOR UPDATE) — resolved
Resolved (lode-x45). query.lock(q, "FOR UPDATE") appends a lock clause
(rendered verbatim at the end of the SELECT); run it inside a repo.transaction.
Referenced by:
Overview.
first / last helpers — resolved
Resolved (lode-f0n). query.first(q, by:) and query.last(q, by:) order by
the field (ascending / descending) and take one row. Referenced by:
CRUD.
Query / schema prefixes — resolved
Resolved (lode-220). query.prefix(q, schema) schema-qualifies reads (FROM
- joins, and
update_all/delete_all), andchangeset.put_prefix(cs, schema)(plusrepo.insert_prefixed/update_prefixed) qualifies writes — every table renders as"schema"."table", like Ecto’s:prefix. A per-connectionsearch_path(a per-tenantRepo) remains available as a complement. Referenced by: Multi tenancy with query prefixes.
Window functions — resolved
Resolved (lode-p49). expr.over(call, partition_by:, order_by:) projects a
window application in select — the callee is a ranking function
(expr.row_number(), expr.rank(), expr.dense_rank()) or any aggregate
expression (expr.sum, expr.count_all, …), and the window’s ordering is
built with expr.win_asc/expr.win_desc. Named windows mirror Ecto’s
windows:: declare with query.window(q, name, partition_by:, order_by:) and
project with expr.over_named(call, window) — rendered as a standard
WINDOW <name> AS (...) clause between HAVING and ORDER BY. Both dialects
render identical standard SQL (SQLite has window functions since 3.25; the
bundled amalgamation is 3.50.x). Out of scope: frame clauses
(ROWS BETWEEN ...) are not modeled — use repo.query_raw for frames. Window
expressions are SQL-only — the in-memory adapter does not evaluate them (see
in-memory query coverage). Referenced by:
Aggregates and subqueries.
Common table expressions (with_cte) — resolved
Resolved (lode-ac3). query.with_cte(q, name:, as_:, recursive:) attaches
a named CTE, rendered as a single WITH "name" AS (...), ... prefix —
WITH RECURSIVE when any attached CTE is recursive (one keyword covers the
list, as in both engines). The CTE’s parameters render before the main
statement’s, taking the first $n/?n numbers, exactly like from_subquery’s
threading. Reference the CTE by name as an ordinary source
(query.from(source: name, ...) or a join); a recursive body is
base |> query.union_all(step) with the step referencing the CTE’s own name.
Inside a CTE body, union operands render without parentheses — SQLite
rejects parenthesized compound operands and the recursive form requires the
bare shape — so CTE-body union operands must not carry their own
order_by/limit. Works on both engines (SQLite has had CTEs since 3.8.3).
Out of scope: CTEs on update_all/delete_all (SELECT-only) and CTE
column aliases (WITH name (cols) AS ... — name columns with an explicit
select in the body); use repo.query_raw for those. SQL-only — the in-memory
adapter does not evaluate CTEs (see
in-memory query coverage). Referenced by:
Aggregates and subqueries. (The label is
as_ because as is a Gleam keyword.)
Aggregates and reads
repo.aggregate and COUNT(*) — resolved
Resolved (lode-94o). repo.aggregate(repo:, query:, by:) now computes
sum/avg/min/max/count server-side, and repo.count pushes
SELECT count(*) down — falling back to materialize-and-count only when the
query carries limit/offset/group_by/distinct (which need a subquery; see
Subqueries). Referenced by:
Aggregates and subqueries.
Repo.stream — resolved
Resolved (lode-amt, lode-c5h). repo.stream_fold(repo:, schema:, query:, batch_size:, from:, with:) folds a query’s full result set in batches, holding
only one batch of loaded rows at a time; repo.stream_for_each is the
side-effecting form. On Postgres this is a real server-side cursor: the
adapter opens (or nests into) a transaction, DECLAREs a cursor over the
query — $n parameters and all — FETCH FORWARD batch_size at a time folding
each batch, then CLOSEs. So peak memory is one batch regardless of result
size, and every row comes from one MVCC snapshot — concurrent writes never
duplicate or skip a row, and there are no OFFSET re-scans (an O(n) read). The
in-memory adapter has no cursor engine, so it folds its matched rows directly
(a test convenience, not bounded-memory).
What still differs (shape, not capability — a design re-expression, not a
gap): Ecto’s Repo.stream returns a lazy Enumerable you pipe into
Enum/Flow and must wrap in Repo.transaction yourself. Gleam has no lazy
stream type and no macros, so lode exposes a fold (stream_fold) /
side-effect loop (stream_for_each) instead, and manages the transaction for
you. Rows arrive in the query’s order; with no order_by the stream defaults
to primary-key order (an index scan, so still cheap) for determinism across
calls. Referenced by: CRUD.
Loading schemas from raw rows — resolved
Resolved (lode-o33). repo.query_raw_as(repo:, schema:, sql:, params:) runs
raw SQL and loads each result row into a typed struct via schema.load, in one
call (columns matched by name). Referenced by: CRUD,
Schemaless queries.
repo.reload
Open (lode-oil). Ecto’s Repo.reload/2 re-fetches a struct (or a list of
structs) by primary key. No equivalent here. Today: call repo.get with the
schema and the row’s key yourself (the list case is a WHERE pk IN query). A
thin helper over schema.primary_key + repo.get.
Repo writes
insert_or_update from primary-key presence
Implemented, with a divergent decision rule (lode-o4j).
repo.insert_or_update(repo:, schema:, changeset:) exists and delegates to
insert/update (so association writes, prefixes, and constraint-error
mapping all carry through).
The divergence: Ecto decides insert-vs-update from the struct’s
__meta__.state — :built (never persisted) inserts, :loaded (read from the
DB) updates. 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, not integer 0, not "" — the row
is updated; otherwise it is inserted. This is the same persisted-vs-new rule
cast_assoc/put_assoc already apply to child rows.
The consequences to know:
- Natural keys look persisted. A brand-new row whose primary key you
assigned yourself (a UUID or natural key, not a DB-generated serial) is seen
as already-persisted and updated — the update simply affects no rows.
With server-generated keys (the common case) a freshly built struct has
id = 0/NULLand inserts correctly. For client-assigned keys, callrepo.insert(orrepo.upsert) explicitly instead. - No
action-based dispatch. Ecto can also be steered by the changeset’s action; here the primary key is the only signal.
Referenced by: CRUD.
Bulk writes and update_all
update_all with expression values — resolved
Resolved (lode-ube). repo.update_all_set(repo:, query:, set:) takes
query.Assignments: query.set_expr("col", to: <expr>) sets a column from an
arbitrary expression (e.g. expr.Col(0, "other"), or a function of it), beside
the literal query.set. repo.update_all (literal #(col, value) pairs) is
unchanged. Referenced by: Associations.
update_all inc / push / pull — resolved
Resolved (lode-dzs). query.inc("col", by:) (col = col + n),
query.push("col", value) (array_append), and query.pull("col", value)
(array_remove) build query.Assignments for repo.update_all_set. In-memory,
inc is evaluated (numeric arithmetic); push/pull (array ops) need Postgres.
Note Postgres rejects two assignments to the same column in one UPDATE, so
push and pull the same column in separate calls. Referenced by:
Schemaless queries.
Schemaless insert_all — resolved
Resolved (lode-320). repo.insert_all_raw(repo:, source:, columns:, rows:)
bulk-inserts value lists into a bare table with no schema (Ecto’s
Repo.insert_all("table", rows)), returning the inserted count;
insert_all_raw_returning returns the inserted Rows. The schema-based
repo.insert_all/upsert_all remain for typed rows. Referenced by:
Schemaless queries.
Ecto.Multi
Multi step coverage — resolved
Resolved (lode-c5t). lode/multi now covers Ecto’s core step set: the
changeset steps (insert, update, delete, run), bulk steps
(insert_all, update_all, delete_all — the stored result is the affected
count), query steps (one, all, exists), put, and composition
(merge, append, prepend). Multi.insert_or_update/4, Multi.error/3,
and Multi.inspect/2 have no dedicated step — a run step covers each
(call repo.insert_or_update on the transaction-scoped repo, return
Error(..) to short-circuit, or print the results so far and pass them
through). merge receives the results so far and its
steps run at the merge position. Step names must be unique across the
composed multi: transaction returns
Error(MultiFailure(name:, reason: QueryError(..))) for a duplicate —
before any step runs for statically-known names, at the merge point (rolling
back) for merge-introduced ones. DIVERGENCE (shape): Ecto raises
ArgumentError at add/merge time; lode raises nothing, so the check
runs when transaction executes — and it also rejects same-name steps that
previously overwrote each other silently. The transaction-scoped repo the
steps run on inherits the outer repo’s prepare hook. Referenced by:
CRUD cheatsheet.
Changesets and constraints
Schemaless changesets — resolved
Resolved (lode-21y). schema.schemaless([schema.field("q", primitive.string()), ...]) builds a Schema(Dict(String, Value)) over a bare row — Ecto’s
cast({data, types}, params, keys). The fields supply the casting/validation
types; load/dump are the identity, so the whole changeset machinery (cast,
validators, apply_changes) works and apply_changes returns a
Dict(String, Value). Referenced by:
Data mapping and validation.
Constraint errors on changesets — resolved
Resolved (lode-68l). When a constraint declared on the changeset
(unique_constraint/foreign_key_constraint/check_constraint) has the same
database name as a returned violation, repo.insert/repo.update now convert
it into a ChangesetInvalid carrying that constraint’s field error (e.g.
"has already been taken"), ready for form rendering — Ecto’s
{:error, changeset}. An undeclared violation still propagates as the raw
Error(error.ConstraintError(..)). Referenced by:
Constraints and upserts,
Multi tenancy with foreign keys.
Exclusion / no-assoc constraint helpers — resolved
Resolved (lode-f5x). changeset.exclusion_constraint(field, name) and
changeset.no_assoc_constraint(field, name) record the constraint like
unique_constraint/foreign_key_constraint, so a violation reported by the
adapter maps to a field error ({:error, changeset}) instead of a raw
ConstraintError. no_assoc_constraint is a foreign-key constraint with the
“is still associated with this entry” message; create the underlying exclusion
constraint itself with migration.execute. Referenced by:
Constraints and upserts.
optimistic_lock — resolved
Resolved (lode-dnm). changeset.optimistic_lock(field) reads the current
version from the changeset’s data; repo.update and repo.delete_changeset
then filter the write on it (WHERE <field> = <current>), update also SETs
<field> = <current> + 1, and zero affected rows — another writer got there
first — surfaces as Error(error.StaleEntry), Ecto’s raised
Ecto.StaleEntryError as a Result. repo.delete takes a bare struct, so a
locked delete goes through the changeset-taking repo.delete_changeset. The
incrementer is a fixed + 1 (no custom incrementer). Referenced by:
Basic CRUD.
prepare_changes
Open (lode-mgu). Ecto’s prepare_changes/2 registers callbacks that run
inside the repo transaction immediately before the write, with repo access
(the classic use is bumping a counter cache). No equivalent slot on
Changeset. Today: compose the extra writes explicitly with
repo.transaction or multi.
unsafe_validate_unique
Open (lode-0ej). Ecto’s unsafe_validate_unique/4 runs a pre-flight
SELECT so a duplicate reports as a normal validation error before the write
(racy by design — the database constraint stays the authority, hence
“unsafe”). Only the post-write mapping exists here
(changeset.unique_constraint). Today: query for the duplicate yourself before
repo.insert, and keep unique_constraint for the race.
Associations and embeds
cast_assoc id-based matching — resolved
Resolved (lode-rpg). changeset.cast_assoc_by_key(name:, existing:, blank:, params:, key_param:, child_key:, with:) matches each param map to an existing
child by key_param (e.g. "id"): a match casts that child (an update
preserving its other columns), a non-match casts blank (an insert), and
existing children matched by no param are handled by the association’s
on_replace at write time. Pass the loaded children as existing; plain
cast_assoc (build-all-from-blank) remains. Referenced by:
Associations.
cast_assoc :sort_param / :drop_param — resolved
Resolved (lode-8wn). changeset.sort_drop_params(params:, key_param:, sort:, drop:) reorders and removes child param maps before cast_assoc — drop
removes params whose key is listed, sort lists keys in the desired order
(keyless/new params keep their order after). Referenced by:
Associations.
many_to_many join schema — resolved
Resolved (lode-70e, lode-7ee, lode-3a5, lode-k8q). A many_to_many join table can
carry more than the two foreign keys: association.join_defaults([#("col", value), ...]) sets static columns on each inserted link, and
association.join_row(with: fn(parent, child) { [#("col", value), ...] })
computes columns per link from the parent and the specific child (a role,
a position, an app-computed timestamp) — merged over the static defaults.
Together these cover Ecto’s :join_through with a schema for the
data-carrying join row. (Columns with a database default can be omitted.)
association.join_row_checked(with: fn(parent, child) { Ok(cols) / Error([#("col", FieldError(..))]) }) is the fallible form: rejecting a link
rolls back the whole write and surfaces ChangesetInvalid errors keyed
"<assoc>.<col>" with association/join_child_key metadata — Ecto’s
join-through-schema changeset. The same keying now covers invalid child
changesets staged with put_assoc/cast_assoc on any writable association
(lode-t16): the flush rejects the whole batch before writing anything, each
error keyed "<assoc>.<field>" with association/child_index metadata.
association.join_constraint(name:, message:) covers the constraint side of
Ecto’s join schema (unique_constraint on the join changeset): a database
violation on a new link becomes a ChangesetInvalid keyed on the association
with constraint/constraint_kind + association/join_child_key metadata,
rolling the write back; declare the name per engine (Postgres constraint name /
SQLite "table.col, table.col").
Referenced by: Associations.
Embed identity & on_replace — resolved
Resolved (lode-dg0). embed.cast_embed_many_by_key(name:, existing:, blank:, params:, key_param:, child_key:, on_replace:, with:) matches embeds by an id
field — a match casts that embed (an update preserving its id), a non-match
inserts — mirroring cast_assoc_by_key for inline embeds. embed.put_new_id(p, "id") stamps a generated v4 UUID on new embeds (Ecto’s autogenerated embed
binary_id); call it in your with cast. Embeds dropped from the params follow
on_replace: EmbedDelete removes them (the inline default), EmbedRaise
invalidates the parent. (Plain cast_embed_many — replace-the-whole-list —
still exists for the simple case.) Referenced by:
Embedded schemas.
Types
Interval / duration type — resolved
Resolved (lode-e7k, lode-smo). temporal.duration() is an LodeType
mapping a Postgres interval, and it round-trips through the normal
bound-parameter path — no inline literals or raw SQL. There is a VInterval
(months, days, microseconds) Value variant: a read decodes the interval
into it keeping the parts separate (Postgres never normalizes across them), and
a write binds it via pgo’s interval encoder (a small FFI, since pog doesn’t
expose interval values). temporal.duration().load folds a VInterval into a
gleam/time/duration.Duration (a fixed span, so months = 30×86400s, days =
86400s on read); dump emits the whole span in the microseconds component.
Referenced by: Duration types with pog.
Repo hooks and multi-tenancy
prepare_query / default_options — resolved
Resolved (lode-qgq). repo.with_prepare(repo, fn(query) { ... }) returns a
repo whose reads and bulk writes (all/one/get/count/aggregate/
exists/update_all(_set)/delete_all/stream_fold) pass every query through
the hook first — so a tenant or soft-delete scope is applied implicitly, and the
hook is inherited by transaction-scoped repos. It is a function on the Repo
value rather than a generated module callback (lode has no use Repo), so
build one prepared repo per scope. Referenced by:
Multi tenancy with foreign keys.
Migrations, DDL, and tooling
DDL coverage — resolved (foreign keys & timestamps)
Resolved (lode-cqr). The DDL layer now has the column foreign-key option and
the timestamps helper:
ddl.references(column, ddl.reference("table"))makes a column a foreign key, withddl.on_delete/ddl.on_updatetaking aReferentialAction(NoAction/Restrict/Cascade/SetNull— mapping to Ecto’s:nothing/:restrict/:delete_all/:nilify_all) andddl.references_columnfor a non-idtarget. TheREFERENCESclause renders inline, so it works increate_tableandadd_columnalike. (Unlike Ecto, the column keeps the type you declared — lode doesn’t infer it from the referenced key.)ddl.timestamps()returns the conventionalinserted_at/updated_atpair (NOT NULLTimestamp, defaulting to the current timestamp — rendered per dialect:now()on Postgres, astrftimeexpression on SQLite viaddl.default_now); splice it into a column list with[..ddl.timestamps()].
Schema/namespace DDL is also covered now (lode-tvz):
migration.create_schema(name) / drop_schema(name) emit CREATE SCHEMA /
DROP SCHEMA ... CASCADE (auto-reversible), for the prefix-based multi-tenancy
strategy. Referenced by:
Multi tenancy with query prefixes.
Storage create / drop — resolved
Resolved (lode-l0c). lode/storage’s storage.create(repo, database:) and
storage.drop(repo, database:) run CREATE DATABASE / DROP DATABASE IF EXISTS (Ecto’s storage_up/storage_down). As in Ecto, run them against a
Repo connected to a different database (conventionally postgres), since
Postgres can’t create or drop the database it’s connected to. There is still no
mix-style CLI wrapper — call them from a gleam run entrypoint.
Referenced by: Overview,
Getting started,
Testing with Lode.
Generators (migration & schema) — resolved
Resolved (lode-hl9). lode/migration/gen’s gen.module_source(name:, version:) returns the text of a migration module (a pub fn migration() over
migration.new(version) with a commented skeleton), and gen.file_name its
<name>.gleam name — no timestamp prefix, because a Gleam module name can’t
start with a digit (ordering is by the version inside, and the migrations()
list, not the file name).
lode/gen/schema is the macro-less mix phx.gen.schema: it parses
field:type[:modifier] args (incl. references:table and :unique) into either
a spec.table(...) snippet to paste into the spec (spec mode, the default,
§14-pure) or a finished standalone schema module via codegen
(--standalone, the §14.6 manual opt-out), plus the create_table
migration. context_source additionally emits a context module of CRUD wrappers
(list_/get_/create_/update_/delete_/change_ over the schema) — Phoenix’s
mix phx.gen.context. There is no mix, so these are pure source generators
wired into a gleam run entrypoint
(examples/codegen_demo/src/gen_schema.gleam, gen_context.gleam), writing
files with the codegen FFI. Referenced by:
Getting started.
Testing
SQL Sandbox — resolved
Resolved (lode-bj3). lode/sandbox’s sandbox.run(repo, body) runs a test
in a transaction that is always rolled back — per-test isolation without
truncation, and (built on repo.transaction) a transaction the test opens
inside becomes a savepoint. Not replicated (by design): Ecto’s
process-ownership registry (allow/3, shared mode) — there is no
process-dictionary repo (DESIGN.md §11); concurrency is “own Repo per async
test.” Referenced by:
Testing with Lode.
In-memory adapter query coverage — bounded by design
Resolved as a documented, intentional boundary (lode-qj6). The in-memory
adapter is a single-source test aid, not a Postgres emulator. It evaluates:
where (incl. like/ilike, in, is null, numeric arithmetic),
order_by/limit/offset, a single aggregate select
(count/sum/avg/min/max), group_by with a projected aggregate
select, a subquery FROM source, and uncorrelated IN/EXISTS. It does
not evaluate: joins (single-source by design — so preload.join,
association.join, and join_subquery are Postgres-only), HAVING, correlated
subqueries, or DISTINCT (every stored row gets a unique generated id, so
full-row dedup never fires). It also does not evaluate window functions
(expr.over/expr.over_named) — a windowed select comes back as plain full
rows; prove window queries on Postgres or SQLite. Nor does it evaluate CTEs
(query.with_cte) — the attached CTE is ignored and its name is not a stored
source, so the query returns no rows; prove CTE queries on Postgres or SQLite.
Test those against Postgres;
use the memory adapter for changeset/logic and basic-query tests. Referenced by:
Testing with Lode.
SQLite adapter — resolved
Resolved (lode-mr1). lode ships a SQLite adapter (lode/adapters/sqlite, on
sqlight/esqlite) alongside Postgres — the parallel to Ecto’s
Ecto.Adapters.SQLite3. The SQL/DDL renderer is parameterized by a Dialect
(sql.postgres()/sql.sqlite(), ddl.postgres()/ddl.sqlite()), so the query
builder, changesets, repo, and migrations are unchanged across engines — only the
adapter line differs (repo.new(sqlite.new(conn))). The adapter carries an
engine tag, which the migrator reads to pick the DDL dialect, so
migrator.migrate targets SQLite with no caller change (Serial →
INTEGER PRIMARY KEY AUTOINCREMENT). ddl.timestamps() is portable too: its
current-timestamp default renders per dialect (now() vs a strftime
expression that matches the adapter’s Timestamp TEXT shape). SQLite is
embedded, so tests run against sqlight.open(":memory:") with no server.
Type fidelity follows SQLite’s storage classes: Bool binds as 0/1,
Decimal/Uuid/Date/Time/Timestamp/JSON travel as TEXT (the field’s
typed load reconstructs them — the same schema round-trips on both engines).
SQLite-specific limits: ON CONFLICT ON CONSTRAINT <name> is Postgres-only (use a
column target — the adapter rejects it with a clear error), and UNIQUE/NOT NULL/FOREIGN KEY/CHECK violations map to a typed ConstraintError; and
SQLite cannot ADD COLUMN with a non-constant default, so default_now
columns must be part of create_table; repo.stream_fold on SQLite steps a
prepared statement row by row (bounded memory, one row at a time —
batch_size is irrelevant with no server round trips). The adapter drives a
single SQLite connection, serialized by a per-adapter lock: concurrent
processes sharing one repo take turns — a transaction (or a stream, for its
whole walk) owns the connection for its whole body, plain writes queue and run
outside it, and a transaction whose process crashes is rolled back and the
lock freed. That is coarser than a Postgres pool (one writer at a time — use
Postgres for parallel throughput); for parallel tests, keep one :memory:
repo per test as the suite does.
Codegen introspection (codegen.introspect/bootstrap_spec/generate) and
drift.check are engine-aware too (sqlite_master + PRAGMA on SQLite);
drift there compares at SQLite’s type-affinity level, so intent changes within
one affinity (e.g. Uuid vs Text) are invisible — see the lode/drift docs.
Still Postgres-only: the FOR UPDATE row lock, DISTINCT ON, schema
prefixes, and migration.up_sql/down_sql as a whole-Migration SQLite render
through the migrator (the per-op DDL dialect is what the migrator uses). No
MySQL adapter — see MySQL / MSSQL adapters.
Referenced by: Overview,
Testing with Lode.
MySQL / MSSQL adapters
Open (lode-gtn, backlog). Ecto’s ecto_sql ships MySQL (myxql) and
MSSQL (tds) adapters alongside Postgres. lode ships Postgres, SQLite,
and the in-memory test adapter. The Dialect seam added for SQLite
(lode/query/sql, lode/migration/ddl) is the extension point; a MySQL
adapter also needs a Gleam MySQL driver. Filed as backlog — no tracked demand.
Tracking
All of the above are tracked in the project’s bd issue tracker under the
divergence label (issue ids shown per entry). To see them:
bd list --label divergence