• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sds / mock_redis / 19143465204

06 Nov 2025 04:59PM UTC coverage: 98.115% (+0.008%) from 98.107%
19143465204

Pull #338

github

web-flow
Merge 5b587d7eb into fe3651589
Pull Request #338: Fixes for redis.call("info")

26 of 26 new or added lines in 2 files covered. (100.0%)

1 existing line in 1 file now uncovered.

1874 of 1910 relevant lines covered (98.12%)

1615.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.55
/lib/mock_redis/database.rb
1
require 'mock_redis/assertions'
8✔
2
require 'mock_redis/exceptions'
8✔
3
require 'mock_redis/hash_methods'
8✔
4
require 'mock_redis/list_methods'
8✔
5
require 'mock_redis/set_methods'
8✔
6
require 'mock_redis/string_methods'
8✔
7
require 'mock_redis/zset_methods'
8✔
8
require 'mock_redis/sort_method'
8✔
9
require 'mock_redis/indifferent_hash'
8✔
10
require 'mock_redis/info_method'
8✔
11
require 'mock_redis/utility_methods'
8✔
12
require 'mock_redis/geospatial_methods'
8✔
13
require 'mock_redis/stream_methods'
8✔
14
require 'mock_redis/connection_method'
8✔
15
require 'mock_redis/memory_method'
8✔
16

17
class MockRedis
8✔
18
  class Database
8✔
19
    include HashMethods
8✔
20
    include ListMethods
8✔
21
    include SetMethods
8✔
22
    include StringMethods
8✔
23
    include ZsetMethods
8✔
24
    include SortMethod
8✔
25
    include InfoMethod
8✔
26
    include UtilityMethods
8✔
27
    include GeospatialMethods
8✔
28
    include StreamMethods
8✔
29
    include ConnectionMethod
8✔
30
    include MemoryMethod
8✔
31

32
    attr_reader :data, :expire_times
8✔
33

34
    def initialize(base, *_args)
8✔
35
      @base = base
908✔
36
      @data = MockRedis::IndifferentHash.new
908✔
37
      @expire_times = []
908✔
38
    end
39

40
    def initialize_copy(_source)
8✔
41
      @data = @data.clone
1,524✔
42
      @data.each_key { |k| @data[k] = @data[k].clone }
1,656✔
43
      @expire_times = @expire_times.map(&:clone)
1,524✔
44
    end
45

46
    # Redis commands go below this line and above 'private'
47

48
    # FIXME: Current implementation of `call` does not work propetly with kwarg-options.
49
    # i.e. `call("EXPIRE", "foo", 40, "NX")` (which redis-rb will simply transmit to redis-server)
50
    # will be passed to `#expire` without keywords transformation.
51
    def call(*command, &_block)
8✔
52
      # flatten any nested arrays (eg from [:call, ["GET", "X"]] in pipelined commands)
53
      command = command.flatten
56✔
54

55
      cmd_name = command[0].downcase.to_s
56✔
56

57
      if cmd_name.include?('expire')
56✔
UNCOV
58
        send_expires(command)
×
59
      elsif cmd_name == 'info'
56✔
60
        # call(:info) returns a string, not a parsed hash
61
        info_raw(*command[1..])
8✔
62
      else
63
        public_send(cmd_name, *command[1..])
48✔
64
      end
65
    end
66

67
    def auth(_)
8✔
68
      'OK'
4✔
69
    end
70

71
    def bgrewriteaof
8✔
72
      'Background append only file rewriting started'
4✔
73
    end
74

75
    def bgsave
8✔
76
      'Background saving started'
4✔
77
    end
78

79
    def disconnect
8✔
80
      nil
81
    end
82
    alias close disconnect
8✔
83
    alias disconnect! close
8✔
84

85
    def connected?
8✔
86
      true
4✔
87
    end
88

89
    def dbsize
8✔
90
      data.keys.length
8✔
91
    end
92

93
    def del(*keys)
8✔
94
      keys = keys.flatten.map(&:to_s)
8,647✔
95
      # assert_has_args(keys, 'del') # no longer errors in redis > v4.5
96

97
      keys.
8,647✔
98
        find_all { |key| data[key] }.
8,659✔
99
        each { |k| persist(k) }.
7,580✔
100
        each { |k| data.delete(k) }.
7,580✔
101
        length
102
    end
103
    alias unlink del
8✔
104

105
    def echo(msg)
8✔
106
      msg.to_s
12✔
107
    end
108

109
    def expire(key, seconds, nx: nil, xx: nil, lt: nil, gt: nil) # rubocop:disable Metrics/ParameterLists
8✔
110
      seconds = Integer(seconds)
368✔
111

112
      pexpire(key, seconds.to_i * 1000, nx: nx, xx: xx, lt: lt, gt: gt)
364✔
113
    end
114

115
    def pexpire(key, ms, nx: nil, xx: nil, lt: nil, gt: nil) # rubocop:disable Metrics/ParameterLists
8✔
116
      ms = Integer(ms)
444✔
117

118
      now, miliseconds = @base.now
440✔
119
      now_ms = (now * 1000) + miliseconds
440✔
120
      pexpireat(key, now_ms + ms.to_i, nx: nx, xx: xx, lt: lt, gt: gt)
440✔
121
    end
122

123
    def expireat(key, timestamp, nx: nil, xx: nil, lt: nil, gt: nil) # rubocop:disable Metrics/ParameterLists
8✔
124
      timestamp = Integer(timestamp)
48✔
125

126
      pexpireat(key, timestamp.to_i * 1000, nx: nx, xx: xx, lt: lt, gt: gt)
48✔
127
    end
128

129
    def pexpireat(key, timestamp_ms, nx: nil, xx: nil, lt: nil, gt: nil) # rubocop:disable Metrics/ParameterLists
8✔
130
      timestamp_ms = Integer(timestamp_ms)
528✔
131

132
      if nx && gt || gt && lt || lt && nx || nx && xx
528✔
133
        raise Error.command_error(
64✔
134
          'ERR NX and XX, GT or LT options at the same time are not compatible',
135
          self
136
        )
137
      end
138

139
      return false unless exists?(key)
464✔
140

141
      expiry = expiration(key)
448✔
142
      new_expiry = @base.time_at(Rational(timestamp_ms.to_i, 1000))
448✔
143

144
      if should_update_expiration?(expiry, new_expiry, nx: nx, xx: xx, lt: lt, gt: gt)
448✔
145
        set_expiration(key, new_expiry)
364✔
146
        true
364✔
147
      else
148
        false
84✔
149
      end
150
    end
151

152
    def exists(*keys)
8✔
153
      keys.count { |key| data.key?(key) }
56✔
154
    end
155

156
    def exists?(*keys)
8✔
157
      keys.each { |key| return true if data.key?(key) }
17,240✔
158
      false
188✔
159
    end
160

161
    def flushdb
8✔
162
      data.each_key { |k| del(k) }
76✔
163
      'OK'
48✔
164
    end
165

166
    def dump(key)
8✔
167
      value = data[key]
32✔
168
      value ? Marshal.dump(value) : nil
32✔
169
    end
170

171
    def restore(key, ttl, value, replace: false)
8✔
172
      if !replace && exists?(key)
20✔
173
        raise Error.command_error('BUSYKEY Target key name already exists.', self)
4✔
174
      end
175
      data[key] = Marshal.load(value) # rubocop:disable Security/MarshalLoad
16✔
176
      if ttl > 0
16✔
177
        pexpire(key, ttl)
4✔
178
      end
179
      'OK'
16✔
180
    end
181

182
    def keys(format = '*')
8✔
183
      data.keys.grep(redis_pattern_to_ruby_regex(format))
9,356✔
184
    end
185

186
    def scan(cursor, opts = {})
8✔
187
      common_scan(data.keys, cursor, opts)
56✔
188
    end
189

190
    def scan_each(opts = {}, &block)
8✔
191
      return to_enum(:scan_each, opts) unless block_given?
40✔
192
      cursor = 0
20✔
193
      loop do
20✔
194
        cursor, keys = scan(cursor, opts)
24✔
195
        keys.each(&block)
24✔
196
        break if cursor == '0'
24✔
197
      end
198
    end
199

200
    def lastsave
8✔
201
      now.first
4✔
202
    end
203

204
    def persist(key)
8✔
205
      if exists?(key) && has_expiration?(key)
7,636✔
206
        remove_expiration(key)
232✔
207
        true
232✔
208
      else
209
        false
7,404✔
210
      end
211
    end
212

213
    def ping(response = 'PONG')
8✔
214
      response
8✔
215
    end
216

217
    def quit
8✔
218
      'OK'
4✔
219
    end
220

221
    def randomkey
8✔
222
      data.keys[rand(data.length)]
8✔
223
    end
224

225
    def rename(key, newkey)
8✔
226
      unless data.include?(key)
32✔
227
        raise Error.command_error('ERR no such key', self)
4✔
228
      end
229

230
      if key != newkey
28✔
231
        data[newkey] = data.delete(key)
24✔
232
        if has_expiration?(key)
24✔
233
          set_expiration(newkey, expiration(key))
4✔
234
          remove_expiration(key)
4✔
235
        end
236
      end
237

238
      'OK'
28✔
239
    end
240

241
    def renamenx(key, newkey)
8✔
242
      unless data.include?(key)
24✔
243
        raise Error.command_error('ERR no such key', self)
4✔
244
      end
245

246
      if exists?(newkey)
20✔
247
        false
12✔
248
      else
249
        rename(key, newkey)
8✔
250
        true
8✔
251
      end
252
    end
253

254
    def save
8✔
255
      'OK'
4✔
256
    end
257

258
    def ttl(key)
8✔
259
      if !exists?(key)
108✔
260
        -2
4✔
261
      elsif has_expiration?(key)
104✔
262
        now, = @base.now
80✔
263
        expiration(key).to_i - now
80✔
264
      else
265
        -1
24✔
266
      end
267
    end
268

269
    def pttl(key)
8✔
270
      now, miliseconds = @base.now
32✔
271
      now_ms = now * 1000 + miliseconds
32✔
272

273
      if !exists?(key)
32✔
274
        -2
4✔
275
      elsif has_expiration?(key)
28✔
276
        (expiration(key).to_r * 1000).to_i - now_ms
20✔
277
      else
278
        -1
8✔
279
      end
280
    end
281

282
    def now
8✔
283
      current_time = @base.options[:time_class].now
141,721✔
284
      miliseconds = (current_time.to_r - current_time.to_i) * 1_000
141,721✔
285
      [current_time.to_i, miliseconds.to_i]
141,721✔
286
    end
287
    alias time now
8✔
288

289
    def type(key)
8✔
290
      if !exists?(key)
108✔
291
        'none'
4✔
292
      elsif hashy?(key)
104✔
293
        'hash'
20✔
294
      elsif stringy?(key)
84✔
295
        'string'
24✔
296
      elsif listy?(key)
60✔
297
        'list'
20✔
298
      elsif sety?(key)
40✔
299
        'set'
20✔
300
      elsif zsety?(key)
20✔
301
        'zset'
20✔
302
      elsif streamy?(key)
×
303
        'stream'
×
304
      else
305
        raise ArgumentError, "Not sure how #{data[key].inspect} got in here"
×
306
      end
307
    end
308

309
    def script(subcommand, *args); end
8✔
310

311
    def evalsha(*args); end
8✔
312

313
    def eval(*args); end
8✔
314

315
    private
8✔
316

317
    def assert_valid_timeout(timeout)
8✔
318
      timeout = Integer(timeout)
108✔
319

320
      if timeout < 0
108✔
321
        raise ArgumentError, 'time interval must not be negative'
16✔
322
      end
323

324
      timeout
92✔
325
    end
326

327
    def can_incr?(value)
8✔
328
      value.nil? || looks_like_integer?(value)
216✔
329
    end
330

331
    def can_incr_float?(value)
8✔
332
      value.nil? || looks_like_float?(value)
72✔
333
    end
334

335
    def extract_timeout(arglist)
8✔
336
      options = arglist.last
88✔
337
      if options.is_a?(Hash) && options[:timeout]
88✔
338
        timeout = assert_valid_timeout(options[:timeout])
24✔
339
        [arglist[0..-2], timeout]
16✔
340
      elsif options.is_a?(Integer)
64✔
341
        timeout = assert_valid_timeout(options)
24✔
342
        [arglist[0..-2], timeout]
24✔
343
      else
344
        [arglist, 0]
40✔
345
      end
346
    end
347

348
    def expiration(key)
8✔
349
      expire_times.find { |(_, k)| k == key.to_s }&.first
788✔
350
    end
351

352
    def has_expiration?(key)
8✔
353
      expire_times.any? { |(_, k)| k == key.to_s }
8,124✔
354
    end
355

356
    def looks_like_integer?(str)
8✔
357
      !!Integer(str) rescue false
163✔
358
    end
359

360
    def looks_like_float?(str)
8✔
361
      !!Float(str) rescue false
3,498✔
362
    end
363

364
    def send_expires(command)
8✔
365
      command, key, ttl, option = *command
×
366
      public_send(command, key, ttl, option.downcase.to_sym => option)
×
367
    end
368

369
    def should_update_expiration?(expiry, new_expiry, nx:, xx:, lt:, gt:) # rubocop:disable Metrics/ParameterLists
8✔
370
      return false if nx && expiry || xx && !expiry
448✔
371
      return false if lt && expiry && new_expiry > expiry
408✔
372
      return false if gt && (!expiry || new_expiry < expiry)
388✔
373

374
      true
364✔
375
    end
376

377
    def redis_pattern_to_ruby_regex(pattern)
8✔
378
      Regexp.new(
10,060✔
379
        "^#{pattern}$".
380
        gsub(/([+|(){}])/, '\\\\\1').
381
        gsub(/(?<!\\)\?/, '\\1.').
382
        gsub(/([^\\])\*/, '\\1.*')
383
      )
384
    end
385

386
    def remove_expiration(key)
8✔
387
      expire_times.delete_if do |(_t, k)|
3,892✔
388
        key.to_s == k
336✔
389
      end
390
    end
391

392
    def set_expiration(key, time)
8✔
393
      remove_expiration(key)
368✔
394
      found = expire_times.each_with_index.to_a.bsearch { |item, _| item.first >= time }
376✔
395
      index = found ? found.last : -1
368✔
396
      expire_times.insert(index, [time, key.to_s])
368✔
397
    end
398

399
    def zero_pad(string, desired_length)
8✔
400
      padding = "\000" * [(desired_length - string.length), 0].max
412✔
401
      string + padding
412✔
402
    end
403

404
    public
8✔
405

406
    # This method isn't private, but it also isn't a Redis command, so
407
    # it doesn't belong up above with all the Redis commands.
408
    def expire_keys
8✔
409
      now_sec, miliseconds = now
141,153✔
410
      now_ms = now_sec * 1_000 + miliseconds
141,153✔
411

412
      to_delete = expire_times.take_while do |(time, _key)|
141,153✔
413
        (time.to_r * 1_000).to_i <= now_ms
2,797✔
414
      end
415

416
      to_delete.each do |(_time, key)|
141,153✔
417
        del(key)
76✔
418
      end
419
    end
420
  end
421
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc