Packages
double_down
0.41.1
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/test.ex
# Stateless test handler for DoubleDown.Repo.
#
# Provides a function handler for use with set_fn_handler. Write operations
# apply changeset changes and return {:ok, struct}. Read operations go
# through a user-supplied fallback function, or raise.
#
# ## Usage
#
# DoubleDown.Testing.set_fn_handler(DoubleDown.Repo, DoubleDown.Repo.Test.new())
#
# # With fallback for reads:
# DoubleDown.Testing.set_fn_handler(
# DoubleDown.Repo,
# DoubleDown.Repo.Test.new(
# fallback_fn: fn
# :all, [User] -> [%User{id: 1, name: "Alice"}]
# :get, [User, 1] -> %User{id: 1, name: "Alice"}
# end
# )
# )
#
if Code.ensure_loaded?(Ecto) do
defmodule DoubleDown.Repo.Test do
@behaviour DoubleDown.Dispatch.StubHandler
@moduledoc """
Stateless test handler for `DoubleDown.Repo`.
Provides a function handler via `new/1` for use with
`DoubleDown.Testing.set_fn_handler/2`. Write operations (`insert`, `update`,
`delete`) apply changeset changes and return `{:ok, struct}`. All read
operations go through an optional fallback function, or raise a clear
error.
This applies the same "fail when consistency cannot be proven" approach
as `DoubleDown.Repo.InMemory` — reads never silently return `nil` or `[]`
because the adapter has no basis for claiming a record does or doesn't
exist.
## Usage
# Writes only — reads will raise:
DoubleDown.Testing.set_fn_handler(DoubleDown.Repo, DoubleDown.Repo.Test.new())
# With fallback for reads:
DoubleDown.Testing.set_fn_handler(
DoubleDown.Repo,
DoubleDown.Repo.Test.new(
fallback_fn: fn
:get, [User, 1] -> %User{id: 1, name: "Alice"}
:all, [User] -> [%User{id: 1, name: "Alice"}]
end
)
)
# With logging:
DoubleDown.Testing.set_fn_handler(DoubleDown.Repo, DoubleDown.Repo.Test.new())
DoubleDown.Testing.enable_log(DoubleDown.Repo)
## Differences from Repo.InMemory
`Repo.Test` is stateless — writes apply changesets and return `{:ok, struct}`
but nothing is stored. There is no read-after-write consistency.
`Repo.InMemory` is stateful — writes store records and PK-based reads can
find them. Use `Repo.InMemory` when your test needs read-after-write
consistency. Use `Repo.Test` when you only need fire-and-forget writes.
"""
@doc """
Create a new Test handler function.
Returns a 2-arity function `(operation, args) -> result` suitable for
use with `DoubleDown.Double.stub/2` or `DoubleDown.Testing.set_fn_handler/2`.
## Arguments
* `fallback_fn` — an optional 2-arity function `(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 (StubHandler)
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Test)
# With fallback for specific reads
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Test,
fn
:get, [User, 1] -> %User{id: 1, name: "Alice"}
:all, [User] -> [%User{id: 1, name: "Alice"}]
:exists?, [User] -> true
end
)
## Legacy keyword-only form (still supported)
DoubleDown.Repo.Test.new(fallback_fn: fn :get, [User, 1] -> %User{} end)
"""
@impl DoubleDown.Dispatch.StubHandler
@spec new((atom(), [term()] -> term()) | nil, keyword()) :: (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 operation, args ->
dispatch(operation, args, fallback_fn)
end
end
# -----------------------------------------------------------------
# Write Operations — always authoritative
# -----------------------------------------------------------------
defp dispatch(:insert, [%Ecto.Changeset{valid?: false} = changeset], _fallback_fn) do
{:error, changeset}
end
defp dispatch(:insert, [changeset], _fallback_fn) do
alias DoubleDown.Repo.Autogenerate
record = Autogenerate.apply_changes(changeset, :insert)
schema = record.__struct__
case Autogenerate.maybe_autogenerate_id(record, schema, fn _schema ->
# Repo.Test 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
defp dispatch(:update, [%Ecto.Changeset{valid?: false} = changeset], _fallback_fn) do
{:error, changeset}
end
defp dispatch(:update, [changeset], _fallback_fn) do
{:ok, DoubleDown.Repo.Autogenerate.apply_changes(changeset, :update)}
end
defp dispatch(:delete, [record], _fallback_fn) do
{:ok, record}
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(:insert, [changeset, _opts], fallback_fn),
do: dispatch(:insert, [changeset], fallback_fn)
defp dispatch(:update, [changeset, _opts], fallback_fn),
do: dispatch(:update, [changeset], fallback_fn)
defp dispatch(:delete, [record, _opts], fallback_fn),
do: dispatch(:delete, [record], fallback_fn)
defp dispatch(:get, [queryable, id, _opts], fallback_fn),
do: dispatch(:get, [queryable, id], fallback_fn)
defp dispatch(:get!, [queryable, id, _opts], fallback_fn),
do: dispatch(:get!, [queryable, id], fallback_fn)
defp dispatch(:get_by, [queryable, clauses, _opts], fallback_fn),
do: dispatch(:get_by, [queryable, clauses], fallback_fn)
defp dispatch(:get_by!, [queryable, clauses, _opts], fallback_fn),
do: dispatch(:get_by!, [queryable, clauses], fallback_fn)
defp dispatch(:one, [queryable, _opts], fallback_fn),
do: dispatch(:one, [queryable], fallback_fn)
defp dispatch(:one!, [queryable, _opts], fallback_fn),
do: dispatch(:one!, [queryable], fallback_fn)
defp dispatch(:all, [queryable, _opts], fallback_fn),
do: dispatch(:all, [queryable], fallback_fn)
defp dispatch(:exists?, [queryable, _opts], fallback_fn),
do: dispatch(:exists?, [queryable], fallback_fn)
defp dispatch(:aggregate, [queryable, aggregate, field, _opts], fallback_fn),
do: dispatch(:aggregate, [queryable, aggregate, field], fallback_fn)
# -----------------------------------------------------------------
# Read and bulk operations — fallback or error
# -----------------------------------------------------------------
defp dispatch(operation, args, fallback_fn)
when operation in [
:get,
:get!,
:get_by,
:get_by!,
:one,
:one!,
:all,
:exists?,
:aggregate,
:insert_all,
:update_all,
:delete_all
] do
try_fallback(fallback_fn, operation, args)
end
# -----------------------------------------------------------------
# Transaction Operations
#
# The facade's pre_dispatch wraps 1-arity fns into 0-arity thunks,
# so implementations always receive a 0-arity fn or an Ecto.Multi.
# -----------------------------------------------------------------
defp dispatch(:transact, [fun, _opts], _fallback_fn) when is_function(fun, 0) do
%DoubleDown.Dispatch.Defer{fn: fn -> run_in_transaction(fun) end}
end
defp dispatch(:transact, [%Ecto.Multi{} = multi, opts], _fallback_fn) do
repo_facade = Keyword.get(opts, DoubleDown.Repo.Facade)
%DoubleDown.Dispatch.Defer{
fn: fn ->
run_in_transaction(fn -> DoubleDown.Repo.MultiStepper.run(multi, repo_facade) end)
end
}
end
defp dispatch(:rollback, [value], _fallback_fn) do
%DoubleDown.Dispatch.Defer{fn: fn -> throw({:rollback, value}) end}
end
defp run_in_transaction(fun) do
fun.()
catch
{:rollback, value} -> {:error, value}
end
# -----------------------------------------------------------------
# Fallback dispatch
# -----------------------------------------------------------------
defp try_fallback(nil, operation, args) do
raise_no_fallback(operation, args)
end
defp try_fallback(fallback_fn, operation, args) when is_function(fallback_fn, 2) do
fallback_fn.(operation, args)
rescue
FunctionClauseError -> raise_no_fallback(operation, args)
end
defp raise_no_fallback(operation, args) do
raise ArgumentError, """
DoubleDown.Repo.Test cannot service :#{operation} with args #{inspect(args)}.
The Test adapter can only answer authoritatively for:
- Write operations (insert, update, delete)
For all other operations, register a fallback function:
DoubleDown.Repo.Test.new(
fallback_fn: fn
:#{operation}, #{inspect(args)} -> # your result here
end
)
"""
end
end
end