├── .ameba.yml ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── crystal.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── data_compressor_spec.cr ├── file_store_spec.cr ├── log_spec.cr ├── memory_store_spec.cr ├── null_store_spec.cr └── spec_helper.cr └── src ├── cache.cr └── cache ├── data_compressor.cr ├── entry.cr ├── store.cr ├── stores ├── file_store.cr ├── memory_store.cr └── null_store.cr └── version.cr /.ameba.yml: -------------------------------------------------------------------------------- 1 | LargeNumbers: 2 | Enabled: false 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | 9 | # Maintain dependencies for GitHub Actions 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /.github/workflows/crystal.yml: -------------------------------------------------------------------------------- 1 | name: Crystal CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | check_format: 11 | runs-on: ubuntu-latest 12 | container: crystallang/crystal 13 | steps: 14 | - name: Check out repository code 15 | uses: actions/checkout@v4 16 | 17 | - name: Install dependencies 18 | run: shards install 19 | 20 | - name: Check format 21 | run: crystal tool format --check 22 | check_ameba: 23 | runs-on: ubuntu-latest 24 | container: crystallang/crystal 25 | steps: 26 | - name: Check out repository code 27 | uses: actions/checkout@v4 28 | 29 | - name: Install dependencies 30 | run: shards install 31 | 32 | - name: Check ameba 33 | run: ./bin/ameba 34 | test: 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | crystal: ["crystallang/crystal", "crystallang/crystal:nightly"] 39 | container: ${{ matrix.crystal }} 40 | steps: 41 | # Downloads a copy of the code in your repository before running CI tests 42 | - name: Check out repository code 43 | uses: actions/checkout@v4 44 | 45 | # Performs a clean installation of all dependencies in the `shard.yml` file 46 | - name: Install dependencies 47 | run: shards install 48 | 49 | - name: Run tests 50 | run: crystal spec 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | 6 | # Libraries don't need dependency lock 7 | # Dependencies will be locked in application that uses them 8 | /shard.lock 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [...] 4 | 5 | ## 0.14.0 6 | 7 | * **breaking change** underlying cache implementations must implement `delete_impl` and `exists_impl` methods instead of `delete` and `exists?` accordingly 8 | * **breaking change** Added `namespace` property to `Cache::Store`. It can be used by underlying cache implementations for keys with namespace 9 | * Logging `delete` method 10 | 11 | ## 0.13.0 12 | 13 | * Store keys as the actual key datatype, not String. by @rymiel in https://github.com/crystal-cache/cache/pull/29 14 | 15 | ## 0.12.1 16 | 17 | * Internal changes 18 | 19 | ## 0.12.0 20 | 21 | * **breaking change** Split Redis and Memcached stores out into their own shards 22 | 23 | ## 0.11.1 24 | 25 | * Internal changes 26 | 27 | ## 0.11.0 28 | 29 | * Add logging 30 | 31 | ## 0.10.0 32 | 33 | * Allow to set `false` as a value 34 | * Fix `MemoryStore` and `FileStore` with generic types values 35 | * Add `Store#exists?` to check if the cache contains an entry for the given key. 36 | * Add `Store#increment` and `Store#decrement` for integer values in the cache. 37 | 38 | ## 0.9.0 39 | 40 | * Ignore `compress` options for other then `MemoryStore(String, String)` 41 | * Fix `MemoryStore(K, V)` can store any serializable Crystal object. 42 | 43 | ## 0.8.0 44 | 45 | * Crystal 0.35.0 support 46 | 47 | ## 0.7.0 48 | 49 | * Crystal 0.34.0 support 50 | 51 | ## 0.6.0 52 | 53 | * Crystal 0.32.0 support 54 | 55 | ## 0.5.0 56 | 57 | * Allow `Redis::PooledClient` in `RedisStore` 58 | * Compress data with Zlib in `MemoryStore` 59 | 60 | ## 0.4.0 61 | 62 | * Crystal 0.30 support 63 | * Use latest Redis and Memcached shards 64 | 65 | ## 0.3.0 66 | 67 | * Crystal 0.27 support 68 | 69 | ## 0.2.1 70 | 71 | * Use crystal-redis 2.1.0 72 | 73 | ## 0.2.0 74 | 75 | * add `.clear` method which deletes all entries from the cache. 76 | 77 | ## 0.1.0 78 | 79 | * Crystal 0.26 support 80 | * Add `FileStore` which stores everything on the filesystem 81 | * Add method to delete an entry in the cache 82 | * Bug fixes and other improvements 83 | 84 | ## 0.0.7 85 | 86 | * Memcached support 87 | 88 | ## 0.0.6 89 | 90 | * Crystal 0.25 support 91 | 92 | ## 0.0.5 93 | 94 | * Add NullStore - store implementation which doesn't actually store anything 95 | 96 | ## 0.0.4 97 | 98 | * Redis support improvements 99 | 100 | ## 0.0.3 101 | 102 | * Redis support 103 | 104 | ## 0.0.2 105 | 106 | * Add expires in 107 | 108 | ## 0.0.1 109 | 110 | * Initial release 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2022 Anton Maminov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Caché 2 | 3 | A key/value store where pairs can expire after a specified interval 4 | 5 | ![Crystal CI](https://github.com/crystal-cache/cache/workflows/Crystal%20CI/badge.svg) 6 | [![GitHub release](https://img.shields.io/github/release/crystal-cache/cache.svg)](https://github.com/crystal-cache/cache/releases) 7 | [![License](https://img.shields.io/github/license/crystal-cache/cache.svg)](https://github.com/crystal-cache/cache/blob/main/LICENSE) 8 | 9 | ## Installation 10 | 11 | Add this to your application's `shard.yml`: 12 | 13 | ```yaml 14 | dependencies: 15 | cache: 16 | github: crystal-cache/cache 17 | ``` 18 | 19 | ## Example 20 | 21 | Caching means to store content generated during the request-response cycle 22 | and to reuse it when responding to similar requests. 23 | 24 | The first time the result is returned from the query it is stored in the query cache (in memory) 25 | and the second time it's pulled from memory. 26 | 27 | Memory cache can store any serializable Crystal objects. 28 | 29 | Next example show how to get a single Github user and cache the result in memory. 30 | 31 | ```crystal 32 | require "http/client" 33 | require "json" 34 | require "cache" 35 | 36 | cache = Cache::MemoryStore(String, String).new(expires_in: 30.minutes) 37 | github_client = HTTP::Client.new(URI.parse("https://api.github.com")) 38 | 39 | # Define how an object is mapped to JSON. 40 | class User 41 | include JSON::Serializable 42 | 43 | property login : String 44 | property id : Int32 45 | end 46 | 47 | username = "crystal-lang" 48 | 49 | # First request. 50 | # Getting data from Github and write it to cache. 51 | user_json = cache.fetch("user_#{username}") do 52 | response = github_client.get("/users/#{username}") 53 | User.from_json(response.body).to_json 54 | end 55 | 56 | user = User.from_json(user_json) 57 | user.id # => 6539796 58 | 59 | # Second request. 60 | # Getting data from cache. 61 | user_json = cache.fetch("user_#{username}") do 62 | response = github_client.get("/users/#{username}") 63 | User.from_json(response.body).to_json 64 | end 65 | 66 | user = User.from_json(user_json) 67 | user.id # => 6539796 68 | ``` 69 | 70 | ## Usage 71 | 72 | ### Available stores 73 | 74 | * [x] Null store 75 | * [x] Memory 76 | * [x] Filesystem 77 | 78 | There are multiple cache store implementations, 79 | each having its own additional features. See the classes 80 | under the `/src/cache/stores` directory, e.g. 81 | 82 | ### Third-party store implementations 83 | 84 | * [redis_cache_store](https://github.com/crystal-cache/redis_cache_store) 85 | * [redis_legacy_cache_store](https://github.com/crystal-cache/redis_legacy_cache_store) 86 | * [mem_cache_store](https://github.com/crystal-cache/mem_cache_store) 87 | * [postgres_cache_store](https://github.com/crystal-cache/postgres_cache_store) 88 | * [mysql_cache_store](https://github.com/crystal-cache/mysql_cache_store) 89 | 90 | ### Commands 91 | 92 | All store's implementations should support: 93 | 94 | * `fetch` 95 | * `write` 96 | * `read` 97 | * `delete` 98 | * `clear` 99 | 100 | #### fetch 101 | 102 | Fetches data from the cache, using the given `key`. If there is data in the cache 103 | with the given `key`, then that data is returned. 104 | 105 | If there is no such data in the cache, then a `block` will be passed the `key` 106 | and executed in the event of a cache miss. 107 | 108 | Setting `:expires_in` will set an expiration time on the cache. 109 | All caches support auto-expiring content after a specified number of seconds. 110 | This value can be specified as an option to the constructor (in which case all entries will be affected), 111 | or it can be supplied to the `fetch` or `write` method to effect just one entry. 112 | 113 | #### write 114 | 115 | Writes the `value` to the cache, with the `key`. 116 | 117 | Optional `expires_in` will set an expiration time on the `key`. 118 | 119 | > Options are passed to the underlying cache implementation. 120 | 121 | ```crystal 122 | store = Cache::MemoryStore(String, String).new(12.hours) 123 | 124 | store.write("foo", "bar") 125 | ``` 126 | 127 | #### read 128 | 129 | Reads data from the cache, using the given `key`. 130 | 131 | If there is data in the cache with the given `key`, then that data is returned. 132 | Otherwise, `nil` is returned. 133 | 134 | ```crystal 135 | store = Cache::MemoryStore(String, String).new(12.hours) 136 | store.write("foo", "bar") 137 | 138 | store.read("foo") # => "bar" 139 | ``` 140 | 141 | #### delete 142 | 143 | Deletes an entry in the cache. Returns `true` if an entry is deleted. 144 | 145 | > Options are passed to the underlying cache implementation. 146 | 147 | ```crystal 148 | store = Cache::MemoryStore(String, String).new(12.hours) 149 | 150 | store.write("foo", "bar") 151 | store.read("foo") # => "bar" 152 | 153 | store.delete("foo") # => true 154 | store.read("foo") # => nil 155 | ``` 156 | 157 | #### clear 158 | 159 | Deletes all items from the cache. 160 | 161 | > Options are passed to the underlying cache implementation. 162 | 163 | ```crystal 164 | store = Cache::MemoryStore(String, String).new(12.hours) 165 | 166 | store.write("foo", "bar") 167 | store.read("foo") # => "bar" 168 | 169 | store.clear 170 | store.read("foo") # => nil 171 | ``` 172 | 173 | ### Memory 174 | 175 | A cache store implementation which stores everything into memory in the 176 | same process. 177 | 178 | Can store any serializable Crystal object. 179 | 180 | ```crystal 181 | cache = Cache::MemoryStore(String, Hash(String | Int32)).new(expires_in: 1.minute) 182 | cache.fetch("data_key") do 183 | {"name" => "John", "age" => 18} 184 | end 185 | ``` 186 | 187 | Cached data for `MemoryStore(String, String)` are compressed by default. 188 | To turn off compression, pass `compress: false` to the initializer. 189 | 190 | For another type of keys `compress` option ignored. 191 | 192 | ```crystal 193 | cache = Cache::MemoryStore(String, String).new(expires_in: 1.minute, compress: false) 194 | cache.fetch("today") do 195 | Time.utc.day_of_week 196 | end 197 | ``` 198 | 199 | ### Filesystem 200 | 201 | A cache store implementation which stores everything on the filesystem. 202 | 203 | ```crystal 204 | cache_path = "#{__DIR__}/cache" 205 | 206 | cache = Cache::FileStore(String, String).new(expires_in: 12.hours, cache_path: cache_path) 207 | 208 | cache.fetch("today") do 209 | Time.utc.day_of_week 210 | end 211 | ``` 212 | 213 | ### Null store 214 | 215 | A cache store implementation which doesn't actually store anything. Useful in 216 | development and test environments where you don't want caching turned on but 217 | need to go through the caching interface. 218 | 219 | ```crystal 220 | cache = Cache::NullStore(String, String).new(expires_in: 1.minute) 221 | cache.fetch("today") do 222 | Time.utc.day_of_week 223 | end 224 | ``` 225 | 226 | ## Logging 227 | 228 | For activation, simply setup the log to `:debug` level: 229 | 230 | ```crystal 231 | Log.builder.bind "cache.*", :debug, Log::IOBackend.new 232 | ``` 233 | 234 | ## Contributing 235 | 236 | 1. Fork it () 237 | 2. Create your feature branch (git checkout -b my-new-feature) 238 | 3. Commit your changes (git commit -am 'Add some feature') 239 | 4. Push to the branch (git push origin my-new-feature) 240 | 5. Create a new Pull Request 241 | 242 | ## Contributors 243 | 244 | * [mamantoha](https://github.com/mamantoha) Anton Maminov - creator, maintainer 245 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: cache 2 | version: 0.14.0 3 | 4 | authors: 5 | - Anton Maminov 6 | 7 | crystal: ">= 1.0.0" 8 | 9 | development_dependencies: 10 | ameba: 11 | github: crystal-ameba/ameba 12 | branch: master 13 | 14 | license: MIT 15 | -------------------------------------------------------------------------------- /spec/data_compressor_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cache::DataCompressor do 4 | it "should deflate/inflate data" do 5 | string = "this is test string" 6 | 7 | compressed_string = Cache::DataCompressor.deflate(string) 8 | decompressed_string = Cache::DataCompressor.inflate(compressed_string) 9 | 10 | string.should eq(decompressed_string) 11 | end 12 | 13 | it "should deflate data with base64" do 14 | string = "hello" 15 | 16 | Cache::DataCompressor.deflate(string).should eq("eJzLSM3JyQcABiwCFQ==\n") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/file_store_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cache do 4 | context Cache::FileStore do 5 | cache_path = "#{__DIR__}/cache" 6 | 7 | Spec.after_each do 8 | FileUtils.rm_rf(cache_path) 9 | end 10 | 11 | it "initialize" do 12 | store = Cache::FileStore(String, String).new(expires_in: 12.hours, cache_path: cache_path) 13 | 14 | store.should be_a(Cache::Store(String, String)) 15 | store.cache_path.should end_with("/spec/cache") 16 | end 17 | 18 | it "write to cache first time" do 19 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 20 | 21 | value = store.fetch("foo") { "bar" } 22 | value.should eq("bar") 23 | end 24 | 25 | it "has keys" do 26 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 27 | 28 | store.fetch("foo") { "bar" } 29 | store.keys.should eq(Set{"foo"}) 30 | end 31 | 32 | it "fetch from cache" do 33 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 34 | 35 | value = store.fetch("foo") { "bar" } 36 | value.should eq("bar") 37 | 38 | value = store.fetch("foo") { "baz" } 39 | value.should eq("bar") 40 | end 41 | 42 | it "fetch from cache with generic types values" do 43 | store = Cache::FileStore(String, String | Int32).new(expires_in: 12.hours, cache_path: cache_path) 44 | 45 | value = store.fetch("string") { "bar" } 46 | value.should eq("bar") 47 | 48 | value = store.fetch("integer") { 13 } 49 | value.should eq(13) 50 | end 51 | 52 | it "fetch from cache with false values" do 53 | store = Cache::FileStore(String, String | Bool).new(expires_in: 12.hours, cache_path: cache_path) 54 | 55 | value = store.fetch("foo") { false } 56 | value.should eq(false) 57 | 58 | value = store.fetch("foo") { "bar" } 59 | value.should eq(false) 60 | end 61 | 62 | it "don't fetch from cache if expired" do 63 | store = Cache::FileStore(String, String).new(1.seconds, cache_path: cache_path) 64 | 65 | value = store.fetch("foo") { "bar" } 66 | value.should eq("bar") 67 | 68 | sleep 2 69 | 70 | value = store.fetch("foo") { "baz" } 71 | value.should eq("baz") 72 | end 73 | 74 | it "fetch with expires_in from cache" do 75 | store = Cache::FileStore(String, String).new(1.seconds, cache_path: cache_path) 76 | 77 | value = store.fetch("foo", expires_in: 1.hours) { "bar" } 78 | value.should eq("bar") 79 | 80 | sleep 2 81 | 82 | value = store.fetch("foo") { "baz" } 83 | value.should eq("bar") 84 | end 85 | 86 | it "don't fetch with expires_in from cache if expires" do 87 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 88 | 89 | value = store.fetch("foo", expires_in: 1.seconds) { "bar" } 90 | value.should eq("bar") 91 | 92 | sleep 2 93 | 94 | value = store.fetch("foo") { "baz" } 95 | value.should eq("baz") 96 | end 97 | 98 | it "write" do 99 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 100 | store.write("foo", "bar", expires_in: 1.minute) 101 | 102 | value = store.fetch("foo") { "bar" } 103 | value.should eq("bar") 104 | end 105 | 106 | it "read" do 107 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 108 | store.write("foo", "bar") 109 | 110 | value = store.read("foo") 111 | value.should eq("bar") 112 | end 113 | 114 | it "set a custom expires_in value for one entry on write" do 115 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 116 | store.write("foo", "bar", expires_in: 1.second) 117 | 118 | sleep 2 119 | 120 | value = store.read("foo") 121 | value.should eq(nil) 122 | end 123 | 124 | it "delete from cache" do 125 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 126 | 127 | value = store.fetch("foo") { "bar" } 128 | value.should eq("bar") 129 | File.exists?(File.join(cache_path, "foo")).should be_true 130 | 131 | result = store.delete("foo") 132 | result.should eq(true) 133 | 134 | value = store.read("foo") 135 | value.should eq(nil) 136 | File.exists?(File.join(cache_path, "foo")).should be_false 137 | store.keys.should be_empty 138 | end 139 | 140 | it "deletes all items from the cache" do 141 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 142 | 143 | value = store.fetch("foo") { "bar" } 144 | value.should eq("bar") 145 | File.exists?(File.join(cache_path, "foo")).should be_true 146 | 147 | store.clear 148 | 149 | File.exists?(File.join(cache_path, "foo")).should be_false 150 | store.keys.should be_empty 151 | end 152 | 153 | it "#exists?" do 154 | store = Cache::FileStore(String, String).new(12.hours, cache_path: cache_path) 155 | 156 | store.write("foo", "bar") 157 | 158 | store.exists?("foo").should eq(true) 159 | store.exists?("foz").should eq(false) 160 | end 161 | 162 | it "#exists? expires" do 163 | store = Cache::FileStore(String, String).new(1.second, cache_path: cache_path) 164 | 165 | store.write("foo", "bar") 166 | 167 | sleep 2 168 | 169 | store.exists?("foo").should eq(false) 170 | end 171 | 172 | it "#increment" do 173 | store = Cache::FileStore(String, Int32).new(12.hours, cache_path: cache_path) 174 | 175 | store.write("num", 1) 176 | store.increment("num", 1) 177 | 178 | value = store.read("num") 179 | 180 | value.should eq(2) 181 | end 182 | 183 | it "#decrement" do 184 | store = Cache::FileStore(String, Int32).new(12.hours, cache_path: cache_path) 185 | 186 | store.write("num", 2) 187 | store.decrement("num", 1) 188 | 189 | value = store.read("num") 190 | 191 | value.should eq(1) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /spec/log_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cache::Log do 4 | it "logging" do 5 | IO.pipe do |read, write| 6 | log_backend = Log::IOBackend.new(write) 7 | Log.builder.bind "cache.*", :debug, log_backend 8 | 9 | store = Cache::MemoryStore(String, String).new(expires_in: 1.second) 10 | 11 | store.fetch("foo") { "bar" } 12 | store.delete("foo") 13 | 14 | read.gets.should match(/DEBUG - cache: Cache read: foo/) 15 | read.gets.should match(/DEBUG - cache: Cache write: foo/) 16 | read.gets.should match(/DEBUG - cache: Cache delete: foo/) 17 | end 18 | 19 | Log.builder.clear 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/memory_store_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cache do 4 | context Cache::MemoryStore do 5 | context "initialize" do 6 | it "String as key" do 7 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours) 8 | 9 | store.should be_a(Cache::Store(String, String)) 10 | end 11 | 12 | it "with union Int32 | String as key" do 13 | store = Cache::MemoryStore(Int32 | String, String).new(expires_in: 12.hours) 14 | 15 | store.should be_a(Cache::Store(Int32 | String, String)) 16 | 17 | store.fetch("foo") { "bar" } 18 | store.fetch(1) { "baz" } 19 | store.keys.should eq(Set{"foo", 1}) 20 | store.read("foo").should eq("bar") 21 | store.read(1).should eq("baz") 22 | end 23 | end 24 | 25 | [true, false].each do |compress| 26 | context "with compress #{compress}" do 27 | it "write to cache first time" do 28 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 29 | 30 | value = store.fetch("foo") { "bar" } 31 | value.should eq("bar") 32 | end 33 | 34 | it "has keys" do 35 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 36 | 37 | store.fetch("foo") { "bar" } 38 | store.keys.should eq(Set{"foo"}) 39 | end 40 | 41 | it "fetch from cache" do 42 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 43 | 44 | value = store.fetch("foo") { "bar" } 45 | value.should eq("bar") 46 | 47 | value = store.fetch("foo") { "baz" } 48 | value.should eq("bar") 49 | end 50 | 51 | it "fetch from cache with generic types values" do 52 | store = Cache::MemoryStore(String, String | Int32).new(expires_in: 12.hours, compress: compress) 53 | 54 | value = store.fetch("string") { "bar" } 55 | value.should eq("bar") 56 | 57 | value = store.fetch("integer") { 13 } 58 | value.should eq(13) 59 | end 60 | 61 | it "fetch from cache with false values" do 62 | store = Cache::MemoryStore(String, String | Bool).new(expires_in: 12.hours, compress: compress) 63 | 64 | value = store.fetch("foo") { false } 65 | value.should eq(false) 66 | 67 | value = store.fetch("foo") { "bar" } 68 | value.should eq(false) 69 | end 70 | 71 | it "don't fetch from cache if expires" do 72 | store = Cache::MemoryStore(String, String).new(expires_in: 1.seconds, compress: compress) 73 | 74 | value = store.fetch("foo") { "bar" } 75 | value.should eq("bar") 76 | 77 | sleep 2 78 | 79 | value = store.fetch("foo") { "baz" } 80 | value.should eq("baz") 81 | end 82 | 83 | it "fetch with expires_in from cache" do 84 | store = Cache::MemoryStore(String, String).new(expires_in: 1.seconds, compress: compress) 85 | 86 | value = store.fetch("foo", expires_in: 1.hours) { "bar" } 87 | value.should eq("bar") 88 | 89 | sleep 2 90 | 91 | value = store.fetch("foo") { "baz" } 92 | value.should eq("bar") 93 | end 94 | 95 | it "don't fetch with expires_in from cache if expires" do 96 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 97 | 98 | value = store.fetch("foo", expires_in: 1.seconds) { "bar" } 99 | value.should eq("bar") 100 | 101 | sleep 2 102 | 103 | value = store.fetch("foo") { "baz" } 104 | value.should eq("baz") 105 | end 106 | 107 | it "write" do 108 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 109 | store.write("foo", "bar", expires_in: 1.minute) 110 | 111 | value = store.fetch("foo") { "bar" } 112 | value.should eq("bar") 113 | end 114 | 115 | it "read" do 116 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 117 | store.write("foo", "bar") 118 | 119 | value = store.read("foo") 120 | value.should eq("bar") 121 | end 122 | 123 | it "read nil if key does not exists" do 124 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 125 | 126 | value = store.read("foo") 127 | value.should eq(nil) 128 | end 129 | 130 | it "fetch without block" do 131 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 132 | store.write("foo", "bar") 133 | 134 | value = store.fetch("foo") 135 | value.should eq("bar") 136 | end 137 | 138 | it "set a custom expires_in value for one entry on write" do 139 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 140 | store.write("foo", "bar", expires_in: 1.second) 141 | 142 | sleep 2 143 | 144 | value = store.read("foo") 145 | value.should eq(nil) 146 | end 147 | 148 | it "delete from cache" do 149 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 150 | 151 | value = store.fetch("foo") { "bar" } 152 | value.should eq("bar") 153 | 154 | result = store.delete("foo") 155 | result.should eq(true) 156 | 157 | value = store.read("foo") 158 | value.should eq(nil) 159 | store.keys.should eq(Set(String).new) 160 | end 161 | 162 | it "deletes all items from the cache" do 163 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 164 | 165 | value = store.fetch("foo") { "bar" } 166 | value.should eq("bar") 167 | 168 | store.clear 169 | 170 | value = store.read("foo") 171 | value.should eq(nil) 172 | store.keys.should be_empty 173 | end 174 | 175 | it "#exists?" do 176 | store = Cache::MemoryStore(String, String).new(expires_in: 12.hours, compress: compress) 177 | 178 | store.write("foo", "bar") 179 | 180 | store.exists?("foo").should eq(true) 181 | store.exists?("foz").should eq(false) 182 | end 183 | 184 | it "#exists? expires" do 185 | store = Cache::MemoryStore(String, String).new(expires_in: 1.second, compress: compress) 186 | 187 | store.write("foo", "bar") 188 | 189 | sleep 2 190 | 191 | store.exists?("foo").should eq(false) 192 | end 193 | 194 | it "#increment" do 195 | store = Cache::MemoryStore(String, Int32).new(expires_in: 12.hours, compress: compress) 196 | 197 | store.write("num", 1) 198 | store.increment("num", 1) 199 | 200 | value = store.read("num") 201 | 202 | value.should eq(2) 203 | end 204 | 205 | it "#decrement" do 206 | store = Cache::MemoryStore(String, Int32).new(expires_in: 12.hours, compress: compress) 207 | 208 | store.write("num", 2) 209 | store.decrement("num", 1) 210 | 211 | value = store.read("num") 212 | 213 | value.should eq(1) 214 | end 215 | end 216 | end 217 | 218 | context "with Hash as value" do 219 | [true, false].each do |compress| 220 | context "with compress #{compress}" do 221 | it "fetch from cache" do 222 | store = Cache::MemoryStore(String, Hash(String, String | Int32)) 223 | .new(expires_in: 30.seconds, compress: compress) 224 | 225 | data = { 226 | "a" => 1, 227 | "b" => "foo", 228 | } 229 | 230 | new_data = { 231 | "a" => 2, 232 | "b" => "bar", 233 | } 234 | 235 | value = store.fetch("data_key") { data } 236 | value.should eq(data) 237 | 238 | value = store.fetch("data_key") { new_data } 239 | value.should eq(data) 240 | end 241 | 242 | it "write and read" do 243 | store = Cache::MemoryStore(String, Hash(String, String | Int32)) 244 | .new(expires_in: 30.seconds, compress: compress) 245 | 246 | data = { 247 | "a" => 1, 248 | "b" => "bla", 249 | } 250 | 251 | store.write("data_key", data) 252 | 253 | result = store.read("data_key") 254 | result.should eq(data) 255 | end 256 | end 257 | end 258 | end 259 | end 260 | end 261 | -------------------------------------------------------------------------------- /spec/null_store_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cache do 4 | context Cache::NullStore do 5 | it "initialize" do 6 | store = Cache::NullStore(String, String).new(expires_in: 12.hours) 7 | 8 | store.should be_a(Cache::Store(String, String)) 9 | end 10 | 11 | it "fetch" do 12 | store = Cache::NullStore(String, String).new(12.hours) 13 | 14 | value = store.fetch("foo") { "bar" } 15 | value.should eq("bar") 16 | end 17 | 18 | it "fetch with expires_in" do 19 | store = Cache::NullStore(String, String).new(12.hours) 20 | 21 | value = store.fetch("foo", expires_in: 3.hours) { "bar" } 22 | value.should eq("bar") 23 | end 24 | 25 | it "has keys" do 26 | store = Cache::NullStore(String, String).new(12.hours) 27 | 28 | store.fetch("foo") { "bar" } 29 | store.keys.should eq(Set{"foo"}) 30 | end 31 | 32 | it "delete from cache" do 33 | store = Cache::NullStore(String, String).new(12.hours) 34 | 35 | value = store.fetch("foo") { "bar" } 36 | value.should eq("bar") 37 | 38 | result = store.delete("foo") 39 | result.should eq(true) 40 | 41 | value = store.read("foo") 42 | value.should eq(nil) 43 | store.keys.should eq(Set(String).new) 44 | end 45 | 46 | it "deletes all items from the cache" do 47 | store = Cache::NullStore(String, String).new(12.hours) 48 | 49 | value = store.fetch("foo") { "bar" } 50 | value.should eq("bar") 51 | 52 | store.clear 53 | 54 | store.keys.should be_empty 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/cache" 3 | -------------------------------------------------------------------------------- /src/cache.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "./cache/*" 3 | 4 | # :nodoc: 5 | module Cache 6 | Log = ::Log.for(self) 7 | end 8 | -------------------------------------------------------------------------------- /src/cache/data_compressor.cr: -------------------------------------------------------------------------------- 1 | require "compress/zlib" 2 | 3 | module Cache 4 | module DataCompressor 5 | extend self 6 | 7 | def deflate(data : String) : String 8 | io = IO::Memory.new 9 | 10 | Compress::Zlib::Writer.open(io, &.print(data)) 11 | 12 | # base64-encode the compressed data to make it printable 13 | Base64.encode(io.to_s) 14 | end 15 | 16 | def inflate(data : String) : String 17 | encoded_data = Base64.decode_string(data) 18 | 19 | io = IO::Memory.new(encoded_data.to_slice) 20 | 21 | Compress::Zlib::Reader.open(io, &.gets_to_end) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /src/cache/entry.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | module Cache 4 | # Used to represent cache entries. Cache entries have a value, and expiration time. 5 | struct Entry(V) 6 | include YAML::Serializable 7 | 8 | @expires_at : Time 9 | 10 | getter value 11 | getter expires_at 12 | 13 | def initialize(@value : V, expires_in : Time::Span) 14 | @expires_at = Time.utc + expires_in 15 | end 16 | 17 | # Checks if the entry is expired. 18 | def expired? 19 | @expires_at && @expires_at <= Time.utc 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /src/cache/store.cr: -------------------------------------------------------------------------------- 1 | require "./entry" 2 | 3 | module Cache 4 | # An abstract cache store class. 5 | # 6 | # There are multiple cache store implementations, 7 | # each having its own additional features. 8 | # 9 | # See the classes 10 | # under the `/src/cache/stores` directory, e.g. 11 | # All implementations should support method , `write`, `read`, `fetch`, and `delete`. 12 | abstract struct Store(K, V) 13 | @keys : Set(K) = Set(K).new 14 | @namespace : String? = nil 15 | 16 | property keys 17 | 18 | # Fetches data from the cache, using the given `key`. If there is data in the cache 19 | # with the given `key`, then that data is returned. 20 | # 21 | # If there is no such data in the cache, then a `block` will be passed the `key` 22 | # and executed in the event of a cache miss. 23 | # Setting `:expires_in` will set an expiration time on the cache. 24 | # All caches support auto-expiring content after a specified number of seconds. 25 | # This value can be specified as an option to the constructor (in which case all entries will be affected), 26 | # or it can be supplied to the `fetch` or `write` method to effect just one entry. 27 | # 28 | # ``` 29 | # cache = Cache::MemoryStore(String, String).new(expires_in: 1.hours) 30 | # # Set a lower value for one entry 31 | # cache.fetch("today", expires_in: 10.minutes) do 32 | # Time.utc.day_of_week 33 | # end 34 | # ``` 35 | def fetch(key : K, *, expires_in = @expires_in, &) 36 | value = read(key) 37 | return value unless value.nil? 38 | 39 | value = yield 40 | 41 | write(key, value, expires_in: expires_in) 42 | value 43 | end 44 | 45 | # :nodoc: 46 | def fetch(key : K) 47 | read(key) 48 | end 49 | 50 | # Writes the `value` to the cache, with the `key`. 51 | # 52 | # Optional `expires_in` will set an expiration time on the `key`. 53 | # 54 | # Options are passed to the underlying cache implementation. 55 | def write(key : K, value : V, *, expires_in = @expires_in) 56 | key = namespace_key(key) 57 | 58 | instrument(:write, key) do 59 | write_impl(key, value, expires_in: expires_in) 60 | end 61 | end 62 | 63 | # Reads data from the cache, using the given `key`. 64 | # 65 | # If there is data in the cache with the given `key`, then that data is returned. 66 | # Otherwise, `nil` is returned. 67 | def read(key : K) 68 | key = namespace_key(key) 69 | 70 | instrument(:read, key) do 71 | read_impl(key) 72 | end 73 | end 74 | 75 | def delete(key : K) : Bool 76 | key = namespace_key(key) 77 | 78 | instrument(:delete, key) do 79 | delete_impl(key) 80 | end 81 | end 82 | 83 | def exists?(key : K) : Bool 84 | key = namespace_key(key) 85 | 86 | exists_impl(key) 87 | end 88 | 89 | private def instrument(operation, key, &) 90 | Log.debug { "Cache #{operation}: #{key}" } 91 | 92 | yield 93 | end 94 | 95 | # Implementation of writing an entry. 96 | private abstract def write_impl(key : K, value : V, *, expires_in) 97 | 98 | # Implementation of reading an entry. 99 | # Returns the entry, if it existed, `nil` otherwise. 100 | private abstract def read_impl(key : K) 101 | 102 | # Deletes an entry in the cache. Returns `true` if an entry is deleted. 103 | # 104 | # Options are passed to the underlying cache implementation. 105 | private abstract def delete_impl(key : K) : Bool 106 | 107 | # Returns true if the cache contains an entry for the given key. 108 | # 109 | # Options are passed to the underlying cache implementation. 110 | private abstract def exists_impl(key : K) : Bool 111 | 112 | # Deletes all entries from the cache. 113 | # 114 | # Options are passed to the underlying cache implementation. 115 | abstract def clear 116 | 117 | private def clear_keys 118 | @keys.clear 119 | end 120 | 121 | # Increment an integer value in the cache. 122 | def increment(key : K, amount = 1) 123 | if num = read(key) 124 | return unless num.is_a?(Int) 125 | 126 | num += amount 127 | write(key, num) 128 | end 129 | end 130 | 131 | # Decrement an integer value in the cache. 132 | def decrement(key : K, amount = 1) 133 | if num = read(key) 134 | return unless num.is_a?(Int) 135 | 136 | num -= amount 137 | write(key, num) 138 | end 139 | end 140 | 141 | private def namespace_key(key : K) : String | K 142 | if @namespace 143 | "#{@namespace}:#{key}" 144 | else 145 | key 146 | end 147 | end 148 | end 149 | end 150 | 151 | require "./stores/*" 152 | -------------------------------------------------------------------------------- /src/cache/stores/file_store.cr: -------------------------------------------------------------------------------- 1 | require "../store" 2 | require "file_utils" 3 | 4 | module Cache 5 | # A cache store implementation which stores everything on the filesystem. 6 | # 7 | # ``` 8 | # cache_path = "#{__DIR__}/cache" 9 | # store = Cache::FileStore(String, String).new(expires_in: 12.hours, cache_path: cache_path) 10 | # cache.fetch("today") do 11 | # Time.utc.day_of_week 12 | # end 13 | # ``` 14 | struct FileStore(K, V) < Store(K, V) 15 | EXCLUDED_DIRS = [".", ".."] 16 | 17 | property cache_path 18 | 19 | def initialize(@expires_in : Time::Span, @cache_path : String) 20 | end 21 | 22 | private def write_impl(key : K, value : V, *, expires_in = @expires_in) 23 | @keys << key 24 | 25 | file = File.join(@cache_path, key) 26 | entry = Entry(V).new(value, expires_in) 27 | 28 | ensure_cache_path(File.dirname(file)) 29 | File.write(file, entry.to_yaml) 30 | end 31 | 32 | private def read_impl(key : K) 33 | entry = entry_for(key) 34 | 35 | if entry && !entry.expired? 36 | entry.value 37 | else 38 | nil 39 | end 40 | end 41 | 42 | private def delete_impl(key : K) : Bool 43 | @keys.delete(key) 44 | File.delete(File.join(@cache_path, key)) 45 | 46 | true 47 | end 48 | 49 | private def exists_impl(key : K) : Bool 50 | entry = entry_for(key) 51 | (entry && !entry.expired?) || false 52 | end 53 | 54 | def clear 55 | clear_keys 56 | 57 | root_dirs = Dir.entries(cache_path) 58 | root_dirs = root_dirs.reject { |f| EXCLUDED_DIRS.includes?(f) } 59 | 60 | files = root_dirs.map { |f| File.join(cache_path, f) } 61 | 62 | FileUtils.rm_r(files) 63 | end 64 | 65 | private def entry_for(key : K) 66 | file = File.join(@cache_path, key) 67 | 68 | return nil unless File.exists?(file) 69 | 70 | Entry(V).from_yaml(File.read(file)) 71 | end 72 | 73 | # Make sure a file path's directories exist. 74 | private def ensure_cache_path(path) 75 | FileUtils.mkdir_p(path) unless File.exists?(path) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /src/cache/stores/memory_store.cr: -------------------------------------------------------------------------------- 1 | require "../store" 2 | 3 | module Cache 4 | # A cache store implementation which stores everything into memory in the 5 | # same process. 6 | # 7 | # Cached data are compressed by default. To turn off compression, pass `compress: false` to the initializer. 8 | # 9 | # ``` 10 | # cache = Cache::MemoryStore(String, String).new(expires_in: 1.minute) 11 | # cache.fetch("today") do 12 | # Time.utc.day_of_week 13 | # end 14 | # ``` 15 | struct MemoryStore(K, V) < Store(K, V) 16 | def initialize(@expires_in : Time::Span, @compress : Bool = true) 17 | @cache = {} of K => Entry(V) 18 | end 19 | 20 | private def write_impl(key : K, value : V, *, expires_in = @expires_in) 21 | @keys << key 22 | 23 | {% if V.is_a?(String) %} 24 | value = Cache::DataCompressor.deflate(value) if @compress 25 | {% end %} 26 | 27 | @cache[key] = Entry(V).new(value, expires_in) 28 | end 29 | 30 | private def read_impl(key : K) 31 | entry = @cache[key]? 32 | value = nil 33 | 34 | if entry && !entry.expired? 35 | value = entry.value 36 | 37 | {% if V.is_a?(String) %} 38 | value = Cache::DataCompressor.inflate(value) if @compress 39 | {% end %} 40 | end 41 | 42 | value 43 | end 44 | 45 | private def delete_impl(key : K) : Bool 46 | @keys.delete(key) 47 | 48 | @cache.delete(key).nil? ? false : true 49 | end 50 | 51 | private def exists_impl(key : K) : Bool 52 | entry = @cache[key]? 53 | (entry && !entry.expired?) || false 54 | end 55 | 56 | def clear 57 | clear_keys 58 | 59 | @cache.clear 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /src/cache/stores/null_store.cr: -------------------------------------------------------------------------------- 1 | require "../store" 2 | 3 | module Cache 4 | # A cache store implementation which doesn't actually store anything. Useful in 5 | # development and test environments where you don't want caching turned on but 6 | # need to go through the caching interface. 7 | struct NullStore(K, V) < Store(K, V) 8 | def initialize(@expires_in : Time::Span) 9 | end 10 | 11 | private def write_impl(key : K, value : V, *, expires_in = @expires_in) 12 | @keys << key 13 | end 14 | 15 | private def read_impl(key : K) 16 | end 17 | 18 | private def delete_impl(key : K) : Bool 19 | @keys.delete(key) 20 | 21 | true 22 | end 23 | 24 | private def exists_impl(key : K) : Bool 25 | @keys.includes?(key) 26 | end 27 | 28 | def clear 29 | clear_keys 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/cache/version.cr: -------------------------------------------------------------------------------- 1 | module Cache 2 | VERSION = {{ `shards version #{__DIR__}`.chomp.stringify }} 3 | end 4 | --------------------------------------------------------------------------------