Packages

DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.

Current section

Files

Jump to
carve lib links.ex
Raw

lib/links.ex

defmodule Carve.Links do
# Public API — returns lists (backward compatible)
def get_links_by_id(module, id, visited \\ %{}, whitelist \\ nil, cache_key \\ nil) do
{links, _visited} = do_get_links_by_id(module, id, visited, whitelist, cache_key)
links
end
def get_links_by_data(module, data, visited \\ %{}, whitelist \\ nil, cache_key \\ nil) do
{links, _visited} = do_get_links_by_data(module, data, visited, whitelist, cache_key)
links
end
# Internal API — returns {links, visited} to accumulate visited across items
defp do_get_links_by_id(_module, nil, visited, _whitelist, _cache_key), do: {[], visited}
defp do_get_links_by_id(module, id, visited, whitelist, cache_key) when not is_list(id) do
cache_key = cache_key || Carve.Cache.get_or_create_context()
case Map.get(visited, {module, id}) do
nil ->
data = Carve.Cache.fetch(cache_key, {module, :get, id}, fn ->
module.get_by_id(id)
end)
case data do
nil ->
{[], visited}
data ->
{links, visited} = do_get_links_by_data(module, data, visited, whitelist, cache_key)
{prepare_result(links, whitelist), visited}
end
_ ->
{[], visited}
end
end
defp do_get_links_by_id(module, ids, visited, whitelist, cache_key) when is_list(ids) do
cache_key = cache_key || Carve.Cache.get_or_create_context()
{links, visited} =
Enum.reduce(ids, {[], visited}, fn id, {acc, vis} ->
{new_links, vis} = do_get_links_by_id(module, id, vis, whitelist, cache_key)
{acc ++ new_links, vis}
end)
{prepare_result(links, whitelist), visited}
end
defp do_get_links_by_data(_module, nil, visited, _whitelist, _cache_key), do: {[], visited}
defp do_get_links_by_data(module, data_list, visited, whitelist, cache_key)
when is_list(data_list) do
cache_key = cache_key || Carve.Cache.get_or_create_context()
warm_level(module, data_list, visited, whitelist, cache_key)
{links, visited} =
Enum.reduce(data_list, {[], visited}, fn item, {acc, vis} ->
{new_links, vis} = do_get_links_by_data(module, item, vis, whitelist, cache_key)
{acc ++ new_links, vis}
end)
{prepare_result(links, whitelist), visited}
end
defp do_get_links_by_data(_module, data, visited, _whitelist, _cache_key)
when not is_map(data),
do: {[], visited}
defp do_get_links_by_data(module, data, visited, whitelist, cache_key)
when not is_list(data) do
cache_key = cache_key || Carve.Cache.get_or_create_context()
case fetch_id(data) do
{:ok, id} ->
if Map.get(visited, {module, id}) do
{[], visited}
else
visited = Map.put(visited, {module, id}, true)
cached = fetch_cached(module, data, id, cache_key)
raw_links = fetch_links(module, data, id, cached, cache_key)
warm_link_targets([raw_links], visited, whitelist, cache_key)
links = filter_and_evaluate_links(raw_links, whitelist)
{links, visited} =
Enum.reduce(links, {[], visited}, fn {link_module, link_data_or_ids}, {acc, vis} ->
link_data_or_ids
|> normalize_link_ids()
|> Enum.reduce({acc, vis}, fn link_id_or_data, {acc, vis} ->
link = process_single_link(link_module, link_id_or_data, vis, cache_key)
# let do_get_links_by_id/data mark visited internally
{children, vis} =
if is_map(link_id_or_data) do
do_get_links_by_data(link_module, link_id_or_data, vis, whitelist, cache_key)
else
do_get_links_by_id(link_module, link_id_or_data, vis, whitelist, cache_key)
end
{acc ++ [link | children], vis}
end)
end)
{prepare_result(links, whitelist), visited}
end
:error ->
{[], visited}
end
end
# Fetch cached data for a module, using the Cachex cache
defp fetch_cached(module, data, id, cache_key) do
Carve.Cache.fetch(cache_key, {module, :cache, id}, fn ->
module.__cache__(data)
end)
end
# ── batched warm-up (cache_many) ─────────────────────────────────────
# Memoized declare_links, so the warm-up pre-pass and the traversal
# evaluate each entity's links function once per request.
defp fetch_links(module, data, id, cached, cache_key) do
Carve.Cache.fetch(cache_key, {module, :links, id}, fn ->
module.declare_links(data, cached)
end)
end
# Cross-item warm-up for a list render: batch the list's own cache, then
# batch the cache of every linked entity set whose view defines
# cache_many, so the per-item traversal below hits the cache throughout.
# Skipped when caching is disabled (nil cache_key) — the traversal stays
# correct through the per-entity batch-of-one __cache__ fallback.
defp warm_level(_module, _data_list, _visited, _whitelist, nil), do: :ok
defp warm_level(module, data_list, visited, whitelist, cache_key) do
items =
Enum.filter(data_list, fn item ->
with true <- is_map(item),
{:ok, id} <- fetch_id(item) do
!Map.get(visited, {module, id})
else
_ -> false
end
end)
cached_by_id = Carve.Batch.warm(module, items, cache_key)
raw_links_list =
Enum.map(items, fn item ->
{:ok, id} = fetch_id(item)
fetch_links(module, item, id, Map.get(cached_by_id, id), cache_key)
end)
warm_link_targets(raw_links_list, visited, whitelist, cache_key)
end
# Warm the cache of link targets, grouped by view module, one batch per
# module that defines cache_many. Lazy (function) links are left to the
# traversal so they are never evaluated twice.
defp warm_link_targets(_raw_links_list, _visited, _whitelist, nil), do: :ok
defp warm_link_targets(raw_links_list, visited, whitelist, cache_key) do
raw_links_list
|> Enum.flat_map(fn raw_links ->
for {link_module, value} <- raw_links,
not is_function(value),
link_whitelisted?(link_module, whitelist),
Carve.Batch.batched?(link_module),
target <- normalize_link_ids(value),
do: {link_module, target}
end)
|> Enum.group_by(fn {module, _} -> module end, fn {_, target} -> target end)
|> Enum.each(fn {link_module, targets} ->
entities =
targets
|> Enum.reject(fn target ->
case fetch_id(target) do
{:ok, id} -> Map.get(visited, {link_module, id}) != nil
:error -> true
end
end)
|> Enum.map(fn
id when is_number(id) or is_binary(id) ->
Carve.Cache.fetch(cache_key, {link_module, :get, id}, fn ->
link_module.get_by_id(id)
end)
data ->
data
end)
|> Enum.reject(&is_nil/1)
Carve.Batch.warm(link_module, entities, cache_key)
end)
end
defp link_whitelisted?(_module, nil), do: true
defp link_whitelisted?(_module, []), do: false
defp link_whitelisted?(module, whitelist) when is_list(whitelist),
do: module.type_name() in whitelist
defp process_single_link(module, id, visited, cache_key) when is_number(id) or is_binary(id) do
case Map.get(visited, {module, id}) do
nil ->
data = Carve.Cache.fetch(cache_key, {module, :get, id}, fn ->
module.get_by_id(id)
end)
case data do
nil -> nil
data ->
cached = fetch_cached(module, data, id, cache_key)
module.prepare_for_view(data, cached)
end
_ ->
nil
end
end
defp process_single_link(module, data, visited, cache_key) do
case fetch_id(data) do
{:ok, id} ->
case Map.get(visited, {module, id}) do
nil ->
cached = fetch_cached(module, data, id, cache_key)
module.prepare_for_view(data, cached)
_ ->
nil
end
:error ->
nil
end
end
defp filter_and_evaluate_links(links, nil) do
links
|> Enum.filter(fn {_, value} -> not is_function(value) end)
|> Enum.into(%{})
end
defp filter_and_evaluate_links(_links, []), do: %{}
defp filter_and_evaluate_links(links, whitelist) when is_list(whitelist) do
links
|> Enum.filter(fn {module, _} -> module.type_name() in whitelist end)
|> Enum.map(fn {module, value} ->
evaluated =
case value do
fun when is_function(fun, 0) -> fun.()
other -> other
end
{module, evaluated}
end)
|> Enum.into(%{})
end
def prepare_result(result, whitelist \\ nil) do
result
|> List.flatten()
|> Enum.reject(&is_nil/1)
|> Enum.uniq_by(fn %{type: type, id: id} -> {type, id} end)
|> filter_result(whitelist)
end
defp filter_result(_result, []), do: []
defp filter_result(result, nil), do: result
defp filter_result(result, whitelist) do
Enum.filter(result, fn %{type: type} -> type in whitelist end)
end
defp extract_id(nil), do: nil
defp extract_id(id) when is_number(id) or is_binary(id), do: id
defp extract_id(data) when is_map(data) do
case fetch_id(data) do
{:ok, id} -> id
:error -> nil
end
end
defp normalize_link_ids(link_ids) when is_list(link_ids), do: link_ids
defp normalize_link_ids(link_id), do: [link_id]
defp fetch_id(data) when is_map(data) do
cond do
Map.has_key?(data, :id) -> {:ok, data.id}
Map.has_key?(data, "id") -> {:ok, data["id"]}
true ->
case Enum.at(data, 0) do
{key, value} -> {:ok, {key, value}}
nil -> :error
end
end
end
defp fetch_id(id) when is_integer(id) or is_binary(id), do: {:ok, id}
defp fetch_id(_), do: :error
end