Current section

Files

Jump to
double_down lib double_down repo stub.ex
Raw

lib/double_down/repo/stub.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.Stub do
@behaviour DoubleDown.Contract.Dispatch.StubHandler
@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.Contract.Dispatch.StubHandler`, so it can
be used by module name with `Double.stub`:
## Usage with Double.stub
# Writes only — reads will raise with a helpful message:
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Stub)
# With fallback for specific reads:
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Stub,
fn
:get, [User, 1] -> %User{id: 1, name: "Alice"}
:all, [User] -> [%User{id: 1, name: "Alice"}]
:exists?, [User] -> true
end
)
# Layer expects on top for failure simulation:
DoubleDown.Repo
|> DoubleDown.Double.stub(DoubleDown.Repo.Stub)
|> DoubleDown.Double.expect(:insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)
## When to use Repo.Stub
Use `Repo.Stub` 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.Stub` | 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 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.Stub)
# With fallback for specific reads
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Stub,
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.Stub.new(fallback_fn: fn :get, [User, 1] -> %User{} end)
"""
@impl DoubleDown.Contract.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, [%Ecto.Changeset{} = changeset], _fallback_fn) do
do_insert(Ecto.Changeset.apply_changes(changeset))
end
defp dispatch(:insert, [%{__struct__: _} = struct], _fallback_fn) do
do_insert(struct)
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.Impl.Autogenerate.apply_changes(changeset, :update)}
end
defp dispatch(:delete, [%Ecto.Changeset{valid?: false} = changeset], _fallback_fn) do
{:error, changeset}
end
defp dispatch(:delete, [%Ecto.Changeset{} = changeset], _fallback_fn) do
{:ok, Ecto.Changeset.apply_changes(changeset)}
end
defp dispatch(:delete, [record], _fallback_fn) do
{:ok, record}
end
# -----------------------------------------------------------------
# Bang Write Operations
# -----------------------------------------------------------------
defp dispatch(:insert!, [changeset], fallback_fn) do
case dispatch(:insert, [changeset], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: :insert, changeset: changeset
end
end
defp dispatch(:update!, [changeset], fallback_fn) do
case dispatch(:update, [changeset], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: :update, changeset: changeset
end
end
defp dispatch(:delete!, [record], fallback_fn) do
case dispatch(:delete, [record], fallback_fn) do
{:ok, record} ->
record
{:error, changeset} ->
raise Ecto.InvalidChangesetError, action: :delete, changeset: changeset
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(: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(: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.Contract.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.Contract.Dispatch.Defer{
fn: fn ->
run_in_transaction(fn -> DoubleDown.Repo.Impl.MultiStepper.run(multi, repo_facade) end)
end
}
end
@transaction_key DoubleDown.Repo.InTransaction
defp dispatch(:rollback, [value], _fallback_fn) do
%DoubleDown.Contract.Dispatch.Defer{
fn: fn ->
if Process.get(@transaction_key, false) do
throw({:rollback, value})
else
raise RuntimeError,
"cannot call rollback outside of transaction"
end
end
}
end
defp run_in_transaction(fun) do
prev = Process.get(@transaction_key, false)
Process.put(@transaction_key, true)
try do
fun.()
catch
{:rollback, value} -> {:error, value}
after
Process.put(@transaction_key, prev)
end
end
# -----------------------------------------------------------------
# Insert helper (after all dispatch clauses to avoid grouping warning)
# -----------------------------------------------------------------
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.Stub 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(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.Stub cannot service :#{operation} with args #{inspect(args)}.
Repo.Stub can only answer authoritatively for:
- Write operations (insert, update, delete)
For all other operations, register a fallback function:
DoubleDown.Repo.Stub.new(
fallback_fn: fn
:#{operation}, #{inspect(args)} -> # your result here
end
)
"""
end
end
end