From 69e7584252d89459ea7412538362872dab621ce3 Mon Sep 17 00:00:00 2001 From: Remy Marronnier Date: Mon, 21 Jul 2025 18:47:06 +0200 Subject: [PATCH] Code and specs form lucky cache PR + github CI --- .github/workflows/ci.yml | 55 ++++ .github/workflows/docs.yml | 23 ++ README.md | 81 +++++- shard.yml | 10 +- .../redis_store_spec.cr | 256 ++++++++++++++++++ spec/lucky_cache_redis_store_spec.cr | 9 - spec/spec_helper.cr | 2 + src/lucky_cache_redis_store.cr | 5 +- src/lucky_cache_redis_store/redis_store.cr | 222 +++++++++++++++ 9 files changed, 643 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 spec/lucky_cache_redis_store/redis_store_spec.cr delete mode 100644 spec/lucky_cache_redis_store_spec.cr create mode 100644 src/lucky_cache_redis_store/redis_store.cr diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..741749c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: LuckyCache Redis Store CI + +on: + push: + branches: [main] + pull_request: + branches: "*" + +jobs: + check_format: + strategy: + fail-fast: false + runs-on: ubuntu-latest + continue-on-error: false + steps: + - name: Download source + uses: actions/checkout@v3 + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + - name: Install shards + run: shards install + - name: Format + run: crystal tool format --check + - name: Lint + run: ./bin/ameba + specs: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + crystal_version: [latest] + include: + - os: ubuntu-latest + crystal_version: 1.4.0 + runs-on: ${{ matrix.os }} + continue-on-error: false + services: + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v3 + - uses: crystal-lang/install-crystal@v1 + with: + crystal: ${{ matrix.crystal_version }} + - name: Install dependencies + run: shards install --skip-postinstall --skip-executables + - name: Run tests + run: crystal spec \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..228a17c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,23 @@ +name: Deploy docs + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + persist-credentials: false + - uses: crystal-lang/install-crystal@v1 + - name: "Install shards" + run: shards install + - name: "Generate docs" + run: crystal docs + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs \ No newline at end of file diff --git a/README.md b/README.md index d3341b0..4888748 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -# LuckyCacheRedisStore +# LuckyCache Redis Store -An adapter for [LuckyCache](https://github.com/luckyframework/lucky_cache/) to store -data in Redis. +A Redis storage backend for [LuckyCache](https://github.com/luckyframework/lucky_cache/), providing distributed caching capabilities for Lucky Framework applications. ## Installation @@ -20,14 +19,84 @@ data in Redis. ## Usage ```crystal +require "lucky_cache" require "lucky_cache_redis_store" +require "redis" + +LuckyCache.configure do |settings| + settings.storage = LuckyCache::RedisStore.new( + Redis::Client.new(host: "localhost", port: 6379), + prefix: "myapp:cache:" + ) + settings.default_duration = 5.minutes +end ``` -TODO: Write usage instructions here +### Basic Usage + +```crystal +cache = LuckyCache.settings.storage + +# Write to cache +cache.write("my_key", expires_in: 1.hour) { "my value" } + +# Read from cache +if item = cache.read("my_key") + puts item.value # => "my value" +end + +# Fetch (read-through cache) +value = cache.fetch("computed_key", as: String, expires_in: 10.minutes) do + # This block is only executed if the key doesn't exist + expensive_computation +end + +# Delete from cache +cache.delete("my_key") + +# Clear all cached items with the configured prefix +cache.flush +``` + +### Supported Types + +The Redis store supports the following types: +- Basic types: `String`, `Int32`, `Int64`, `Float64`, `Bool`, `Time`, `UUID`, `JSON::Any` +- Arrays of basic types: `Array(String)`, `Array(Int32)`, `Array(Int64)`, `Array(Float64)`, `Array(Bool)` + +**Note:** Custom objects that include `LuckyCache::Cachable` are not supported by RedisStore due to serialization limitations. Use MemoryStore for caching custom objects. + +### Workaround for Custom Objects + +You can cache JSON representations of your objects: + +```crystal +# Instead of caching the object directly +# cache.write("user:123") { User.new("test@example.com") } # This will raise an error + +# Cache a JSON representation +user_data = {"id" => 123, "email" => "test@example.com"} +cache.write("user:123") { JSON::Any.new(user_data) } + +# Retrieve and reconstruct +cached_data = cache.read("user:123").not_nil!.value.as(JSON::Any) +user = User.new(cached_data["email"].as_s) +``` ## Development -TODO: Write development instructions here +To run the tests: + +1. Make sure Redis is running locally on the default port (6379) +2. Run `crystal spec` + +The test suite includes tests for: +- Basic type caching +- Array type caching +- Expiration functionality +- Key deletion and cache flushing +- Custom prefix support +- Error handling for non-serializable types ## Contributing @@ -39,4 +108,4 @@ TODO: Write development instructions here ## Contributors -- [your-name-here](https://github.com/your-github-user) - creator and maintainer +- [Jeremy Woertink](https://github.com/jwoertink) - creator and maintainer diff --git a/shard.yml b/shard.yml index d3c5d1a..b5d0486 100644 --- a/shard.yml +++ b/shard.yml @@ -8,9 +8,15 @@ crystal: '>= 1.14.1' license: MIT +dependencies: + redis: + github: jgaskins/redis + lucky_cache: + github: luckyframework/lucky_cache + development_dependencies: ameba: github: crystal-ameba/ameba version: ~> 1.5.0 - lucky_cache: - github: luckyframework/lucky_cache + timecop: + github: crystal-community/timecop.cr diff --git a/spec/lucky_cache_redis_store/redis_store_spec.cr b/spec/lucky_cache_redis_store/redis_store_spec.cr new file mode 100644 index 0000000..1f576c3 --- /dev/null +++ b/spec/lucky_cache_redis_store/redis_store_spec.cr @@ -0,0 +1,256 @@ +require "../spec_helper" +require "redis" + +describe LuckyCache::RedisStore do + describe "#fetch" do + it "raises error for custom cachable objects" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + expect_raises(ArgumentError, "RedisStore cannot serialize custom Cachable objects") do + cache.write("user") { User.new("test@example.com") } + end + + cache.flush + end + + it "caches basic types" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + str = cache.fetch("string:key", as: String) { "test" } + int = cache.fetch("int:key", as: Int64) { 0_i64 } + bul = cache.fetch("bool:key", as: Bool) { false } + tym = cache.fetch("time:key", as: Time) { Time.local(1999, 10, 31, 18, 30) } + + str.should eq("test") + int.should eq(0_i64) + bul.should eq(false) + tym.should eq(Time.local(1999, 10, 31, 18, 30)) + + cache.flush + end + + it "caches arrays of basic types" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + str_array = cache.fetch("strings", as: Array(String)) { ["hello", "world"] } + int_array = cache.fetch("ints", as: Array(Int32)) { [1, 2, 3] } + bool_array = cache.fetch("bools", as: Array(Bool)) { [true, false, true] } + + str_array.should eq(["hello", "world"]) + int_array.should eq([1, 2, 3]) + bool_array.should eq([true, false, true]) + + cache.flush + end + + it "expires at the specified time" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + Timecop.freeze(Time.local(2042, 3, 17, 21, 49)) do + cache.fetch("coupon", expires_in: 2.seconds, as: UUID) do + UUID.random + end + cache.read("coupon").not_nil!.expired?.should eq(false) + + sleep 3.seconds + cache.read("coupon").should eq(nil) + end + + cache.flush + end + end + + describe "#read" do + it "returns nil when no key is found" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + cache.read("key").should eq(nil) + + cache.flush + end + + it "returns nil when the item is expired" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + cache.write("key", expires_in: 1.second) { "some data" } + sleep 2.seconds + cache.read("key").should eq(nil) + + cache.flush + end + end + + describe "#delete" do + it "returns nil when no item exists" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + cache.delete("key").should eq(nil) + + cache.flush + end + + it "deletes the value from cache" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + cache.write("key") { 123 } + cache.read("key").should_not be_nil + cache.delete("key") + cache.read("key").should be_nil + + cache.flush + end + end + + describe "#flush" do + it "resets all of the cache" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + cache.write("numbers") { 123 } + cache.write("letters") { "abc" } + cache.write("false") { true } + cache.read("numbers").should_not be_nil + cache.read("letters").should_not be_nil + cache.read("false").should_not be_nil + + cache.flush + + cache.read("numbers").should be_nil + cache.read("letters").should be_nil + cache.read("false").should be_nil + end + end + + describe "#size" do + it "returns the total number of items in the cache" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + cache.size.should eq(0) + + cache.write("numbers") { 123 } + cache.write("letters") { "abc" } + cache.size.should eq(2) + + cache.flush + cache.size.should eq(0) + end + end + + describe "with custom prefix" do + it "uses the custom prefix for keys" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client, prefix: "myapp:") + cache.flush + + cache.write("test") { "value" } + + redis_client.keys("myapp:*").size.should eq(1) + redis_client.get("myapp:test").should_not be_nil + + cache.flush + end + end + + describe "#write" do + it "supports JSON::Any values" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + json = JSON.parse(%({"name": "test", "count": 42})) + cache.write("json_data") { json } + + result = cache.read("json_data") + result.should_not be_nil + result.not_nil!.value.as(JSON::Any)["name"].as_s.should eq("test") + result.not_nil!.value.as(JSON::Any)["count"].as_i.should eq(42) + + cache.flush + end + + it "stores UUID values" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + uuid = UUID.random + cache.write("uuid_key") { uuid } + + result = cache.read("uuid_key") + result.should_not be_nil + result.not_nil!.value.as(UUID).should eq(uuid) + + cache.flush + end + + it "stores arrays of basic types" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + str_array = ["hello", "world"] + int_array = [1, 2, 3] + bool_array = [true, false, true] + + cache.write("strings") { str_array } + cache.write("ints") { int_array } + cache.write("bools") { bool_array } + + cache.read("strings").not_nil!.value.as(Array(String)).should eq(str_array) + cache.read("ints").not_nil!.value.as(Array(Int32)).should eq(int_array) + cache.read("bools").not_nil!.value.as(Array(Bool)).should eq(bool_array) + + cache.flush + end + end + + describe "workaround for custom objects" do + it "can cache JSON representations of custom objects" do + redis_client = Redis::Client.new + cache = LuckyCache::RedisStore.new(redis_client) + cache.flush + + # Instead of caching the User object directly, cache its JSON representation + user_data = Hash(String, JSON::Any).new + user_data["email"] = JSON::Any.new("fred@email.net") + cache.write("user:fred") { JSON::Any.new(user_data) } + + # Retrieve and reconstruct + cached_data = cache.read("user:fred").not_nil!.value.as(JSON::Any) + cached_data["email"].as_s.should eq("fred@email.net") + + # You can reconstruct the User object from the JSON data + # user = User.new(cached_data["email"].as_s) + + cache.flush + end + end +end + +# Define User class only for error testing +class User + include LuckyCache::Cachable + property email : String + + def initialize(@email : String) + end +end diff --git a/spec/lucky_cache_redis_store_spec.cr b/spec/lucky_cache_redis_store_spec.cr deleted file mode 100644 index 3060378..0000000 --- a/spec/lucky_cache_redis_store_spec.cr +++ /dev/null @@ -1,9 +0,0 @@ -require "./spec_helper" - -describe LuckyCacheRedisStore do - # TODO: Write tests - - it "works" do - false.should eq(true) - end -end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index e4faca7..7713c2e 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,4 @@ require "spec" +require "timecop" +require "lucky_cache" require "../src/lucky_cache_redis_store" diff --git a/src/lucky_cache_redis_store.cr b/src/lucky_cache_redis_store.cr index ed23845..c22da72 100644 --- a/src/lucky_cache_redis_store.cr +++ b/src/lucky_cache_redis_store.cr @@ -1,6 +1,5 @@ -# TODO: Write documentation for `LuckyCacheRedisStore` +require "./lucky_cache_redis_store/**" + module LuckyCacheRedisStore VERSION = "0.1.0" - - # TODO: Put your code here end diff --git a/src/lucky_cache_redis_store/redis_store.cr b/src/lucky_cache_redis_store/redis_store.cr new file mode 100644 index 0000000..1b8165e --- /dev/null +++ b/src/lucky_cache_redis_store/redis_store.cr @@ -0,0 +1,222 @@ +require "redis" +require "json" + +module LuckyCache + struct RedisStore < BaseStore + private getter redis : Redis::Client + private getter prefix : String + + def initialize(@redis : Redis::Client = Redis::Client.new, @prefix : String = "lucky_cache:") + end + + def read(key : CacheKey) : CacheItem? + prefixed_key = "#{prefix}#{key}" + + if data = redis.get(prefixed_key) + if cache_item = deserialize_cache_item(data) + cache_item.expired? ? nil : cache_item + end + end + end + + def write(key : CacheKey, *, expires_in : Time::Span = LuckyCache.settings.default_duration, &) + data = yield + + # For Redis storage, we need to check if the data is serializable + # Custom Cachable objects cannot be serialized to JSON without custom serialization logic + unless serializable?(data) + raise ArgumentError.new("RedisStore cannot serialize custom Cachable objects. Use MemoryStore for custom objects or store serializable representations (Hash, NamedTuple, JSON::Any).") + end + + cache_item = CacheItem.new( + value: data, + expires_in: expires_in + ) + + prefixed_key = "#{prefix}#{key}" + serialized = serialize_cache_item(cache_item) + + redis.set(prefixed_key, serialized, ex: expires_in.total_seconds.to_i) + + data + end + + def delete(key : CacheKey) + prefixed_key = "#{prefix}#{key}" + result = redis.del(prefixed_key) + result > 0 ? result : nil + end + + def flush : Nil + keys = redis.keys("#{prefix}*").map(&.to_s) + redis.del(keys) unless keys.empty? + end + + def fetch(key : CacheKey, *, as : Array(T).class, expires_in : Time::Span = LuckyCache.settings.default_duration, &) forall T + if cache_item = read(key) + case value = cache_item.value + when Array + value.map { |v| v.as(T) } + else + raise TypeCastError.new("Expected Array but got #{value.class}") + end + else + write(key, expires_in: expires_in) { yield } + end + end + + def fetch(key : CacheKey, *, as : T.class, expires_in : Time::Span = LuckyCache.settings.default_duration, &) forall T + if cache_item = read(key) + cache_item.value.as(T) + else + write(key, expires_in: expires_in) { yield } + end + end + + def size : Int32 + redis.keys("#{prefix}*").size + end + + private def serializable?(value) : Bool + case value + when String, Int32, Int64, Float64, Bool, Time, UUID, JSON::Any + true + when Array + value.all? { |v| serializable?(v) } + else + false + end + end + + private def serialize_cache_item(item : CacheItem) : String + value_json = serialize_value(item.value) + created_at = Time.utc + + { + "value" => value_json, + "expires_in" => item.expires_in.total_seconds, + "created_at" => created_at.to_rfc3339, + "type" => determine_type(item.value), + }.to_json + end + + private def deserialize_cache_item(data : String) : CacheItem? + parsed = JSON.parse(data) + + type_name = parsed["type"].as_s + value_json = parsed["value"] + expires_in = Time::Span.new(seconds: parsed["expires_in"].as_f.to_i) + created_at = Time.parse_rfc3339(parsed["created_at"].as_s) + + # Check if expired based on created_at + expires_in + if created_at + expires_in < Time.utc + return nil + end + + value = deserialize_value(value_json, type_name) + + CacheItem.new(value: value, expires_in: expires_in) + rescue + nil + end + + private def serialize_value(value : CachableTypes) : JSON::Any + case value + when String + JSON::Any.new(value) + when Int32 + JSON::Any.new(value.to_i64) + when Int64 + JSON::Any.new(value) + when Float64 + JSON::Any.new(value) + when Bool + JSON::Any.new(value) + when Time + JSON::Any.new(value.to_rfc3339) + when UUID + JSON::Any.new(value.to_s) + when JSON::Any + value + when Array(String) + JSON::Any.new(value.map { |v| JSON::Any.new(v) }) + when Array(Int32) + JSON::Any.new(value.map { |v| JSON::Any.new(v.to_i64) }) + when Array(Int64) + JSON::Any.new(value.map { |v| JSON::Any.new(v) }) + when Array(Float64) + JSON::Any.new(value.map { |v| JSON::Any.new(v) }) + when Array(Bool) + JSON::Any.new(value.map { |v| JSON::Any.new(v) }) + else + raise ArgumentError.new("Cannot serialize value of type #{value.class}") + end + end + + private def deserialize_value(json : JSON::Any, type_name : String) : CachableTypes + case type_name + when "String" + json.as_s + when "Int32" + json.as_i + when "Int64" + json.as_i64 + when "Float64" + json.as_f + when "Bool" + json.as_bool + when "Time" + Time.parse_rfc3339(json.as_s) + when "UUID" + UUID.new(json.as_s) + when "JSON::Any" + json + when "Array(String)" + json.as_a.map(&.as_s) + when "Array(Int32)" + json.as_a.map(&.as_i) + when "Array(Int64)" + json.as_a.map(&.as_i64) + when "Array(Float64)" + json.as_a.map(&.as_f) + when "Array(Bool)" + json.as_a.map(&.as_bool) + else + raise ArgumentError.new("Cannot deserialize type #{type_name}") + end + end + + private def determine_type(value : CachableTypes) : String + case value + when String + "String" + when Int32 + "Int32" + when Int64 + "Int64" + when Float64 + "Float64" + when Bool + "Bool" + when Time + "Time" + when UUID + "UUID" + when JSON::Any + "JSON::Any" + when Array(String) + "Array(String)" + when Array(Int32) + "Array(Int32)" + when Array(Int64) + "Array(Int64)" + when Array(Float64) + "Array(Float64)" + when Array(Bool) + "Array(Bool)" + else + "Unknown" + end + end + end +end