Packages
double_down
0.60.3
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/impl/autogenerate.ex
# Shared helpers for autogenerating fields in test adapters.
#
# Handles timestamps, parameterized-type PKs (Ecto.UUID, Uniq.UUID, etc.),
# and :id/:binary_id PKs via Ecto schema metadata.
if Code.ensure_loaded?(Ecto) do
defmodule DoubleDown.Repo.Impl.Autogenerate do
@moduledoc false
# -----------------------------------------------------------------
# apply_changes — changeset to struct with autogenerated fields
# -----------------------------------------------------------------
@doc """
Apply changeset changes and populate autogenerated fields.
For `:insert`, populates fields from `schema.__schema__(:autogenerate)`
(timestamps and parameterized-type PKs like Ecto.UUID/Uniq.UUID).
For `:update`, populates fields from `schema.__schema__(:autoupdate)`
(typically only `updated_at`).
Only fills fields that are `nil` after applying changes — explicitly
set values are preserved.
"""
@spec apply_changes(Ecto.Changeset.t(), :insert | :update) :: struct()
def apply_changes(%Ecto.Changeset{} = changeset, action) do
changeset
|> Ecto.Changeset.apply_changes()
|> apply_autogenerate(action)
end
@doc """
Apply autogenerated fields (timestamps, parameterized PKs) to a bare struct.
Same as `apply_changes/2` but skips the changeset step — for use when
inserting bare structs rather than changesets (e.g. ExMachina factories).
"""
@spec apply_timestamps(struct(), :insert | :update) :: struct()
def apply_timestamps(%{__struct__: _} = record, action) do
apply_autogenerate(record, action)
end
# -----------------------------------------------------------------
# Primary key helpers
# -----------------------------------------------------------------
@doc """
Extract the primary key value from a record.
Returns `nil` if the PK field is nil, a single value for single-field
PKs, or a tuple for composite PKs. Returns `nil` for schemas with
`@primary_key false`.
"""
@spec get_primary_key(struct()) :: term()
def get_primary_key(record) do
schema = record.__struct__
if function_exported?(schema, :__schema__, 1) do
case schema.__schema__(:primary_key) do
[] -> nil
[pk_field] -> Map.get(record, pk_field)
fields when is_list(fields) -> List.to_tuple(Enum.map(fields, &Map.get(record, &1)))
end
else
Map.get(record, :id)
end
end
@doc """
Autogenerate the primary key for an insert if needed.
Returns `{id, record}` where the record may have been updated with
a generated PK value. The `existing_integer_ids_fn` is called only
for `:id`-type (integer) PKs to determine the next auto-increment
value — it receives the schema module and should return a list of
existing integer IDs.
## PK handling by type
- `@primary_key false` — no PK, returns `{nil, record}` unchanged
- PK already set — returns `{pk_value, record}` unchanged
- `:autogenerate` metadata covers the PK field — already populated
by `apply_changes/2`, returns the populated value
- `autogenerate_id` with type `:id` — integer auto-increment
- `autogenerate_id` with type `:binary_id` — generates a UUID
- No autogeneration configured — raises `ArgumentError`
"""
@spec maybe_autogenerate_id(struct(), module(), (module() -> [integer()])) ::
{term(), struct()} | {:error, {:no_autogenerate, String.t()}}
def maybe_autogenerate_id(record, schema, existing_integer_ids_fn) do
if function_exported?(schema, :__schema__, 1) do
case schema.__schema__(:primary_key) do
[] ->
# @primary_key false — no PK to generate
{nil, record}
[pk_field] ->
case Map.get(record, pk_field) do
nil ->
# PK is nil — try to autogenerate
autogenerate_pk(record, schema, pk_field, existing_integer_ids_fn)
value ->
# PK already set (explicit or via apply_autogenerate)
{value, record}
end
fields when is_list(fields) ->
# Composite PK — don't autogenerate, just return the tuple key
key = List.to_tuple(Enum.map(fields, &Map.get(record, &1)))
{key, record}
end
else
# Not an Ecto schema — fall back to :id field
{Map.get(record, :id), record}
end
end
# -----------------------------------------------------------------
# Private helpers
# -----------------------------------------------------------------
defp apply_autogenerate(record, action) do
schema = record.__struct__
if function_exported?(schema, :__schema__, 1) do
autogen_fields =
case action do
:insert -> schema.__schema__(:autogenerate)
:update -> schema.__schema__(:autoupdate)
end
Enum.reduce(autogen_fields, record, fn {fields, {mod, fun, args}}, acc ->
generated_value = apply(mod, fun, args)
Enum.reduce(fields, acc, fn field, rec ->
if Map.get(rec, field) == nil do
Map.put(rec, field, generated_value)
else
rec
end
end)
end)
else
record
end
end
defp autogenerate_pk(record, schema, pk_field, existing_integer_ids_fn) do
case schema.__schema__(:autogenerate_id) do
{^pk_field, _source, :id} ->
# Integer auto-increment
new_id = next_integer_id(existing_integer_ids_fn.(schema))
record = Map.put(record, pk_field, new_id)
{new_id, record}
{^pk_field, _source, :binary_id} ->
# UUID via Ecto's built-in binary_id type
new_id = Ecto.UUID.generate()
record = Map.put(record, pk_field, new_id)
{new_id, record}
_ ->
# No autogenerate_id match — the PK should have been populated
# by apply_autogenerate (parameterized types like Ecto.UUID,
# Uniq.UUID). If we get here, autogeneration isn't configured.
{:error,
{:no_autogenerate,
"""
Cannot autogenerate primary key #{inspect(pk_field)} for #{inspect(schema)}.
The schema has no autogeneration configured for its primary key,
and no value was provided. Either:
- Set the primary key explicitly in the changeset data
- Configure autogeneration: `@primary_key {:#{pk_field}, :id, autogenerate: true}`
"""}}
end
end
defp next_integer_id([]), do: 1
defp next_integer_id(existing_ids) do
Enum.max(existing_ids) + 1
end
end
end