Current section

Files

Jump to
double_down lib double_down repo impl preloader.ex
Raw

lib/double_down/repo/impl/preloader.ex

if Code.ensure_loaded?(Ecto) do
defmodule DoubleDown.Repo.Impl.Preloader do
@moduledoc false
# In-memory preloader that uses Ecto schema reflection to resolve
# associations from the InMemory store.
#
# Supports: has_many, has_one, belongs_to, many_to_many (when join
# schema is in store), has_through (by chaining).
alias DoubleDown.Repo.Impl.InMemoryShared
@doc false
@spec preload(term(), term(), InMemoryShared.store()) :: term()
def preload(nil, _preloads, _store), do: nil
def preload([], _preloads, _store), do: []
def preload(structs, preloads, store) when is_list(structs) do
Enum.map(structs, &preload(&1, preloads, store))
end
def preload(struct, preloads, store) when is_map(struct) do
preloads
|> normalize_preloads()
|> Enum.reduce(struct, fn {field, sub_preloads}, acc ->
schema = acc.__struct__
assoc = schema.__schema__(:association, field)
if assoc == nil do
raise ArgumentError,
"schema #{inspect(schema)} does not have association #{inspect(field)}"
end
loaded = resolve_assoc(assoc, acc, store)
# Apply nested preloads
loaded =
case sub_preloads do
[] -> loaded
nested when is_list(loaded) -> Enum.map(loaded, &preload(&1, nested, store))
nested when is_map(loaded) -> preload(loaded, nested, store)
_nested -> loaded
end
Map.put(acc, field, loaded)
end)
end
# -----------------------------------------------------------------
# Association resolution
# -----------------------------------------------------------------
defp resolve_assoc(%Ecto.Association.Has{} = assoc, struct, store) do
%{
owner_key: owner_key,
related_key: related_key,
related: related,
cardinality: cardinality,
where: where_clauses
} = assoc
owner_val = Map.fetch!(struct, owner_key)
records = InMemoryShared.records_for_schema(store, related)
matching =
records
|> Enum.filter(&(Map.get(&1, related_key) == owner_val))
|> apply_where_clauses(where_clauses)
case cardinality do
:many -> matching
:one -> List.first(matching)
end
end
defp resolve_assoc(%Ecto.Association.BelongsTo{} = assoc, struct, store) do
%{owner_key: owner_key, related_key: related_key, related: related} = assoc
fk_val = Map.get(struct, owner_key)
if is_nil(fk_val) do
nil
else
records = InMemoryShared.records_for_schema(store, related)
Enum.find(records, &(Map.get(&1, related_key) == fk_val))
end
end
defp resolve_assoc(%Ecto.Association.ManyToMany{} = assoc, struct, store) do
%{
owner_key: owner_key,
related: related,
join_keys: [{join_owner_key, _owner_key}, {join_related_key, related_key}],
join_through: join_through,
where: where_clauses
} = assoc
owner_val = Map.fetch!(struct, owner_key)
# Get join records
join_records =
case join_through do
mod when is_atom(mod) ->
InMemoryShared.records_for_schema(store, mod)
table when is_binary(table) ->
# String table names not supported without a schema module
[]
end
# Find matching join records
related_keys =
join_records
|> Enum.filter(&(Map.get(&1, join_owner_key) == owner_val))
|> Enum.map(&Map.get(&1, join_related_key))
# Look up related records
related_records = InMemoryShared.records_for_schema(store, related)
related_records
|> Enum.filter(&(Map.get(&1, related_key) in related_keys))
|> apply_where_clauses(where_clauses)
end
defp resolve_assoc(%Ecto.Association.HasThrough{} = assoc, struct, store) do
%{through: through, cardinality: cardinality} = assoc
# Walk the chain of associations
result =
Enum.reduce(through, [struct], fn assoc_name, current ->
current
|> List.wrap()
|> Enum.reject(&is_nil/1)
|> Enum.flat_map(fn item ->
schema = item.__struct__
step_assoc = schema.__schema__(:association, assoc_name)
resolved = resolve_assoc(step_assoc, item, store)
List.wrap(resolved)
end)
end)
# Deduplicate by PK if the schema has one
result = deduplicate(result)
case cardinality do
:many -> result
:one -> List.first(result)
end
end
# -----------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------
defp normalize_preloads(preloads) when is_atom(preloads), do: [{preloads, []}]
defp normalize_preloads(preloads) when is_list(preloads) do
Enum.flat_map(preloads, fn
{field, sub} when is_atom(field) -> [{field, normalize_preloads(sub)}]
field when is_atom(field) -> [{field, []}]
end)
end
defp apply_where_clauses(records, nil), do: records
defp apply_where_clauses(records, []), do: records
defp apply_where_clauses(records, clauses) do
Enum.filter(records, fn record ->
Enum.all?(clauses, fn {field, value} ->
Map.get(record, field) == value
end)
end)
end
defp deduplicate([]), do: []
defp deduplicate([first | _] = records) do
schema = first.__struct__
if function_exported?(schema, :__schema__, 1) do
case schema.__schema__(:primary_key) do
[pk_field] ->
records
|> Enum.uniq_by(&Map.get(&1, pk_field))
_ ->
records
end
else
records
end
end
end
end