├── .github └── workflows │ ├── ci.yml │ └── rubocop.yml ├── .gitignore ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── bin ├── console ├── release ├── rubocop └── test ├── kredis.gemspec ├── lib ├── install │ ├── install.rb │ └── shared.yml ├── kredis.rb ├── kredis │ ├── attributes.rb │ ├── connections.rb │ ├── default_values.rb │ ├── log_subscriber.rb │ ├── migration.rb │ ├── namespace.rb │ ├── railtie.rb │ ├── type │ │ ├── boolean.rb │ │ ├── datetime.rb │ │ └── json.rb │ ├── type_casting.rb │ ├── types.rb │ ├── types │ │ ├── callbacks_proxy.rb │ │ ├── counter.rb │ │ ├── cycle.rb │ │ ├── enum.rb │ │ ├── flag.rb │ │ ├── hash.rb │ │ ├── limiter.rb │ │ ├── list.rb │ │ ├── ordered_set.rb │ │ ├── proxy.rb │ │ ├── proxy │ │ │ └── failsafe.rb │ │ ├── proxying.rb │ │ ├── scalar.rb │ │ ├── set.rb │ │ ├── slots.rb │ │ └── unique_list.rb │ └── version.rb └── tasks │ └── kredis │ └── install.rake └── test ├── attributes_callbacks_test.rb ├── attributes_test.rb ├── callbacks_test.rb ├── connections_test.rb ├── fixtures └── config │ └── redis │ └── shared.yml ├── log_subscriber_test.rb ├── migration_test.rb ├── namespace_test.rb ├── proxy_test.rb ├── test_helper.rb └── types ├── counter_test.rb ├── cycle_test.rb ├── enum_test.rb ├── flag_test.rb ├── hash_test.rb ├── limiter_test.rb ├── list_test.rb ├── ordered_set_test.rb ├── scalar_test.rb ├── set_test.rb ├── slots_test.rb └── unique_list_test.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | redis_server: ["4", "5", "6", "7"] 9 | ruby: ["3.2", "3.3"] 10 | 11 | name: Redis server ${{ matrix.redis_server }} - Ruby ${{ matrix.ruby }} 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Ruby 17 | uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: ${{ matrix.ruby }} 20 | bundler-cache: true 21 | 22 | - name: Set up Redis ${{ matrix.redis_server }} 23 | uses: supercharge/redis-github-action@1.2.0 24 | with: 25 | redis-version: ${{ matrix.redis_server }} 26 | 27 | - name: Run tests 28 | run: bin/test 29 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Ruby 3.2 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 3.2 19 | bundler-cache: true 20 | 21 | - name: Run RuboCop 22 | run: bin/rubocop --parallel 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .rubocop-* 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # Omakase Ruby styling for Rails 2 | inherit_gem: { rubocop-rails-omakase: rubocop.yml } 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "rake" 8 | gem "debug", ">= 1.0.0" 9 | gem "rubocop-rails-omakase" 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | kredis (1.8.0) 5 | activemodel (>= 6.0.0) 6 | activesupport (>= 6.0.0) 7 | redis (>= 4.2, < 6) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | actioncable (8.0.2) 13 | actionpack (= 8.0.2) 14 | activesupport (= 8.0.2) 15 | nio4r (~> 2.0) 16 | websocket-driver (>= 0.6.1) 17 | zeitwerk (~> 2.6) 18 | actionmailbox (8.0.2) 19 | actionpack (= 8.0.2) 20 | activejob (= 8.0.2) 21 | activerecord (= 8.0.2) 22 | activestorage (= 8.0.2) 23 | activesupport (= 8.0.2) 24 | mail (>= 2.8.0) 25 | actionmailer (8.0.2) 26 | actionpack (= 8.0.2) 27 | actionview (= 8.0.2) 28 | activejob (= 8.0.2) 29 | activesupport (= 8.0.2) 30 | mail (>= 2.8.0) 31 | rails-dom-testing (~> 2.2) 32 | actionpack (8.0.2) 33 | actionview (= 8.0.2) 34 | activesupport (= 8.0.2) 35 | nokogiri (>= 1.8.5) 36 | rack (>= 2.2.4) 37 | rack-session (>= 1.0.1) 38 | rack-test (>= 0.6.3) 39 | rails-dom-testing (~> 2.2) 40 | rails-html-sanitizer (~> 1.6) 41 | useragent (~> 0.16) 42 | actiontext (8.0.2) 43 | actionpack (= 8.0.2) 44 | activerecord (= 8.0.2) 45 | activestorage (= 8.0.2) 46 | activesupport (= 8.0.2) 47 | globalid (>= 0.6.0) 48 | nokogiri (>= 1.8.5) 49 | actionview (8.0.2) 50 | activesupport (= 8.0.2) 51 | builder (~> 3.1) 52 | erubi (~> 1.11) 53 | rails-dom-testing (~> 2.2) 54 | rails-html-sanitizer (~> 1.6) 55 | activejob (8.0.2) 56 | activesupport (= 8.0.2) 57 | globalid (>= 0.3.6) 58 | activemodel (8.0.2) 59 | activesupport (= 8.0.2) 60 | activerecord (8.0.2) 61 | activemodel (= 8.0.2) 62 | activesupport (= 8.0.2) 63 | timeout (>= 0.4.0) 64 | activestorage (8.0.2) 65 | actionpack (= 8.0.2) 66 | activejob (= 8.0.2) 67 | activerecord (= 8.0.2) 68 | activesupport (= 8.0.2) 69 | marcel (~> 1.0) 70 | activesupport (8.0.2) 71 | base64 72 | benchmark (>= 0.3) 73 | bigdecimal 74 | concurrent-ruby (~> 1.0, >= 1.3.1) 75 | connection_pool (>= 2.2.5) 76 | drb 77 | i18n (>= 1.6, < 2) 78 | logger (>= 1.4.2) 79 | minitest (>= 5.1) 80 | securerandom (>= 0.3) 81 | tzinfo (~> 2.0, >= 2.0.5) 82 | uri (>= 0.13.1) 83 | ast (2.4.3) 84 | base64 (0.2.0) 85 | benchmark (0.4.0) 86 | bigdecimal (3.1.9) 87 | builder (3.3.0) 88 | concurrent-ruby (1.3.5) 89 | connection_pool (2.5.1) 90 | crass (1.0.6) 91 | date (3.4.1) 92 | debug (1.10.0) 93 | irb (~> 1.10) 94 | reline (>= 0.3.8) 95 | drb (2.2.1) 96 | erubi (1.13.1) 97 | globalid (1.2.1) 98 | activesupport (>= 6.1) 99 | i18n (1.14.7) 100 | concurrent-ruby (~> 1.0) 101 | io-console (0.8.0) 102 | irb (1.15.2) 103 | pp (>= 0.6.0) 104 | rdoc (>= 4.0.0) 105 | reline (>= 0.4.2) 106 | json (2.10.2) 107 | language_server-protocol (3.17.0.4) 108 | lint_roller (1.1.0) 109 | logger (1.7.0) 110 | loofah (2.24.0) 111 | crass (~> 1.0.2) 112 | nokogiri (>= 1.12.0) 113 | mail (2.8.1) 114 | mini_mime (>= 0.1.1) 115 | net-imap 116 | net-pop 117 | net-smtp 118 | marcel (1.0.4) 119 | mini_mime (1.1.5) 120 | mini_portile2 (2.8.8) 121 | minitest (5.25.5) 122 | net-imap (0.5.6) 123 | date 124 | net-protocol 125 | net-pop (0.1.2) 126 | net-protocol 127 | net-protocol (0.2.2) 128 | timeout 129 | net-smtp (0.5.1) 130 | net-protocol 131 | nio4r (2.7.4) 132 | nokogiri (1.18.7) 133 | mini_portile2 (~> 2.8.2) 134 | racc (~> 1.4) 135 | nokogiri (1.18.7-aarch64-linux-gnu) 136 | racc (~> 1.4) 137 | nokogiri (1.18.7-arm-linux-gnu) 138 | racc (~> 1.4) 139 | nokogiri (1.18.7-arm64-darwin) 140 | racc (~> 1.4) 141 | nokogiri (1.18.7-x86_64-darwin) 142 | racc (~> 1.4) 143 | nokogiri (1.18.7-x86_64-linux-gnu) 144 | racc (~> 1.4) 145 | parallel (1.27.0) 146 | parser (3.3.8.0) 147 | ast (~> 2.4.1) 148 | racc 149 | pp (0.6.2) 150 | prettyprint 151 | prettyprint (0.2.0) 152 | prism (1.4.0) 153 | psych (5.2.3) 154 | date 155 | stringio 156 | racc (1.8.1) 157 | rack (3.1.13) 158 | rack-session (2.1.0) 159 | base64 (>= 0.1.0) 160 | rack (>= 3.0.0) 161 | rack-test (2.2.0) 162 | rack (>= 1.3) 163 | rackup (2.2.1) 164 | rack (>= 3) 165 | rails (8.0.2) 166 | actioncable (= 8.0.2) 167 | actionmailbox (= 8.0.2) 168 | actionmailer (= 8.0.2) 169 | actionpack (= 8.0.2) 170 | actiontext (= 8.0.2) 171 | actionview (= 8.0.2) 172 | activejob (= 8.0.2) 173 | activemodel (= 8.0.2) 174 | activerecord (= 8.0.2) 175 | activestorage (= 8.0.2) 176 | activesupport (= 8.0.2) 177 | bundler (>= 1.15.0) 178 | railties (= 8.0.2) 179 | rails-dom-testing (2.2.0) 180 | activesupport (>= 5.0.0) 181 | minitest 182 | nokogiri (>= 1.6) 183 | rails-html-sanitizer (1.6.2) 184 | loofah (~> 2.21) 185 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 186 | railties (8.0.2) 187 | actionpack (= 8.0.2) 188 | activesupport (= 8.0.2) 189 | irb (~> 1.13) 190 | rackup (>= 1.0.0) 191 | rake (>= 12.2) 192 | thor (~> 1.0, >= 1.2.2) 193 | zeitwerk (~> 2.6) 194 | rainbow (3.1.1) 195 | rake (13.2.1) 196 | rdoc (6.13.1) 197 | psych (>= 4.0.0) 198 | redis (5.4.0) 199 | redis-client (>= 0.22.0) 200 | redis-client (0.24.0) 201 | connection_pool 202 | regexp_parser (2.10.0) 203 | reline (0.6.1) 204 | io-console (~> 0.5) 205 | rubocop (1.75.2) 206 | json (~> 2.3) 207 | language_server-protocol (~> 3.17.0.2) 208 | lint_roller (~> 1.1.0) 209 | parallel (~> 1.10) 210 | parser (>= 3.3.0.2) 211 | rainbow (>= 2.2.2, < 4.0) 212 | regexp_parser (>= 2.9.3, < 3.0) 213 | rubocop-ast (>= 1.44.0, < 2.0) 214 | ruby-progressbar (~> 1.7) 215 | unicode-display_width (>= 2.4.0, < 4.0) 216 | rubocop-ast (1.44.1) 217 | parser (>= 3.3.7.2) 218 | prism (~> 1.4) 219 | rubocop-performance (1.25.0) 220 | lint_roller (~> 1.1) 221 | rubocop (>= 1.75.0, < 2.0) 222 | rubocop-ast (>= 1.38.0, < 2.0) 223 | rubocop-rails (2.31.0) 224 | activesupport (>= 4.2.0) 225 | lint_roller (~> 1.1) 226 | rack (>= 1.1) 227 | rubocop (>= 1.75.0, < 2.0) 228 | rubocop-ast (>= 1.38.0, < 2.0) 229 | rubocop-rails-omakase (1.1.0) 230 | rubocop (>= 1.72) 231 | rubocop-performance (>= 1.24) 232 | rubocop-rails (>= 2.30) 233 | ruby-progressbar (1.13.0) 234 | securerandom (0.4.1) 235 | stringio (3.1.6) 236 | thor (1.3.2) 237 | timeout (0.4.3) 238 | tzinfo (2.0.6) 239 | concurrent-ruby (~> 1.0) 240 | unicode-display_width (3.1.4) 241 | unicode-emoji (~> 4.0, >= 4.0.4) 242 | unicode-emoji (4.0.4) 243 | uri (1.0.3) 244 | useragent (0.16.11) 245 | websocket-driver (0.7.7) 246 | base64 247 | websocket-extensions (>= 0.1.0) 248 | websocket-extensions (0.1.5) 249 | zeitwerk (2.7.2) 250 | 251 | PLATFORMS 252 | aarch64-linux 253 | arm-linux 254 | arm64-darwin 255 | ruby 256 | x86-linux 257 | x86_64-darwin 258 | x86_64-linux 259 | 260 | DEPENDENCIES 261 | debug (>= 1.0.0) 262 | kredis! 263 | rails (>= 6.0.0) 264 | rake 265 | rubocop-rails-omakase 266 | 267 | BUNDLED WITH 268 | 2.6.8 269 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2025 37signals LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kredis 2 | 3 | Kredis (Keyed Redis) encapsulates higher-level types and data structures around a single key, so you can interact with them as coherent objects rather than isolated procedural commands. These higher-level structures can be configured as attributes within Active Models and Active Records using a declarative DSL. 4 | 5 | Kredis is configured using env-aware YAML files, using `Rails.application.config_for`, so you can locate the data structures on separate Redis instances, if you've reached a scale where a single shared instance is no longer sufficient. 6 | 7 | Kredis provides namespacing support for keys such that you can safely run parallel testing against the data structures without different tests trampling each others data. 8 | 9 | 10 | ## Examples 11 | 12 | Kredis provides typed scalars for strings, integers, decimals, floats, booleans, datetimes, and JSON hashes: 13 | 14 | ```ruby 15 | string = Kredis.string "mystring" 16 | string.value = "hello world!" # => SET mystring "hello world" 17 | "hello world!" == string.value # => GET mystring 18 | 19 | integer = Kredis.integer "myinteger" 20 | integer.value = 5 # => SET myinteger "5" 21 | 5 == integer.value # => GET myinteger 22 | 23 | decimal = Kredis.decimal "mydecimal" # accuracy! 24 | decimal.value = "%.47f" % (1.0 / 10) # => SET mydecimal "0.10000000000000000555111512312578270211815834045" 25 | BigDecimal("0.10000000000000000555111512312578270211815834045e0") == decimal.value # => GET mydecimal 26 | 27 | float = Kredis.float "myfloat" # speed! 28 | float.value = 1.0 / 10 # => SET myfloat "0.1" 29 | 0.1 == float.value # => GET myfloat 30 | 31 | boolean = Kredis.boolean "myboolean" 32 | boolean.value = true # => SET myboolean "t" 33 | true == boolean.value # => GET myboolean 34 | 35 | datetime = Kredis.datetime "mydatetime" 36 | memoized_midnight = Time.zone.now.midnight 37 | datetime.value = memoized_midnight # SET mydatetime "2021-07-27T00:00:00.000000000Z" 38 | memoized_midnight == datetime.value # => GET mydatetime 39 | 40 | json = Kredis.json "myjson" 41 | json.value = { "one" => 1, "two" => "2" } # => SET myjson "{\"one\":1,\"two\":\"2\"}" 42 | { "one" => 1, "two" => "2" } == json.value # => GET myjson 43 | ``` 44 | 45 | There are data structures for counters, enums, flags, lists, unique lists, sets, and slots: 46 | 47 | ```ruby 48 | list = Kredis.list "mylist" 49 | list << "hello world!" # => RPUSH mylist "hello world!" 50 | [ "hello world!" ] == list.elements # => LRANGE mylist 0, -1 51 | list.clear # => DEL mylist 52 | 53 | integer_list = Kredis.list "myintegerlist", typed: :integer, default: [ 1, 2, 3 ] # => EXISTS? myintegerlist, RPUSH myintegerlist "1" "2" "3" 54 | integer_list.append([ 4, 5, 6 ]) # => RPUSH myintegerlist "4" "5" "6" 55 | integer_list << 7 # => RPUSH myintegerlist "7" 56 | [ 1, 2, 3, 4, 5, 6, 7 ] == integer_list.elements # => LRANGE myintegerlist 0 -1 57 | integer_list.clear # => DEL myintegerlist 58 | 59 | unique_list = Kredis.unique_list "myuniquelist" 60 | unique_list.append(%w[ 2 3 4 ]) # => LREM myuniquelist 0, "2" + LREM myuniquelist 0, "3" + LREM myuniquelist 0, "4" + RPUSH myuniquelist "2", "3", "4" 61 | unique_list.prepend(%w[ 1 2 3 4 ]) # => LREM myuniquelist 0, "1" + LREM myuniquelist 0, "2" + LREM myuniquelist 0, "3" + LREM myuniquelist 0, "4" + LPUSH myuniquelist "1", "2", "3", "4" 62 | unique_list.append([]) 63 | unique_list << "5" # => LREM myuniquelist 0, "5" + RPUSH myuniquelist "5" 64 | unique_list.remove(3) # => LREM myuniquelist 0, "3" 65 | [ "4", "2", "1", "5" ] == unique_list.elements # => LRANGE myuniquelist 0, -1 66 | unique_list.clear # => DEL myuniquelist 67 | 68 | ordered_set = Kredis.ordered_set "myorderedset" 69 | ordered_set.append(%w[ 2 3 4 ]) # => ZADD myorderedset 1646131025.4953232 2 1646131025.495326 3 1646131025.4953272 4 70 | ordered_set.prepend(%w[ 1 2 3 4 ]) # => ZADD myorderedset -1646131025.4957051 1 -1646131025.495707 2 -1646131025.4957082 3 -1646131025.4957092 4 71 | ordered_set.append([]) 72 | ordered_set << "5" # => ZADD myorderedset 1646131025.4960442 5 73 | ordered_set.remove(3) # => ZREM myorderedset 3 74 | [ "4", "2", "1", "5" ] == ordered_set.elements # => ZRANGE myorderedset 0 -1 75 | 76 | set = Kredis.set "myset", typed: :datetime 77 | set.add(DateTime.tomorrow, DateTime.yesterday) # => SADD myset "2021-02-03 00:00:00 +0100" "2021-02-01 00:00:00 +0100" 78 | set << DateTime.tomorrow # => SADD myset "2021-02-03 00:00:00 +0100" 79 | 2 == set.size # => SCARD myset 80 | [ DateTime.tomorrow, DateTime.yesterday ] == set.members # => SMEMBERS myset 81 | set.clear # => DEL myset 82 | 83 | hash = Kredis.hash "myhash" 84 | hash.update("key" => "value", "key2" => "value2") # => HSET myhash "key", "value", "key2", "value2" 85 | { "key" => "value", "key2" => "value2" } == hash.to_h # => HGETALL myhash 86 | "value2" == hash["key2"] # => HMGET myhash "key2" 87 | %w[ key key2 ] == hash.keys # => HKEYS myhash 88 | %w[ value value2 ] == hash.values # => HVALS myhash 89 | hash.remove # => DEL myhash 90 | 91 | high_scores = Kredis.hash "high_scores", typed: :integer 92 | high_scores.update(space_invaders: 100, pong: 42) # HSET high_scores "space_invaders", "100", "pong", "42" 93 | %w[ space_invaders pong ] == high_scores.keys # HKEYS high_scores 94 | [ 100, 42 ] == high_scores.values # HVALS high_scores 95 | { "space_invaders" => 100, "pong" => 42 } == high_scores.to_h # HGETALL high_scores 96 | 97 | head_count = Kredis.counter "headcount" 98 | 0 == head_count.value # => GET "headcount" 99 | head_count.increment # => SET headcount 0 NX + INCRBY headcount 1 100 | head_count.increment # => SET headcount 0 NX + INCRBY headcount 1 101 | head_count.decrement # => SET headcount 0 NX + DECRBY headcount 1 102 | 1 == head_count.value # => GET "headcount" 103 | 104 | counter = Kredis.counter "mycounter", expires_in: 5.seconds 105 | counter.increment by: 2 # => SET mycounter 0 EX 5 NX + INCRBY "mycounter" 2 106 | 2 == counter.value # => GET "mycounter" 107 | sleep 6.seconds 108 | 0 == counter.value # => GET "mycounter" 109 | counter.reset # => DEL mycounter 110 | 111 | cycle = Kredis.cycle "mycycle", values: %i[ one two three ] 112 | :one == cycle.value # => GET mycycle 113 | cycle.next # => GET mycycle + SET mycycle 1 114 | :two == cycle.value # => GET mycycle 115 | cycle.next # => GET mycycle + SET mycycle 2 116 | :three == cycle.value # => GET mycycle 117 | cycle.next # => GET mycycle + SET mycycle 0 118 | :one == cycle.value # => GET mycycle 119 | cycle.reset # => DEL mycycle 120 | 121 | enum = Kredis.enum "myenum", values: %w[ one two three ], default: "one" 122 | "one" == enum.value # => GET myenum 123 | true == enum.one? # => GET myenum 124 | enum.value = "two" # => SET myenum "two" 125 | "two" == enum.value # => GET myenum 126 | enum.three! # => SET myenum "three" 127 | "three" == enum.value # => GET myenum 128 | enum.value = "four" 129 | "three" == enum.value # => GET myenum 130 | enum.reset # => DEL myenum 131 | "one" == enum.value # => GET myenum 132 | 133 | slots = Kredis.slots "myslots", available: 3 134 | true == slots.available? # => GET myslots 135 | slots.reserve # => INCR myslots 136 | true == slots.available? # => GET myslots 137 | slots.reserve # => INCR myslots 138 | true == slots.available? # => GET myslots 139 | slots.reserve # => INCR myslots 140 | false == slots.available? # => GET myslots 141 | slots.reserve # => INCR myslots + DECR myslots 142 | false == slots.available? # => GET myslots 143 | slots.release # => DECR myslots 144 | true == slots.available? # => GET myslots 145 | slots.reset # => DEL myslots 146 | 147 | 148 | slot = Kredis.slot "myslot" 149 | true == slot.available? # => GET myslot 150 | slot.reserve # => INCR myslot 151 | false == slot.available? # => GET myslot 152 | slot.release # => DECR myslot 153 | true == slot.available? # => GET myslot 154 | slot.reset # => DEL myslot 155 | 156 | flag = Kredis.flag "myflag" 157 | false == flag.marked? # => EXISTS myflag 158 | flag.mark # => SET myflag 1 159 | true == flag.marked? # => EXISTS myflag 160 | flag.remove # => DEL myflag 161 | false == flag.marked? # => EXISTS myflag 162 | 163 | true == flag.mark(expires_in: 1.second, force: false) #=> SET myflag 1 EX 1 NX 164 | false == flag.mark(expires_in: 10.seconds, force: false) #=> SET myflag 10 EX 1 NX 165 | true == flag.marked? #=> EXISTS myflag 166 | sleep 0.5.seconds 167 | true == flag.marked? #=> EXISTS myflag 168 | sleep 0.6.seconds 169 | false == flag.marked? #=> EXISTS myflag 170 | 171 | limiter = Kredis.limiter "mylimit", limit: 3, expires_in: 5.seconds 172 | 0 == limiter.value # => GET "limiter" 173 | limiter.poke # => SET limiter 0 NX + INCRBY limiter 1 174 | limiter.poke # => SET limiter 0 NX + INCRBY limiter 1 175 | false == limiter.exceeded? # => GET "limiter" 176 | limiter.poke # => SET limiter 0 NX + INCRBY limiter 1 177 | true == limiter.exceeded? # => GET "limiter" 178 | sleep 6 179 | limiter.poke # => SET limiter 0 NX + INCRBY limiter 1 180 | limiter.poke # => SET limiter 0 NX + INCRBY limiter 1 181 | false == limiter.exceeded? # => GET "limiter" 182 | ``` 183 | 184 | ### Models 185 | 186 | You can use all these structures in models: 187 | 188 | ```ruby 189 | class Person < ApplicationRecord 190 | kredis_list :names 191 | kredis_list :names_with_custom_key_via_lambda, key: ->(p) { "person:#{p.id}:names_customized" } 192 | kredis_list :names_with_custom_key_via_method, key: :generate_names_key 193 | kredis_unique_list :skills, limit: 2 194 | kredis_enum :morning, values: %w[ bright blue black ], default: "bright" 195 | kredis_counter :steps, expires_in: 1.hour 196 | 197 | private 198 | def generate_names_key 199 | "key-generated-from-private-method" 200 | end 201 | end 202 | 203 | person = Person.find(5) 204 | person.names.append "David", "Heinemeier", "Hansson" # => RPUSH people:5:names "David" "Heinemeier" "Hansson" 205 | true == person.morning.bright? # => GET people:5:morning 206 | person.morning.value = "blue" # => SET people:5:morning 207 | true == person.morning.blue? # => GET people:5:morning 208 | ``` 209 | 210 | ### Default values 211 | 212 | You can set a default value for all types. For example: 213 | 214 | ```ruby 215 | list = Kredis.list "favorite_colors", default: [ "red", "green", "blue" ] 216 | 217 | # or, in a model 218 | class Person < ApplicationRecord 219 | kredis_string :name, default: "Unknown" 220 | kredis_list :favorite_colors, default: [ "red", "green", "blue" ] 221 | end 222 | ``` 223 | 224 | There's a performance overhead to consider though. When you first read or write an attribute in a model, Kredis will 225 | check if the underlying Redis key exists, while watching for concurrent changes, and if it does not, 226 | write the specified default value. 227 | 228 | This means that using default values in a typical Rails app additional Redis calls (WATCH, EXISTS, UNWATCH) will be 229 | executed for each Kredis attribute with a default value read or written during a request. 230 | 231 | ### Callbacks 232 | 233 | You can also define `after_change` callbacks that trigger on mutations: 234 | 235 | ```ruby 236 | class Person < ApplicationRecord 237 | kredis_list :names, after_change: ->(p) { } 238 | kredis_unique_list :skills, limit: 2, after_change: :skillset_changed 239 | 240 | def skillset_changed 241 | end 242 | end 243 | ``` 244 | 245 | ### Multiple Redis servers 246 | 247 | And using structures on a different than the default `shared` redis instance, relying on `config/redis/secondary.yml`: 248 | 249 | ```ruby 250 | one_string = Kredis.string "mystring" 251 | two_string = Kredis.string "mystring", config: :secondary 252 | 253 | one_string.value = "just on shared" 254 | two_string.value != one_string.value 255 | ``` 256 | 257 | ## Installation 258 | 259 | 1. Run `./bin/bundle add kredis` 260 | 2. Run `./bin/rails kredis:install` to add a default configuration at [`config/redis/shared.yml`](lib/install/shared.yml) 261 | 262 | Additional configurations can be added under `config/redis/*.yml` and referenced when a type is created. For example, `Kredis.string("mystring", config: :strings)` would lookup `config/redis/strings.yml`. 263 | 264 | Kredis passes the configuration to `Redis.new` to establish the connection. See the [Redis documentation](https://github.com/redis/redis-rb) for other configuration options. 265 | 266 | If you don't have `config/redis/shared.yml` (or use another named configuration), Kredis will default to look in env for `REDIS_URL`, then fallback to a default URL of `redis://127.0.0.1:6379/0`. 267 | 268 | ### Redis support 269 | 270 | Kredis works with Redis server 4.0+, with the [Redis Ruby](https://github.com/redis/redis-rb) client version 4.2+. Redis Cluster is not supported. 271 | 272 | ### Setting SSL options on Redis Connections 273 | 274 | If you need to connect to Redis with SSL, the recommended approach is to set your Redis instance manually by adding an entry to the `Kredis::Connections.connections` hash. Below an example showing how to connect to Redis using Client Authentication: 275 | 276 | ```ruby 277 | Kredis::Connections.connections[:shared] = Redis.new( 278 | url: ENV["REDIS_URL"], 279 | ssl_params: { 280 | cert_store: OpenSSL::X509::Store.new.tap { |store| 281 | store.add_file(Rails.root.join("config", "ca_cert.pem").to_s) 282 | }, 283 | 284 | cert: OpenSSL::X509::Certificate.new(File.read( 285 | Rails.root.join("config", "client.crt") 286 | )), 287 | 288 | key: OpenSSL::PKey::RSA.new( 289 | Rails.application.credentials.redis[:client_key] 290 | ), 291 | 292 | verify_mode: OpenSSL::SSL::VERIFY_PEER 293 | } 294 | ) 295 | ``` 296 | 297 | The above code could be added to either `config/environments/production.rb` or an initializer. Please ensure that your client private key, if used, is stored your credentials file or another secure location. 298 | 299 | ### Configure how the redis client is created 300 | 301 | You can configure how the redis client is created by setting `config.kredis.connector` in your `application.rb`: 302 | 303 | ```ruby 304 | config.kredis.connector = ->(config) { SomeRedisProxy.new(config) } 305 | ``` 306 | 307 | By default Kredis will use `Redis.new(config)`. 308 | 309 | ## Development 310 | 311 | A development console is available by running `bin/console`. 312 | 313 | From there, you can experiment with Kredis. e.g. 314 | 315 | ```erb 316 | >> str = Kredis.string "mystring" 317 | Kredis (0.1ms) Connected to shared 318 | => 319 | #> str.value = "hello, world" 322 | Kredis Proxy (2.4ms) SET mystring ["hello, world"] 323 | => "hello, world" 324 | >> str.value 325 | ``` 326 | 327 | Run tests with `bin/test`. 328 | 329 | [`debug`](https://github.com/ruby/debug) can be used in the development console and in the test suite by inserting a 330 | breakpoint, e.g. `debugger`. 331 | 332 | ## License 333 | 334 | Kredis is released under the [MIT License](https://opensource.org/licenses/MIT). 335 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "irb" 5 | require "bundler/inline" 6 | 7 | gemfile do 8 | source "https://rubygems.org" 9 | 10 | gem "debug", ">= 1.0.0" 11 | 12 | gem "kredis", path: "../" 13 | end 14 | 15 | require "debug" 16 | 17 | Kredis.configurator = Class.new do 18 | def config_for(name) { db: "2" } end 19 | def root() Pathname.new(".") end 20 | end.new 21 | ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) 22 | 23 | IRB.start 24 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION=$1 4 | 5 | printf "module Kredis\n VERSION = \"$VERSION\"\nend\n" > ./lib/kredis/version.rb 6 | bundle 7 | git add Gemfile.lock lib/kredis/version.rb 8 | git commit -m "Bump version for $VERSION" 9 | git push 10 | git tag v$VERSION 11 | git push --tags 12 | gem build kredis.gemspec 13 | gem push "kredis-$VERSION.gem" --host https://rubygems.org 14 | rm "kredis-$VERSION.gem" 15 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rubocop", "rubocop") 28 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | $: << File.expand_path("../test", __dir__) 5 | 6 | require "bundler/setup" 7 | require "rails/plugin/test" 8 | -------------------------------------------------------------------------------- /kredis.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/kredis/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "kredis" 7 | s.version = Kredis::VERSION 8 | s.authors = [ "Kasper Timm Hansen", "David Heinemeier Hansson" ] 9 | s.email = "david@hey.com" 10 | s.summary = "Higher-level data structures built on Redis." 11 | s.homepage = "https://github.com/rails/kredis" 12 | s.license = "MIT" 13 | 14 | s.required_ruby_version = ">= 2.7.0" 15 | s.add_dependency "activesupport", ">= 6.0.0" 16 | s.add_dependency "activemodel", ">= 6.0.0" 17 | s.add_dependency "redis", ">= 4.2", "< 6" 18 | s.add_development_dependency "rails", ">= 6.0.0" 19 | 20 | s.files = Dir["lib/**/*", "MIT-LICENSE", "README.md"] 21 | end 22 | -------------------------------------------------------------------------------- /lib/install/install.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | yaml_path = Rails.root.join("config/redis/shared.yml") 4 | unless yaml_path.exist? 5 | say "Adding `config/redis/shared.yml`" 6 | empty_directory yaml_path.parent.to_s 7 | copy_file "#{__dir__}/shared.yml", yaml_path 8 | end 9 | -------------------------------------------------------------------------------- /lib/install/shared.yml: -------------------------------------------------------------------------------- 1 | production: &production 2 | url: <%= ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0") %> 3 | timeout: 1 4 | 5 | development: &development 6 | url: <%= ENV.fetch("REDIS_URL", "redis://127.0.0.1:6379/0") %> 7 | timeout: 1 8 | 9 | # You can also specify host, port, and db instead of url 10 | # host: <%= ENV.fetch("REDIS_SHARED_HOST", "127.0.0.1") %> 11 | # port: <%= ENV.fetch("REDIS_SHARED_PORT", "6379") %> 12 | # db: <%= ENV.fetch("REDIS_SHARED_DB", "11") %> 13 | 14 | test: 15 | <<: *development 16 | -------------------------------------------------------------------------------- /lib/kredis.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require "active_support/core_ext/module/attribute_accessors" 5 | require "active_support/core_ext/module/attribute_accessors_per_thread" 6 | 7 | require "kredis/version" 8 | 9 | require "kredis/connections" 10 | require "kredis/log_subscriber" 11 | require "kredis/namespace" 12 | require "kredis/type_casting" 13 | require "kredis/default_values" 14 | require "kredis/types" 15 | require "kredis/attributes" 16 | 17 | require "kredis/railtie" if defined?(Rails::Railtie) 18 | 19 | module Kredis 20 | include Connections, Namespace, TypeCasting, Types 21 | extend self 22 | 23 | autoload :Migration, "kredis/migration" 24 | 25 | mattr_accessor :logger 26 | 27 | def redis(config: :shared) 28 | configured_for(config) 29 | end 30 | 31 | def instrument(channel, **options, &block) 32 | ActiveSupport::Notifications.instrument("#{channel}.kredis", **options, &block) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/kredis/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis::Attributes 4 | extend ActiveSupport::Concern 5 | 6 | class_methods do 7 | def kredis_proxy(name, key: nil, config: :shared, after_change: nil) 8 | kredis_connection_with __method__, name, key, config: config, after_change: after_change 9 | end 10 | 11 | def kredis_string(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 12 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 13 | end 14 | 15 | def kredis_integer(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 16 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 17 | end 18 | 19 | def kredis_decimal(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 20 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 21 | end 22 | 23 | def kredis_datetime(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 24 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 25 | end 26 | 27 | def kredis_flag(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 28 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 29 | 30 | define_method("#{name}?") do 31 | send(name).marked? 32 | end 33 | end 34 | 35 | def kredis_float(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 36 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 37 | end 38 | 39 | def kredis_enum(name, key: nil, values:, default:, config: :shared, after_change: nil) 40 | kredis_connection_with __method__, name, key, values: values, default: default, config: config, after_change: after_change 41 | end 42 | 43 | def kredis_json(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 44 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 45 | end 46 | 47 | def kredis_list(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) 48 | kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change 49 | end 50 | 51 | def kredis_unique_list(name, limit: nil, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) 52 | kredis_connection_with __method__, name, key, default: default, limit: limit, typed: typed, config: config, after_change: after_change 53 | end 54 | 55 | def kredis_set(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) 56 | kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change 57 | end 58 | 59 | def kredis_ordered_set(name, limit: nil, default: nil, key: nil, typed: :string, config: :shared, after_change: nil) 60 | kredis_connection_with __method__, name, key, default: default, limit: limit, typed: typed, config: config, after_change: after_change 61 | end 62 | 63 | def kredis_slot(name, key: nil, config: :shared, after_change: nil) 64 | kredis_connection_with __method__, name, key, config: config, after_change: after_change 65 | end 66 | 67 | def kredis_slots(name, available:, key: nil, config: :shared, after_change: nil) 68 | kredis_connection_with __method__, name, key, available: available, config: config, after_change: after_change 69 | end 70 | 71 | def kredis_counter(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 72 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 73 | end 74 | 75 | def kredis_limiter(name, limit:, key: nil, config: :shared, after_change: nil, expires_in: nil) 76 | kredis_connection_with __method__, name, key, limit: limit, config: config, after_change: after_change, expires_in: expires_in 77 | end 78 | 79 | def kredis_hash(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) 80 | kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change 81 | end 82 | 83 | def kredis_boolean(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) 84 | kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in 85 | end 86 | 87 | private 88 | def kredis_connection_with(method, name, key, **options) 89 | ivar_symbol = :"@#{name}_#{method}" 90 | type = method.to_s.sub("kredis_", "") 91 | after_change = options.delete(:after_change) 92 | 93 | define_method(name) do 94 | if instance_variable_defined?(ivar_symbol) 95 | instance_variable_get(ivar_symbol) 96 | else 97 | options[:default] = kredis_default_evaluated(options[:default]) if options[:default] 98 | new_type = Kredis.send(type, kredis_key_evaluated(key) || kredis_key_for_attribute(name), **options) 99 | instance_variable_set ivar_symbol, 100 | after_change ? enrich_after_change_with_record_access(new_type, after_change) : new_type 101 | end 102 | end 103 | end 104 | end 105 | 106 | private 107 | def kredis_key_evaluated(key) 108 | case key 109 | when String then key 110 | when Proc then key.call(self) 111 | when Symbol then send(key) 112 | end 113 | end 114 | 115 | def kredis_key_for_attribute(name) 116 | "#{self.class.name.tableize.tr("/", ":")}:#{extract_kredis_id}:#{name}" 117 | end 118 | 119 | def extract_kredis_id 120 | try(:id) or raise NotImplementedError, "kredis needs a unique id, either implement an id method or pass a custom key." 121 | end 122 | 123 | def enrich_after_change_with_record_access(type, original_after_change) 124 | case original_after_change 125 | when Proc then Kredis::Types::CallbacksProxy.new(type, ->(_) { original_after_change.call(self) }) 126 | when Symbol then Kredis::Types::CallbacksProxy.new(type, ->(_) { send(original_after_change) }) 127 | end 128 | end 129 | 130 | def kredis_default_evaluated(default) 131 | case default 132 | when Proc then Proc.new { default.call(self) } 133 | when Symbol then send(default) 134 | else default 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/kredis/connections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "redis" 4 | 5 | module Kredis::Connections 6 | DEFAULT_REDIS_URL = "redis://127.0.0.1:6379/0" 7 | DEFAULT_REDIS_TIMEOUT = 1 8 | 9 | mattr_accessor :connections, default: Hash.new 10 | mattr_accessor :configurator 11 | mattr_accessor :connector, default: ->(config) { Redis.new(config) } 12 | 13 | def configured_for(name) 14 | connections[name] ||= Kredis.instrument :meta, message: "Connected to #{name}" do 15 | if configurator.root.join("config/redis/#{name}.yml").exist? 16 | connector.call configurator.config_for("redis/#{name}") 17 | elsif name == :shared 18 | Redis.new url: ENV.fetch("REDIS_URL", DEFAULT_REDIS_URL), timeout: DEFAULT_REDIS_TIMEOUT 19 | else 20 | raise "No configuration found for #{name}" 21 | end 22 | end 23 | end 24 | 25 | def clear_all 26 | Kredis.instrument :meta, message: "Connections all cleared" do 27 | connections.each_value do |connection| 28 | if Kredis.namespace 29 | keys = connection.keys("#{Kredis.namespace}:*") 30 | connection.del keys if keys.any? 31 | else 32 | connection.flushdb 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/kredis/default_values.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis::DefaultValues 4 | extend ActiveSupport::Concern 5 | 6 | prepended do 7 | attr_writer :default 8 | 9 | proxying :watch, :unwatch, :exists? 10 | 11 | def default 12 | case @default 13 | when Proc then @default.call 14 | when Symbol then send(@default) 15 | else @default 16 | end 17 | end 18 | 19 | private 20 | def set_default 21 | raise NotImplementedError, "Kredis type #{self.class} needs to define #set_default" 22 | end 23 | end 24 | 25 | def initialize(...) 26 | super 27 | 28 | if default 29 | watch do 30 | set_default unless exists? 31 | 32 | unwatch 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/kredis/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/log_subscriber" 4 | 5 | class Kredis::LogSubscriber < ActiveSupport::LogSubscriber 6 | def proxy(event) 7 | debug formatted_in(YELLOW, event, type: "Proxy") 8 | end 9 | 10 | def migration(event) 11 | debug formatted_in(YELLOW, event, type: "Migration") 12 | end 13 | 14 | def meta(event) 15 | info formatted_in(MAGENTA, event) 16 | end 17 | 18 | private 19 | def formatted_in(color, event, type: nil) 20 | color " Kredis #{type} (#{event.duration.round(1)}ms) #{event.payload[:message]}", color, bold: true 21 | end 22 | end 23 | 24 | Kredis::LogSubscriber.attach_to :kredis 25 | -------------------------------------------------------------------------------- /lib/kredis/migration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | 5 | class Kredis::Migration 6 | singleton_class.delegate :migrate_all, :migrate, :delete_all, to: :new 7 | 8 | def initialize(config = :shared) 9 | @redis = Kredis.configured_for config 10 | # TODO: Replace script loading with `copy` command once Redis 6.2+ is the minimum supported version. 11 | @copy_sha = @redis.script "load", "redis.call('SETNX', KEYS[2], redis.call('GET', KEYS[1])); return 1;" 12 | end 13 | 14 | def migrate_all(key_pattern) 15 | each_key_batch_matching(key_pattern) do |keys, pipeline| 16 | keys.each do |key| 17 | ids = key.scan(/\d+/).map(&:to_i) 18 | migrate from: key, to: yield(key, *ids), pipeline: pipeline 19 | end 20 | end 21 | end 22 | 23 | def migrate(from:, to:, pipeline: nil) 24 | namespaced_to = Kredis.namespaced_key(to) 25 | 26 | if to.present? && from != namespaced_to 27 | log_migration "Migrating key #{from} to #{namespaced_to}" do 28 | (pipeline || @redis).evalsha @copy_sha, keys: [ from, namespaced_to ] 29 | end 30 | else 31 | log_migration "Skipping blank/unaltered migration key #{from} → #{to}" 32 | end 33 | end 34 | 35 | def delete_all(*key_patterns) 36 | log_migration "DELETE ALL #{key_patterns.inspect}" do 37 | if key_patterns.length > 1 38 | @redis.del(*key_patterns) 39 | else 40 | each_key_batch_matching(key_patterns.first) do |keys, pipeline| 41 | pipeline.del(*keys) 42 | end 43 | end 44 | end 45 | end 46 | 47 | private 48 | SCAN_BATCH_SIZE = 1_000 49 | 50 | def each_key_batch_matching(key_pattern, &block) 51 | cursor = "0" 52 | begin 53 | cursor, keys = @redis.scan(cursor, match: key_pattern, count: SCAN_BATCH_SIZE) 54 | @redis.multi { |pipeline| yield keys, pipeline } 55 | end until cursor == "0" 56 | end 57 | 58 | def log_migration(message, &block) 59 | Kredis.instrument :migration, message: message, &block 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/kredis/namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis::Namespace 4 | attr_accessor :global_namespace 5 | 6 | def namespace 7 | if global_namespace 8 | if value = thread_namespace 9 | "#{global_namespace}:#{value}" 10 | else 11 | global_namespace 12 | end 13 | else 14 | thread_namespace 15 | end 16 | end 17 | 18 | def thread_namespace 19 | Thread.current[:kredis_thread_namespace] 20 | end 21 | 22 | def thread_namespace=(value) 23 | Thread.current[:kredis_thread_namespace] = value 24 | end 25 | 26 | # Backward compatibility 27 | alias_method :namespace=, :thread_namespace= 28 | 29 | def namespaced_key(key) 30 | namespace ? "#{namespace}:#{key}" : key 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/kredis/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Railtie < ::Rails::Railtie 4 | config.kredis = ActiveSupport::OrderedOptions.new 5 | 6 | initializer "kredis.testing" do 7 | ActiveSupport.on_load(:active_support_test_case) do 8 | parallelize_setup { |worker| Kredis.global_namespace = [ Kredis.global_namespace, :test, worker ].compact.join("-") } 9 | teardown { Kredis.clear_all } 10 | end 11 | end 12 | 13 | initializer "kredis.logger" do 14 | Kredis::LogSubscriber.logger = config.kredis.logger || Rails.logger 15 | end 16 | 17 | initializer "kredis.configuration" do 18 | Kredis::Connections.connector = config.kredis.connector || ->(config) { Redis.new(config) } 19 | end 20 | 21 | initializer "kredis.configurator" do 22 | Kredis.configurator = Rails.application 23 | end 24 | 25 | initializer "kredis.attributes" do 26 | # No load hook for Active Model, just defer until after initialization. 27 | config.after_initialize do 28 | ActiveModel::Model.include Kredis::Attributes if defined?(ActiveModel::Model) 29 | end 30 | 31 | ActiveSupport.on_load(:active_record) do 32 | include Kredis::Attributes 33 | end 34 | end 35 | 36 | rake_tasks do 37 | path = File.expand_path("..", __dir__) 38 | Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/kredis/type/boolean.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis 4 | module Type 5 | class Boolean < ActiveModel::Type::Boolean 6 | def serialize(value) 7 | super ? 1 : 0 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/kredis/type/datetime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis 4 | module Type 5 | class DateTime < ActiveModel::Type::DateTime 6 | def serialize(value) 7 | super&.utc&.iso8601(9) 8 | end 9 | 10 | def cast_value(value) 11 | super&.to_time 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/kredis/type/json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis 4 | module Type 5 | class Json < ActiveModel::Type::Value 6 | def type 7 | :json 8 | end 9 | 10 | def cast_value(value) 11 | JSON.parse(value) 12 | end 13 | 14 | def serialize(value) 15 | JSON.dump(value) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/kredis/type_casting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "active_model/type" 5 | require "kredis/type/boolean" 6 | require "kredis/type/datetime" 7 | require "kredis/type/json" 8 | 9 | module Kredis::TypeCasting 10 | class InvalidType < StandardError; end 11 | 12 | TYPES = { 13 | string: ActiveModel::Type::String.new, 14 | integer: ActiveModel::Type::Integer.new, 15 | decimal: ActiveModel::Type::Decimal.new, 16 | float: ActiveModel::Type::Float.new, 17 | boolean: Kredis::Type::Boolean.new, 18 | datetime: Kredis::Type::DateTime.new, 19 | json: Kredis::Type::Json.new 20 | } 21 | 22 | def type_to_string(value, type) 23 | raise InvalidType if type && !TYPES.key?(type) 24 | 25 | TYPES[type || :string].serialize(value) 26 | end 27 | 28 | def string_to_type(value, type) 29 | raise InvalidType if type && !TYPES.key?(type) 30 | 31 | TYPES[type || :string].cast(value) 32 | end 33 | 34 | def types_to_strings(values, type) 35 | Array(values).flatten.map { |value| type_to_string(value, type) } 36 | end 37 | 38 | def strings_to_types(values, type) 39 | Array(values).flatten.map { |value| string_to_type(value, type) } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/kredis/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis::Types 4 | autoload :CallbacksProxy, "kredis/types/callbacks_proxy" 5 | 6 | def proxy(key, config: :shared, after_change: nil) 7 | type_from(Proxy, config, key, after_change: after_change) 8 | end 9 | 10 | 11 | def scalar(key, typed: :string, default: nil, config: :shared, after_change: nil, expires_in: nil) 12 | type_from(Scalar, config, key, after_change: after_change, typed: typed, default: default, expires_in: expires_in) 13 | end 14 | 15 | def string(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 16 | type_from(Scalar, config, key, after_change: after_change, typed: :string, default: default, expires_in: expires_in) 17 | end 18 | 19 | def integer(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 20 | type_from(Scalar, config, key, after_change: after_change, typed: :integer, default: default, expires_in: expires_in) 21 | end 22 | 23 | def decimal(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 24 | type_from(Scalar, config, key, after_change: after_change, typed: :decimal, default: default, expires_in: expires_in) 25 | end 26 | 27 | def float(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 28 | type_from(Scalar, config, key, after_change: after_change, typed: :float, default: default, expires_in: expires_in) 29 | end 30 | 31 | def boolean(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 32 | type_from(Scalar, config, key, after_change: after_change, typed: :boolean, default: default, expires_in: expires_in) 33 | end 34 | 35 | def datetime(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 36 | type_from(Scalar, config, key, after_change: after_change, typed: :datetime, default: default, expires_in: expires_in) 37 | end 38 | 39 | def json(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 40 | type_from(Scalar, config, key, after_change: after_change, typed: :json, default: default, expires_in: expires_in) 41 | end 42 | 43 | 44 | def counter(key, expires_in: nil, default: nil, config: :shared, after_change: nil) 45 | type_from(Counter, config, key, after_change: after_change, default: default, expires_in: expires_in) 46 | end 47 | 48 | def cycle(key, values:, expires_in: nil, config: :shared, after_change: nil) 49 | type_from(Cycle, config, key, after_change: after_change, values: values, expires_in: expires_in) 50 | end 51 | 52 | def flag(key, default: nil, config: :shared, after_change: nil, expires_in: nil) 53 | type_from(Flag, config, key, after_change: after_change, default: default, expires_in: expires_in) 54 | end 55 | 56 | def enum(key, values:, default:, config: :shared, after_change: nil) 57 | type_from(Enum, config, key, after_change: after_change, values: values, default: default) 58 | end 59 | 60 | def hash(key, typed: :string, default: nil, config: :shared, after_change: nil) 61 | type_from(Hash, config, key, after_change: after_change, default: default, typed: typed) 62 | end 63 | 64 | def list(key, default: nil, typed: :string, config: :shared, after_change: nil) 65 | type_from(List, config, key, after_change: after_change, default: default, typed: typed) 66 | end 67 | 68 | def unique_list(key, default: nil, typed: :string, limit: nil, config: :shared, after_change: nil) 69 | type_from(UniqueList, config, key, after_change: after_change, default: default, typed: typed, limit: limit) 70 | end 71 | 72 | def set(key, default: nil, typed: :string, config: :shared, after_change: nil) 73 | type_from(Set, config, key, after_change: after_change, default: default, typed: typed) 74 | end 75 | 76 | def ordered_set(key, default: nil, typed: :string, limit: nil, config: :shared, after_change: nil) 77 | type_from(OrderedSet, config, key, after_change: after_change, default: default, typed: typed, limit: limit) 78 | end 79 | 80 | def slot(key, config: :shared, after_change: nil) 81 | type_from(Slots, config, key, after_change: after_change, available: 1) 82 | end 83 | 84 | def slots(key, available:, config: :shared, after_change: nil) 85 | type_from(Slots, config, key, after_change: after_change, available: available) 86 | end 87 | 88 | def limiter(key, limit:, expires_in: nil, config: :shared, after_change: nil) 89 | type_from(Limiter, config, key, after_change: after_change, expires_in: expires_in, limit: limit) 90 | end 91 | 92 | private 93 | def type_from(type_klass, config, key, after_change: nil, **options) 94 | type_klass.new(configured_for(config), namespaced_key(key), **options).then do |type| 95 | after_change ? CallbacksProxy.new(type, after_change) : type 96 | end 97 | end 98 | end 99 | 100 | require "kredis/types/proxy" 101 | require "kredis/types/proxying" 102 | 103 | require "kredis/types/scalar" 104 | require "kredis/types/counter" 105 | require "kredis/types/cycle" 106 | require "kredis/types/flag" 107 | require "kredis/types/enum" 108 | require "kredis/types/hash" 109 | require "kredis/types/list" 110 | require "kredis/types/unique_list" 111 | require "kredis/types/set" 112 | require "kredis/types/ordered_set" 113 | require "kredis/types/slots" 114 | require "kredis/types/limiter" 115 | -------------------------------------------------------------------------------- /lib/kredis/types/callbacks_proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::CallbacksProxy 4 | attr_reader :type 5 | delegate :to_s, to: :type 6 | 7 | AFTER_CHANGE_OPERATIONS = { 8 | Kredis::Types::Counter => %i[ increment decrement reset ], 9 | Kredis::Types::Cycle => %i[ next reset ], 10 | Kredis::Types::Enum => %i[ value= reset ], 11 | Kredis::Types::Flag => %i[ mark remove ], 12 | Kredis::Types::Hash => %i[ update delete []= remove ], 13 | Kredis::Types::List => %i[ remove prepend append << ], 14 | Kredis::Types::Scalar => %i[ value= clear ], 15 | Kredis::Types::Set => %i[ add << remove replace take clear ], 16 | Kredis::Types::Slots => %i[ reserve release reset ], 17 | Kredis::Types::UniqueList => %i[ remove prepend append << ] 18 | } 19 | 20 | def initialize(type, callback) 21 | @type, @callback = type, callback 22 | end 23 | 24 | def method_missing(method, *args, **kwargs, &block) 25 | result = type.send(method, *args, **kwargs, &block) 26 | invoke_suitable_after_change_callback_for method 27 | result 28 | end 29 | 30 | private 31 | def invoke_suitable_after_change_callback_for(method) 32 | @callback.call(type) if AFTER_CHANGE_OPERATIONS[type.class]&.include? method 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/kredis/types/counter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Counter < Kredis::Types::Proxying 4 | prepend Kredis::DefaultValues 5 | 6 | proxying :multi, :set, :incrby, :decrby, :get, :del, :exists? 7 | 8 | attr_accessor :expires_in 9 | 10 | def increment(by: 1) 11 | multi do 12 | set 0, ex: expires_in, nx: true if expires_in 13 | incrby by 14 | end[-1] 15 | end 16 | 17 | def decrement(by: 1) 18 | multi do 19 | set 0, ex: expires_in, nx: true if expires_in 20 | decrby by 21 | end[-1] 22 | end 23 | 24 | def value 25 | get.to_i 26 | end 27 | 28 | def reset 29 | del 30 | end 31 | 32 | private 33 | def set_default 34 | increment by: default 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/kredis/types/cycle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Cycle < Kredis::Types::Counter 4 | attr_accessor :values 5 | 6 | alias index value 7 | 8 | def value 9 | values[index] 10 | end 11 | 12 | def next 13 | set (index + 1) % values.size 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/kredis/types/enum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/object/inclusion" 4 | 5 | class Kredis::Types::Enum < Kredis::Types::Proxying 6 | prepend Kredis::DefaultValues 7 | 8 | InvalidDefault = Class.new(StandardError) 9 | 10 | proxying :set, :get, :del, :exists?, :multi 11 | 12 | attr_accessor :values 13 | 14 | def initialize(...) 15 | super 16 | define_predicates_for_values 17 | end 18 | 19 | def value=(value) 20 | if validated_choice = value.presence_in(values) 21 | set validated_choice 22 | end 23 | end 24 | 25 | def value 26 | get 27 | end 28 | 29 | def reset 30 | multi do 31 | del 32 | set_default 33 | end 34 | end 35 | 36 | private 37 | def define_predicates_for_values 38 | values.each do |defined_value| 39 | define_singleton_method("#{defined_value}?") { value == defined_value } 40 | define_singleton_method("#{defined_value}!") { self.value = defined_value } 41 | end 42 | end 43 | 44 | def set_default 45 | if default.in?(values) || default.nil? 46 | set default 47 | else 48 | raise InvalidDefault, "Default value #{default.inspect} for #{key} is not a valid option (Valid values: #{values.join(", ")})" 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/kredis/types/flag.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Flag < Kredis::Types::Proxying 4 | prepend Kredis::DefaultValues 5 | 6 | proxying :set, :exists?, :del 7 | 8 | attr_accessor :expires_in 9 | 10 | def mark(expires_in: nil, force: true) 11 | set 1, ex: expires_in || self.expires_in, nx: !force 12 | end 13 | 14 | def marked? 15 | exists? 16 | end 17 | 18 | def remove 19 | del 20 | end 21 | 22 | private 23 | def set_default 24 | mark if default 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/kredis/types/hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/hash" 4 | 5 | class Kredis::Types::Hash < Kredis::Types::Proxying 6 | prepend Kredis::DefaultValues 7 | 8 | proxying :hget, :hset, :hmget, :hdel, :hgetall, :hkeys, :hvals, :del, :exists? 9 | 10 | attr_accessor :typed 11 | 12 | def [](key) 13 | string_to_type(hget(key), typed) 14 | end 15 | 16 | def []=(key, value) 17 | update key => value 18 | end 19 | 20 | def update(**entries) 21 | hset entries.transform_values { |val| type_to_string(val, typed) }.compact if entries.flatten.any? 22 | end 23 | 24 | def values_at(*keys) 25 | strings_to_types(hmget(keys) || [], typed) 26 | end 27 | 28 | def delete(*keys) 29 | hdel keys if keys.flatten.any? 30 | end 31 | 32 | def remove 33 | del 34 | end 35 | alias clear remove 36 | 37 | def entries 38 | (hgetall || {}).transform_values { |val| string_to_type(val, typed) }.with_indifferent_access 39 | end 40 | alias to_h entries 41 | 42 | def keys 43 | hkeys || [] 44 | end 45 | 46 | def values 47 | strings_to_types(hvals || [], typed) 48 | end 49 | 50 | private 51 | def set_default 52 | update(**default) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/kredis/types/limiter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A limiter is a specialized form of a counter that can be checked whether it has been exceeded and is provided fail safe. This means it can be used to guard login screens from brute force attacks without denying access in case Redis is offline. 4 | # 5 | # It will usually be used as an expiring limiter. Note that the limiter expires in total after the `expires_in` time used upon the first poke. 6 | # 7 | # It offers no guarentee that you can't poke yourself above the limit. You're responsible for checking `#exceeded?` yourself first, and this may produce a race condition. So only use this when the exact number of pokes is not critical. 8 | class Kredis::Types::Limiter < Kredis::Types::Counter 9 | class LimitExceeded < StandardError; end 10 | 11 | attr_accessor :limit 12 | 13 | def poke 14 | failsafe returning: true do 15 | increment 16 | end 17 | end 18 | 19 | def exceeded? 20 | failsafe returning: false do 21 | value >= limit 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/kredis/types/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::List < Kredis::Types::Proxying 4 | prepend Kredis::DefaultValues 5 | 6 | proxying :lrange, :lrem, :lpush, :ltrim, :rpush, :exists?, :del 7 | 8 | attr_accessor :typed 9 | 10 | def elements 11 | strings_to_types(lrange(0, -1) || [], typed) 12 | end 13 | alias to_a elements 14 | 15 | def remove(*elements) 16 | types_to_strings(elements, typed).each { |element| lrem 0, element } 17 | end 18 | 19 | def prepend(*elements) 20 | lpush types_to_strings(elements, typed) if elements.flatten.any? 21 | end 22 | 23 | def append(*elements) 24 | rpush types_to_strings(elements, typed) if elements.flatten.any? 25 | end 26 | alias << append 27 | 28 | def clear 29 | del 30 | end 31 | 32 | def last(n = nil) 33 | n ? lrange(-n, -1) : lrange(-1, -1).first 34 | end 35 | 36 | private 37 | def set_default 38 | append default 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/kredis/types/ordered_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::OrderedSet < Kredis::Types::Proxying 4 | prepend Kredis::DefaultValues 5 | 6 | proxying :multi, :zrange, :zrem, :zadd, :zremrangebyrank, :zcard, :exists?, :del, :zscore 7 | 8 | attr_accessor :typed 9 | attr_reader :limit 10 | 11 | def elements 12 | strings_to_types(zrange(0, -1) || [], typed) 13 | end 14 | alias to_a elements 15 | 16 | def remove(*elements) 17 | zrem(types_to_strings(elements, typed)) 18 | end 19 | 20 | def include?(element) 21 | !!zscore(type_to_string(element, typed)) 22 | end 23 | 24 | def prepend(elements) 25 | insert(elements, prepending: true) 26 | end 27 | 28 | def append(elements) 29 | insert(elements) 30 | end 31 | alias << append 32 | 33 | def limit=(limit) 34 | raise "Limit must be greater than 0" if limit && limit <= 0 35 | 36 | @limit = limit 37 | end 38 | 39 | private 40 | def insert(elements, prepending: false) 41 | elements = Array(elements) 42 | return if elements.empty? 43 | 44 | elements_with_scores = types_to_strings(elements, typed).map.with_index do |element, index| 45 | incremental_score = index * 0.000001 46 | 47 | score = if prepending 48 | -base_score - incremental_score 49 | else 50 | base_score + incremental_score 51 | end 52 | 53 | [ score, element ] 54 | end 55 | 56 | multi do 57 | zadd(elements_with_scores) 58 | trim(from_beginning: prepending) 59 | end 60 | end 61 | 62 | def base_score 63 | process_start_time + process_uptime 64 | end 65 | 66 | def process_start_time 67 | @process_start_time ||= unproxied_redis.time.join(".").to_f - process_uptime 68 | end 69 | 70 | def process_uptime 71 | Process.clock_gettime(Process::CLOCK_MONOTONIC) 72 | end 73 | 74 | def trim(from_beginning:) 75 | return unless limit 76 | 77 | if from_beginning 78 | zremrangebyrank(limit, -1) 79 | else 80 | zremrangebyrank(0, -(limit + 1)) 81 | end 82 | end 83 | 84 | def set_default 85 | append default 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/kredis/types/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Proxy 4 | require_relative "proxy/failsafe" 5 | include Failsafe 6 | 7 | attr_accessor :key 8 | 9 | thread_mattr_accessor :pipeline 10 | 11 | def initialize(redis, key, **options) 12 | @redis, @key = redis, key 13 | options.each { |key, value| send("#{key}=", value) } 14 | end 15 | 16 | def multi(*args, **kwargs, &block) 17 | redis.multi(*args, **kwargs) do |pipeline| 18 | self.pipeline = pipeline 19 | block.call 20 | ensure 21 | self.pipeline = nil 22 | end 23 | end 24 | 25 | def watch(&block) 26 | redis.watch(key) do 27 | block.call 28 | end 29 | end 30 | 31 | def unwatch 32 | redis.unwatch 33 | end 34 | 35 | def method_missing(method, *args, **kwargs) 36 | Kredis.instrument :proxy, **log_message(method, *args, **kwargs) do 37 | failsafe do 38 | redis.public_send method, key, *args, **kwargs 39 | end 40 | end 41 | end 42 | 43 | private 44 | def redis 45 | pipeline || @redis 46 | end 47 | 48 | def log_message(method, *args, **kwargs) 49 | args = args.flatten.reject(&:blank?).presence 50 | kwargs = kwargs.reject { |_k, v| v.blank? }.presence 51 | 52 | { message: "#{method.upcase} #{key} #{args&.inspect} #{kwargs&.inspect}".chomp } 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/kredis/types/proxy/failsafe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Kredis::Types::Proxy::Failsafe 4 | def initialize(*) 5 | super 6 | @fail_safe_suppressed = false 7 | end 8 | 9 | def failsafe 10 | yield 11 | rescue Redis::BaseError 12 | raise if fail_safe_suppressed? 13 | end 14 | 15 | def suppress_failsafe_with(returning: nil) 16 | old_fail_safe_suppressed, @fail_safe_suppressed = @fail_safe_suppressed, true 17 | yield 18 | rescue Redis::BaseError 19 | returning 20 | ensure 21 | @fail_safe_suppressed = old_fail_safe_suppressed 22 | end 23 | 24 | private 25 | def fail_safe_suppressed? 26 | @fail_safe_suppressed 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/kredis/types/proxying.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/module/delegation" 4 | 5 | class Kredis::Types::Proxying 6 | attr_accessor :proxy, :key 7 | 8 | def self.proxying(*commands) 9 | delegate(*commands, to: :proxy) 10 | end 11 | 12 | def initialize(redis, key, **options) 13 | @redis = redis 14 | @key = key 15 | @proxy = Kredis::Types::Proxy.new(redis, key) 16 | options.each { |key, value| send("#{key}=", value) } 17 | end 18 | 19 | def failsafe(returning: nil, &block) 20 | proxy.suppress_failsafe_with(returning: returning, &block) 21 | end 22 | 23 | def unproxied_redis 24 | # Generally, this should not be used. It's only here for the rare case where we need to 25 | # call Redis commands that don't reference a key and don't want to be pipelined. 26 | @redis 27 | end 28 | 29 | private 30 | delegate :type_to_string, :string_to_type, :types_to_strings, :strings_to_types, to: :Kredis 31 | end 32 | -------------------------------------------------------------------------------- /lib/kredis/types/scalar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Scalar < Kredis::Types::Proxying 4 | prepend Kredis::DefaultValues 5 | 6 | proxying :set, :get, :exists?, :del, :expire, :expireat 7 | 8 | attr_accessor :typed, :expires_in 9 | 10 | def value=(value) 11 | set type_to_string(value, typed), ex: expires_in 12 | end 13 | 14 | def value 15 | value_after_casting = string_to_type(get, typed) 16 | 17 | if value_after_casting.nil? 18 | default 19 | else 20 | value_after_casting 21 | end 22 | end 23 | 24 | def to_s 25 | get || default&.to_s 26 | end 27 | 28 | def assigned? 29 | exists? 30 | end 31 | 32 | def clear 33 | del 34 | end 35 | 36 | def expire_in(seconds) 37 | expire seconds.to_i 38 | end 39 | 40 | def expire_at(datetime) 41 | expireat datetime.to_i 42 | end 43 | 44 | private 45 | def set_default 46 | self.value = default 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/kredis/types/set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Set < Kredis::Types::Proxying 4 | prepend Kredis::DefaultValues 5 | 6 | proxying :smembers, :sadd, :srem, :multi, :del, :sismember, :scard, :spop, :exists?, :srandmember, :smove 7 | 8 | attr_accessor :typed 9 | def move(set, member) 10 | destination = set.respond_to?(:key) ? set.key : set 11 | smove(destination, member) 12 | end 13 | def members 14 | strings_to_types(smembers || [], typed).sort 15 | end 16 | alias to_a members 17 | 18 | def add(*members) 19 | sadd types_to_strings(members, typed) if members.flatten.any? 20 | end 21 | alias << add 22 | 23 | def remove(*members) 24 | srem types_to_strings(members, typed) if members.flatten.any? 25 | end 26 | 27 | def replace(*members) 28 | multi do 29 | del 30 | add members 31 | end 32 | end 33 | 34 | def include?(member) 35 | sismember type_to_string(member, typed) 36 | end 37 | 38 | def size 39 | scard.to_i 40 | end 41 | 42 | def take 43 | string_to_type(spop, typed) 44 | end 45 | 46 | def clear 47 | del 48 | end 49 | 50 | def sample(count = nil) 51 | if count.nil? 52 | string_to_type(srandmember(count), typed) 53 | else 54 | strings_to_types(srandmember(count), typed) 55 | end 56 | end 57 | 58 | private 59 | def set_default 60 | add default 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/kredis/types/slots.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kredis::Types::Slots < Kredis::Types::Proxying 4 | class NotAvailable < StandardError; end 5 | 6 | proxying :incr, :decr, :get, :del, :exists? 7 | 8 | attr_accessor :available 9 | 10 | def reserve 11 | failsafe returning: false do 12 | if block_given? 13 | begin 14 | if reserve 15 | yield 16 | true 17 | else 18 | false 19 | end 20 | ensure 21 | release 22 | end 23 | else 24 | if available? 25 | incr 26 | true 27 | else 28 | false 29 | end 30 | end 31 | end 32 | end 33 | 34 | def release 35 | if taken > 0 36 | decr 37 | true 38 | else 39 | false 40 | end 41 | end 42 | 43 | def available? 44 | failsafe returning: false do 45 | taken < available 46 | end 47 | end 48 | 49 | def reset 50 | del 51 | end 52 | 53 | def taken 54 | get.to_i 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/kredis/types/unique_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # You'd normally call this a set, but Redis already has another data type for that 4 | class Kredis::Types::UniqueList < Kredis::Types::List 5 | proxying :multi, :ltrim, :exists? 6 | 7 | attr_accessor :typed, :limit 8 | 9 | def prepend(elements) 10 | elements = Array(elements).uniq 11 | return if elements.empty? 12 | 13 | multi do 14 | remove elements 15 | super 16 | ltrim 0, (limit - 1) if limit 17 | end 18 | end 19 | 20 | def append(elements) 21 | elements = Array(elements).uniq 22 | return if elements.empty? 23 | 24 | multi do 25 | remove elements 26 | super 27 | ltrim(-limit, -1) if limit 28 | end 29 | end 30 | alias << append 31 | end 32 | -------------------------------------------------------------------------------- /lib/kredis/version.rb: -------------------------------------------------------------------------------- 1 | module Kredis 2 | VERSION = "1.8.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/kredis/install.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :kredis do 4 | desc "Install kredis" 5 | task :install do 6 | system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{File.expand_path("../../install/install.rb", __dir__)}" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/attributes_callbacks_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class AttributesCallbacksTest < ActiveSupport::TestCase 6 | class Person 7 | include Kredis::Attributes 8 | 9 | def self.name 10 | "Person" 11 | end 12 | 13 | def id 14 | 8 15 | end 16 | end 17 | 18 | test "list with after_change callback" do 19 | assert_callback_executed_for :kredis_list, :proc, ->(type) { type.append %w[ david kasper ] } 20 | assert_callback_executed_for :kredis_list, :method, ->(type) { type << %w[ david kasper ] } 21 | end 22 | 23 | test "unique_list with after_change callback" do 24 | assert_callback_executed_for :kredis_unique_list, :proc, ->(type) { type.append %w[ david kasper ] } 25 | assert_callback_executed_for :kredis_unique_list, :method, ->(type) { type << %w[ david kasper ] } 26 | end 27 | 28 | test "flag with after_change callback" do 29 | assert_callback_executed_for :kredis_flag, :proc, ->(type) { type.mark } 30 | assert_callback_executed_for :kredis_flag, :method, ->(type) { type.mark } 31 | end 32 | 33 | test "string with after_change callback" do 34 | assert_callback_executed_for :kredis_string, :proc, ->(type) { type.value = "Copenhagen" } 35 | assert_callback_executed_for :kredis_string, :method, ->(type) { type.value = "Copenhagen" } 36 | end 37 | 38 | test "slot with after_change callback" do 39 | assert_callback_executed_for :kredis_slot, :proc, ->(type) { type.reserve } 40 | assert_callback_executed_for :kredis_slot, :method, ->(type) { type.reserve } 41 | end 42 | 43 | test "enum with after_change callback" do 44 | assert_callback_executed_for :kredis_enum, :proc, ->(type) { type.value = "blue" }, values: %w[ bright blue black ], default: "bright" 45 | assert_callback_executed_for :kredis_enum, :method, ->(type) { type.value = "blue" }, values: %w[ bright blue black ], default: "bright" 46 | end 47 | 48 | test "set with after_change callback" do 49 | assert_callback_executed_for :kredis_set, :proc, ->(type) { type.add "paris" } 50 | assert_callback_executed_for :kredis_set, :method, ->(type) { type << "paris" } 51 | end 52 | 53 | test "json with after_change callback" do 54 | assert_callback_executed_for :kredis_json, :proc, ->(type) { type.value = { "color" => "red", "count" => 2 } } 55 | assert_callback_executed_for :kredis_json, :method, ->(type) { type.value = { "color" => "red", "count" => 2 } } 56 | end 57 | 58 | test "counter with after_change callback" do 59 | assert_callback_executed_for :kredis_counter, :proc, ->(type) { type.increment } 60 | assert_callback_executed_for :kredis_counter, :method, ->(type) { type.increment } 61 | end 62 | 63 | test "hash with after_change callback" do 64 | assert_callback_executed_for :kredis_hash, :proc, ->(type) { type.update space_invaders: 100, pong: 42 } 65 | assert_callback_executed_for :kredis_hash, :method, ->(type) { type.update space_invaders: 100, pong: 42 } 66 | 67 | assert_callback_executed_for :kredis_hash, :proc, ->(type) { type[:space_invaders] = 0 } 68 | assert_callback_executed_for :kredis_hash, :method, ->(type) { type[:space_invaders] = 0 } 69 | 70 | assert_callback_executed_for :kredis_hash, :proc, ->(type) { type.delete "key" } 71 | 72 | assert_callback_executed_for :kredis_hash, :method, ->(type) { type.remove } 73 | end 74 | 75 | private 76 | def assert_callback_executed_for(attribute_type, kind, executor, **options) 77 | called = false 78 | 79 | new_person = Class.new(Person) do 80 | send attribute_type, :type, **options, after_change: kind == :proc ? proc { called = true } : :changed 81 | define_method(:changed) { called = true } 82 | end 83 | 84 | executor.call(new_person.new.type) 85 | assert called 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/attributes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class Person 7 | include Kredis::Attributes 8 | 9 | kredis_proxy :anything 10 | kredis_proxy :nothing, key: "something:else" 11 | kredis_proxy :something, key: ->(p) { "person:#{p.id}:something" } 12 | kredis_list :names 13 | kredis_list :names_with_custom_key_via_lambda, key: ->(p) { "person:#{p.id}:names_customized" } 14 | kredis_list :names_with_custom_key_via_method, key: :generate_key 15 | kredis_list :names_with_default_via_lambda, default: ->(p) { [ "Random", p.name ] } 16 | kredis_unique_list :skills, limit: 2 17 | kredis_unique_list :skills_with_default_via_lambda, default: ->(p) { [ "Random", "Random", p.name ] } 18 | kredis_ordered_set :reading_list, limit: 2 19 | kredis_flag :special 20 | kredis_flag :temporary_special, expires_in: 1.second 21 | kredis_string :address 22 | kredis_string :address_with_default_via_lambda, default: ->(p) { p.name } 23 | kredis_integer :age 24 | kredis_integer :age_with_default_via_lambda, default: ->(p) { Date.today.year - p.birthdate.year } 25 | kredis_decimal :salary 26 | kredis_decimal :salary_with_default_via_lambda, default: ->(p) { p.hourly_wage * 40 * 52 } 27 | kredis_datetime :last_seen_at 28 | kredis_datetime :last_seen_at_with_default_via_lambda, default: ->(p) { p.last_login } 29 | kredis_float :height 30 | kredis_float :height_with_default_via_lambda, default: ->(p) { JSON.parse(p.anthropometry)["height"] } 31 | kredis_enum :morning, values: %w[ bright blue black ], default: "bright" 32 | kredis_enum :eye_color_with_default_via_lambda, values: %w[ hazel blue brown ], default: ->(p) { { ha: "hazel", bl: "blue", br: "brown" }[p.eye_color.to_sym] } 33 | kredis_slot :attention 34 | kredis_slots :meetings, available: 3 35 | kredis_set :vacations 36 | kredis_set :vacations_with_default_via_lambda, default: ->(p) { JSON.parse(p.vacation_destinations).map { |location| location["city"] } } 37 | kredis_json :settings 38 | kredis_json :settings_with_default_via_lambda, default: ->(p) { JSON.parse(p.anthropometry).merge(eye_color: p.eye_color) } 39 | kredis_counter :amount 40 | kredis_counter :amount_with_default_via_lambda, default: ->(p) { Date.today.year - p.birthdate.year } 41 | kredis_counter :expiring_amount, expires_in: 1.second 42 | kredis_string :temporary_password, expires_in: 1.second 43 | kredis_hash :high_scores, typed: :integer 44 | kredis_hash :high_scores_with_default_via_lambda, typed: :integer, default: ->(p) { { high_score: JSON.parse(p.scores).max } } 45 | kredis_boolean :onboarded 46 | kredis_boolean :adult_with_default_via_lambda, default: ->(p) { Date.today.year - p.birthdate.year >= 18 } 47 | kredis_limiter :update_limit, limit: 3, expires_in: 1.second 48 | 49 | def self.name 50 | "Person" 51 | end 52 | 53 | def id 54 | 8 55 | end 56 | 57 | def name 58 | "Jason" 59 | end 60 | 61 | def birthdate 62 | Date.today - 25.years 63 | end 64 | 65 | def anthropometry 66 | { height: 73.2, weight: 182.4 }.to_json 67 | end 68 | 69 | def eye_color 70 | "ha" 71 | end 72 | 73 | def scores 74 | [ 10, 28, 2, 7 ].to_json 75 | end 76 | 77 | def hourly_wage 78 | 15.26 79 | end 80 | 81 | def last_login 82 | Time.new(2002, 10, 31, 2, 2, 2, "+02:00") 83 | end 84 | 85 | def vacation_destinations 86 | [ 87 | { city: "Paris", region: "Île-de-France", country: "FR" }, 88 | { city: "Paris", region: "Texas", country: "US" } 89 | ].to_json 90 | end 91 | 92 | def update! 93 | if update_limit.exceeded? 94 | raise "Limiter exceeded" 95 | else 96 | update_limit.poke 97 | end 98 | end 99 | 100 | private 101 | def generate_key 102 | "some-generated-key" 103 | end 104 | end 105 | 106 | class MissingIdPerson 107 | include Kredis::Attributes 108 | 109 | kredis_proxy :anything 110 | kredis_proxy :nothing, key: "something:else" 111 | end 112 | 113 | class AttributesTest < ActiveSupport::TestCase 114 | setup { @person = Person.new } 115 | 116 | test "proxy" do 117 | @person.anything.set "something" 118 | assert_equal "something", @person.anything.get 119 | end 120 | 121 | test "proxy with custom string key" do 122 | @person.nothing.set "everything" 123 | assert_equal "everything", Kredis.redis.get(Kredis.namespaced_key("something:else")) 124 | end 125 | 126 | test "proxy with custom proc key" do 127 | @person.something.set "everything" 128 | assert_equal "everything", Kredis.redis.get(Kredis.namespaced_key("person:8:something")) 129 | end 130 | 131 | test "list" do 132 | @person.names.append(%w[ david kasper ]) 133 | assert_equal %w[ david kasper ], @person.names.elements 134 | end 135 | 136 | test "list with custom proc key" do 137 | @person.names_with_custom_key_via_lambda.append(%w[ david kasper ]) 138 | assert_equal %w[ david kasper ], Kredis.redis.lrange(Kredis.namespaced_key("person:8:names_customized"), 0, -1) 139 | end 140 | 141 | test "list with custom method key" do 142 | @person.names_with_custom_key_via_method.append(%w[ david kasper ]) 143 | assert_equal %w[ david kasper ], Kredis.redis.lrange(Kredis.namespaced_key("some-generated-key"), 0, -1) 144 | end 145 | 146 | test "list with default proc value" do 147 | assert_equal %w[ Random Jason ], @person.names_with_default_via_lambda.elements 148 | assert_equal %w[ Random Jason ], Kredis.redis.lrange(Kredis.namespaced_key("people:8:names_with_default_via_lambda"), 0, -1) 149 | end 150 | 151 | test "unique list" do 152 | @person.skills.prepend(%w[ trolling photography ]) 153 | @person.skills.prepend("racing") 154 | @person.skills.prepend("racing") 155 | assert_equal %w[ racing photography ], @person.skills.elements 156 | end 157 | 158 | test "unique list with default proc value" do 159 | assert_equal %w[ Random Jason ], @person.skills_with_default_via_lambda.elements 160 | assert_equal %w[ Random Jason ], Kredis.redis.lrange(Kredis.namespaced_key("people:8:skills_with_default_via_lambda"), 0, -1) 161 | end 162 | 163 | test "ordered set" do 164 | @person.reading_list.prepend(%w[ rework shapeup remote ]) 165 | assert_equal %w[ remote shapeup ], @person.reading_list.elements 166 | end 167 | 168 | test "flag" do 169 | assert_not @person.special? 170 | 171 | @person.special.mark 172 | assert @person.special? 173 | 174 | @person.special.remove 175 | assert_not @person.special? 176 | end 177 | 178 | test "string" do 179 | assert_not @person.address.assigned? 180 | 181 | @person.address.value = "Copenhagen" 182 | assert @person.address.assigned? 183 | assert_equal "Copenhagen", @person.address.to_s 184 | 185 | @person.address.clear 186 | assert_not @person.address.assigned? 187 | end 188 | 189 | test "string with default proc value" do 190 | assert_equal "Jason", @person.address_with_default_via_lambda.to_s 191 | 192 | @person.address.clear 193 | assert_not @person.address.assigned? 194 | end 195 | 196 | test "integer" do 197 | @person.age.value = 41 198 | assert_equal 41, @person.age.value 199 | assert_equal "41", @person.age.to_s 200 | end 201 | 202 | test "integer with default proc value" do 203 | assert_equal 25, @person.age_with_default_via_lambda.value 204 | assert_equal "25", @person.age_with_default_via_lambda.to_s 205 | end 206 | 207 | test "decimal" do 208 | @person.salary.value = 10000.07 209 | assert_equal 10000.07, @person.salary.value 210 | assert_equal "0.1000007e5", @person.salary.to_s 211 | end 212 | 213 | test "decimal with default proc value" do 214 | assert_equal 31_740.80.to_d, @person.salary_with_default_via_lambda.value 215 | assert_equal "0.317408e5", @person.salary_with_default_via_lambda.to_s 216 | end 217 | 218 | test "float" do 219 | @person.height.value = 1.85 220 | assert_equal 1.85, @person.height.value 221 | assert_equal "1.85", @person.height.to_s 222 | end 223 | 224 | test "float with default proc value" do 225 | assert_not_equal 73.2, Kredis.redis.get(Kredis.namespaced_key("people:8:height_with_default_via_lambda")) 226 | assert_equal 73.2, @person.height_with_default_via_lambda.value 227 | assert_equal "73.2", @person.height_with_default_via_lambda.to_s 228 | end 229 | 230 | test "datetime with default proc value" do 231 | freeze_time 232 | @person.last_seen_at.value = Time.now 233 | assert_equal Time.now, @person.last_seen_at.value 234 | end 235 | 236 | test "datetime" do 237 | assert_equal Time.new(2002, 10, 31, 2, 2, 2, "+02:00"), @person.last_seen_at_with_default_via_lambda.value 238 | end 239 | 240 | test "slot" do 241 | assert @person.attention.reserve 242 | assert_not @person.attention.available? 243 | assert_not @person.attention.reserve 244 | 245 | @person.attention.release 246 | assert @person.attention.available? 247 | 248 | used_attention = false 249 | 250 | @person.attention.reserve do 251 | used_attention = true 252 | end 253 | 254 | assert used_attention 255 | 256 | @person.attention.reserve 257 | 258 | assert_equal "did not run", (@person.attention.reserve { "ran!" } || "did not run") 259 | end 260 | 261 | test "slots" do 262 | assert @person.meetings.reserve 263 | assert @person.meetings.available? 264 | 265 | assert @person.meetings.reserve 266 | assert @person.meetings.reserve 267 | assert_not @person.meetings.available? 268 | assert_not @person.meetings.reserve 269 | 270 | @person.meetings.release 271 | assert @person.meetings.available? 272 | 273 | used_meeting = false 274 | 275 | @person.meetings.reserve do 276 | used_meeting = true 277 | end 278 | 279 | assert used_meeting 280 | 281 | @person.meetings.reset 282 | 283 | 3.times { @person.meetings.reserve } 284 | assert_equal "did not run", (@person.meetings.reserve { "ran!" } || "did not run") 285 | end 286 | 287 | test "enum" do 288 | assert @person.morning.bright? 289 | 290 | assert @person.morning.value = "blue" 291 | assert @person.morning.blue? 292 | 293 | assert_not @person.morning.black? 294 | 295 | assert @person.morning.value = "nonsense" 296 | assert @person.morning.blue? 297 | 298 | @person.morning.reset 299 | assert @person.morning.bright? 300 | end 301 | 302 | test "enum with default proc value" do 303 | assert @person.eye_color_with_default_via_lambda.hazel? 304 | end 305 | 306 | 307 | test "set" do 308 | @person.vacations.add "paris" 309 | @person.vacations.add "paris" 310 | assert_equal [ "paris" ], @person.vacations.to_a 311 | 312 | @person.vacations << "berlin" 313 | assert_equal %w[ paris berlin ].sort, @person.vacations.members.sort 314 | 315 | assert @person.vacations.include?("berlin") 316 | assert_equal 2, @person.vacations.size 317 | 318 | @person.vacations.remove("berlin") 319 | assert_equal "paris", @person.vacations.take 320 | end 321 | 322 | test "set with default proc value" do 323 | assert_equal [ "Paris" ], @person.vacations_with_default_via_lambda.members 324 | assert_equal [ "Paris" ], Kredis.redis.smembers(Kredis.namespaced_key("people:8:vacations_with_default_via_lambda")) 325 | end 326 | 327 | test "json" do 328 | @person.settings.value = { "color" => "red", "count" => 2 } 329 | assert_equal({ "color" => "red", "count" => 2 }, @person.settings.value) 330 | end 331 | 332 | test "json with default proc value" do 333 | expect = { "height" => 73.2, "weight" => 182.4, "eye_color" => "ha" } 334 | assert_equal expect, @person.settings_with_default_via_lambda.value 335 | assert_equal expect.to_json, Kredis.redis.get(Kredis.namespaced_key("people:8:settings_with_default_via_lambda")) 336 | end 337 | 338 | 339 | test "counter" do 340 | @person.amount.increment 341 | assert_equal 1, @person.amount.value 342 | @person.amount.decrement 343 | assert_equal 0, @person.amount.value 344 | end 345 | 346 | test "counter with expires_at" do 347 | @person.expiring_amount.increment 348 | assert_changes "@person.expiring_amount.value", from: 1, to: 0 do 349 | sleep 1.1.seconds 350 | end 351 | end 352 | 353 | test "counter with default proc value" do 354 | @person.amount_with_default_via_lambda.increment 355 | assert_equal 26, @person.amount_with_default_via_lambda.value 356 | @person.amount_with_default_via_lambda.decrement 357 | assert_equal 25, @person.amount_with_default_via_lambda.value 358 | end 359 | 360 | test "hash" do 361 | @person.high_scores.update(space_invaders: 100, pong: 42) 362 | assert_equal({ "space_invaders" => 100, "pong" => 42 }, @person.high_scores.to_h) 363 | assert_equal([ "space_invaders", "pong" ], @person.high_scores.keys) 364 | assert_equal([ 100, 42 ], @person.high_scores.values) 365 | end 366 | 367 | test "hash with default proc value" do 368 | assert_equal({ "high_score" => 28 }, @person.high_scores_with_default_via_lambda.to_h) 369 | end 370 | 371 | test "boolean" do 372 | @person.onboarded.value = true 373 | assert @person.onboarded.value 374 | 375 | @person.onboarded.value = false 376 | assert_not @person.onboarded.value 377 | end 378 | 379 | test "boolean with default proc value" do 380 | assert @person.adult_with_default_via_lambda.value 381 | end 382 | 383 | test "missing id to constrain key" do 384 | assert_raise NotImplementedError do 385 | MissingIdPerson.new.anything 386 | end 387 | 388 | assert_nil MissingIdPerson.new.nothing.get 389 | 390 | suddenly_implemented_person = MissingIdPerson.new 391 | def suddenly_implemented_person.id; 8; end 392 | 393 | assert_nil suddenly_implemented_person.anything.get 394 | end 395 | 396 | test "expiring scalars" do 397 | @person.temporary_password.value = "assigned" 398 | assert_changes "@person.temporary_password.value", from: "assigned", to: nil do 399 | sleep 1.1.seconds 400 | end 401 | end 402 | 403 | test "expiring flag" do 404 | @person.temporary_special.mark 405 | assert_changes "@person.temporary_special.marked?", from: true, to: false do 406 | sleep 1.1.seconds 407 | end 408 | end 409 | 410 | test "expiring flag with force" do 411 | assert @person.temporary_special.mark 412 | 413 | sleep 0.5.seconds 414 | assert_not @person.temporary_special.mark(force: false) 415 | 416 | assert_changes "@person.temporary_special.marked?", from: true, to: false do 417 | sleep 0.6.seconds 418 | end 419 | end 420 | 421 | test "limiter exceeded" do 422 | 3.times { @person.update! } 423 | assert_raises { @person.update! } 424 | end 425 | 426 | test "expiring limiter" do 427 | 3.times { @person.update! } 428 | sleep 1.1 429 | assert_nothing_raised { 3.times { @person.update! } } 430 | end 431 | end 432 | -------------------------------------------------------------------------------- /test/callbacks_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class CallbacksTest < ActiveSupport::TestCase 6 | test "list with after_change proc callback" do 7 | @callback_check = nil 8 | names = Kredis.list "names", after_change: ->(list) { @callback_check = list.elements } 9 | names.append %w[ david kasper ] 10 | 11 | assert_equal %w[ david kasper ], @callback_check 12 | end 13 | 14 | test "flag with after_change proc callback" do 15 | @callback_check = nil 16 | special = Kredis.flag "special", after_change: ->(flag) { @callback_check = flag.marked? } 17 | special.mark 18 | 19 | assert @callback_check 20 | end 21 | 22 | test "string with after_change proc callback" do 23 | @callback_check = nil 24 | address = Kredis.string "address", after_change: ->(scalar) { @callback_check = scalar.value } 25 | address.value = "Copenhagen" 26 | 27 | assert_equal "Copenhagen", @callback_check 28 | end 29 | 30 | test "slot with after_change proc callback" do 31 | @callback_check = true 32 | attention = Kredis.slot "attention", after_change: ->(slot) { @callback_check = slot.available? } 33 | attention.reserve 34 | 35 | assert_not @callback_check 36 | end 37 | 38 | test "enum with after_change proc callback" do 39 | @callback_check = nil 40 | morning = Kredis.enum "morning", values: %w[ bright blue black ], default: "bright", after_change: ->(enum) { @callback_check = enum.value } 41 | morning.value = "blue" 42 | 43 | assert_equal "blue", @callback_check 44 | end 45 | 46 | test "set with after_change proc callback" do 47 | @callback_check = nil 48 | vacations = Kredis.set "vacations", after_change: ->(set) { @callback_check = set.members } 49 | vacations.add "paris" 50 | 51 | assert_equal [ "paris" ], @callback_check 52 | end 53 | 54 | test "hash with after_change proc callback" do 55 | @callback_check = nil 56 | high_scores = Kredis.hash "high_scores", typed: :integer, after_change: ->(hash) { @callback_check = hash.entries } 57 | high_scores.update(space_invaders: 100, pong: 42) 58 | 59 | assert_equal({ "space_invaders" => 100, "pong" => 42 }, @callback_check) 60 | end 61 | 62 | test "json with after_change proc callback" do 63 | @callback_check = nil 64 | settings = Kredis.json "settings", after_change: ->(json) { @callback_check = json.value } 65 | settings.value = { "color" => "red", "count" => 2 } 66 | 67 | assert_equal({ "color" => "red", "count" => 2 }, @callback_check) 68 | end 69 | 70 | test "counter with after_change proc callback" do 71 | @callback_check = nil 72 | amount = Kredis.counter "amount", after_change: ->(counter) { @callback_check = counter.value } 73 | amount.increment 74 | 75 | assert_equal 1, @callback_check 76 | end 77 | 78 | test "cycle with after_change proc callback" do 79 | @callback_check = nil 80 | amount = Kredis.cycle "semaphore_light", after_change: ->(cycle) { @callback_check = cycle.value }, values: %w[ green yellow red ] 81 | 82 | amount.next 83 | assert_equal "yellow", @callback_check 84 | 85 | amount.reset 86 | assert_equal "green", @callback_check 87 | end 88 | 89 | test "unique list with after_change proc callback" do 90 | @callback_check = nil 91 | names = Kredis.unique_list "names", after_change: ->(list) { @callback_check = list.elements } 92 | names.append %w[ david kasper ] 93 | 94 | assert_equal %w[ david kasper ], @callback_check 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/connections_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "yaml" 5 | 6 | class ConnectionsTest < ActiveSupport::TestCase 7 | setup do 8 | Kredis.connections = {} 9 | @original_global_namespace, Kredis.global_namespace = Kredis.global_namespace, nil 10 | end 11 | 12 | teardown do 13 | Kredis.global_namespace = @original_global_namespace 14 | Kredis.namespace = nil 15 | end 16 | 17 | test "clear all" do 18 | list = Kredis.list "mylist" 19 | list.append "one" 20 | assert_equal [ "one" ], list.elements 21 | 22 | Kredis.clear_all 23 | assert_equal [], list.elements 24 | end 25 | 26 | test "clear all with namespace" do 27 | Kredis.configured_for(:shared).set "mykey", "don't remove me" 28 | 29 | Kredis.namespace = "test-1" 30 | integer = Kredis.integer "myinteger" 31 | integer.value = 1 32 | 33 | Kredis.clear_all 34 | 35 | assert_nil integer.value 36 | assert_equal "don't remove me", Kredis.configured_for(:shared).get("mykey") 37 | end 38 | 39 | test "config from file" do 40 | fixture_config = YAML.load_file(Pathname.new(Dir.pwd).join("test/fixtures/config/redis/shared.yml"))["test"].symbolize_keys 41 | 42 | Kredis.configurator.stub(:config_for, fixture_config) do 43 | Kredis.configurator.stub(:root, Pathname.new(Dir.pwd).join("test/fixtures")) do 44 | assert_match %r{redis://127.0.0.1:6379/4}, Kredis.redis.inspect 45 | end 46 | end 47 | end 48 | 49 | test "default config in env" do 50 | ENV["REDIS_URL"] = "redis://127.0.0.1:6379/3" 51 | assert_match %r{redis://127.0.0.1:6379/3}, Kredis.redis.inspect 52 | ensure 53 | ENV.delete("REDIS_URL") 54 | end 55 | 56 | test "default config without env" do 57 | assert_match %r{redis://127.0.0.1:6379}, Kredis.redis.inspect 58 | end 59 | 60 | test "custom config is missing" do 61 | assert_raises do 62 | Kredis.configured_for(:missing).set "mykey", "won't get set" 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/fixtures/config/redis/shared.yml: -------------------------------------------------------------------------------- 1 | test: 2 | url: redis://127.0.0.1:6379/4 3 | timeout: 1 4 | -------------------------------------------------------------------------------- /test/log_subscriber_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/log_subscriber/test_helper" 5 | 6 | class LogSubscriberTest < ActiveSupport::TestCase 7 | include ActiveSupport::LogSubscriber::TestHelper 8 | 9 | teardown { ActiveSupport::LogSubscriber.log_subscribers.clear } 10 | 11 | test "proxy" do 12 | ActiveSupport::LogSubscriber.colorize_logging = true 13 | ActiveSupport::LogSubscriber.attach_to :kredis, Kredis::LogSubscriber.new 14 | 15 | instrument "proxy.kredis", message: "foo" 16 | 17 | assert_equal 1, @logger.logged(:debug).size 18 | assert_match( 19 | /\e\[1m\e\[33m Kredis Proxy \(\d+\.\d+ms\) foo\e\[0m/, 20 | @logger.logged(:debug).last 21 | ) 22 | end 23 | 24 | test "migration" do 25 | ActiveSupport::LogSubscriber.colorize_logging = true 26 | ActiveSupport::LogSubscriber.attach_to :kredis, Kredis::LogSubscriber.new 27 | 28 | instrument "migration.kredis", message: "foo" 29 | 30 | assert_equal 1, @logger.logged(:debug).size 31 | assert_match( 32 | /\e\[1m\e\[33m Kredis Migration \(\d+\.\d+ms\) foo\e\[0m/, 33 | @logger.logged(:debug).last 34 | ) 35 | end 36 | 37 | test "meta" do 38 | ActiveSupport::LogSubscriber.colorize_logging = true 39 | ActiveSupport::LogSubscriber.attach_to :kredis, Kredis::LogSubscriber.new 40 | 41 | instrument "meta.kredis", message: "foo" 42 | 43 | assert_equal 1, @logger.logged(:info).size 44 | assert_match( 45 | /\e\[1m\e\[35m Kredis \(\d+\.\d+ms\) foo\e\[0m/, 46 | @logger.logged(:info).last 47 | ) 48 | end 49 | 50 | private 51 | def instrument(...) 52 | ActiveSupport::Notifications.instrument(...) 53 | wait 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/migration_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class MigrationTest < ActiveSupport::TestCase 6 | test "migrate_all" do 7 | 3.times { |index| Kredis.proxy("mykey:#{index}").set "hello there #{index}" } 8 | 9 | Kredis::Migration.migrate_all(Kredis.namespaced_key("mykey:*")) { |key| "thykey:#{key.split(":").last}" } 10 | 11 | 3.times do |index| 12 | assert_equal "hello there #{index}", Kredis.proxy("thykey:#{index}").get 13 | end 14 | end 15 | 16 | test "migrate" do 17 | @original_global_namespace, Kredis.global_namespace = Kredis.global_namespace, nil 18 | 19 | old_proxy = Kredis.string "old_proxy" 20 | old_proxy.set "hello there" 21 | 22 | new_proxy = Kredis.string "new_proxy" 23 | assert_not new_proxy.assigned? 24 | 25 | Kredis::Migration.migrate from: Kredis.namespaced_key("old_proxy"), to: "new_proxy" 26 | assert_equal "hello there", new_proxy.value 27 | assert old_proxy.assigned?, "just copying the data" 28 | ensure 29 | Kredis.global_namespace = @original_global_namespace 30 | end 31 | 32 | test "migrate with blank keys" do 33 | assert_nothing_raised do 34 | Kredis::Migration.migrate from: Kredis.namespaced_key("old_key"), to: nil 35 | Kredis::Migration.migrate from: Kredis.namespaced_key("old_key"), to: "" 36 | end 37 | end 38 | 39 | test "migrate with namespace" do 40 | Kredis.proxy("key").set "x" 41 | 42 | Kredis.namespace = "migrate" 43 | 44 | Kredis::Migration.migrate from: "#{Kredis.global_namespace}:key", to: "key" 45 | 46 | assert_equal "x", Kredis.proxy("key").get 47 | ensure 48 | Kredis.namespace = nil 49 | end 50 | 51 | test "migrate with automatic id extraction" do 52 | Kredis.proxy("mykey:1").set "hey" 53 | 54 | Kredis::Migration.migrate_all Kredis.namespaced_key("mykey:*") do |key, id| 55 | assert_equal 1, id 56 | key 57 | end 58 | end 59 | 60 | test "delete_all with pattern" do 61 | 3.times { |index| Kredis.proxy("mykey:#{index}").set "hello there #{index}" } 62 | 63 | Kredis::Migration.delete_all Kredis.namespaced_key("mykey:*") 64 | 65 | 3.times { |index| assert_nil Kredis.proxy("mykey:#{index}").get } 66 | end 67 | 68 | test "delete_all with keys" do 69 | 3.times { |index| Kredis.proxy("mykey:#{index}").set "hello there #{index}" } 70 | 71 | Kredis::Migration.delete_all(*3.times.map { |index| Kredis.namespaced_key("mykey:#{index}") }) 72 | 73 | 3.times { |index| assert_nil Kredis.proxy("mykey:#{index}").get } 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /test/namespace_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class NamespaceTest < ActiveSupport::TestCase 6 | teardown { Kredis.thread_namespace = nil } 7 | 8 | test "list with per-thread namespace" do 9 | Kredis.thread_namespace = "test-1" 10 | list = Kredis.list "mylist" 11 | list.append "one" 12 | assert_equal [ "one" ], list.elements 13 | 14 | # Aliased to thread_namespace= for back-compat 15 | Kredis.namespace = "test-2" 16 | list = Kredis.list "mylist" 17 | assert_equal [], list.elements 18 | 19 | Kredis.namespace = "test-1" 20 | list = Kredis.list "mylist" 21 | assert_equal [ "one" ], list.elements 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/proxy_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ProxyTest < ActiveSupport::TestCase 6 | setup { @proxy = Kredis.proxy "something" } 7 | 8 | test "proxy set and get and del" do 9 | @proxy.set "one" 10 | assert_equal "one", @proxy.get 11 | 12 | @proxy.del 13 | assert_nil @proxy.get 14 | end 15 | 16 | test "failing open" do 17 | @proxy.set "one" 18 | assert_equal "one", @proxy.get 19 | stub_redis_down(@proxy) { assert_nil @proxy.get } 20 | 21 | assert @proxy.set("two") 22 | stub_redis_down(@proxy) { assert_nil @proxy.set("two") } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "active_support/test_case" 5 | require "active_support/testing/autorun" 6 | require "rails/test_unit/line_filtering" 7 | require "minitest/mock" 8 | require "debug" 9 | 10 | require "kredis" 11 | 12 | Kredis.configurator = Class.new do 13 | def config_for(name) { db: "1" } end 14 | def root() Pathname.new(".") end 15 | end.new 16 | 17 | ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new(STDOUT) if ENV["VERBOSE"] 18 | 19 | class ActiveSupport::TestCase 20 | extend Rails::LineFiltering 21 | 22 | setup { Kredis.global_namespace = "kredis-test" } 23 | teardown { Kredis.global_namespace = nil; Kredis.clear_all } 24 | 25 | class RedisUnavailableProxy 26 | def multi; yield; end 27 | def pipelined; yield; end 28 | def method_missing(*); raise Redis::BaseError; end 29 | end 30 | 31 | def stub_redis_down(redis_holder, &block) 32 | redis_holder.try(:proxy) || redis_holder \ 33 | .stub(:redis, RedisUnavailableProxy.new, &block) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/types/counter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class CounterTest < ActiveSupport::TestCase 7 | setup { @counter = Kredis.counter "mycounter" } 8 | 9 | test "increment" do 10 | assert_equal 0, @counter.value 11 | 12 | @counter.increment 13 | assert_equal 1, @counter.value 14 | 15 | @counter.increment 16 | assert_equal 2, @counter.value 17 | 18 | assert_equal 3, @counter.increment 19 | end 20 | 21 | test "increment by 2" do 22 | assert_equal 0, @counter.value 23 | 24 | @counter.increment by: 2 25 | assert_equal 2, @counter.value 26 | 27 | assert_equal 4, @counter.increment(by: 2) 28 | end 29 | 30 | test "decrement" do 31 | assert_equal 0, @counter.value 32 | 33 | @counter.decrement 34 | assert_equal (-1), @counter.value 35 | 36 | assert_equal (-2), @counter.decrement 37 | end 38 | 39 | test "decrement by 2" do 40 | assert_equal 0, @counter.value 41 | 42 | @counter.decrement by: 2 43 | assert_equal (-2), @counter.value 44 | 45 | assert_equal (-4), @counter.decrement(by: 2) 46 | end 47 | 48 | test "expiring counter" do 49 | @counter = Kredis.counter "mycounter", expires_in: 1.second 50 | 51 | @counter.increment 52 | assert_equal 1, @counter.value 53 | 54 | sleep 0.5.seconds 55 | 56 | @counter.increment 57 | assert_equal 2, @counter.value 58 | 59 | sleep 0.6.seconds 60 | 61 | assert_equal 0, @counter.value 62 | end 63 | 64 | test "reset" do 65 | @counter.increment 66 | assert_equal 1, @counter.value 67 | 68 | @counter.reset 69 | assert_equal 0, @counter.value 70 | end 71 | 72 | test "failing open" do 73 | stub_redis_down(@counter) { @counter.increment } 74 | assert_equal 0, @counter.value 75 | end 76 | 77 | test "exists?" do 78 | assert_not @counter.exists? 79 | 80 | @counter.increment 81 | assert @counter.exists? 82 | end 83 | 84 | test "default value" do 85 | @counter = Kredis.counter "mycounter", default: 10 86 | assert_equal 10, @counter.value 87 | end 88 | 89 | test "expiring counter with default" do 90 | @counter = Kredis.counter "mycounter", default: ->() { 10 }, expires_in: 1.second 91 | 92 | @counter.increment 93 | assert_equal 11, @counter.value 94 | 95 | sleep 0.5.seconds 96 | 97 | @counter.increment 98 | assert_equal 12, @counter.value 99 | 100 | sleep 0.5.seconds 101 | 102 | # Defaults are only set on initialization 103 | assert_equal 0, @counter.value 104 | end 105 | 106 | test "default via proc" do 107 | @counter = Kredis.counter "mycounter", default: ->() { 10 } 108 | assert_equal 10, @counter.value 109 | @counter.decrement 110 | assert_equal 9, @counter.value 111 | end 112 | 113 | test "concurrent initialization with default" do 114 | 5.times.map do 115 | Thread.new do 116 | Kredis.counter("mycounter", default: 5).increment 117 | end 118 | end.each(&:join) 119 | 120 | assert_equal 10, Kredis.counter("mycounter").value 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/types/cycle_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class CycleTest < ActiveSupport::TestCase 7 | setup { @cycle = Kredis.cycle "mycycle", values: %i[ one two three ] } 8 | 9 | test "next" do 10 | assert_equal :one, @cycle.value 11 | 12 | @cycle.next 13 | assert_equal :two, @cycle.value 14 | 15 | @cycle.next 16 | assert_equal :three, @cycle.value 17 | 18 | @cycle.next 19 | assert_equal :one, @cycle.value 20 | end 21 | 22 | test "failing open" do 23 | stub_redis_down(@cycle) { @cycle.next } 24 | assert_equal :one, @cycle.value 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/types/enum_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class EnumTest < ActiveSupport::TestCase 6 | setup { @enum = Kredis.enum "myenum", values: %w[ one two three ], default: "one" } 7 | 8 | test "default" do 9 | assert_equal "one", @enum.value 10 | end 11 | 12 | test "default via proc" do 13 | @enum = Kredis.enum "myenum2", values: %w[ one two three ], default: ->() { "two" } 14 | assert_equal "two", @enum.value 15 | end 16 | 17 | test "default can be nil" do 18 | enum = Kredis.enum "myenum3", values: [ 1, 2, 3 ], default: nil 19 | assert_nil enum.value 20 | end 21 | 22 | test "default value has to be valid if not nil" do 23 | assert_raises Kredis::Types::Enum::InvalidDefault do 24 | Kredis.enum "myenum4", values: [ 1, 2, 3 ], default: 4 25 | end 26 | end 27 | 28 | test "predicates" do 29 | assert @enum.one? 30 | 31 | @enum.value = "two" 32 | assert @enum.two? 33 | 34 | assert_not @enum.three? 35 | 36 | @enum.three! 37 | assert @enum.three? 38 | 39 | assert_not @enum.two? 40 | end 41 | 42 | test "validated value" do 43 | assert @enum.one? 44 | 45 | @enum.value = "nonesense" 46 | assert @enum.one? 47 | end 48 | 49 | test "reset" do 50 | @enum.value = "two" 51 | assert @enum.two? 52 | 53 | @enum.reset 54 | assert @enum.one? 55 | end 56 | 57 | test "exists?" do 58 | enum = Kredis.enum "numbers", values: %w[ one two three ], default: nil 59 | assert_not enum.exists? 60 | 61 | enum.value = "one" 62 | assert enum.exists? 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/types/flag_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class FlagTest < ActiveSupport::TestCase 7 | setup { @flag = Kredis.flag "myflag" } 8 | 9 | test "mark" do 10 | assert_not @flag.marked? 11 | 12 | @flag.mark 13 | assert @flag.marked? 14 | 15 | @flag.remove 16 | assert_not @flag.marked? 17 | end 18 | 19 | test "expiring mark" do 20 | @flag.mark(expires_in: 1.second) 21 | assert @flag.marked? 22 | 23 | sleep 0.5.seconds 24 | assert @flag.marked? 25 | 26 | sleep 0.6.seconds 27 | assert_not @flag.marked? 28 | end 29 | 30 | test "mark with force" do 31 | assert @flag.mark(expires_in: 1.second, force: false) 32 | assert @flag.mark(expires_in: 1.second) 33 | assert @flag.mark(expires_in: 1.second, force: true) 34 | assert_not @flag.mark(expires_in: 10.seconds, force: false) 35 | 36 | assert @flag.marked? 37 | 38 | sleep 0.5.seconds 39 | assert @flag.marked? 40 | 41 | sleep 0.6.seconds 42 | assert_not @flag.marked? 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/types/hash_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class HashTest < ActiveSupport::TestCase 7 | setup { @hash = Kredis.hash "myhash" } 8 | 9 | test "[] reading" do 10 | @hash.update("key2" => "value2", "key3" => "value3") 11 | assert_equal "value2", @hash["key2"] 12 | assert_equal "value3", @hash[:key3] 13 | assert_nil @hash["key"] 14 | end 15 | 16 | test "[]= assigment" do 17 | @hash[:key] = :value 18 | @hash[:key2] = "value2" 19 | assert_equal({ "key" => "value", "key2" => "value2" }, @hash.to_h) 20 | end 21 | 22 | test "update" do 23 | @hash.update(key: :value) 24 | @hash.update("key2" => "value2", "key3" => "value3") 25 | assert_equal({ "key" => "value", "key2" => "value2", "key3" => "value3" }, @hash.to_h) 26 | end 27 | 28 | test "values_at" do 29 | @hash.update("key2" => "value2", "key3" => "value3") 30 | assert_equal %w[ value2 value3 ], @hash.values_at("key2", "key3") 31 | end 32 | 33 | test "delete" do 34 | @hash.update(key: :value) 35 | @hash.update("key2" => "value2", "key3" => "value3") 36 | assert_equal({ "key" => "value", "key2" => "value2", "key3" => "value3" }, @hash.to_h) 37 | 38 | @hash.delete("key") 39 | assert_equal({ "key2" => "value2", "key3" => "value3" }, @hash.to_h) 40 | 41 | @hash.delete("key2", "key3") 42 | assert_equal({}, @hash.to_h) 43 | end 44 | 45 | test "entries" do 46 | @hash.update(key: :value) 47 | @hash.update("key2" => "value2", "key3" => "value3") 48 | assert_equal({ "key" => "value", "key2" => "value2", "key3" => "value3" }, @hash.entries) 49 | assert_equal @hash.to_h, @hash.entries 50 | end 51 | 52 | test "keys" do 53 | @hash.update(key: :value) 54 | @hash.update("key2" => "value2", "key3" => "value3") 55 | assert_equal %w[ key key2 key3 ], @hash.keys 56 | end 57 | 58 | test "values" do 59 | @hash.update(key: :value) 60 | @hash.update("key2" => "value2", "key3" => "value3") 61 | assert_equal %w[ value value2 value3 ], @hash.values 62 | end 63 | 64 | test "typed as integer" do 65 | @hash = Kredis.hash "myhash", typed: :integer 66 | @hash.update(space_invaders: 100, pong: 42) 67 | 68 | assert_equal %w[ space_invaders pong ], @hash.keys 69 | assert_equal [ 100, 42 ], @hash.values 70 | assert_equal 100, @hash[:space_invaders] 71 | assert_equal 42, @hash["pong"] 72 | assert_equal({ "space_invaders" => 100, "pong" => 42 }, @hash.to_h) 73 | end 74 | 75 | test "remove" do 76 | @hash.update("key2" => "value2") 77 | assert_equal "value2", @hash["key2"] 78 | @hash.remove 79 | assert_equal({}, @hash.to_h) 80 | end 81 | 82 | test "clear" do 83 | @hash.update("key2" => "value2") 84 | assert_equal "value2", @hash["key2"] 85 | @hash.clear 86 | assert_equal({}, @hash.to_h) 87 | end 88 | 89 | test "exists?" do 90 | assert_not @hash.exists? 91 | 92 | @hash[:key] = :value 93 | assert @hash.exists? 94 | end 95 | 96 | test "default value" do 97 | @hash = Kredis.hash "myhash", typed: :integer, default: { space_invaders: "100", pong: "42" } 98 | assert_equal({ "space_invaders" => 100, "pong" => 42 }, @hash.to_h) 99 | assert_equal(%w[ space_invaders pong ], @hash.keys) 100 | assert_equal([ 100, 42 ], @hash.values) 101 | assert_equal(100, @hash["space_invaders"]) 102 | assert_equal([ 100, 42 ], @hash.values_at("space_invaders", "pong")) 103 | end 104 | 105 | test "update with default" do 106 | @hash = Kredis.hash "myhash", typed: :integer, default: { space_invaders: "100", pong: "42" } 107 | @hash.update(ping: "54") 108 | assert_equal(%w[ space_invaders pong ping ], @hash.keys) 109 | end 110 | 111 | test "[]= with default" do 112 | @hash = Kredis.hash "myhash", typed: :integer, default: { space_invaders: "100", pong: "42" } 113 | @hash[:ping] = "54" 114 | assert_equal(%w[ space_invaders pong ping ], @hash.keys) 115 | end 116 | 117 | test "delete with default" do 118 | @hash = Kredis.hash "myhash", typed: :integer, default: { space_invaders: "100", pong: "42" } 119 | @hash.delete(:pong) 120 | assert_equal(%w[ space_invaders ], @hash.keys) 121 | end 122 | 123 | test "default via proc" do 124 | @hash = Kredis.hash "myhash", typed: :integer, default: ->() { { space_invaders: "100", pong: "42" } } 125 | assert_equal({ "space_invaders" => 100, "pong" => 42 }, @hash.to_h) 126 | end 127 | 128 | test "handles nil values gracefully" do 129 | @hash.update("key" => nil, "key2" => "value2") 130 | assert_nil @hash["key"] 131 | assert_equal "value2", @hash["key2"] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/types/limiter_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class LimiterTest < ActiveSupport::TestCase 6 | setup { @limiter = Kredis.limiter "mylimit", limit: 5 } 7 | 8 | test "exceeded after limit is reached" do 9 | 4.times do 10 | @limiter.poke 11 | assert_not @limiter.exceeded? 12 | end 13 | 14 | @limiter.poke 15 | assert @limiter.exceeded? 16 | end 17 | 18 | test "never exceeded when redis is down" do 19 | stub_redis_down(@limiter) do 20 | 10.times do 21 | @limiter.poke 22 | assert_not @limiter.exceeded? 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/types/list_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class ListTest < ActiveSupport::TestCase 7 | setup { @list = Kredis.list "mylist" } 8 | 9 | test "append" do 10 | @list.append(%w[ 1 2 3 ]) 11 | @list << 4 12 | assert_equal %w[ 1 2 3 4 ], @list.elements 13 | end 14 | 15 | test "append nothing" do 16 | @list.append(%w[ 1 2 3 ]) 17 | @list.append([]) 18 | assert_equal %w[ 1 2 3 ], @list.to_a 19 | end 20 | 21 | test "prepend" do 22 | @list.prepend(%w[ 1 2 3 ]) 23 | @list.prepend(4) 24 | assert_equal %w[ 4 3 2 1 ], @list.elements 25 | end 26 | 27 | test "prepend nothing" do 28 | @list.prepend("1", "2", "3") 29 | @list.prepend([]) 30 | assert_equal %w[ 3 2 1 ], @list.elements 31 | end 32 | 33 | test "remove" do 34 | @list.append(%w[ 1 2 3 4 ]) 35 | @list.remove(%w[ 1 2 ]) 36 | @list.remove(3) 37 | assert_equal %w[ 4 ], @list.elements 38 | end 39 | 40 | test "clear" do 41 | @list.append(%w[ 1 2 3 4 ]) 42 | @list.clear 43 | assert_equal [], @list.elements 44 | end 45 | 46 | test "last" do 47 | @list.append(%w[ 1 2 3 ]) 48 | assert_equal "3", @list.last 49 | end 50 | 51 | test "last(n)" do 52 | @list.append(%w[ 1 2 3 ]) 53 | assert_equal %w[ 2 3 ], @list.last(2) 54 | end 55 | 56 | test "typed as datetime" do 57 | @list = Kredis.list "mylist", typed: :datetime 58 | 59 | @list.append [ 1.day.from_now.midnight.in_time_zone("Pacific Time (US & Canada)"), 2.days.from_now.midnight.in_time_zone("UTC") ] 60 | assert_equal [ 1.day.from_now.midnight, 2.days.from_now.midnight ], @list.elements 61 | 62 | @list.remove(2.days.from_now.midnight) 63 | assert_equal [ 1.day.from_now.midnight ], @list.elements 64 | end 65 | 66 | test "exists?" do 67 | assert_not @list.exists? 68 | 69 | @list.append(%w[ 1 2 3 ]) 70 | assert @list.exists? 71 | end 72 | 73 | test "ltrim" do 74 | @list.append(%w[ 1 2 3 4 ]) 75 | @list.ltrim(-3, -2) 76 | assert_equal %w[ 2 3 ], @list.elements 77 | end 78 | 79 | 80 | test "default" do 81 | @list = Kredis.list "mylist", default: %w[ 1 2 3 ] 82 | 83 | assert_equal %w[ 1 2 3 ], @list.elements 84 | end 85 | 86 | test "default empty array" do 87 | @list = Kredis.list "mylist", default: [] 88 | 89 | assert_equal [], @list.elements 90 | end 91 | 92 | test "default with nil" do 93 | @list = Kredis.list "mylist", default: nil 94 | 95 | assert_equal [], @list.elements 96 | end 97 | 98 | test "default via proc" do 99 | @list = Kredis.list "mylist", default: ->() { %w[ 1 2 3 ] } 100 | 101 | assert_equal %w[ 1 2 3 ], @list.elements 102 | end 103 | 104 | test "append with default" do 105 | @list = Kredis.list "mylist", default: ->() { %w[ 1 ] } 106 | @list.append(%w[ 2 3 ]) 107 | @list.append(4) 108 | assert_equal %w[ 1 2 3 4 ], @list.elements 109 | end 110 | 111 | test "prepend with default" do 112 | @list = Kredis.list "mylist", default: ->() { %w[ 1 ] } 113 | @list.prepend(%w[ 2 3 ]) 114 | @list.prepend(4) 115 | assert_equal %w[ 4 3 2 1 ], @list.elements 116 | end 117 | 118 | test "remove with default" do 119 | @list = Kredis.list "mylist", default: ->() { %w[ 1 2 3 4 ] } 120 | @list.remove(%w[ 1 2 ]) 121 | @list.remove(3) 122 | assert_equal %w[ 4 ], @list.elements 123 | end 124 | 125 | test "concurrent initialization with default" do 126 | 5.times.map do |i| 127 | Thread.new do 128 | Kredis.list("mylist", default: [ 10, 20, 30 ]).append(i) 129 | end 130 | end.each(&:join) 131 | 132 | assert_equal [ 0, 1, 2, 3, 4, 10, 20, 30 ], Kredis.list("mylist", typed: :integer).to_a.sort 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /test/types/ordered_set_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class OrderedSetTest < ActiveSupport::TestCase 6 | setup { @set = Kredis.ordered_set "ordered-set", limit: 5 } 7 | 8 | test "append" do 9 | @set.append(%w[ 1 2 3 ]) 10 | @set.append(%w[ 1 2 3 4 ]) 11 | assert_equal %w[ 1 2 3 4 ], @set.elements 12 | 13 | @set << "5" 14 | assert_equal %w[ 1 2 3 4 5 ], @set.elements 15 | end 16 | 17 | test "appending the same element re-appends it" do 18 | @set.append(%w[ 1 2 3 ]) 19 | @set.append(%w[ 2 ]) 20 | assert_equal %w[ 1 3 2 ], @set.elements 21 | end 22 | 23 | test "mass append maintains ordering" do 24 | @set = Kredis.ordered_set "ordered-set" # no limit 25 | 26 | thousand_elements = 1000.times.map { [ *"A".."Z" ].sample(10).join } 27 | @set.append(thousand_elements) 28 | assert_equal thousand_elements, @set.elements 29 | 30 | thousand_elements.each { |element| @set.append(element) } 31 | assert_equal thousand_elements, @set.elements 32 | end 33 | 34 | test "prepend" do 35 | @set.prepend(%w[ 1 2 3 ]) 36 | @set.prepend(%w[ 1 2 3 4 ]) 37 | assert_equal %w[ 4 3 2 1 ], @set.elements 38 | end 39 | 40 | test "append nothing" do 41 | @set.append(%w[ 1 2 3 ]) 42 | @set.append([]) 43 | assert_equal %w[ 1 2 3 ], @set.elements 44 | end 45 | 46 | test "prepend nothing" do 47 | @set.prepend(%w[ 1 2 3 ]) 48 | @set.prepend([]) 49 | assert_equal %w[ 3 2 1 ], @set.elements 50 | end 51 | 52 | test "typed as integers" do 53 | @set = Kredis.ordered_set "mylist", typed: :integer 54 | 55 | @set.append [ 1, 2 ] 56 | @set << 2 57 | assert_equal [ 1, 2 ], @set.elements 58 | 59 | @set.remove(2) 60 | assert_equal [ 1 ], @set.elements 61 | 62 | @set.append [ "1-a", 2 ] 63 | 64 | assert_equal [ 1, 2 ], @set.elements 65 | end 66 | 67 | test "exists?" do 68 | assert_not @set.exists? 69 | 70 | @set.append [ 1, 2 ] 71 | assert @set.exists? 72 | end 73 | 74 | test "include?" do 75 | @set.append(%w[ 1 2 3 4 5 ]) 76 | 77 | assert @set.include?(1) 78 | assert_not @set.include?(6) 79 | end 80 | 81 | test "appending over limit" do 82 | @set.append(%w[ 1 2 3 4 5 ]) 83 | @set.append(%w[ 6 7 8 ]) 84 | assert_equal %w[ 4 5 6 7 8 ], @set.elements 85 | end 86 | 87 | test "prepending over limit" do 88 | @set.prepend(%w[ 1 2 3 4 5 ]) 89 | @set.prepend(%w[ 6 7 8 ]) 90 | assert_equal %w[ 8 7 6 5 4 ], @set.elements 91 | end 92 | 93 | test "appending array with duplicates" do 94 | @set.append(%w[ 1 1 1 ]) 95 | assert_equal %w[ 1 ], @set.elements 96 | end 97 | 98 | test "prepending array with duplicates" do 99 | @set.prepend(%w[ 1 1 1 ]) 100 | assert_equal %w[ 1 ], @set.elements 101 | end 102 | 103 | test "limit can't be 0 or less" do 104 | assert_raises do 105 | Kredis.ordered_set "ordered-set", limit: -1 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/types/scalar_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/integer" 5 | 6 | class ScalarTest < ActiveSupport::TestCase 7 | test "string" do 8 | string = Kredis.scalar "myscalar" 9 | string.value = "Something!" 10 | assert_equal "Something!", string.value 11 | end 12 | 13 | test "integer" do 14 | integer = Kredis.scalar "myscalar", typed: :integer 15 | integer.value = 5 16 | assert_equal 5, integer.value 17 | end 18 | 19 | test "decimal" do 20 | decimal = Kredis.decimal "myscalar" 21 | decimal.value = 5.to_d 22 | assert_equal 5.to_d, decimal.value 23 | end 24 | 25 | test "float" do 26 | float = Kredis.float "myscalar" 27 | float.value = 5.7 28 | assert_equal 5.7, float.value 29 | end 30 | 31 | test "boolean" do 32 | boolean = Kredis.boolean "myscalar" 33 | boolean.value = true 34 | assert_equal true, boolean.value 35 | boolean.value = false 36 | assert_equal false, boolean.value 37 | boolean.value = "t" 38 | assert_equal true, boolean.value 39 | boolean.value = "false" 40 | assert_equal false, boolean.value 41 | end 42 | 43 | test "boolean casting" do 44 | boolean = Kredis.boolean "myscalar" 45 | 46 | boolean.value = true 47 | assert_equal "1", boolean.get 48 | 49 | boolean.value = false 50 | assert_equal "0", boolean.get 51 | 52 | boolean.set "true" 53 | assert_equal true, boolean.value 54 | 55 | boolean.set "false" 56 | assert_equal false, boolean.value 57 | end 58 | 59 | test "datetime" do 60 | datetime = Kredis.datetime "myscalar" 61 | datetime.value = 5.days.from_now.midnight 62 | assert_equal 5.days.from_now.midnight, datetime.value 63 | 64 | datetime.value += 0.5.seconds 65 | assert_equal 5.days.from_now.midnight + 0.5.seconds, datetime.value 66 | 67 | datetime.value = nil 68 | assert_nil datetime.value 69 | end 70 | 71 | test "datetime casting Dates" do 72 | datetime = Kredis.datetime "myscalar" 73 | datetime.value = Date.current 74 | assert_equal Date.current.to_time, datetime.value 75 | end 76 | 77 | test "json" do 78 | json = Kredis.json "myscalar" 79 | json.value = { "one" => 1, "string" => "hello" } 80 | assert_equal({ "one" => 1, "string" => "hello" }, json.value) 81 | 82 | json.value = { "json_class" => "String", "raw" => [ 97, 98, 99 ] } 83 | assert_equal({ "json_class" => "String", "raw" => [ 97, 98, 99 ] }, json.value) 84 | end 85 | 86 | test "invalid type" do 87 | nothere = Kredis.scalar "myscalar", typed: :nothere 88 | assert_raises(Kredis::TypeCasting::InvalidType) { nothere.value = true } 89 | 90 | assert_raises(Kredis::TypeCasting::InvalidType) { nothere.value } 91 | end 92 | 93 | test "assigned?" do 94 | string = Kredis.string "myscalar" 95 | assert_not string.assigned? 96 | 97 | string.value = "Something!" 98 | assert string.assigned? 99 | end 100 | 101 | test "clear" do 102 | string = Kredis.string "myscalar" 103 | string.value = "Something!" 104 | string.clear 105 | assert_not string.assigned? 106 | end 107 | 108 | test "default" do 109 | integer = Kredis.scalar "myscalar", typed: :integer, default: 8 110 | assert_equal 8, integer.value 111 | 112 | integer.value = 5 113 | assert_equal 5, integer.value 114 | 115 | integer.clear 116 | assert_equal 8, integer.value 117 | 118 | assert_equal "8", integer.value.to_s 119 | integer.clear 120 | 121 | json = Kredis.json "myscalar", default: { one: 1, string: "hello" } 122 | assert_equal({ "one" => 1, "string" => "hello" }, json.value) 123 | end 124 | 125 | test "default via proc" do 126 | integer = Kredis.scalar "myscalar", typed: :integer, default: ->() { 8 } 127 | assert_equal 8, integer.value 128 | 129 | integer.value = 5 130 | assert_equal 5, integer.value 131 | 132 | integer.clear 133 | assert_equal 8, integer.value 134 | 135 | integer.clear 136 | 137 | json = Kredis.json "myscalar", default: ->() { { one: 1, string: "hello" } } 138 | assert_equal({ "one" => 1, "string" => "hello" }, json.value) 139 | end 140 | 141 | test "does not cache proc results after clear" do 142 | hex = Kredis.scalar "myscalar", default: ->() { SecureRandom.hex } 143 | original_default_value = hex.value 144 | assert_equal original_default_value, hex.value 145 | hex.clear 146 | assert_not_equal original_default_value, hex.value 147 | end 148 | 149 | test "returns default when failing open" do 150 | integer = Kredis.scalar "myscalar", typed: :integer, default: 8 151 | integer.value = 42 152 | 153 | stub_redis_down(integer) { assert_equal 8, integer.value } 154 | end 155 | 156 | test "telling a scalar to expire in a relative amount of time" do 157 | string = Kredis.scalar "myscalar", default: "unassigned" 158 | string.value = "assigned" 159 | assert_changes "string.value", from: "assigned", to: "unassigned" do 160 | string.expire_in 1.second 161 | sleep 1.1.seconds 162 | end 163 | end 164 | 165 | test "telling a scaler to expire at a specific point in time" do 166 | string = Kredis.scalar "myscalar", default: "unassigned" 167 | string.value = "assigned" 168 | assert_changes "string.value", from: "assigned", to: "unassigned" do 169 | string.expire_at 1.second.from_now 170 | sleep 1.1.seconds 171 | end 172 | end 173 | 174 | test "configuring a scaler to always expire after assignment" do 175 | forever_string = Kredis.scalar "forever", default: "unassigned", expires_in: nil 176 | ephemeral_string = Kredis.scalar "ephemeral", default: "unassigned", expires_in: 1.second 177 | 178 | forever_string.value = "assigned" 179 | ephemeral_string.value = "assigned" 180 | 181 | assert_no_changes "forever_string.value" do 182 | assert_changes "ephemeral_string.value", from: "assigned", to: "unassigned" do 183 | sleep 1.1.seconds 184 | end 185 | end 186 | end 187 | 188 | test "all scalar types can be configured with expires_in" do 189 | duration = 1.second 190 | Kredis.scalar("ephemeral", expires_in: duration) 191 | 192 | scalar = Kredis.string("ephemeral", expires_in: duration) 193 | assert_equal duration, scalar.expires_in 194 | 195 | scalar = Kredis.integer("ephemeral", expires_in: duration) 196 | assert_equal duration, scalar.expires_in 197 | 198 | scalar = Kredis.decimal("ephemeral", expires_in: duration) 199 | assert_equal duration, scalar.expires_in 200 | 201 | scalar = Kredis.float("ephemeral", expires_in: duration) 202 | assert_equal duration, scalar.expires_in 203 | 204 | scalar = Kredis.boolean("ephemeral", expires_in: duration) 205 | assert_equal duration, scalar.expires_in 206 | 207 | scalar = Kredis.datetime("ephemeral", expires_in: duration) 208 | assert_equal duration, scalar.expires_in 209 | 210 | scalar = Kredis.json("ephemeral", expires_in: duration) 211 | assert_equal duration, scalar.expires_in 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /test/types/set_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "active_support/core_ext/object/inclusion" 5 | 6 | class SetTest < ActiveSupport::TestCase 7 | setup { @set = Kredis.set "myset" } 8 | 9 | test "add" do 10 | @set.add(%w[ 1 2 3 ]) 11 | @set << 4 12 | @set << 4 13 | assert_equal %w[ 1 2 3 4 ], @set.members 14 | end 15 | 16 | test "add nothing" do 17 | @set.add(%w[ 1 2 3 ]) 18 | @set.add([]) 19 | assert_equal %w[ 1 2 3 ], @set.to_a 20 | end 21 | 22 | test "remove" do 23 | @set.add(%w[ 1 2 3 4 ]) 24 | @set.remove([ %w[ 2 3 ] ]) 25 | @set.remove("1") 26 | assert_equal %w[ 4 ], @set.members 27 | end 28 | 29 | test "remove nothing" do 30 | @set.add(%w[ 1 2 3 4 ]) 31 | @set.remove([]) 32 | assert_equal %w[ 1 2 3 4 ], @set.members 33 | end 34 | 35 | test "replace" do 36 | @set.add(%w[ 1 2 3 4 ]) 37 | @set.replace(%w[ 5 6 ]) 38 | assert_equal %w[ 5 6 ], @set.members 39 | end 40 | 41 | test "include" do 42 | @set.add("1", "2", "3", "4") 43 | assert @set.include?("1") 44 | assert_not @set.include?("5") 45 | 46 | assert "1".in?(@set) 47 | end 48 | 49 | test "size" do 50 | @set.add(%w[ 1 2 3 4 ]) 51 | assert_equal 4, @set.size 52 | end 53 | 54 | test "take" do 55 | @set.add("1") 56 | assert_equal "1", @set.take 57 | 58 | @set.add(%w[ 1 2 3 4 ]) 59 | assert @set.take.in? %w[ 1 2 3 4 ] 60 | end 61 | 62 | test "clear" do 63 | @set.add("1") 64 | @set.clear 65 | assert_equal [], @set.members 66 | end 67 | 68 | test "typed as floats" do 69 | @set = Kredis.set "mylist", typed: :float 70 | 71 | @set.add 1.5, 2.7 72 | @set << 2.7 73 | assert_equal [ 1.5, 2.7 ], @set.members 74 | 75 | @set.remove(2.7) 76 | assert_equal [ 1.5 ], @set.members 77 | 78 | assert_equal 1.5, @set.take 79 | end 80 | 81 | test "failing open" do 82 | stub_redis_down(@set) do 83 | @set.add "1" 84 | assert_equal [], @set.members 85 | assert_equal 0, @set.size 86 | end 87 | end 88 | 89 | test "exists?" do 90 | assert_not @set.exists? 91 | 92 | @set.add(%w[ 1 2 3 ]) 93 | assert @set.exists? 94 | end 95 | 96 | test "srandmember" do 97 | @set = Kredis.set "mylist", typed: :float 98 | @set.add 1.5, 2.7 99 | 100 | assert @set.sample.in?([ 1.5, 2.7 ]) 101 | assert_equal [ 1.5, 2.7 ], @set.sample(2).sort 102 | end 103 | 104 | test "smove" do 105 | @set.add(%w[ 1 2 ]) 106 | another_set = Kredis.set "another_set" 107 | another_set.add(%w[ 3 ]) 108 | 109 | assert @set.smove(another_set.key, "2") 110 | assert_equal %w[ 1 ], @set.members 111 | assert_equal %w[ 2 3 ], another_set.members 112 | end 113 | 114 | test "move with set" do 115 | @set.add(%w[ x y ]) 116 | another_set = Kredis.set "another_set" 117 | another_set.add(%w[ z ]) 118 | 119 | assert @set.move(another_set, "y") 120 | assert_equal %w[ x ], @set.members 121 | assert_equal %w[ y z ], another_set.members 122 | end 123 | 124 | test "move with key" do 125 | @set.add(%w[ a b ]) 126 | another_set = Kredis.set "another_set" 127 | another_set.add(%w[ c ]) 128 | 129 | assert @set.move(another_set.key, "b") 130 | assert_equal %w[ a ], @set.members 131 | assert_equal %w[ b c ], another_set.members 132 | end 133 | 134 | test "default" do 135 | @set = Kredis.set "mylist", default: %w[ 1 2 3 ] 136 | assert_equal %w[ 1 2 3 ], @set.members 137 | end 138 | 139 | test "default is an empty array" do 140 | @set = Kredis.set "mylist", default: [] 141 | assert_equal [], @set.members 142 | end 143 | 144 | test "default is nil" do 145 | @set = Kredis.set "mylist", default: nil 146 | assert_equal [], @set.members 147 | end 148 | 149 | test "default via proc" do 150 | @set = Kredis.set "mylist", default: -> () { %w[ 3 3 1 2 ] } 151 | assert_equal %w[ 1 2 3 ], @set.members 152 | end 153 | 154 | test "add with default" do 155 | @set = Kredis.set "mylist", typed: :integer, default: -> () { %w[ 1 2 3 ] } 156 | @set.add(%w[ 5 6 7 ]) 157 | assert_equal [ 1, 2, 3, 5, 6, 7 ], @set.members 158 | end 159 | 160 | test "remove with default" do 161 | @set = Kredis.set "mylist", default: -> () { %w[ 1 2 3 4 ] } 162 | @set.remove(%w[ 2 3 ]) 163 | @set.remove("1") 164 | assert_equal %w[ 4 ], @set.members 165 | end 166 | 167 | test "replace with default" do 168 | @set = Kredis.set "mylist", typed: :integer, default: -> () { %w[ 1 2 3 ] } 169 | @set.add(%w[ 5 6 7 ]) 170 | @set.replace(%w[ 8 9 10 ]) 171 | assert_equal [ 8, 9, 10 ], @set.members 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /test/types/slots_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class SlotsTest < ActiveSupport::TestCase 6 | setup { @slots = Kredis.slots "myslots", available: 3 } 7 | 8 | test "reserve until no availability" do 9 | assert @slots.reserve 10 | assert @slots.available? 11 | 12 | assert @slots.reserve 13 | assert @slots.available? 14 | 15 | assert @slots.reserve 16 | assert_not @slots.available? 17 | 18 | assert_not @slots.reserve 19 | end 20 | 21 | test "reserve and release" do 22 | @slots.reserve 23 | @slots.reserve 24 | @slots.reserve 25 | assert_not @slots.available? 26 | 27 | @slots.release 28 | assert @slots.available? 29 | end 30 | 31 | test "release when slots are reserved" do 32 | assert_not @slots.release 33 | 34 | 3.times do 35 | assert @slots.reserve 36 | end 37 | 38 | 3.times do 39 | assert @slots.release 40 | end 41 | 42 | assert_not @slots.release 43 | 44 | assert_equal 0, @slots.taken 45 | end 46 | 47 | test "reserve with block" do 48 | assert @slots.reserve 49 | assert @slots.reserve 50 | 51 | assert(@slots.reserve { 52 | assert_not @slots.available? 53 | false # ensure that block return value isn't returned from #reserve 54 | }) 55 | 56 | assert @slots.available? 57 | end 58 | 59 | test "failed reserve with block" do 60 | assert @slots.reserve 61 | assert @slots.reserve 62 | assert @slots.reserve 63 | 64 | ran = false 65 | 66 | assert_not(@slots.reserve { 67 | ran = true 68 | }) 69 | 70 | assert_not ran 71 | end 72 | 73 | test "reset" do 74 | 3.times do 75 | assert @slots.reserve 76 | end 77 | 78 | @slots.reset 79 | 80 | 3.times do 81 | assert @slots.reserve 82 | end 83 | end 84 | 85 | test "single slot" do 86 | slot = Kredis.slot "myslot" 87 | assert slot.reserve 88 | assert_not slot.available? 89 | end 90 | 91 | test "release single slot when reserved" do 92 | slot = Kredis.slot "myslot" 93 | 94 | assert_not slot.release 95 | 96 | assert slot.reserve 97 | assert slot.release 98 | 99 | assert_not slot.release 100 | end 101 | 102 | test "failing open" do 103 | stub_redis_down(@slots) do 104 | assert_not @slots.available? 105 | 106 | assert_not @slots.reserve 107 | 108 | ran = false 109 | assert_not @slots.reserve { ran = true } 110 | assert_not ran 111 | end 112 | end 113 | 114 | test "exists?" do 115 | assert_not @slots.exists? 116 | 117 | @slots.reserve 118 | assert @slots.exists? 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/types/unique_list_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class UniqueListTest < ActiveSupport::TestCase 6 | setup { @list = Kredis.unique_list "myuniquelist", limit: 5 } 7 | 8 | test "append" do 9 | @list.append(%w[ 1 2 3 ]) 10 | @list.append(%w[ 1 2 3 4 ]) 11 | assert_equal %w[ 1 2 3 4 ], @list.elements 12 | 13 | @list << "5" 14 | assert_equal %w[ 1 2 3 4 5 ], @list.elements 15 | end 16 | 17 | test "prepend" do 18 | @list.prepend(%w[ 1 2 3 ]) 19 | @list.prepend(%w[ 1 2 3 4 ]) 20 | assert_equal %w[ 4 3 2 1 ], @list.elements 21 | end 22 | 23 | test "append nothing" do 24 | @list.append(%w[ 1 2 3 ]) 25 | @list.append([]) 26 | assert_equal %w[ 1 2 3 ], @list.elements 27 | end 28 | 29 | test "prepend nothing" do 30 | @list.prepend(%w[ 1 2 3 ]) 31 | @list.prepend([]) 32 | assert_equal %w[ 3 2 1 ], @list.elements 33 | end 34 | 35 | test "typed as integers" do 36 | @list = Kredis.unique_list "mylist", typed: :integer 37 | 38 | @list.append [ 1, 2 ] 39 | @list << 2 40 | assert_equal [ 1, 2 ], @list.elements 41 | 42 | @list.remove(2) 43 | assert_equal [ 1 ], @list.elements 44 | 45 | @list.append [ "1-a", 2 ] 46 | 47 | assert_equal [ 1, 2 ], @list.elements 48 | end 49 | 50 | test "exists?" do 51 | assert_not @list.exists? 52 | 53 | @list.append [ 1, 2 ] 54 | assert @list.exists? 55 | end 56 | 57 | test "appending over limit" do 58 | @list.append(%w[ 1 2 3 4 5 ]) 59 | @list.append(%w[ 6 7 8 ]) 60 | assert_equal %w[ 4 5 6 7 8 ], @list.elements 61 | end 62 | 63 | test "prepending over limit" do 64 | @list.prepend(%w[ 1 2 3 4 5 ]) 65 | @list.prepend(%w[ 6 7 8 ]) 66 | assert_equal %w[ 8 7 6 5 4 ], @list.elements 67 | end 68 | 69 | test "appending array with duplicates" do 70 | @list.append(%w[ 1 1 1 ]) 71 | assert_equal %w[ 1 ], @list.elements 72 | end 73 | 74 | test "prepending array with duplicates" do 75 | @list.prepend(%w[ 1 1 1 ]) 76 | assert_equal %w[ 1 ], @list.elements 77 | end 78 | 79 | test "default" do 80 | @list = Kredis.unique_list "myuniquelist", default: %w[ 1 2 3 ] 81 | 82 | assert_equal %w[ 1 2 3 ], @list.elements 83 | end 84 | 85 | test "default via proc" do 86 | @list = Kredis.unique_list "myuniquelist", default: ->() { %w[ 1 2 3 3 ] } 87 | 88 | assert_equal %w[ 1 2 3 ], @list.elements 89 | end 90 | 91 | test "prepend with default" do 92 | @list = Kredis.unique_list "myuniquelist", default: %w[ 1 2 3 ] 93 | @list.prepend(%w[ 6 7 8 ]) 94 | assert_equal %w[ 8 7 6 1 2 3 ], @list.elements 95 | end 96 | end 97 | --------------------------------------------------------------------------------