Current section

Files

Jump to
double_down lib double_down repo impl autogenerate.ex
Raw

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