View Source Coming from Ecto
ActiveMemory borrows Ecto's programming model deliberately: you define a schema, cast params through a changeset, and read and write through a repo-ish module. If you know Ecto you already know most of this. What follows is what carries over, what is named differently, and — the part worth reading — where the two genuinely differ because the records live in ETS or Mnesia rather than a database.
the-mapping
The mapping
| Ecto | ActiveMemory |
|---|---|
Ecto.Schema / embedded_schema | ActiveMemory.Table — an attributes block, or an Ecto embedded_schema |
MyApp.Repo | ActiveMemory.Store (one table) or ActiveMemory.ActiveRepo (several) |
Repo.get/2, Repo.get!/2 | Store.get/1, Store.get!/1 |
Repo.get_by/3, Repo.get_by!/3 | Store.get_by/1, Store.get_by!/1 |
Repo.one/2, Repo.one!/2 | Store.one/1, Store.one!/1 |
Repo.all/2 | Store.all/1 |
Repo.aggregate(Schema, :count) | Store.count/1 |
Repo.exists?/2 | Store.exists?/2 |
Repo.insert/2, Repo.update/2 | Store.write/1 (an upsert — see below) |
Repo.delete/2 | Store.delete/1 (matches in full — see below) |
Repo.reload/2, Repo.reload!/2 | Store.reload/1, Store.reload!/1 |
Ecto.Query | an attributes map, or the match/1 macro |
Ecto.NoResultsError | ActiveMemory.NotFoundError |
Ecto.MultipleResultsError | ActiveMemory.MultipleResultsError |
| migrations | none — a table is created by its Store at startup |
what-carries-over-unchanged
What carries over unchanged
Schemas. A table can be an Ecto schema, and nothing about it is special:
defmodule MyApp.Planet do
use ActiveMemory.Table, type: :ets
use Ecto.Schema
import Ecto.Changeset
@primary_key {:uuid, Ecto.UUID, autogenerate: true}
embedded_schema do
field :name, :string
field :gravity, :decimal
timestamps()
end
def changeset(planet, attrs) do
planet
|> cast(attrs, [:name, :gravity])
|> validate_required([:name, :gravity])
|> validate_number(:gravity, greater_than: 0)
end
endChangesets. cast/4, every validate_*, put_change/3, apply_action/2 — all
of it, because the struct is an Ecto schema. write/1 also takes the changeset
directly, so a context function is the same shape as its Ecto equivalent:
def create_planet(attrs) do
%MyApp.Planet{}
|> MyApp.Planet.changeset(attrs)
|> MyApp.Planet.Store.write()
endAn invalid changeset comes back as {:error, changeset} with action: :insert, so
to_form/1 renders the errors in a Phoenix form with no extra work.
Autogenerated keys and timestamps. An autogenerating primary key is filled on
write, whether it is the default :binary_id or an explicit
@primary_key {:uuid, Ecto.UUID, autogenerate: true}. timestamps() are stamped
too.
where-it-differs
Where it differs
there-is-no-ecto-query
There is no Ecto.Query
Reads take an attributes map, or the match/1 macro for comparisons and boolean
logic:
Store.select(%{department: "sales", active?: true})
Store.select(match(:age > 30 and :hair_color == "brown"))There is no from, no joins, no aggregates, no subqueries. Ordering and paging are
options rather than query clauses:
Store.all(order_by: {:desc, :inserted_at}, limit: 20)Sorting happens after reading — neither backend can order a result — so it is
O(n log n) over the matched records, not an index backed sort. :limit and
:offset are convenience pagination rather than the indexed pagination a database
gives you: every matched record is read and sorted before the offset is discarded.
write-1-is-an-upsert-not-insert-or-update
write/1 is an upsert, not insert-or-update
There is no Repo.insert versus Repo.update distinction. write/1 puts the
record at its key, creating or replacing it. Consequences worth knowing:
- Nothing raises if a record you meant to update does not exist; you get a new one.
- There is no optimistic locking and no constraints, so
Ecto.Changeset.optimistic_lock/3,unique_constraint/3and friends have nothing to enforce them. timestamps()are stamped when they arenil, soupdated_atdoes not refresh on a later write. Set it yourself in an update changeset if you need it:put_change(:updated_at, NaiveDateTime.utc_now()).
delete-1-matches-the-whole-record
delete/1 matches the whole record
Repo.delete deletes by primary key. Store.delete/1 matches every field, so a
struct that has drifted from the stored copy removes nothing — and still returns
:ok, because deleting is idempotent and never reports whether a record was there.
Use withdraw/1 when you hold an identifier rather than a record you know is
current. It matches on a query, is atomic, and tells you what happened:
case Store.withdraw(%{uuid: uuid}) do
{:ok, planet} -> # removed
{:error, :not_found} -> # nothing matched
end
reads-return-tagged-tuples-not-nil
Reads return tagged tuples, not nil
Repo.get returns nil when nothing matches; ActiveMemory returns
{:error, :not_found}, and the bang variants raise ActiveMemory.NotFoundError.
The tagged tuple is the house style throughout the library.
The multiple-results behavior does match Ecto: one/1 and get_by/1 raise
ActiveMemory.MultipleResultsError when a query meant for one record matches
several. Use select/2 when many are expected.
no-associations
No associations
belongs_to, has_many, preload — none of it. Store an id field and read the
other table yourself. There is no referential integrity and no cross-table
transaction to make a multi-table write atomic.
no-migrations-and-no-durability-by-default
No migrations, and no durability by default
A table is created by its Store when the supervision tree starts, so there is
nothing to migrate — but also nothing on disk. An ETS table lives and dies with the
node (though ActiveMemory.TableHeir keeps it alive across a Store crash), and
Mnesia persists only with disc_copies. ActiveMemory is for data you can rebuild or
afford to lose: sessions, tokens, feature flags, config, reference data. Keep your
system of record in a database.
count-1-is-o-1-with-a-caveat
count/1 is O(1), with a caveat
count/1 asks the table for its size instead of reading the records, so it does not
have the cost of length(Repo.all(...)). On a table with a ttl the number includes
records that expired but have not been swept; count(sweep: true) reconciles it.
types-are-for-casting-not-storage
Types are for casting, not storage
A field's Ecto type drives cast/4. It is not enforced on write — ETS and Mnesia
store any term — so write/1 on a hand-built struct with the wrong type in a field
will happily store it. Validation belongs in the changeset, as it does in Ecto.
a-ttl-needs-an-explicit-expires_at
A ttl needs an explicit expires_at
If you give an Ecto schema table a ttl, declare the field yourself and put it
last so it does not take the key position:
use ActiveMemory.Table, type: :ets, ttl: :timer.minutes(15)
@primary_key {:uuid, Ecto.UUID, autogenerate: true}
embedded_schema do
field :value, :string
field :expires_at, :integer
endActiveMemory does not add fields to a schema you wrote. An attributes block still
gets the field added for it.