Packages
double_down
0.52.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/double/dispatch.ex
defmodule DoubleDown.Double.Dispatch do
@moduledoc """
Dispatch-time logic for `DoubleDown.Double`'s canonical handler.
This module contains the runtime dispatch functions that execute
inside `NimbleOwnership.get_and_update` when a contract operation
is called. It reads expects, per-op fakes, per-op stubs, and
fallback config from `CanonicalHandlerState` at dispatch time.
Separated from `DoubleDown.Double` (setup-time API) for clarity —
setup code is called by tests, dispatch code runs inside the
NimbleOwnership GenServer.
## Dispatch priority
expects > per-op fakes > per-op stubs > fallback > raise
"""
alias DoubleDown.Contract.Dispatch.Defer
alias DoubleDown.Double.CanonicalHandlerState
# -- Canonical handler --
# The contract parameter from Contract.Dispatch is unused — the
# contract is already available as state.contract, set at installation
# time by CanonicalHandlerState.new/1. Fallback handlers that need it
# (invoke_fn_fallback, invoke_stateful_fallback) read it from there.
@doc false
def canonical_handler(
_contract,
operation,
args,
%CanonicalHandlerState{fakes: fakes, stubs: stubs} = state,
all_states
) do
case CanonicalHandlerState.pop_expect(state, operation) do
{:ok, :passthrough, new_state} ->
invoke_fallback_or_raise(new_state, operation, args, all_states)
{:ok, fun, new_state} ->
invoke_expect(fun, args, new_state, all_states, operation)
:none ->
case Map.get(fakes, operation) do
nil ->
case Map.get(stubs, operation) do
nil ->
invoke_fallback_or_raise(state, operation, args, all_states)
stub_fun ->
invoke_stub(stub_fun, args, state, all_states, operation)
end
op_fake_fun ->
invoke_op_fake(op_fake_fun, args, state, all_states, operation)
end
end
end
# -- Expect invocation --
# 1-arity is stateless (bare result).
# 2-arity and 3-arity are stateful (return {result, new_fallback_state}).
#
# Any arity may return %Passthrough{} to delegate to the fallback.
# The expect is still consumed for verify! counting.
defp invoke_expect(fun, args, state, all_states, operation)
when is_function(fun, 1) do
case fun.(args) do
%DoubleDown.Contract.Dispatch.Passthrough{} ->
invoke_fallback_or_raise(state, operation, args, all_states)
result ->
{result, state}
end
end
defp invoke_expect(
fun,
args,
%CanonicalHandlerState{fallback_state: fallback_state} = state,
all_states,
operation
)
when is_function(fun, 2) do
case fun.(args, fallback_state) do
%DoubleDown.Contract.Dispatch.Passthrough{} ->
invoke_fallback_or_raise(state, operation, args, all_states)
{result, new_fallback_state} ->
{result, CanonicalHandlerState.put_fallback_state(state, new_fallback_state)}
other ->
raise_bad_stateful_responder_return(:expect, operation, 2, other)
end
end
defp invoke_expect(
fun,
args,
%CanonicalHandlerState{fallback_state: fallback_state} = state,
all_states,
operation
)
when is_function(fun, 3) do
case fun.(args, fallback_state, all_states) do
%DoubleDown.Contract.Dispatch.Passthrough{} ->
invoke_fallback_or_raise(state, operation, args, all_states)
{result, new_fallback_state} ->
{result, CanonicalHandlerState.put_fallback_state(state, new_fallback_state)}
other ->
raise_bad_stateful_responder_return(:expect, operation, 3, other)
end
end
# -- Per-operation fake invocation --
# 2-arity receives (args, fallback_state),
# 3-arity receives (args, fallback_state, all_states). Both return
# {result, new_fallback_state}. May return passthrough() to delegate.
defp invoke_op_fake(
fun,
args,
%CanonicalHandlerState{fallback_state: fallback_state} = state,
all_states,
operation
)
when is_function(fun, 2) do
case fun.(args, fallback_state) do
%DoubleDown.Contract.Dispatch.Passthrough{} ->
invoke_fallback_or_raise(state, operation, args, all_states)
{result, new_fallback_state} ->
{result, CanonicalHandlerState.put_fallback_state(state, new_fallback_state)}
other ->
raise_bad_stateful_responder_return(:fake, operation, 2, other)
end
end
defp invoke_op_fake(
fun,
args,
%CanonicalHandlerState{fallback_state: fallback_state} = state,
all_states,
operation
)
when is_function(fun, 3) do
case fun.(args, fallback_state, all_states) do
%DoubleDown.Contract.Dispatch.Passthrough{} ->
invoke_fallback_or_raise(state, operation, args, all_states)
{result, new_fallback_state} ->
{result, CanonicalHandlerState.put_fallback_state(state, new_fallback_state)}
other ->
raise_bad_stateful_responder_return(:fake, operation, 3, other)
end
end
# -- Per-operation stub invocation --
# Always 1-arity (stateless).
defp invoke_stub(fun, args, state, all_states, operation)
when is_function(fun, 1) do
case fun.(args) do
%DoubleDown.Contract.Dispatch.Passthrough{} ->
invoke_fallback_or_raise(state, operation, args, all_states)
result ->
{result, state}
end
end
# -- Fallback invocation --
defp invoke_fallback_or_raise(
%CanonicalHandlerState{fallback: fallback, contract: contract} = state,
operation,
args,
all_states
) do
case fallback do
nil ->
msg = unexpected_call_message(contract, state, operation, args)
{Defer.new(fn -> raise msg end), state}
{:stateless, fallback_fn} ->
invoke_fn_fallback(fallback_fn, state, operation, args)
{:stateful, fallback_fn} ->
invoke_stateful_fallback(fallback_fn, state, operation, args, all_states)
{:module, module} ->
invoke_module_fallback(module, state, operation, args)
end
end
defp invoke_fn_fallback(
fallback_fn,
%CanonicalHandlerState{contract: contract} = state,
operation,
args
) do
result = fallback_fn.(contract, operation, args)
{result, state}
rescue
# NOTE: This rescue cannot distinguish between a FunctionClauseError from
# the top-level fallback_fn (no matching clause) and one raised deeper in
# the call stack (a bug in the fallback body). See "Known limitations" in
# the DoubleDown.Double moduledoc.
FunctionClauseError ->
msg = unexpected_call_message(contract, state, operation, args)
{Defer.new(fn -> reraise msg, __STACKTRACE__ end), state}
end
defp invoke_stateful_fallback(
fallback_fn,
%CanonicalHandlerState{contract: contract, fallback_state: fallback_state} = state,
operation,
args,
all_states
) do
handler_result =
if is_function(fallback_fn, 5) do
fallback_fn.(contract, operation, args, fallback_state, all_states)
else
fallback_fn.(contract, operation, args, fallback_state)
end
case handler_result do
{result, new_fallback_state} ->
{result, CanonicalHandlerState.put_fallback_state(state, new_fallback_state)}
other ->
raise_bad_stateful_responder_return(
:fake,
operation,
:erlang.fun_info(fallback_fn)[:arity],
other
)
end
rescue
# NOTE: Same limitation as invoke_fn_fallback — see "Known limitations"
# in the DoubleDown.Double moduledoc.
FunctionClauseError ->
msg = unexpected_call_message(contract, state, operation, args)
{Defer.new(fn -> reraise msg, __STACKTRACE__ end), state}
end
# Module fallback: defer the apply to the calling process via %Defer{}.
# This is critical — the canonical_handler runs inside NimbleOwnership's
# get_and_update (GenServer process). Real implementation modules do I/O
# (e.g. Ecto queries) that require the calling process's context (sandbox
# checkout, process dictionary, etc.). %Defer{} moves the apply outside
# the lock, same mechanism transact uses.
defp invoke_module_fallback(module, state, operation, args) do
{Defer.new(fn -> apply(module, operation, args) end), state}
end
# -- Error messages --
defp raise_bad_stateful_responder_return(kind, operation, arity, got) do
raise ArgumentError, """
Stateful #{kind} responder for :#{operation} must return {result, new_state}.
Got: #{inspect(got)}
#{arity}-arity #{kind} responders must return a {result, new_fallback_state} tuple. \
Use a 1-arity fn [args] -> result end for stateless #{kind}s that return bare results.
"""
end
defp unexpected_call_message(
contract,
%CanonicalHandlerState{expects: expects},
operation,
args
) do
remaining =
expects
|> Enum.reject(fn {_op, queue} -> queue == [] end)
|> Enum.map(fn {op, queue} -> " #{op}: #{length(queue)} expected call(s) remaining" end)
remaining_msg =
if remaining == [] do
" (no expectations remaining)"
else
Enum.join(remaining, "\n")
end
"""
Unexpected call to #{inspect(contract)}.#{operation}/#{length(args)}.
Args: #{inspect(args)}
No expectations or stubs defined for this operation.
Remaining expectations for #{inspect(contract)}:
#{remaining_msg}
"""
end
end