Packages
double_down
0.65.0
0.69.0
0.68.0
0.66.0
0.65.0
0.64.1
0.64.0
0.63.3
0.63.2
0.63.1
0.63.0
0.62.1
0.61.0
0.60.4
0.60.3
0.60.2
0.60.1
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.0
0.52.3
0.52.2
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.1
0.48.0
0.47.2
0.47.1
0.47.0
0.46.3
0.46.2
0.46.1
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.2
0.37.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.31.0
0.30.1
0.30.0
0.29.0
0.28.1
0.28.0
0.27.0
0.26.0
0.24.0
Builds on the Mox pattern — generates behaviours and dispatch facades from `defcallback` declarations — and adds stateful test doubles powerful enough to test Ecto.Repo operations without a database.
Current section
Files
Jump to
Current section
Files
docs/repo.md
# Repo
<!-- nav:header:start -->
[< Double API](double-api.md) | [Up: Guides](../README.md) | [Index](../README.md) | [Changelog >](../CHANGELOG.md)
<!-- nav:header:end -->
DoubleDown ships a complete `Ecto.Repo` contract and three test doubles,
letting you replace the database with an in-memory store in tests while
keeping your existing ExMachina factories and `Ecto.Multi` transactions.
## The contract
`DoubleDown.Repo` defines the full `Ecto.Repo` surface — writes, reads,
aggregates, associations, streaming, and transactions:
| Category | Operations |
|--------------|-----------------------------------------------------------------------------|
| Writes | `insert`, `update`, `delete`, `insert_or_update` + bang variants |
| Bulk | `insert_all`, `update_all`, `delete_all` |
| PK reads | `get`, `get!` |
| Non-PK reads | `get_by`, `get_by!`, `one`, `one!`, `all`, `all_by`, `exists?`, `aggregate` |
| Associations | `preload`, `load`, `reload`, `reload!` |
| Streaming | `stream` |
| Transactions | `transact`, `rollback`, `in_transaction?` |
| Raw SQL | `query`, `query!` |
## Creating a Repo facade
### With ContractFacade (recommended)
```elixir
defmodule MyApp.Repo do
use DoubleDown.ContractFacade, contract: DoubleDown.Repo, otp_app: :my_app
end
```
```elixir
# config/config.exs
config :my_app, DoubleDown.Repo, impl: MyApp.EctoRepo
```
With the default `:static_dispatch?` setting, production dispatch compiles
away entirely — `MyApp.Repo.insert(changeset)` produces identical bytecode
to `MyApp.EctoRepo.insert(changeset)`.
### With DynamicFacade
If your Repo module has custom functions beyond the standard API, or you
don't want a facade module:
```elixir
# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.EctoRepo)
ExUnit.start()
```
Then in tests, use your Ecto Repo module directly as the contract:
```elixir
DoubleDown.Double.fallback(MyApp.EctoRepo, DoubleDown.Repo.InMemory)
```
## Test doubles
| Double | Type | Reads | Best for |
|---------------------|-------------------|-----------------------|-------------------------------|
| `Repo.InMemory` | Closed-world fake | All bare-schema reads | ExMachina, most tests |
| `Repo.OpenInMemory` | Open-world fake | PK reads only | Fine-grained fallback control |
| `Repo.Stateless` | Stateless stub | Fallback only | Fire-and-forget writes |
All three validate changesets (returning `{:error, changeset}` on invalid),
autogenerate primary keys and timestamps via Ecto schema metadata, accept
both changesets and bare structs, and support `Ecto.Multi` transactions.
### Repo.InMemory (recommended)
Closed-world semantics — the in-memory store is the complete truth. If a
record isn't in the store, it doesn't exist. All bare-schema reads
(`get`, `get_by`, `all`, `exists?`, `aggregate`) work without a fallback.
```elixir
setup do
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory)
:ok
end
test "insert then read back" do
{:ok, user} = MyApp.Repo.insert(User.changeset(%{name: "Alice"}))
assert ^user = MyApp.Repo.get(User, user.id)
assert [^user] = MyApp.Repo.all(User)
end
```
Seed data can be passed as the third argument:
```elixir
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory,
[%User{id: 1, name: "Alice"}])
```
`Ecto.Query` operations that InMemory can't evaluate natively delegate to
an optional `fallback_fn`:
```elixir
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory, [],
fallback_fn: fn
_contract, :all, [%Ecto.Query{}], _state -> []
end
)
```
### Repo.OpenInMemory
Open-world semantics — the store may be incomplete. PK reads hit the store
first then fall through to a user-supplied fallback. All other reads
always go through the fallback. Use when you need fine-grained control
over which reads are served from state:
```elixir
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.OpenInMemory, [],
fallback_fn: fn
_contract, :get_by, [User, [email: email]], _state -> %User{email: email}
_contract, :all, [User], state -> state |> Map.get(User, %{}) |> Map.values()
end
)
```
### Repo.Stateless
Writes succeed but store nothing. Reads raise unless you supply a fallback
function. Best for simple command-style functions:
```elixir
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless)
DoubleDown.Double.expect(DoubleDown.Repo, :insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)
```
## ExMachina integration
Point ExMachina at your Repo facade (not your Ecto Repo - unless you used a
DynamicFacade, in which case your Repo facade is your Ecto Repo):
```elixir
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%User{name: sequence(:name, &"User #{&1}"), email: sequence(:email, &"user#{&1}@example.com")}
end
end
```
With `Repo.InMemory` installed, factory inserts land in the in-memory
store and all bare-schema reads find them:
```elixir
setup do
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory)
:ok
end
test "factory-inserted records are readable" do
insert(:user, name: "Alice")
insert(:user, name: "Bob")
assert [_, _] = MyApp.Repo.all(User)
assert %User{name: "Alice"} = MyApp.Repo.get_by(User, name: "Alice")
assert 2 = MyApp.Repo.aggregate(User, :count, :id)
end
```
## Transactions and rollback
`transact/2` mirrors `Ecto.Repo.transact/2` — accepts a function or an
`Ecto.Multi`:
```elixir
# Function
MyApp.Repo.transact(fn ->
{:ok, user} = MyApp.Repo.insert(user_changeset)
{:ok, profile} = MyApp.Repo.insert(profile_changeset(user))
{:ok, {user, profile}}
end, [])
# Ecto.Multi
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> Ecto.Multi.run(:profile, fn repo, %{user: user} ->
repo.insert(profile_changeset(user))
end)
|> MyApp.Repo.transact([])
```
`rollback/1` aborts the transaction and restores the pre-transaction state
in `Repo.InMemory` and `Repo.OpenInMemory`:
```elixir
MyApp.Repo.transact(fn repo ->
{:ok, _} = repo.insert(user_changeset)
if problem?, do: repo.rollback(:constraint_violated)
{:ok, user}
end, [])
# Returns {:error, :constraint_violated}; insert is undone
```
Only the Repo contract's state is restored on rollback — other contracts'
state modified during the transaction is unaffected. The in-memory
adapters do not provide full ACID isolation (sub-operations are
individually atomic but not grouped). For true ACID, use the real Ecto
adapter in integration tests.
## What stays on the database
Tests that exercise database-specific behaviour should continue to run
against a real database:
- **Query correctness** — `Ecto.Query` expressions can't be fully
evaluated in memory
- **Constraint validation** — unique indexes, foreign keys, check
constraints
- **Transaction isolation** — concurrent writes, rollback interaction
The goal isn't to eliminate DB tests — it's to move the ~3/4 of tests that
use the database merely as a slow data-fixture mechanism to run DB-free.
<!-- nav:footer:start -->
---
[< Double API](double-api.md) | [Up: Guides](../README.md) | [Index](../README.md) | [Changelog >](../CHANGELOG.md)
<!-- nav:footer:end -->