Packages
double_down
0.63.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
lib/double_down/repo/stateless.ex
# Stateless stub for DoubleDown.Repo.
#
# Write operations apply changeset changes and return {:ok, struct}.
# Read operations go through a user-supplied fallback function, or raise.
#
if Code.ensure_loaded?(Ecto) do
defmodule DoubleDown.Repo.Stateless do
alias DoubleDown.Dispatch.Defer
@behaviour DoubleDown.Dispatch.StatelessHandler
@moduledoc """
Stateless stub for `DoubleDown.Repo`.
Write operations (`insert`, `update`, `delete`) apply changeset
changes and return `{:ok, struct}` but store nothing. Read
operations go through an optional fallback function, or raise a
clear error.
Implements `DoubleDown.Dispatch.StatelessHandler`, so it can
be used by module name with `Double.fallback`:
## Usage with Double.fallback
# Writes only — reads will raise with a helpful message:
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless)
# With fallback for specific reads:
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless,
fn _contract, operation, args ->
case {operation, args} do
{:get, [User, 1]} -> %User{id: 1, name: "Alice"}
{:all, [User]} -> [%User{id: 1, name: "Alice"}]
{:exists?, [User]} -> true
end
end
)
# Layer expects on top for failure simulation:
DoubleDown.Repo
|> DoubleDown.Double.fallback(DoubleDown.Repo.Stateless)
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)
## When to use Repo.Stateless
Use `Repo.Stateless` when your test only needs fire-and-forget writes
and a few canned read responses. It's the lightest-weight option —
no state to reason about.
For read-after-write consistency, use `Repo.InMemory` (closed-world,
recommended) or `Repo.OpenInMemory` (open-world, fallback-based).
| Fake | State | Reads |
|------|-------|-------|
| `Repo.Stateless` | None | Fallback function or raise |
| `Repo.InMemory` | Complete store | Authoritative for bare schemas |
| `Repo.OpenInMemory` | Partial store | PK lookup in state, fallback for rest |
"""
@doc """
Create a new Test handler function.
Returns a 3-arity function `(contract, operation, args) -> result` suitable for
use with `DoubleDown.Double.fallback/2` or `DoubleDown.Testing.set_stateless_handler/2`.
## Arguments
* `fallback_fn` — an optional 3-arity function `(contract, operation, args) -> result`
that handles read operations. If the function raises `FunctionClauseError`
(no matching clause), dispatch falls through to an error. If omitted or
`nil`, all reads raise immediately.
* `opts` — keyword options (reserved for future use).
## Examples
# Writes only — via module name (StatelessHandler)
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless)
# With fallback for specific reads
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless,
fn _contract, operation, args ->
case {operation, args} do
{:get, [User, 1]} -> %User{id: 1, name: "Alice"}
{:all, [User]} -> [%User{id: 1, name: "Alice"}]
{:exists?, [User]} -> true
end
end
)
## Legacy keyword-only form (still supported)
DoubleDown.Repo.Stateless.new(fallback_fn: fn _contract, :get, [User, 1] -> %User{} end)
"""
@impl DoubleDown.Dispatch.StatelessHandler
@spec new((module(), atom(), [term()] -> term()) | nil, keyword()) :: (module(),
atom(),
[term()] ->
term())
def new(fallback_fn \\ nil, opts \\ [])
# Legacy keyword-only form: new(fallback_fn: fn ...)
def new(opts, []) when is_list(opts) and opts != [] do
if Keyword.keyword?(opts) do
fallback_fn = Keyword.get(opts, :fallback_fn, nil)
build_handler(fallback_fn)
else
# Not a keyword list — shouldn't happen, but handle gracefully
build_handler(nil)
end
end
def new(fallback_fn, _opts) do
build_handler(fallback_fn)
end
defp build_handler(fallback_fn) do
fn contract, operation, args ->
dispatch(contract, operation, args, fallback_fn)
end
end
# -----------------------------------------------------------------
# Write Operations — always authoritative
# -----------------------------------------------------------------
defp dispatch(_contract, :insert, [%Ecto.Changeset{valid?: false} = changeset], _fallback_fn) do
{:error, %{changeset | action: :insert}}
end
defp dispatch(_contract, :insert, [%Ecto.Changeset{} = changeset], _fallback_fn) do
do_insert(Ecto.Changeset.apply_changes(changeset))
end
defp dispatch(_contract, :insert, [%{__struct__: _} = struct], _fallback_fn) do
do_insert(struct)
end
defp dispatch(_contract, :update, [%Ecto.Changeset{valid?: false} = changeset], _fallback_fn) do
{:error, %{changeset | action: :update}}
end
defp dispatch(_contract, :update, [changeset], _fallback_fn) do
{:ok, DoubleDown.Repo.Impl.Autogenerate.apply_changes(changeset, :update)}
end
defp dispatch(_contract, :delete, [%Ecto.Changeset{valid?: false} = changeset], _fallback_fn) do
{:error, %{changeset | action: :delete}}
end
defp dispatch(_contract, :delete, [%Ecto.Changeset{} = changeset], _fallback_fn) do
{:ok, Ecto.Changeset.apply_changes(changeset)}
end
defp dispatch(_contract, :delete, [record], _fallback_fn) do
{:ok, record}
end
# -----------------------------------------------------------------
# Bang Write Operations
# -----------------------------------------------------------------
defp dispatch(contract, :insert!, [changeset], fallback_fn) do
case dispatch(contract, :insert, [changeset], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: :insert, changeset: changeset
end
end
defp dispatch(contract, :update!, [changeset], fallback_fn) do
case dispatch(contract, :update, [changeset], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: :update, changeset: changeset
end
end
defp dispatch(contract, :delete!, [record], fallback_fn) do
case dispatch(contract, :delete, [record], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: :delete, changeset: changeset
end
end
# -----------------------------------------------------------------
# Insert-or-update — delegate to insert/update based on meta state
# -----------------------------------------------------------------
defp dispatch(contract, :insert_or_update, [%Ecto.Changeset{} = changeset], fallback_fn) do
if Ecto.get_meta(changeset.data, :state) == :loaded do
dispatch(contract, :update, [changeset], fallback_fn)
else
dispatch(contract, :insert, [changeset], fallback_fn)
end
end
defp dispatch(contract, :insert_or_update!, [changeset], fallback_fn) do
case dispatch(contract, :insert_or_update, [changeset], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: changeset.action, changeset: changeset
end
end
# -----------------------------------------------------------------
# Load — stateless struct loading
# -----------------------------------------------------------------
defp dispatch(_contract, :load, [schema_or_map, data], _fallback_fn) do
loader = fn _type, value -> {:ok, value} end
case data do
data when is_list(data) ->
do_load(schema_or_map, Map.new(data), loader)
{fields, values} when is_list(fields) and is_list(values) ->
do_load(schema_or_map, Map.new(Enum.zip(fields, values)), loader)
data when is_map(data) ->
do_load(schema_or_map, data, loader)
end
end
# Opts-accepting variants — strip opts, delegate to base arity.
# Ecto.Repo operations all accept an optional opts keyword list as
# the last argument. These are called by Ecto.Multi's internal :run
# callbacks and by user code passing opts through the facade.
defp dispatch(contract, :insert, [changeset, _opts], fallback_fn),
do: dispatch(contract, :insert, [changeset], fallback_fn)
defp dispatch(contract, :update, [changeset, _opts], fallback_fn),
do: dispatch(contract, :update, [changeset], fallback_fn)
defp dispatch(contract, :delete, [record, _opts], fallback_fn),
do: dispatch(contract, :delete, [record], fallback_fn)
defp dispatch(contract, :insert!, [changeset, _opts], fallback_fn),
do: dispatch(contract, :insert!, [changeset], fallback_fn)
defp dispatch(contract, :update!, [changeset, _opts], fallback_fn),
do: dispatch(contract, :update!, [changeset], fallback_fn)
defp dispatch(contract, :delete!, [record, _opts], fallback_fn),
do: dispatch(contract, :delete!, [record], fallback_fn)
defp dispatch(contract, :get, [queryable, id, _opts], fallback_fn),
do: dispatch(contract, :get, [queryable, id], fallback_fn)
defp dispatch(contract, :get!, [queryable, id, _opts], fallback_fn),
do: dispatch(contract, :get!, [queryable, id], fallback_fn)
defp dispatch(contract, :get_by, [queryable, clauses, _opts], fallback_fn),
do: dispatch(contract, :get_by, [queryable, clauses], fallback_fn)
defp dispatch(contract, :get_by!, [queryable, clauses, _opts], fallback_fn),
do: dispatch(contract, :get_by!, [queryable, clauses], fallback_fn)
defp dispatch(contract, :one, [queryable, _opts], fallback_fn),
do: dispatch(contract, :one, [queryable], fallback_fn)
defp dispatch(contract, :one!, [queryable, _opts], fallback_fn),
do: dispatch(contract, :one!, [queryable], fallback_fn)
defp dispatch(contract, :all, [queryable, _opts], fallback_fn),
do: dispatch(contract, :all, [queryable], fallback_fn)
defp dispatch(contract, :exists?, [queryable, _opts], fallback_fn),
do: dispatch(contract, :exists?, [queryable], fallback_fn)
defp dispatch(contract, :aggregate, [queryable, aggregate, field, _opts], fallback_fn),
do: dispatch(contract, :aggregate, [queryable, aggregate, field], fallback_fn)
defp dispatch(contract, :insert_or_update, [changeset, _opts], fallback_fn),
do: dispatch(contract, :insert_or_update, [changeset], fallback_fn)
defp dispatch(contract, :insert_or_update!, [changeset, _opts], fallback_fn),
do: dispatch(contract, :insert_or_update!, [changeset], fallback_fn)
defp dispatch(contract, :all_by, [queryable, clauses, _opts], fallback_fn),
do: dispatch(contract, :all_by, [queryable, clauses], fallback_fn)
defp dispatch(contract, :preload, [struct_or_structs, preloads, _opts], fallback_fn),
do: dispatch(contract, :preload, [struct_or_structs, preloads], fallback_fn)
defp dispatch(contract, :reload, [struct_or_structs, _opts], fallback_fn),
do: dispatch(contract, :reload, [struct_or_structs], fallback_fn)
defp dispatch(contract, :reload!, [struct_or_structs, _opts], fallback_fn),
do: dispatch(contract, :reload!, [struct_or_structs], fallback_fn)
defp dispatch(contract, :stream, [queryable, _opts], fallback_fn),
do: dispatch(contract, :stream, [queryable], fallback_fn)
# -----------------------------------------------------------------
# Read and bulk operations — fallback or error
# -----------------------------------------------------------------
defp dispatch(contract, operation, args, fallback_fn)
when operation in [
:get,
:get!,
:get_by,
:get_by!,
:one,
:one!,
:all,
:all_by,
:exists?,
:aggregate,
:preload,
:reload,
:reload!,
:stream,
:insert_all,
:update_all,
:delete_all
] do
try_fallback(contract, fallback_fn, operation, args)
end
# -----------------------------------------------------------------
# Transaction Operations
#
# Transaction operations.
#
# With ContractFacade, pre_dispatch wraps 1-arity fns into 0-arity
# thunks and always provides opts. With DynamicFacade, raw args
# arrive — may be [fun/1], [fun/0], [fun/0, opts], etc.
# Normalise before dispatching.
# -----------------------------------------------------------------
defp dispatch(contract, :transact, args, fallback_fn),
do: do_dispatch_tx(contract, normalise_transact_args(args, contract), fallback_fn, :transact)
defp dispatch(contract, :transaction, args, fallback_fn),
do: do_dispatch_tx(contract, normalise_transact_args(args, contract), fallback_fn, :transaction)
@transaction_key DoubleDown.Repo.InTransaction
defp dispatch(_contract, :rollback, [value], _fallback_fn) do
Defer.new(fn ->
if Process.get(@transaction_key, false) do
throw({:rollback, value})
else
raise RuntimeError,
"cannot call rollback outside of transaction"
end
end)
end
defp dispatch(_contract, :in_transaction?, [], _fallback_fn) do
Defer.new(fn -> Process.get(@transaction_key, false) end)
end
# -- Transaction helpers (after all dispatch clauses) --
defp do_dispatch_tx(_contract, [fun, _opts], _fallback_fn, mode) when is_function(fun, 0) do
Defer.new(fn -> run_in_transaction(fun, mode) end)
end
defp do_dispatch_tx(_contract, [%Ecto.Multi{} = multi, opts], _fallback_fn, _mode) do
repo_facade = Keyword.get(opts, DoubleDown.Repo.Facade)
Defer.new(fn ->
run_in_transaction(fn -> DoubleDown.Repo.Impl.MultiStepper.run(multi, repo_facade) end, :transact)
end)
end
defp normalise_transact_args([fun], contract) when is_function(fun, 1),
do: [fn -> fun.(contract) end, []]
defp normalise_transact_args([fun], _contract) when is_function(fun, 0),
do: [fun, []]
defp normalise_transact_args([%Ecto.Multi{} = multi], _contract),
do: [multi, []]
defp normalise_transact_args([fun, opts], contract)
when is_function(fun, 1) and is_list(opts),
do: [fn -> fun.(contract) end, opts]
defp normalise_transact_args([fun, opts], _contract)
when is_function(fun, 0) and is_list(opts),
do: [fun, opts]
defp normalise_transact_args([%Ecto.Multi{} = multi, opts], _contract) when is_list(opts),
do: [multi, opts]
defp run_in_transaction(fun, mode) do
prev = Process.get(@transaction_key, false)
Process.put(@transaction_key, true)
try do
result = fun.()
case mode do
:transaction ->
{:ok, result}
:transact ->
case result do
{:ok, _} -> result
{:error, _} -> result
{:error, _, _, _} -> result
_other ->
raise ArgumentError,
"expected {:ok, _} or {:error, _} from transact callback, got: #{inspect(result)}"
end
end
catch
{:rollback, value} -> {:error, value}
after
Process.put(@transaction_key, prev)
end
end
# -----------------------------------------------------------------
# Helpers (after all dispatch clauses to avoid grouping warning)
# -----------------------------------------------------------------
defp do_load(schema, data, loader) when is_atom(schema) do
Ecto.Schema.Loader.unsafe_load(schema, data, loader)
end
defp do_load(types, data, loader) when is_map(types) do
Ecto.Schema.Loader.unsafe_load(%{}, types, data, loader)
end
defp do_insert(record) do
alias DoubleDown.Repo.Impl.Autogenerate
record = Autogenerate.apply_timestamps(record, :insert)
schema = record.__struct__
case Autogenerate.maybe_autogenerate_id(record, schema, fn _schema ->
# Repo.Stateless is stateless — use a monotonic counter for unique integer IDs
[System.unique_integer([:positive, :monotonic])]
end) do
{:error, {:no_autogenerate, message}} ->
raise ArgumentError, message
{_id, record} ->
{:ok, record}
end
end
# -----------------------------------------------------------------
# Fallback dispatch
# -----------------------------------------------------------------
defp try_fallback(_contract, nil, operation, args) do
raise_no_fallback(operation, args)
end
defp try_fallback(contract, fallback_fn, operation, args) when is_function(fallback_fn, 3) do
fallback_fn.(contract, operation, args)
rescue
FunctionClauseError -> raise_no_fallback(operation, args)
end
defp raise_no_fallback(operation, args) do
raise ArgumentError, """
DoubleDown.Repo.Stateless cannot service :#{operation} with args #{inspect(args)}.
Repo.Stateless can only answer authoritatively for:
- Write operations (insert, update, delete)
For all other operations, register a fallback function:
DoubleDown.Repo.Stateless.new(
fallback_fn: fn
:#{operation}, #{inspect(args)} -> # your result here
end
)
"""
end
end
end