Ecto.Adapters.Recall

An Ecto 3 adapter for Mnesia — the distributed, transactional database that ships with the BEAM.

Memory, recollected.

Unlike an ETS-backed adapter, Mnesia gives you real ACID transactions and optional disk persistence (disc_copies) for free, mapping almost directly onto Ecto's Transaction and Storage behaviours. Queries are translated to Erlang match specifications and run through :mnesia.select, so where/select are pushed down to Mnesia rather than evaluated row-by-row in Elixir.

Two existing adapters cover nearby ground; Recall differs from both:

Installation

Add recall to your dependencies:

def deps do
[
{:recall, "~> 0.1.0"}
]
end

Usage

Define a repo as usual, pointing it at the adapter:

defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app, adapter: Ecto.Adapters.Recall
end

Define schemas with Recall.Schema (a drop-in replacement for use Ecto.Schema), naming the table with an atom — the form a Mnesia table actually takes:

defmodule MyApp.User do
use Recall.Schema
schema :users do
field :name, :string
field :age, :integer
end
end

Everything Ecto.Schema provides (the struct, __schema__/1, changesets, associations) keeps working untouched — this only adds the compiled accessors the adapter uses (see Why a dedicated schema macro). The schema is purely logical: a table's type and any secondary indexes are set in migrations, not on the schema.

import Ecto.Query
{:ok, user} = MyApp.Repo.insert(%MyApp.User{name: "Ada", age: 36})
MyApp.Repo.all(from u in MyApp.User, where: u.age > 30, select: u.name)
#=> ["Ada"]
MyApp.Repo.transaction(fn ->
MyApp.Repo.insert!(%MyApp.User{name: "Grace"})
# ...real ACID transaction; raise or Repo.rollback/1 to abort
end)

Tables are created lazily on first use with the configured storage type, so you can get going without migrations. Real apps (especially disc-backed ones) manage their schema with migrations.

Configuration

Set on the repo config (e.g. in config/config.exs):

config :my_app, MyApp.Repo,
storage: :ram_copies, # or :disc_copies, :disc_only_copies
type: :ordered_set, # or :set
nodes: [node()], # nodes that hold copies
dir: "priv/mnesia" # Mnesia data directory (disc storage only)
OptionDefaultDescription
:storage:ram_copiesWhere auto-created tables live: :ram_copies, :disc_copies, or :disc_only_copies.
:type:ordered_set:ordered_set keeps records in primary-key order, so reads come back sorted. Falls back to :set for :disc_only_copies (Mnesia disallows :ordered_set there).
:nodes[node()]Nodes that hold table copies.
:dirMnesia data directory (only relevant for disc storage).

For disc-backed storage, mix ecto.create / mix ecto.drop create and delete the Mnesia schema on the configured nodes.

Migrations

Migrations are written like any Ecto app — create / alter / drop in a migration module — with one difference: run them with the Recall tasks rather than mix ecto.migrate.

mix recall.migrate # run pending migrations
mix recall.rollback --step 1 # roll back
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :age, :integer
end
create index(:users, [:age]) # a secondary index the planners can use
end
end

Supported: create / create_if_not_exists / drop / drop_if_exists for tables and indexes; alter table with add / remove / modify; and column and table renames. Mnesia is typeless, so column types are ignored — only the attribute names, which one is the primary key (Mnesia's key is the first attribute), and which carry an index matter — and modify is a no-op. An alter rewrites every existing record in place via :mnesia.transform_table, defaulting any added column.

Raised loudly rather than silently mishandled: raw SQL (execute "..."), composite primary keys, changing or removing the primary key, unique indexes (Mnesia has no unique secondary constraint — use the primary key for uniqueness), and Postgres-style prefixes. A composite index(:t, [:a, :b]) becomes one single-attribute index per column (with a warning), since Mnesia indexes are single-attribute.

Why a dedicated task? Stock mix ecto.migrate tracks applied versions by querying schema_migrations with a string source (from m in "schema_migrations"), and this adapter only serves schema-backed tables (see Limitations). mix recall.migrate runs the very same migrations through Ecto's migration engine, but does version bookkeeping through a real schema (Recall.SchemaMigration) so it stays within that rule. Both tasks accept --step, --to, --all, and -r MyApp.Repo.

Why a dedicated schema macro

Recall.Schema is required — a plain use Ecto.Schema schema will not work with this adapter.

Ecto.Schema is built for SQL, where a query carries field names and the database resolves them. Recall stores every field as a positional Mnesia attribute, so the macro bakes the schema's logical shape — the table tag, field names in storage order, the struct, and a load recipe — into compiled __recall__/1 accessors, and the adapter reads field metadata only through them (a plain use Ecto.Schema has no fallback path). A field's physical position is resolved at runtime from the table's live layout in Mnesia's catalog (cached in :persistent_term), so a migration that reshapes a table is honored on the next read, and reordering fields in source can't silently misalign storage.

It also requires the table name to be an atom (schema :users, not schema "users") — the form a Mnesia table actually takes. __schema__(:source) still returns the equivalent string "users" for the rest of Ecto, so changesets, associations, and the struct are unaffected.

Secondary indexes

A table's built-in index is its primary key. Additional secondary indexes are physical — created by a migration (create index(:users, [:age])), never declared on the schema or built on the fly. The read and join planners serve an equality (or in) on the primary key, or on any attribute that is physically indexed, with :mnesia.read / :mnesia.index_read instead of a full-table scan; an equality on any other field still runs correctly — it just takes the scan path. The full where is re-checked against the fetched rows either way, so results are identical to a scan.

What works

Match-spec guard fragments

A match spec runs Erlang guards against each record, and guards reach things SQL has no notion of: type tests, structural access into a term, term ordering across heterogeneous types. To expose those, a fragment/1 whose body is a guard expression is translated into the match-spec conditions and pushed down with the rest of the where:

import Ecto.Query
# type test — filter on the *shape* of a term, not its value
from u in User, where: fragment("is_map(?)", u.metadata)
# structural access into a map-typed column
from u in User, where: fragment("map_get(?, ?)", ^"role", u.metadata) == ^"admin"
# arithmetic / bit guards
from u in User, where: fragment("rem(?, ?)", u.id, ^2) == 0
# anything expressible as an Erlang guard
from u in User, where: fragment("length(?) > ?", u.tags, ^min_tags)

This is not the SQL-fragment escape hatch. A SQL adapter passes the fragment string to the database verbatim — there, the string is the query. Mnesia has no query string: :mnesia.select/2 takes an Erlang term ({head, conditions, body}), so the fragment is parsed and translated into guard tuples (e.g. is_map(?){:is_map, :"$5"}), once per query build. The ? placeholders bind to fields or interpolated ^values exactly as in any fragment.

Only the Erlang guard BIFs (is_*, element, map_get, map_size, byte_size, length, rem, abs, the bit operators, node, self, …) and comparison/boolean operators are allowed. Anything else — an unknown name, or a real BIF that isn't a valid match-spec guard — raises at query time rather than building a spec Mnesia would reject or smuggling an arbitrary call into the match. Fragments are supported in where, not in select (the match-spec body has a narrower operation set, and select is projected in Elixir regardless).

Demo app

A small, runnable example lives in examples/blog/ — a blog (authors → posts → comments) backed entirely by this adapter, no external database. From the repo root:

cd examples/blog
mix deps.get
mix blog.demo

mix blog.demo migrates, seeds sample data, and prints a guided tour of an indexed-field index_read, where/order_by/limit pushdown, a posts ⨝ authors join, count/sum/avg aggregates, a committed transaction, a Repo.rollback/1, and — to make the storage model concrete — the raw Mnesia tuples next to the same rows loaded back as Ecto structs. See examples/blog/README.md for the breakdown.

Limitations

Unsupported query clauses raise at query-prepare time rather than silently dropping (so you never get wrong rows back):

Other gaps, fundamental to Mnesia or not yet implemented:

License

MIT.