Current section
Files
Jump to
Current section
Files
lib/cache/sandbox.ex
defmodule Cache.Sandbox do
@moduledoc """
Sandbox adapter for isolated testing of applications using ElixirCache.
This module provides a mock implementation of all cache adapters, allowing tests to run in isolation
without interfering with each other's data. The sandbox uses a basic `Agent` to store data in memory
and implements the full range of caching operations supported by all adapters.
## Features
* Isolated cache namespaces for concurrent testing
* Support for all standard cache operations
* Implementation of Redis-specific features like hash and JSON operations
* Implementation of ETS-specific operations for complete testing compatibility
* Lightweight in-memory storage for fast test execution
## Usage
The Sandbox adapter is typically enabled through the `sandbox?` option when defining a cache module:
```elixir
defmodule MyApp.TestCache do
use Cache,
adapter: Cache.Redis, # Original adapter doesn't matter when sandbox is enabled
name: :test_cache,
opts: [],
sandbox?: Mix.env() == :test
end
```
In your tests, use `Cache.SandboxRegistry.start(MyApp.TestCache)` in the setup block to ensure
proper isolation between test cases.
> **Note**: This adapter should not be used in production environments.
## Isolation
Each Agent process keeps state shaped as `%{sandbox_id => %{key => value}}`. The
`sandbox_id` is resolved per-caller via `Cache.SandboxRegistry` BEFORE entering
the Agent callback (so the caller's `self()`/`$callers`/`$ancestors` are used
for lookup, not the Agent's). When no sandbox is registered for the caller we
fall back to `:__unscoped__` — keeping the adapter usable outside of test.
"""
use Agent
alias Cache.Redis.JSON
alias Cache.TermEncoder
@behaviour Cache
@sandbox_registry :elixir_cache_sandbox
@unscoped :__unscoped__
@impl Cache
def opts_definition, do: []
@impl Cache
def start_link(opts \\ []) do
with {:error, {:already_started, pid}} <- Agent.start_link(fn -> %{} end, opts) do
{:ok, pid}
end
end
@impl Cache
def child_spec({cache_name, opts}) do
%{
id: "#{cache_name}_elixir_cache_agent",
start: {__MODULE__, :start_link, [Keyword.put(opts, :name, cache_name)]}
}
end
# SECTION: sandbox scoping helpers
# Resolve the sandbox id for the current caller. Must be called from the
# caller process (NOT inside the Agent callback) so SandboxRegistry can walk
# the caller's $callers/$ancestors.
defp current_sandbox_id(cache_name) do
case Process.whereis(@sandbox_registry) do
nil ->
@unscoped
_pid ->
case SandboxRegistry.lookup(@sandbox_registry, cache_name) do
{:ok, id} -> id
{:error, _} -> @unscoped
end
end
end
defp scoped_get(state, sid), do: Map.get(state, sid, %{})
defp scoped_put(state, sid, sub), do: Map.put(state, sid, sub)
defp scoped_agent_get(cache_name, fun) do
sid = current_sandbox_id(cache_name)
Agent.get(cache_name, fn state -> fun.(scoped_get(state, sid)) end)
end
defp scoped_agent_update(cache_name, fun) do
sid = current_sandbox_id(cache_name)
Agent.update(cache_name, fn state -> scoped_put(state, sid, fun.(scoped_get(state, sid))) end)
end
defp scoped_agent_get_and_update(cache_name, fun) do
sid = current_sandbox_id(cache_name)
Agent.get_and_update(cache_name, fn state ->
{reply, new_sub} = fun.(scoped_get(state, sid))
{reply, scoped_put(state, sid, new_sub)}
end)
end
# SECTION: core cache API
@impl Cache
def get(cache_name, key, _opts \\ []) do
scoped_agent_get(cache_name, fn sub -> {:ok, Map.get(sub, key)} end)
end
@impl Cache
def put(cache_name, key, _ttl \\ nil, value, _opts \\ []) do
scoped_agent_update(cache_name, fn sub -> Map.put(sub, key, value) end)
end
@impl Cache
def delete(cache_name, key, _opts \\ []) do
scoped_agent_update(cache_name, fn sub -> Map.delete(sub, key) end)
end
def get_or_store(cache_name, key, _ttl, store_fun) do
scoped_agent_get_and_update(cache_name, fn sub ->
case Map.fetch(sub, key) do
{:ok, value} ->
{value, sub}
:error ->
value = store_fun.()
{value, Map.put(sub, key, value)}
end
end)
end
def dirty_get_or_store(cache_name, key, store_fun) do
scoped_agent_get_and_update(cache_name, fn sub ->
case Map.fetch(sub, key) do
{:ok, value} ->
{value, sub}
:error ->
value = store_fun.()
{value, Map.put(sub, key, value)}
end
end)
end
# SECTION: hash API
def hash_delete(cache_name, key, hash_key, _opts) do
scoped_agent_get_and_update(cache_name, fn sub ->
case Map.get(sub, key) do
nil ->
{{:ok, 0}, sub}
value when is_map(value) ->
if Map.has_key?(value, hash_key) do
updated = Map.delete(value, hash_key)
new_sub =
if Enum.empty?(updated) do
Map.delete(sub, key)
else
Map.put(sub, key, updated)
end
{{:ok, 1}, new_sub}
else
{{:ok, 0}, sub}
end
_ ->
{{:ok, 0}, sub}
end
end)
end
def hash_get(cache_name, key, hash_key, _opts) do
scoped_agent_get(cache_name, fn sub ->
value =
sub
|> Map.get(key, %{})
|> Map.get(hash_key)
{:ok, value}
end)
end
def hash_get_all(cache_name, key, _opts) do
scoped_agent_get(cache_name, fn sub ->
case sub[key] do
nil -> {:ok, %{}}
value -> {:ok, value}
end
end)
end
def hash_get_many(cache_name, keys_fields, _opts) do
scoped_agent_get(cache_name, fn sub ->
values =
Enum.map(keys_fields, fn {key, fields} ->
hash = Map.get(sub, key, %{})
Enum.map(fields, &Map.get(hash, &1))
end)
{:ok, values}
end)
end
def hash_values(cache_name, key, _opts) do
scoped_agent_get(cache_name, fn sub ->
{:ok, Map.values(sub[key] || %{})}
end)
end
def hash_set(cache_name, key, field, value, ttl, _opts) do
count =
scoped_agent_get_and_update(cache_name, fn sub ->
hash = Map.get(sub, key, %{})
is_new_field = not Map.has_key?(hash, field)
updated_hash = Map.put(hash, field, value)
{if(is_new_field, do: 1, else: 0), Map.put(sub, key, updated_hash)}
end)
if ttl do
{:ok, [count, 1]}
else
{:ok, count}
end
end
def hash_set_many(cache_name, keys_fields_values, ttl, _opts) do
counts =
scoped_agent_get_and_update(cache_name, fn sub ->
{counts, new_sub} =
Enum.map_reduce(keys_fields_values, sub, fn {key, fields_values}, acc ->
hash = Map.get(acc, key, %{})
{updated_hash, count} =
Enum.reduce(fields_values, {hash, 0}, fn {field, value}, {hash_acc, count_acc} ->
is_new_field = not Map.has_key?(hash_acc, field)
updated_hash = Map.put(hash_acc, field, value)
new_count = if is_new_field, do: count_acc + 1, else: count_acc
{updated_hash, new_count}
end)
{count, Map.put(acc, key, updated_hash)}
end)
{counts, new_sub}
end)
if ttl do
expiry_resps = List.duplicate(1, enum_length(keys_fields_values))
{:ok, counts ++ expiry_resps}
else
{:ok, counts}
end
end
# SECTION: JSON API
def json_get(cache_name, key, path, _opts) when path in [nil, ["."]] do
get(cache_name, key)
end
def json_get(cache_name, key, path, _opts) do
if contains_index?(path) do
[index | path] = Enum.reverse(path)
with {:ok, value} <- serialize_path_and_get_value(cache_name, key, path) do
{:ok, Enum.at(value, index)}
end
else
serialize_path_and_get_value(cache_name, key, path)
end
end
def json_set(cache_name, key, path, value, _opts) when path in [nil, ["."]] do
put(cache_name, key, stringify_value(value))
end
def json_set(cache_name, key, path, value, _opts) do
sub = scoped_agent_get(cache_name, & &1)
path = JSON.serialize_path(path)
with :ok <- check_key_exists(sub, key),
:ok <- check_path_exists(sub, key, path) do
path = add_defaults([key | String.split(path, ".")])
value = stringify_value(value)
scoped_agent_update(cache_name, fn sub ->
put_in(sub, path, value)
end)
end
end
def json_incr(cache_name, key, path, incr \\ 1, _opts) do
path_parts = json_path_parts(path)
path_string = json_path_string(path)
scoped_agent_get_and_update(cache_name, fn sub ->
case get_in(sub, [key | path_parts]) do
nil ->
{{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, sub}
value ->
new_value = value + incr
{{:ok, new_value}, put_in(sub, [key | path_parts], new_value)}
end
end)
end
def json_clear(cache_name, key, path, _opts) do
path_parts = json_path_parts(path)
scoped_agent_get_and_update(cache_name, fn sub ->
case get_in(sub, [key | path_parts]) do
nil ->
{{:ok, 0}, sub}
value ->
updated_value =
case value do
integer when is_integer(integer) -> 0
list when is_list(list) -> []
map when is_map(map) -> %{}
_ -> nil
end
{{:ok, 1}, put_in(sub, [key | path_parts], updated_value)}
end
end)
end
def json_delete(cache_name, key, path, _opts) do
path_parts = json_path_parts(path)
scoped_agent_get_and_update(cache_name, fn sub ->
case get_in(sub, [key | path_parts]) do
nil ->
{{:ok, 0}, sub}
_value ->
{_, updated} = pop_in(sub, [key | path_parts])
{{:ok, 1}, updated}
end
end)
end
def json_array_append(cache_name, key, path, values, _opts) when is_list(values) do
append_json_array(cache_name, key, path, values, &stringify_value/1)
end
def json_array_append(cache_name, key, path, value, _opts) do
append_json_array(cache_name, key, path, [value], &stringify_value/1)
end
defp append_json_array(cache_name, key, path, values, value_transformer) do
path_parts = json_path_parts(path)
path_string = json_path_string(path)
updated_values = Enum.map(values, value_transformer)
scoped_agent_get_and_update(cache_name, fn sub ->
case get_in(sub, [key | path_parts]) do
nil ->
{{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, sub}
list when is_list(list) ->
updated_list = list ++ updated_values
new_sub = put_in(sub, [key | path_parts], updated_list)
{{:ok, enum_length(updated_list)}, new_sub}
_ ->
{{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, sub}
end
end)
end
# SECTION: Redis compatibility
def pipeline(_cache_name, _commands, _opts) do
raise "Not Implemented"
end
def pipeline!(_cache_name, _commands, _opts) do
raise "Not Implemented"
end
def command(_cache_name, _command, _opts) do
raise "Not Implemented"
end
def command!(_cache_name, _command, _opts) do
raise "Not Implemented"
end
def scan(cache_name, scan_opts, _opts) do
match = scan_opts[:match] || "*"
count = scan_opts[:count]
type = scan_opts[:type]
scoped_agent_get(cache_name, fn sub ->
values =
sub
|> Stream.filter(fn {_key, value} -> scan_type_match?(value, type) end)
|> Stream.map(fn {key, _value} -> key end)
|> Stream.filter(fn key -> scan_match?(key, match) end)
|> Enum.to_list()
|> apply_scan_count(count)
{:ok, values}
end)
end
def hash_scan(cache_name, key, scan_opts, _opts) do
match = scan_opts[:match] || "*"
count = scan_opts[:count]
scoped_agent_get(cache_name, fn sub ->
case Map.get(sub, key) do
map when is_map(map) ->
elements =
map
|> Enum.filter(fn {field, _value} -> scan_match?(field, match) end)
|> apply_scan_count(count)
{:ok, elements}
_ ->
{:ok, []}
end
end)
end
# SECTION: ETS & DETS compatibility
#
# All ETS/DETS-style helpers below operate on the scoped sub-map for the
# current sandbox so tests stay isolated from each other.
def all do
sid = current_sandbox_id(Cache.Sandbox)
Agent.get(Cache.Sandbox, fn state -> state |> scoped_get(sid) |> Map.keys() end)
rescue
_ -> []
end
def delete_table(cache_name) do
scoped_agent_update(cache_name, fn _ -> %{} end)
true
end
def delete_all_objects(cache_name) do
scoped_agent_update(cache_name, fn _ -> %{} end)
true
end
def delete_object(cache_name, object) when is_tuple(object) do
key = elem(object, 0)
scoped_agent_update(cache_name, fn sub ->
case Map.get(sub, key) do
^object -> Map.delete(sub, key)
_ -> sub
end
end)
true
end
def first(cache_name) do
scoped_agent_get(cache_name, fn sub ->
case Map.keys(sub) do
[] -> :"$end_of_table"
[key | _] -> key
end
end)
end
def first_lookup(cache_name) do
scoped_agent_get(cache_name, fn sub ->
case Map.to_list(sub) do
[] -> :"$end_of_table"
[{key, value} | _] -> {key, [{key, value}]}
end
end)
end
def last(cache_name) do
scoped_agent_get(cache_name, fn sub ->
case sub |> Map.keys() |> Enum.reverse() do
[] -> :"$end_of_table"
[key | _] -> key
end
end)
end
def last_lookup(cache_name) do
scoped_agent_get(cache_name, fn sub ->
case sub |> Map.to_list() |> Enum.reverse() do
[] -> :"$end_of_table"
[{key, value} | _] -> {key, [{key, value}]}
end
end)
end
def next(cache_name, key) do
scoped_agent_get(cache_name, fn sub ->
keys = sub |> Map.keys() |> Enum.sort()
find_next_key(keys, key)
end)
end
def next_lookup(cache_name, key) do
scoped_agent_get(cache_name, fn sub ->
keys = sub |> Map.keys() |> Enum.sort()
case find_next_key(keys, key) do
:"$end_of_table" -> :"$end_of_table"
next_key -> {next_key, [{next_key, Map.get(sub, next_key)}]}
end
end)
end
def prev(cache_name, key) do
scoped_agent_get(cache_name, fn sub ->
keys = sub |> Map.keys() |> Enum.sort() |> Enum.reverse()
find_next_key(keys, key)
end)
end
def prev_lookup(cache_name, key) do
scoped_agent_get(cache_name, fn sub ->
keys = sub |> Map.keys() |> Enum.sort() |> Enum.reverse()
case find_next_key(keys, key) do
:"$end_of_table" -> :"$end_of_table"
prev_key -> {prev_key, [{prev_key, Map.get(sub, prev_key)}]}
end
end)
end
defp find_next_key([], _key), do: :"$end_of_table"
defp find_next_key([_], _key), do: :"$end_of_table"
defp find_next_key([current, next | _rest], key) when current === key do
next
end
defp find_next_key([_ | rest], key), do: find_next_key(rest, key)
def foldl(cache_name, function, acc) do
scoped_agent_get(cache_name, fn sub ->
sub
|> Map.to_list()
|> Enum.reduce(acc, fn {key, value}, acc_inner ->
function.({key, value}, acc_inner)
end)
end)
end
def foldr(cache_name, function, acc) do
scoped_agent_get(cache_name, fn sub ->
sub
|> Map.to_list()
|> Enum.reverse()
|> Enum.reduce(acc, fn {key, value}, acc_inner ->
function.({key, value}, acc_inner)
end)
end)
end
def member(cache_name, key) do
scoped_agent_get(cache_name, fn sub -> Map.has_key?(sub, key) end)
end
def lookup(cache_name, key) do
scoped_agent_get(cache_name, fn sub ->
case Map.get(sub, key) do
nil -> []
value -> [{key, value}]
end
end)
end
def lookup_element(cache_name, key, pos) do
scoped_agent_get(cache_name, fn sub ->
case Map.get(sub, key) do
nil -> raise ArgumentError, "key not found"
value when is_tuple(value) -> elem(value, pos - 1)
value -> value
end
end)
end
def lookup_element(cache_name, key, pos, default) do
scoped_agent_get(cache_name, fn sub ->
case Map.get(sub, key) do
nil -> default
value when is_tuple(value) -> elem(value, pos - 1)
value -> value
end
end)
end
def update_counter(cache_name, key, {_pos, incr}) do
scoped_agent_get_and_update(cache_name, fn sub ->
current_value = sub[key] || 0
new_value = current_value + incr
{new_value, Map.put(sub, key, new_value)}
end)
end
def update_counter(cache_name, key, incr) when is_integer(incr) do
scoped_agent_get_and_update(cache_name, fn sub ->
current_value = sub[key] || 0
new_value = current_value + incr
{new_value, Map.put(sub, key, new_value)}
end)
end
def update_counter(cache_name, key, update_op, default) do
scoped_agent_get_and_update(cache_name, fn sub ->
if Map.has_key?(sub, key) do
current_value = sub[key]
incr = if is_tuple(update_op), do: elem(update_op, 1), else: update_op
new_value = current_value + incr
{new_value, Map.put(sub, key, new_value)}
else
default_value = if is_tuple(default), do: elem(default, 1), else: default
incr = if is_tuple(update_op), do: elem(update_op, 1), else: update_op
new_value = default_value + incr
{new_value, Map.put(sub, key, new_value)}
end
end)
end
def insert_raw(cache_name, data) when is_tuple(data) do
key = elem(data, 0)
value = if tuple_size(data) === 2, do: elem(data, 1), else: data
scoped_agent_update(cache_name, fn sub -> Map.put(sub, key, value) end)
true
end
def insert_raw(cache_name, data) when is_list(data) do
scoped_agent_update(cache_name, fn sub ->
Enum.reduce(data, sub, fn tuple, acc ->
key = elem(tuple, 0)
value = if tuple_size(tuple) === 2, do: elem(tuple, 1), else: tuple
Map.put(acc, key, value)
end)
end)
true
end
def insert_new(cache_name, data) when is_tuple(data) do
key = elem(data, 0)
value = if tuple_size(data) === 2, do: elem(data, 1), else: data
scoped_agent_get_and_update(cache_name, fn sub ->
if Map.has_key?(sub, key) do
{false, sub}
else
{true, Map.put(sub, key, value)}
end
end)
end
def insert_new(cache_name, data) when is_list(data) do
scoped_agent_get_and_update(cache_name, fn sub ->
keys_exist = Enum.any?(data, fn tuple -> Map.has_key?(sub, elem(tuple, 0)) end)
if keys_exist do
{false, sub}
else
new_sub =
Enum.reduce(data, sub, fn tuple, acc ->
key = elem(tuple, 0)
value = if tuple_size(tuple) === 2, do: elem(tuple, 1), else: tuple
Map.put(acc, key, value)
end)
{true, new_sub}
end
end)
end
def take(cache_name, key) do
scoped_agent_get_and_update(cache_name, fn sub ->
case Map.pop(sub, key) do
{nil, sub} -> {[], sub}
{value, new_sub} -> {[{key, value}], new_sub}
end
end)
end
def tab2list(cache_name) do
scoped_agent_get(cache_name, fn sub -> Map.to_list(sub) end)
end
def match_object(cache_name, pattern) do
scoped_agent_get(cache_name, fn sub ->
sub
|> Map.to_list()
|> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end)
end)
end
def match_object(cache_name, pattern, limit) do
scoped_agent_get(cache_name, fn sub ->
results =
sub
|> Map.to_list()
|> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end)
|> Enum.take(limit)
{results, :end_of_table}
end)
end
def match_pattern(cache_name, pattern) do
scoped_agent_get(cache_name, fn sub ->
sub
|> Map.to_list()
|> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end)
|> Enum.map(fn obj -> extract_bindings(obj, pattern) end)
end)
end
def match_pattern(cache_name, pattern, limit) do
scoped_agent_get(cache_name, fn sub ->
results =
sub
|> Map.to_list()
|> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end)
|> Enum.take(limit)
|> Enum.map(fn obj -> extract_bindings(obj, pattern) end)
{results, :end_of_table}
end)
end
defp match_pattern?(_object, :_), do: true
defp match_pattern?(object, pattern) when is_tuple(pattern) and is_tuple(object) do
if tuple_size(object) === tuple_size(pattern) do
object_list = Tuple.to_list(object)
pattern_list = Tuple.to_list(pattern)
object_list
|> Enum.zip(pattern_list)
|> Enum.all?(fn {obj_elem, pat_elem} -> match_element?(obj_elem, pat_elem) end)
else
false
end
end
defp match_pattern?(object, pattern), do: match_element?(object, pattern)
defp match_element?(_obj, :_), do: true
defp match_element?(obj, pattern) when is_atom(pattern) do
if binding?(pattern), do: true, else: obj === pattern
end
defp match_element?(obj, pattern) when is_tuple(obj) and is_tuple(pattern) do
match_pattern?(obj, pattern)
end
defp match_element?(obj, pattern), do: obj === pattern
defp binding?(atom) when is_atom(atom) do
atom_str = Atom.to_string(atom)
String.starts_with?(atom_str, "$")
end
defp binding?(_), do: false
defp extract_bindings(object, pattern) when is_tuple(pattern) and is_tuple(object) do
object_list = Tuple.to_list(object)
pattern_list = Tuple.to_list(pattern)
object_list
|> Enum.zip(pattern_list)
|> Enum.filter(fn {_obj_elem, pat_elem} -> binding?(pat_elem) end)
|> Enum.map(fn {obj_elem, _pat_elem} -> obj_elem end)
end
defp extract_bindings(_object, _pattern), do: []
# Match specs are compiled and run via :ets primitives so sandbox semantics
# match real ETS (body tuple handling, guard evaluation, malformed-spec errors).
# `:ets.match_spec_compile/1` raises `ArgumentError` on invalid specs in the
# caller process; `:ets.match_spec_run/2` evaluates guards and body terms with
# full ETS semantics against the per-sandbox sub-map. Continuations are not
# implemented — `select/3` returns `{results, :end_of_table}` unconditionally,
# so callers cannot resume via `select/1`.
def select(cache_name, match_spec) do
compiled = :ets.match_spec_compile(match_spec)
scoped_agent_get(cache_name, fn sub ->
sub |> Map.to_list() |> :ets.match_spec_run(compiled)
end)
end
def select(cache_name, match_spec, limit) do
compiled = :ets.match_spec_compile(match_spec)
scoped_agent_get(cache_name, fn sub ->
results =
sub
|> Map.to_list()
|> :ets.match_spec_run(compiled)
|> Enum.take(limit)
{results, :end_of_table}
end)
end
def select_count(cache_name, match_spec) do
compiled = :ets.match_spec_compile(match_spec)
scoped_agent_get(cache_name, fn sub ->
sub
|> Map.to_list()
|> :ets.match_spec_run(compiled)
|> Enum.count(&(&1 === true))
end)
end
def select_delete(cache_name, match_spec) do
compiled = :ets.match_spec_compile(match_spec)
scoped_agent_get_and_update(cache_name, fn sub ->
{to_delete, to_keep} =
sub
|> Map.to_list()
|> Enum.split_with(fn obj ->
[obj] |> :ets.match_spec_run(compiled) |> Enum.any?(&(&1 === true))
end)
{length(to_delete), Map.new(to_keep)}
end)
end
# select_replace splits the read and update across two Agent calls so the
# key-change ArgumentError raises in the caller's process. Raising inside a
# scoped_agent_get_and_update callback would crash the Agent and surface as
# an {:exit, ...} instead of ArgumentError. Non-atomic read-modify-write is
# acceptable for sandbox/test use where there's one caller per sandbox_id.
def select_replace(cache_name, match_spec) do
compiled = :ets.match_spec_compile(match_spec)
entries = scoped_agent_get(cache_name, &Map.to_list/1)
updates =
Enum.flat_map(entries, fn {key, _value} = obj ->
case :ets.match_spec_run([obj], compiled) do
[new_obj] when is_tuple(new_obj) and tuple_size(new_obj) >= 1 ->
new_key = elem(new_obj, 0)
if new_key !== key do
raise ArgumentError,
"select_replace cannot change the key of an object"
end
new_value =
if tuple_size(new_obj) === 2, do: elem(new_obj, 1), else: new_obj
[{key, new_value}]
_ ->
[]
end
end)
if updates !== [] do
scoped_agent_update(cache_name, fn sub ->
Enum.reduce(updates, sub, fn {k, v}, acc -> Map.put(acc, k, v) end)
end)
end
length(updates)
end
def match_delete(cache_name, pattern) do
scoped_agent_update(cache_name, fn sub ->
sub
|> Map.to_list()
|> Enum.reject(fn {key, value} -> match_pattern?({key, value}, pattern) end)
|> Map.new()
end)
true
end
def info(cache_name) do
scoped_agent_get(cache_name, fn sub ->
[
name: cache_name,
size: map_size(sub),
type: :set,
named_table: true,
keypos: 1,
protection: :public
]
end)
end
def info(cache_name, item) do
info = info(cache_name)
Keyword.get(info, item)
end
def slot(cache_name, i) do
scoped_agent_get(cache_name, fn sub ->
list = Map.to_list(sub)
if i >= length(list) do
:"$end_of_table"
else
[Enum.at(list, i)]
end
end)
end
def safe_fixtable(_cache_name, _fix) do
true
end
def init_table(cache_name, init_fun) do
scoped_agent_update(cache_name, fn _sub -> read_init_fun(init_fun, %{}) end)
true
end
defp read_init_fun(init_fun, acc) do
case init_fun.(:read) do
:end_of_input ->
acc
objects when is_list(objects) ->
new_acc =
Enum.reduce(objects, acc, fn tuple, inner_acc ->
key = elem(tuple, 0)
value = if tuple_size(tuple) === 2, do: elem(tuple, 1), else: tuple
Map.put(inner_acc, key, value)
end)
read_init_fun(init_fun, new_acc)
object when is_tuple(object) ->
key = elem(object, 0)
value = if tuple_size(object) === 2, do: elem(object, 1), else: object
read_init_fun(init_fun, Map.put(acc, key, value))
end
end
def to_dets(_cache_name, _dets_table) do
{:error, :not_supported_in_sandbox}
end
def from_dets(_cache_name, _dets_table) do
{:error, :not_supported_in_sandbox}
end
def to_ets(_cache_name) do
{:error, :not_supported_in_sandbox}
end
def to_ets(_cache_name, _ets_table) do
{:error, :not_supported_in_sandbox}
end
def from_ets(_cache_name, _ets_table) do
{:error, :not_supported_in_sandbox}
end
def close(_cache_name) do
:ok
end
def sync(_cache_name) do
:ok
end
def traverse(cache_name, fun) do
scoped_agent_get_and_update(cache_name, fn sub ->
{results, new_sub} =
sub
|> Map.to_list()
|> Enum.reduce({[], sub}, fn {key, value}, {acc, current_sub} ->
case fun.({key, value}) do
:continue ->
{acc, current_sub}
{:continue, result} ->
{[result | acc], current_sub}
{:done, result} ->
{[result | acc], current_sub}
:done ->
{acc, current_sub}
end
end)
{Enum.reverse(results), new_sub}
end)
end
def bchunk(_cache_name, _continuation) do
{:error, :not_supported_in_sandbox}
end
# credo:disable-for-next-line Credo.Check.Readability.PredicateFunctionNames
def is_compatible_bchunk_format(_cache_name, _bchunk_format) do
false
end
# credo:disable-for-next-line Credo.Check.Readability.PredicateFunctionNames
def is_dets_file(_filename) do
false
end
def open_file(_filename) do
{:error, :not_supported_in_sandbox}
end
def open_file(_name, _args) do
{:error, :not_supported_in_sandbox}
end
def pid2name(_pid) do
:undefined
end
def repair_continuation(continuation, _match_spec) do
continuation
end
def file2tab(_filename) do
{:error, :not_supported_in_sandbox}
end
def file2tab(_filename, _options) do
{:error, :not_supported_in_sandbox}
end
def tab2file(_cache_name, _filename) do
{:error, :not_supported_in_sandbox}
end
def tab2file(_cache_name, _filename, _options) do
{:error, :not_supported_in_sandbox}
end
def tabfile_info(_filename) do
{:error, :not_supported_in_sandbox}
end
def table(_cache_name) do
{:error, :not_supported_in_sandbox}
end
def table(_cache_name, _options) do
{:error, :not_supported_in_sandbox}
end
def give_away(_cache_name, _pid, _gift_data) do
{:error, :not_supported_in_sandbox}
end
def rename(_cache_name, _name) do
{:error, :not_supported_in_sandbox}
end
def setopts(_cache_name, _opts) do
{:error, :not_supported_in_sandbox}
end
def whereis(_cache_name) do
:undefined
end
def test_ms(tuple, match_spec) do
:ets.test_ms(tuple, match_spec)
end
def match_spec_compile(match_spec) do
:ets.match_spec_compile(match_spec)
end
def match_spec_run(list, compiled_match_spec) do
:ets.match_spec_run(list, compiled_match_spec)
end
# credo:disable-for-next-line Credo.Check.Readability.PredicateFunctionNames
def is_compiled_ms(term) do
:ets.is_compiled_ms(term)
end
def update_element(cache_name, key, element_spec) do
scoped_agent_get_and_update(cache_name, fn sub ->
case Map.get(sub, key) do
nil ->
{false, sub}
value when is_tuple(value) ->
new_value = apply_element_spec(value, element_spec)
{true, Map.put(sub, key, new_value)}
_ ->
{false, sub}
end
end)
end
def update_element(cache_name, key, element_spec, default) do
scoped_agent_get_and_update(cache_name, fn sub ->
case Map.get(sub, key) do
nil ->
{true, Map.put(sub, key, default)}
value when is_tuple(value) ->
new_value = apply_element_spec(value, element_spec)
{true, Map.put(sub, key, new_value)}
_ ->
{false, sub}
end
end)
end
defp apply_element_spec(tuple, {pos, value}) do
put_elem(tuple, pos - 1, value)
end
defp apply_element_spec(tuple, specs) when is_list(specs) do
Enum.reduce(specs, tuple, fn {pos, value}, acc ->
put_elem(acc, pos - 1, value)
end)
end
def select_reverse(cache_name, match_spec) do
result = select(cache_name, match_spec)
Enum.reverse(result)
end
def select_reverse(cache_name, match_spec, limit) do
{results, continuation} = select(cache_name, match_spec, limit)
{Enum.reverse(results), continuation}
end
def smembers(_cache_name, _key, _opts) do
raise "Not Implemented"
end
def sadd(_cache_name, _key, _value, _opts) do
raise "Not Implemented"
end
# SECTION: internal helpers
defp check_key_exists(state, key) do
if Map.has_key?(state, key) do
:ok
else
{:error, ErrorMessage.bad_request("ERR new objects must be created at the root")}
end
end
defp check_path_exists(state, key, path) do
case get_in(state, [key | String.split(path, ".")]) do
nil -> {:ok, nil}
_ -> :ok
end
end
defp add_defaults([key | keys]) do
[Access.key(key, key_default(key)) | add_defaults(keys)]
end
defp add_defaults(keys), do: keys
defp key_default(key) do
if Regex.match?(~r/\d+/, key), do: [], else: %{}
end
defp stringify_value(value) do
value
|> TermEncoder.encode_json()
|> TermEncoder.decode_json()
end
defp contains_index?(path) do
path
|> List.last()
|> is_integer()
end
defp apply_scan_count(values, nil), do: values
defp apply_scan_count(values, count) when is_integer(count), do: Enum.take(values, count)
defp scan_match?(value, pattern) do
value
|> to_string()
|> then(&Regex.match?(scan_pattern_regex(pattern), &1))
end
defp scan_type_match?(_value, nil), do: true
defp scan_type_match?(value, "hash"), do: is_map(value)
defp scan_type_match?(value, "list"), do: is_list(value)
defp scan_type_match?(_value, "string"), do: true
defp scan_type_match?(_value, _type), do: false
defp scan_pattern_regex(pattern) do
pattern
|> Regex.escape()
|> String.replace("\\*", ".*")
|> String.replace("\\?", ".")
|> then(&("^" <> &1 <> "$"))
|> Regex.compile!()
end
defp json_path_parts(path) when path in [nil, ["."]] do
[]
end
defp json_path_parts(path) when is_list(path) do
path
|> JSON.serialize_path()
|> String.split(".")
end
defp json_path_parts(path) when is_binary(path) do
String.split(path, ".")
end
defp json_path_string(path) when path in [nil, ["."]] do
""
end
defp json_path_string(path) when is_list(path) do
JSON.serialize_path(path)
end
defp json_path_string(path) when is_binary(path) do
path
end
defp serialize_path_and_get_value(cache_name, key, path) do
path = JSON.serialize_path(path)
scoped_agent_get(cache_name, fn sub ->
case get_in(sub, [key | String.split(path, ".")]) do
nil -> {:error, ErrorMessage.not_found("ERR Path '$.#{path}' does not exist")}
value -> {:ok, value}
end
end)
end
defp enum_length(m) when is_map(m), do: m |> Map.to_list() |> enum_length()
defp enum_length(l), do: length(l)
end