Packages

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

Current section

12 Versions

Jump to

Compare versions

7 files changed
+315 additions
-56 deletions
  @@ -416,6 +416,32 @@ The `cache` function runs once per entity per request. If the same user is linke
416 416
417 417 Modules without `cache` are unaffected — single-argument `links` and `view` work as before.
418 418
419 + ### Batch queries for lists (`cache_many`)
420 +
421 + A query inside `cache` runs once per entity — an index of 100 posts runs it 100 times. Use `cache_many` to load the data for all of them with one query:
422 +
423 + ```elixir
424 + defmodule PostJSON do
425 + use Carve.View, :post
426 +
427 + # Runs once per render with all posts being rendered.
428 + # Return a map of post id => value.
429 + cache_many fn posts ->
430 + counts = Comments.count_for(Enum.map(posts, & &1.id))
431 + Map.new(posts, fn post -> {post.id, %{comment_count: counts[post.id]}} end)
432 + end
433 +
434 + view fn post, cached ->
435 + %{
436 + id: hash(post.id),
437 + comment_count: cached.comment_count
438 + }
439 + end
440 + end
441 + ```
442 +
443 + `view` and `links` receive each entity's own value as the second argument, same as with `cache`. Entities missing from the returned map get `nil`. A view defines either `cache` or `cache_many`, not both.
444 +
419 445 ## How does it work?
420 446
421 447 * Carve macros create view functions `index(%{ result: users })` and `show(%{ result: user })`
  @@ -4,9 +4,9 @@
4 4 <<"DSL for building JSON APIs fast. Creates endpoint views, renders linked data automatically.">>}.
5 5 {<<"elixir">>,<<"~> 1.12">>}.
6 6 {<<"files">>,
7 - [<<"lib">>,<<"lib/links.ex">>,<<"lib/cache.ex">>,<<"lib/config.ex">>,
8 - <<"lib/carve.ex">>,<<"lib/view.ex">>,<<"lib/hashids.ex">>,
9 - <<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>]}.
7 + [<<"lib">>,<<"lib/batch.ex">>,<<"lib/links.ex">>,<<"lib/cache.ex">>,
8 + <<"lib/config.ex">>,<<"lib/carve.ex">>,<<"lib/view.ex">>,
9 + <<"lib/hashids.ex">>,<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>]}.
10 10 {<<"licenses">>,[<<"MIT">>]}.
11 11 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/azer/carve">>}]}.
12 12 {<<"name">>,<<"carve">>}.
  @@ -31,4 +31,4 @@
31 31 {<<"optional">>,false},
32 32 {<<"repository">>,<<"hexpm">>},
33 33 {<<"requirement">>,<<"~> 3.6">>}]]}.
34 - {<<"version">>,<<"0.5.1">>}.
34 + {<<"version">>,<<"0.6.0">>}.
  @@ -0,0 +1,81 @@
1 + defmodule Carve.Batch do
2 + @moduledoc """
3 + Batched cache warm-up for views that define `cache_many`.
4 +
5 + `warm/3` resolves the cached value for a set of entities of one view in a
6 + single `__cache_many__/1` call, instead of one `__cache__/1` call per
7 + entity. Views without `cache_many` fall back to the per-entity path,
8 + preserving the existing `cache` behavior exactly.
9 + """
10 +
11 + @doc """
12 + Returns a map of entity id => cached value for every entity in `items`,
13 + warming the request cache along the way.
14 +
15 + For a view with `cache_many`, entities already present in the request
16 + cache are excluded from the batch call; ids missing from the returned
17 + map are cached as nil. Batching also works when caching is disabled
18 + (`cache_key` is nil): the batch still runs once and the results are
19 + returned directly.
20 + """
21 + def warm(module, items, cache_key) when is_list(items) do
22 + items =
23 + items
24 + |> Enum.reject(&(entity_id(&1) == nil))
25 + |> Enum.uniq_by(&entity_id/1)
26 +
27 + if batched?(module) do
28 + warm_batched(module, items, cache_key)
29 + else
30 + Map.new(items, fn item ->
31 + id = entity_id(item)
32 +
33 + value =
34 + Carve.Cache.fetch(cache_key, {module, :cache, id}, fn ->
35 + module.__cache__(item)
36 + end)
37 +
38 + {id, value}
39 + end)
40 + end
41 + end
42 +
43 + @doc """
44 + Whether the view module declares `cache_many`.
45 + """
46 + def batched?(module) do
47 + Code.ensure_loaded?(module) and function_exported?(module, :__cache_many__, 1)
48 + end
49 +
50 + @doc false
51 + def entity_id(%{id: id}), do: id
52 + def entity_id(%{"id" => id}), do: id
53 + def entity_id(_), do: nil
54 +
55 + defp warm_batched(module, items, cache_key) do
56 + {hits, misses} =
57 + Enum.reduce(items, {%{}, []}, fn item, {hits, misses} ->
58 + id = entity_id(item)
59 +
60 + case Carve.Cache.peek(cache_key, {module, :cache, id}) do
61 + {:ok, value} -> {Map.put(hits, id, value), misses}
62 + :error -> {hits, [item | misses]}
63 + end
64 + end)
65 +
66 + case Enum.reverse(misses) do
67 + [] ->
68 + hits
69 +
70 + misses ->
71 + batch = module.__cache_many__(misses)
72 +
73 + Enum.reduce(misses, hits, fn item, acc ->
74 + id = entity_id(item)
75 + value = Map.get(batch, id)
76 + Carve.Cache.put(cache_key, {module, :cache, id}, value)
77 + Map.put(acc, id, value)
78 + end)
79 + end
80 + end
81 + end
  @@ -35,37 +35,59 @@ defmodule Carve.Cache do
35 35
36 36 def fetch(cache_key, key, fun) when is_function(fun, 0) do
37 37 if Carve.Config.caching_enabled?() do
38 - cache_data =
39 - case Cachex.get(:carve_cache, cache_key) do
40 - {:ok, nil} -> %{}
41 - {:ok, data} -> data
42 - {:error, _} -> %{}
43 - end
44 -
45 - case Map.get(cache_data, key) do
46 - nil ->
47 - value = fun.()
48 -
49 - # Re-read cache to pick up writes from nested fetch calls
50 - current_cache =
51 - case Cachex.get(:carve_cache, cache_key) do
52 - {:ok, nil} -> %{}
53 - {:ok, data} -> data
54 - {:error, _} -> %{}
55 - end
56 -
57 - updated_cache = Map.put(current_cache, key, value)
58 - Cachex.put(:carve_cache, cache_key, updated_cache, ttl: Carve.Config.cache_ttl())
59 - value
60 -
61 - cached_value ->
38 + # Map.fetch, not Map.get: a cached nil (e.g. a missing entity or a
39 + # cache_many id absent from the batch result) is a hit, not a miss.
40 + case Map.fetch(read(cache_key), key) do
41 + {:ok, cached_value} ->
62 42 cached_value
43 +
44 + :error ->
45 + value = fun.()
46 + put(cache_key, key, value)
47 + value
63 48 end
64 49 else
65 50 fun.()
66 51 end
67 52 end
68 53
54 + @doc """
55 + Looks up a cached value without computing it.
56 + Returns `{:ok, value}` (value may be nil) or `:error` when the key is
57 + absent or caching is disabled.
58 + """
59 + def peek(nil, _key), do: :error
60 +
61 + def peek(cache_key, key) do
62 + if Carve.Config.caching_enabled?() do
63 + Map.fetch(read(cache_key), key)
64 + else
65 + :error
66 + end
67 + end
68 +
69 + @doc """
70 + Stores a value in the request cache. No-op when caching is disabled.
71 + """
72 + def put(nil, _key, _value), do: :ok
73 +
74 + def put(cache_key, key, value) do
75 + if Carve.Config.caching_enabled?() do
76 + # Re-read before writing to pick up writes from nested fetch calls
77 + updated = Map.put(read(cache_key), key, value)
78 + Cachex.put(:carve_cache, cache_key, updated, ttl: Carve.Config.cache_ttl())
79 + end
80 +
81 + :ok
82 + end
83 +
84 + defp read(cache_key) do
85 + case Cachex.get(:carve_cache, cache_key) do
86 + {:ok, %{} = data} -> data
87 + _ -> %{}
88 + end
89 + end
90 +
69 91 @doc """
70 92 Gets the current cache key from the process dictionary, or creates a new one.
71 93 Returns nil if caching is disabled.
  @@ -56,6 +56,8 @@ defmodule Carve.Links do
56 56 when is_list(data_list) do
57 57 cache_key = cache_key || Carve.Cache.get_or_create_context()
58 58
59 + warm_level(module, data_list, visited, whitelist, cache_key)
60 +
59 61 {links, visited} =
60 62 Enum.reduce(data_list, {[], visited}, fn item, {acc, vis} ->
61 63 {new_links, vis} = do_get_links_by_data(module, item, vis, whitelist, cache_key)
  @@ -82,9 +84,11 @@ defmodule Carve.Links do
82 84
83 85 cached = fetch_cached(module, data, id, cache_key)
84 86
85 - links =
86 - module.declare_links(data, cached)
87 - |> filter_and_evaluate_links(whitelist)
87 + raw_links = fetch_links(module, data, id, cached, cache_key)
88 +
89 + warm_link_targets([raw_links], visited, whitelist, cache_key)
90 +
91 + links = filter_and_evaluate_links(raw_links, whitelist)
88 92
89 93 {links, visited} =
90 94 Enum.reduce(links, {[], visited}, fn {link_module, link_data_or_ids}, {acc, vis} ->
  @@ -120,6 +124,91 @@ defmodule Carve.Links do
120 124 end)
121 125 end
122 126
127 + # ── batched warm-up (cache_many) ─────────────────────────────────────
128 +
129 + # Memoized declare_links, so the warm-up pre-pass and the traversal
130 + # evaluate each entity's links function once per request.
131 + defp fetch_links(module, data, id, cached, cache_key) do
132 + Carve.Cache.fetch(cache_key, {module, :links, id}, fn ->
133 + module.declare_links(data, cached)
134 + end)
135 + end
136 +
137 + # Cross-item warm-up for a list render: batch the list's own cache, then
138 + # batch the cache of every linked entity set whose view defines
139 + # cache_many, so the per-item traversal below hits the cache throughout.
140 + # Skipped when caching is disabled (nil cache_key) — the traversal stays
141 + # correct through the per-entity batch-of-one __cache__ fallback.
142 + defp warm_level(_module, _data_list, _visited, _whitelist, nil), do: :ok
143 +
144 + defp warm_level(module, data_list, visited, whitelist, cache_key) do
145 + items =
146 + Enum.filter(data_list, fn item ->
147 + with true <- is_map(item),
148 + {:ok, id} <- fetch_id(item) do
149 + !Map.get(visited, {module, id})
150 + else
151 + _ -> false
152 + end
153 + end)
154 +
155 + cached_by_id = Carve.Batch.warm(module, items, cache_key)
156 +
157 + raw_links_list =
158 + Enum.map(items, fn item ->
159 + {:ok, id} = fetch_id(item)
160 + fetch_links(module, item, id, Map.get(cached_by_id, id), cache_key)
161 + end)
162 +
163 + warm_link_targets(raw_links_list, visited, whitelist, cache_key)
164 + end
165 +
166 + # Warm the cache of link targets, grouped by view module, one batch per
167 + # module that defines cache_many. Lazy (function) links are left to the
168 + # traversal so they are never evaluated twice.
169 + defp warm_link_targets(_raw_links_list, _visited, _whitelist, nil), do: :ok
170 +
171 + defp warm_link_targets(raw_links_list, visited, whitelist, cache_key) do
172 + raw_links_list
173 + |> Enum.flat_map(fn raw_links ->
174 + for {link_module, value} <- raw_links,
175 + not is_function(value),
176 + link_whitelisted?(link_module, whitelist),
177 + Carve.Batch.batched?(link_module),
178 + target <- normalize_link_ids(value),
179 + do: {link_module, target}
180 + end)
181 + |> Enum.group_by(fn {module, _} -> module end, fn {_, target} -> target end)
182 + |> Enum.each(fn {link_module, targets} ->
183 + entities =
184 + targets
185 + |> Enum.reject(fn target ->
186 + case fetch_id(target) do
187 + {:ok, id} -> Map.get(visited, {link_module, id}) != nil
188 + :error -> true
189 + end
190 + end)
191 + |> Enum.map(fn
192 + id when is_number(id) or is_binary(id) ->
193 + Carve.Cache.fetch(cache_key, {link_module, :get, id}, fn ->
194 + link_module.get_by_id(id)
195 + end)
196 +
197 + data ->
198 + data
199 + end)
200 + |> Enum.reject(&is_nil/1)
201 +
202 + Carve.Batch.warm(link_module, entities, cache_key)
203 + end)
204 + end
205 +
206 + defp link_whitelisted?(_module, nil), do: true
207 + defp link_whitelisted?(_module, []), do: false
208 +
209 + defp link_whitelisted?(module, whitelist) when is_list(whitelist),
210 + do: module.type_name() in whitelist
211 +
123 212 defp process_single_link(module, id, visited, cache_key) when is_number(id) or is_binary(id) do
124 213 case Map.get(visited, {module, id}) do
125 214 nil ->
Loading more files…