Current section

Files

Jump to
double_down lib double_down repo impl in_memory_shared.ex
Raw

lib/double_down/repo/impl/in_memory_shared.ex

if Code.ensure_loaded?(Ecto) do
defmodule DoubleDown.Repo.Impl.InMemoryShared do
@moduledoc false
# Shared helpers for stateful in-memory Repo fakes.
#
# Used by both `Repo.OpenInMemory` (open-world: absence is inconclusive)
# and `Repo.InMemory` (closed-world: absence means not found).
#
# Provides:
# - State construction (new, seed, build_store)
# - State access (get_record, put_record, delete_record, records_for_schema)
# - Write operations (insert, update, delete)
# - Transaction operations (transact, rollback)
# - Opts-stripping dispatch
# - Fallback dispatch (try_fallback, defer_raise)
# - Query helpers (extract_schema, normalize_clauses, fields_match?,
# extract_pk_from_clauses)
@type store :: %{optional(module()) => %{optional(term()) => struct()}}
@fallback_fn_key :__fallback_fn__
# -------------------------------------------------------------------
# Store construction
# -------------------------------------------------------------------
@doc false
def fallback_fn_key, do: @fallback_fn_key
@doc false
@spec new(term(), keyword()) :: store()
def new(seed \\ %{}, opts \\ [])
# Legacy keyword-only form: new(seed: [...], fallback_fn: fn ...)
def new(opts, []) when is_list(opts) and opts != [] do
case Keyword.keyword?(opts) do
true ->
seed_records = Keyword.get(opts, :seed, [])
fallback_fn = Keyword.get(opts, :fallback_fn, nil)
build_store(seed_records, fallback_fn)
false ->
# It's a plain list of structs as seed
build_store(opts, nil)
end
end
# new(seed_list, opts)
def new(seed, opts) when is_list(seed) do
fallback_fn = Keyword.get(opts, :fallback_fn, nil)
build_store(seed, fallback_fn)
end
# new(seed_map, opts)
def new(seed, opts) when is_map(seed) do
fallback_fn = Keyword.get(opts, :fallback_fn, nil)
if fallback_fn do
Map.put(seed, @fallback_fn_key, fallback_fn)
else
seed
end
end
defp build_store(seed_records, fallback_fn) do
store = seed(seed_records)
if fallback_fn do
Map.put(store, @fallback_fn_key, fallback_fn)
else
store
end
end
@doc false
@spec seed(list(struct())) :: store()
def seed(records) when is_list(records) do
Enum.reduce(records, %{}, fn record, store ->
schema = record.__struct__
id = DoubleDown.Repo.Impl.Autogenerate.get_primary_key(record)
put_record(store, schema, id, record)
end)
end
# -------------------------------------------------------------------
# Write operations
# -------------------------------------------------------------------
@doc false
def dispatch_insert([%Ecto.Changeset{valid?: false} = changeset], store) do
{{:error, changeset}, store}
end
def dispatch_insert([%Ecto.Changeset{} = changeset], store) do
do_insert(Ecto.Changeset.apply_changes(changeset), :insert, store)
end
def dispatch_insert([%{__struct__: _} = struct], store) do
do_insert(struct, :insert, store)
end
defp do_insert(record, action, store) do
alias DoubleDown.Repo.Impl.Autogenerate
alias DoubleDown.Repo.Impl.EctoParity
record =
record
|> EctoParity.backfill_foreign_keys()
|> EctoParity.reset_associations()
|> Autogenerate.apply_timestamps(action)
schema = record.__struct__
case Autogenerate.maybe_autogenerate_id(record, schema, fn s ->
store
|> records_for_schema(s)
|> Enum.map(&Autogenerate.get_primary_key/1)
|> Enum.filter(&is_integer/1)
end) do
{:error, {:no_autogenerate, message}} ->
{%DoubleDown.Contract.Dispatch.Defer{fn: fn -> raise ArgumentError, message end}, store}
{id, record} ->
{{:ok, record}, put_record(store, schema, id, record)}
end
end
@doc false
def dispatch_update([%Ecto.Changeset{valid?: false} = changeset], store) do
{{:error, changeset}, store}
end
def dispatch_update([changeset], store) do
record = DoubleDown.Repo.Impl.Autogenerate.apply_changes(changeset, :update)
schema = record.__struct__
id = DoubleDown.Repo.Impl.Autogenerate.get_primary_key(record)
{{:ok, record}, put_record(store, schema, id, record)}
end
@doc false
def dispatch_delete([%Ecto.Changeset{valid?: false} = changeset], store) do
{{:error, changeset}, store}
end
def dispatch_delete([%Ecto.Changeset{} = changeset], store) do
record = Ecto.Changeset.apply_changes(changeset)
schema = record.__struct__
id = DoubleDown.Repo.Impl.Autogenerate.get_primary_key(record)
{{:ok, record}, delete_record(store, schema, id)}
end
def dispatch_delete([record], store) do
schema = record.__struct__
id = DoubleDown.Repo.Impl.Autogenerate.get_primary_key(record)
{{:ok, record}, delete_record(store, schema, id)}
end
# -------------------------------------------------------------------
# Bang write operations
# -------------------------------------------------------------------
@doc false
def dispatch_insert!(args, store) do
case dispatch_insert(args, store) do
{{:ok, record}, new_store} -> {record, new_store}
{{:error, changeset}, store} -> bang_raise(:insert!, changeset, store)
end
end
@doc false
def dispatch_update!(args, store) do
case dispatch_update(args, store) do
{{:ok, record}, new_store} -> {record, new_store}
{{:error, changeset}, store} -> bang_raise(:update!, changeset, store)
end
end
@doc false
def dispatch_delete!(args, store) do
{{:ok, record}, new_store} = dispatch_delete(args, store)
{record, new_store}
end
defp bang_raise(action, %Ecto.Changeset{} = changeset, store) do
{%DoubleDown.Contract.Dispatch.Defer{
fn: fn ->
raise Ecto.InvalidChangesetError, action: action, changeset: changeset
end
}, store}
end
# -------------------------------------------------------------------
# Transaction operations
# -------------------------------------------------------------------
@doc false
def dispatch_transact([fun, _opts], store, contract) when is_function(fun, 0) do
snapshot = store
{%DoubleDown.Contract.Dispatch.Defer{
fn: fn -> run_in_transaction(fun, contract, snapshot) end
}, store}
end
def dispatch_transact([%Ecto.Multi{} = multi, opts], store, contract) do
repo_facade = Keyword.get(opts, DoubleDown.Repo.Facade)
snapshot = store
{%DoubleDown.Contract.Dispatch.Defer{
fn: fn ->
run_in_transaction(
fn -> DoubleDown.Repo.Impl.MultiStepper.run(multi, repo_facade) end,
contract,
snapshot
)
end
}, store}
end
@doc false
def dispatch_rollback([value], store) do
{%DoubleDown.Contract.Dispatch.Defer{fn: fn -> throw({:rollback, value}) end}, store}
end
defp run_in_transaction(fun, contract, snapshot) do
fun.()
catch
{:rollback, value} ->
DoubleDown.Contract.Dispatch.restore_state(contract, snapshot)
{:error, value}
end
# -------------------------------------------------------------------
# Fallback dispatch
#
# Because dispatch/3 runs inside NimbleOwnership.get_and_update
# (a GenServer call), we must not raise here — that would crash
# the ownership server. Instead, we use %DoubleDown.Contract.Dispatch.Defer{}
# to move the raise outside the lock.
# -------------------------------------------------------------------
@doc false
def try_fallback(store, operation, args) do
case Map.get(store, @fallback_fn_key) do
nil ->
{:no_fallback, operation, args}
fallback_fn when is_function(fallback_fn, 3) ->
clean_state = Map.delete(store, @fallback_fn_key)
try do
{fallback_fn.(operation, args, clean_state), store}
rescue
# FunctionClauseError means no matching clause — treat as missing fallback.
FunctionClauseError ->
{:no_fallback, operation, args}
# Any other exception from user-supplied fallback code must not crash
# the NimbleOwnership GenServer. Capture the exception and stacktrace,
# then defer the reraise to the calling test process.
exception ->
stacktrace = __STACKTRACE__
{%DoubleDown.Contract.Dispatch.Defer{fn: fn -> reraise exception, stacktrace end},
store}
end
end
end
@doc false
def defer_raise(message, store) do
{%DoubleDown.Contract.Dispatch.Defer{fn: fn -> raise ArgumentError, message end}, store}
end
@doc false
def defer_raise_no_results(queryable, store) do
{%DoubleDown.Contract.Dispatch.Defer{
fn: fn -> raise Ecto.NoResultsError, queryable: queryable end
}, store}
end
@doc false
def defer_raise_multiple_results(queryable, count, store) do
{%DoubleDown.Contract.Dispatch.Defer{
fn: fn -> raise Ecto.MultipleResultsError, queryable: queryable, count: count end
}, store}
end
# -------------------------------------------------------------------
# State access helpers
# -------------------------------------------------------------------
@doc false
def get_record(store, schema, id) do
store
|> Map.get(schema, %{})
|> Map.get(id)
end
@doc false
def put_record(store, schema, id, record) do
schema_map = Map.get(store, schema, %{})
Map.put(store, schema, Map.put(schema_map, id, record))
end
@doc false
def delete_record(store, schema, id) do
case Map.get(store, schema) do
nil -> store
schema_map -> Map.put(store, schema, Map.delete(schema_map, id))
end
end
@doc false
def records_for_schema(store, schema) do
store
|> Map.get(schema, %{})
|> Map.values()
end
# -------------------------------------------------------------------
# Query helpers
# -------------------------------------------------------------------
@doc false
def extract_schema(queryable) when is_atom(queryable), do: queryable
def extract_schema(%Ecto.Query{from: %Ecto.Query.FromExpr{source: {_table, schema}}})
when is_atom(schema) and not is_nil(schema) do
schema
end
def extract_schema(queryable), do: queryable
@doc false
def normalize_clauses(clauses) when is_map(clauses), do: Enum.to_list(clauses)
def normalize_clauses(clauses) when is_list(clauses), do: clauses
@doc false
def fields_match?(_record, []), do: true
def fields_match?(record, clauses) do
Enum.all?(clauses, fn {field, value} ->
Map.get(record, field) == value
end)
end
@doc false
def extract_pk_from_clauses(schema, clauses_kw) do
if function_exported?(schema, :__schema__, 1) do
case schema.__schema__(:primary_key) do
[] ->
:not_pk_inclusive
[pk_field] ->
case Keyword.fetch(clauses_kw, pk_field) do
{:ok, pk_value} ->
remaining = Keyword.delete(clauses_kw, pk_field)
{:ok, pk_value, remaining}
:error ->
:not_pk_inclusive
end
pk_fields when is_list(pk_fields) ->
pk_values = Enum.map(pk_fields, &Keyword.fetch(clauses_kw, &1))
if Enum.all?(pk_values, &match?({:ok, _}, &1)) do
pk_value = pk_values |> Enum.map(fn {:ok, v} -> v end) |> List.to_tuple()
remaining = Enum.reject(clauses_kw, fn {k, _v} -> k in pk_fields end)
{:ok, pk_value, remaining}
else
:not_pk_inclusive
end
end
else
:not_pk_inclusive
end
end
end
end