diff --git a/CHANGELOG.md b/CHANGELOG.md index c24f65d..244afe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ## Bug Fixes +- fix: `Cache.Sandbox` no longer raises `"Not Implemented"` from the Redis-only surface. `smembers/3` and `sadd/4` are real in-memory sets with Redis semantics (`SADD` returns the new-member count, `SMEMBERS` of a missing key is `[]`). `command/3` and `pipeline/3` handle `PING`, `GET`, `EXISTS` and `DEL` against the sandbox map and return `{:error, %ErrorMessage{code: :not_implemented}}` for anything else — the error shape the Redis adapter itself produces — with `command!/3`/`pipeline!/3` raising only on that branch. Under `sandbox?: Mix.env() === :test` the `use Cache` wrappers delegate to the sandbox, so dialyzer (running in test) had been correctly reporting every Redis cache module's injected `command/1`, `pipeline/1`, `sadd/2`, `smembers/2` (and their default-arg arities) as having no local return, forcing a `:no_return` ignore entry per Redis cache module in every consuming app. Those ignores can now be deleted. - fix: `:compression_level` is reachable. It was unusable on every path — no adapter declares it, so `NimbleOptions` rejected it on compile-time adapter opts, and it resolved to `nil` before it could reach the encoder otherwise. It is now an option on the `use Cache` line (`compression_level: 6`), it is taken off the adapter opts before they are validated so the `opts: [compression_level: 6]` spelling works too, and it is never handed to the adapter. Setting it forces encoding on adapters that hold terms natively — asking for compression is asking for bytes. A cache using a strategy adapter raises at compile time rather than ignoring the option. - fix: `Cache.ConCache.get_or_store/3` followed by `get/1` no longer raises. `get_or_store/3` writes through ConCache directly, bypassing the encode in `put/3`, so the matching `get/1` tried to `binary_to_term/1` a raw term. - fix: a binary value wrapped in braces but not valid JSON (eg `"{oops}"`) no longer raises `Jason.DecodeError` on read. `Cache.TermEncoder.decode/1` used `Jason.decode!/1`, and now falls back to returning the binary unchanged. diff --git a/lib/cache/sandbox.ex b/lib/cache/sandbox.ex index c72404e..f022efe 100644 --- a/lib/cache/sandbox.ex +++ b/lib/cache/sandbox.ex @@ -397,22 +397,66 @@ defmodule Cache.Sandbox do # SECTION: Redis compatibility - def pipeline(_cache_name, _commands, _opts) do - raise "Not Implemented" + # Raw command passthrough. The sandbox is a term map, not a Redis server, so + # only the commands whose semantics map directly onto that map are supported; + # anything else is a `:not_implemented` error rather than a raise, so the + # `use Cache` wrappers keep a real return type and callers see the same + # `{:error, %ErrorMessage{}}` shape the Redis adapter produces on failure. + def pipeline(cache_name, commands, opts) do + with {:ok, replies} <- run_commands(cache_name, commands, opts) do + {:ok, Enum.reverse(replies)} + end + end + + def pipeline!(cache_name, commands, opts) do + case pipeline(cache_name, commands, opts) do + {:ok, replies} -> replies + {:error, %ErrorMessage{message: message}} -> raise message + end + end + + def command(_cache_name, ["PING"], _opts), do: {:ok, "PONG"} + + def command(cache_name, ["GET", key], _opts) do + scoped_agent_get(cache_name, fn sub -> {:ok, Map.get(sub, key)} end) end - def pipeline!(_cache_name, _commands, _opts) do - raise "Not Implemented" + def command(cache_name, ["EXISTS", key], _opts) do + scoped_agent_get(cache_name, fn sub -> {:ok, boolean_to_integer(Map.has_key?(sub, key))} end) end - def command(_cache_name, _command, _opts) do - raise "Not Implemented" + def command(cache_name, ["DEL", key], _opts) do + scoped_agent_get_and_update(cache_name, fn sub -> + {{:ok, boolean_to_integer(Map.has_key?(sub, key))}, Map.delete(sub, key)} + end) end - def command!(_cache_name, _command, _opts) do - raise "Not Implemented" + def command(_cache_name, [command | _args], _opts) do + {:error, + ErrorMessage.not_implemented("#{command} is not implemented by the cache sandbox", %{ + command: command + })} end + def command!(cache_name, command, opts) do + case command(cache_name, command, opts) do + {:ok, reply} -> reply + {:error, %ErrorMessage{message: message}} -> raise message + end + end + + defp run_commands(cache_name, commands, opts) do + Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, replies} -> + case command(cache_name, command, opts) do + {:ok, reply} -> {:cont, {:ok, [reply | replies]}} + {:error, _} = error -> {:halt, error} + end + end) + end + + defp boolean_to_integer(true), do: 1 + defp boolean_to_integer(false), do: 0 + def scan(cache_name, scan_opts, _opts) do match = scan_opts[:match] || "*" count = scan_opts[:count] @@ -1171,12 +1215,22 @@ defmodule Cache.Sandbox do {Enum.reverse(results), continuation} end - def smembers(_cache_name, _key, _opts) do - raise "Not Implemented" + # SECTION: set API + + # Sets are stored as MapSets under the key. Like Redis, SMEMBERS of a + # missing key is the empty set and SADD returns how many members were new. + def smembers(cache_name, key, _opts) do + scoped_agent_get(cache_name, fn sub -> + {:ok, sub |> Map.get(key, MapSet.new()) |> MapSet.to_list()} + end) end - def sadd(_cache_name, _key, _value, _opts) do - raise "Not Implemented" + def sadd(cache_name, key, value, _opts) do + scoped_agent_get_and_update(cache_name, fn sub -> + set = Map.get(sub, key, MapSet.new()) + added = boolean_to_integer(not MapSet.member?(set, value)) + {{:ok, added}, Map.put(sub, key, MapSet.put(set, value))} + end) end # SECTION: internal helpers diff --git a/test/cache/redis_sandbox_test.exs b/test/cache/redis_sandbox_test.exs new file mode 100644 index 0000000..6b3ade5 --- /dev/null +++ b/test/cache/redis_sandbox_test.exs @@ -0,0 +1,104 @@ +defmodule Cache.RedisSandboxTest do + @moduledoc """ + Sandbox coverage for the Redis-only surface `use Cache, adapter: Cache.Redis` + injects (set ops, raw command/pipeline). These run against `Cache.Sandbox`, + so no Redis is needed. + """ + use ExUnit.Case, async: true + + defmodule RedisSandboxCache do + use Cache, + adapter: Cache.Redis, + name: :test_cache_redis_sandbox, + opts: [uri: "redis://localhost:6379"], + sandbox?: true + end + + setup do + Cache.SandboxRegistry.start(RedisSandboxCache) + + :ok + end + + describe "&sadd/3 and &smembers/2" do + test "smembers of a missing key is an empty list" do + assert {:ok, []} === RedisSandboxCache.smembers("missing", []) + end + + test "sadd returns the number of newly added members and smembers returns the set" do + assert {:ok, 1} === RedisSandboxCache.sadd("symbols", "AAPL") + assert {:ok, 1} === RedisSandboxCache.sadd("symbols", "TSLA") + assert {:ok, 0} === RedisSandboxCache.sadd("symbols", "AAPL") + + assert {:ok, members} = RedisSandboxCache.smembers("symbols", []) + assert ["AAPL", "TSLA"] === Enum.sort(members) + end + + test "members round-trip as terms, not binaries" do + assert {:ok, 1} === RedisSandboxCache.sadd("terms", %{symbol: "AAPL", strike: 150.0}) + assert {:ok, [%{symbol: "AAPL", strike: 150.0}]} === RedisSandboxCache.smembers("terms", []) + end + + test "sets are isolated per sandbox" do + assert {:ok, []} === RedisSandboxCache.smembers("symbols", []) + end + end + + describe "&command/2" do + test "PING" do + assert {:ok, "PONG"} === RedisSandboxCache.command(["PING"]) + end + + test "GET / EXISTS / DEL against keys written through the cache API" do + assert :ok === RedisSandboxCache.put("key", "value") + + # Raw GET returns the stored binary undecoded, exactly like Redis would — + # the Redis-backed sandbox term-encodes on put to round-trip faithfully. + assert {:ok, encoded} = RedisSandboxCache.command(["GET", "key"]) + assert "value" === Cache.TermEncoder.decode(encoded) + assert {:ok, 1} === RedisSandboxCache.command(["EXISTS", "key"]) + assert {:ok, 1} === RedisSandboxCache.command(["DEL", "key"]) + assert {:ok, nil} === RedisSandboxCache.command(["GET", "key"]) + assert {:ok, 0} === RedisSandboxCache.command(["EXISTS", "key"]) + assert {:ok, 0} === RedisSandboxCache.command(["DEL", "key"]) + end + + test "unsupported commands return a not-implemented error instead of raising" do + assert {:error, %ErrorMessage{code: :not_implemented, details: %{command: "ZADD"}}} = + RedisSandboxCache.command(["ZADD", "key", "1", "member"]) + end + end + + describe "&pipeline/2" do + test "runs each command in order and returns the replies as a list" do + assert :ok === RedisSandboxCache.put("key", "value") + + assert {:ok, ["PONG", encoded, 1]} = + RedisSandboxCache.pipeline([["PING"], ["GET", "key"], ["DEL", "key"]]) + + assert "value" === Cache.TermEncoder.decode(encoded) + end + + test "an unsupported command fails the whole pipeline" do + assert {:error, %ErrorMessage{code: :not_implemented}} = + RedisSandboxCache.pipeline([["PING"], ["ZADD", "key", "1", "member"]]) + end + end + + describe "&command!/2 and &pipeline!/2" do + test "return the raw reply on success" do + assert "PONG" === RedisSandboxCache.command!(["PING"]) + assert ["PONG", "PONG"] === RedisSandboxCache.pipeline!([["PING"], ["PING"]]) + end + + test "raise on an unsupported command" do + assert_raise RuntimeError, ~r/not implemented/, fn -> + RedisSandboxCache.command!(["ZADD", "key", "1", "member"]) + end + + assert_raise RuntimeError, ~r/not implemented/, fn -> + RedisSandboxCache.pipeline!([["ZADD", "key", "1", "member"]]) + end + end + end +end