redis 5.4.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,530 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class Redis
6
+ module Commands
7
+ # Commands for the RedisJSON module (built into Redis core since 8.0, or available
8
+ # earlier through the RedisJSON module in Redis Stack).
9
+ #
10
+ # Values are serialized to JSON text on the client before they are sent, and replies
11
+ # that carry JSON text are parsed back into Ruby objects. The serialized form of these
12
+ # replies is identical under RESP2 and RESP3 (RESP3 has no native JSON type), so the
13
+ # handling here is protocol-independent. Note that some other RedisJSON commands — e.g.
14
+ # JSON.NUMINCRBY — DO return differently shaped replies under RESP3 and will need
15
+ # protocol-aware reshaping once RESP3 is supported at this layer.
16
+ module Json
17
+ # Normalize a JSON.NUMINCRBY reply to a protocol-independent value: an array of numbers for
18
+ # a JSONPath, a single number for a legacy path. Under RESP2 the result arrives as a
19
+ # JSON-encoded string ("[3,4]" / "7"); under RESP3 it arrives as native numbers (an array,
20
+ # even for a legacy path). Both shapes map to the same value.
21
+ #
22
+ # A legacy path can also match multiple values (e.g. "..a"). RESP2 collapses those to the
23
+ # last changed scalar ("7"), while RESP3 returns the full match array with nil for every
24
+ # non-numeric match ([nil, 4, 7, nil]). To stay RESP2-compatible we take the last non-nil
25
+ # element of that array, not its first.
26
+ NumincrbyNormalize = lambda do |reply, jsonpath|
27
+ return nil if reply.nil?
28
+
29
+ reply = ::JSON.parse(reply) if reply.is_a?(String) # RESP2 string form
30
+ if jsonpath
31
+ Array(reply)
32
+ elsif reply.is_a?(Array)
33
+ reply.compact.last # RESP3 wraps even a legacy reply in an array; RESP2 keeps the last changed scalar
34
+ else
35
+ reply
36
+ end
37
+ end
38
+
39
+ # Normalize a JSON.TYPE reply to a protocol-independent value: an array of type strings for
40
+ # a JSONPath, a single type string for a legacy path. RESP2 returns ["integer"] / "integer";
41
+ # RESP3 nests one level further as [["integer"]] / ["integer"]. Both map to the same value.
42
+ TypeNormalize = lambda do |reply, jsonpath|
43
+ return nil if reply.nil?
44
+
45
+ if jsonpath
46
+ reply.flatten # RESP2 ["integer"] stays flat; RESP3 [["integer"]] collapses
47
+ elsif reply.is_a?(Array)
48
+ reply.first # RESP3 legacy ["integer"] -> "integer"
49
+ else
50
+ reply # RESP2 legacy "integer"
51
+ end
52
+ end
53
+
54
+ # Set the JSON value at +path+ in the document stored under +key+.
55
+ #
56
+ # By default +value+ is a Ruby object that is serialized to JSON text with
57
+ # JSON.generate. Pass +raw: true+ to send an already-encoded JSON string through
58
+ # untouched (no re-serialization), which avoids double-encoding pre-built JSON.
59
+ #
60
+ # @example
61
+ # redis.json_set("doc", "$", { "a" => 1, "nested" => { "b" => 2 } })
62
+ # # => "OK"
63
+ #
64
+ # @example pre-encoded JSON
65
+ # redis.json_set("doc", "$", '{"a":1}', raw: true)
66
+ # # => "OK"
67
+ #
68
+ # @example conditional set with NX/XX returns a boolean
69
+ # redis.json_set("doc", "$.a", 2, nx: true)
70
+ # # => false
71
+ #
72
+ # @param [String] key
73
+ # @param [String] path a JSONPath, e.g. "$" for the document root
74
+ # @param [Object] value a JSON-serializable Ruby object, or a pre-encoded JSON string
75
+ # when +raw+ is true
76
+ # @param [Boolean] nx only set when the path does not already exist
77
+ # @param [Boolean] xx only set when the path already exists
78
+ # @param [Boolean] raw treat +value+ as an already-encoded JSON string and send it as-is
79
+ # @return [Boolean, String] when +nx+ or +xx+ is given, +true+ on success and +false+
80
+ # when the condition was not met; otherwise the raw +"OK"+ reply
81
+ # @raise [ArgumentError] if both +nx+ and +xx+ are given (they are mutually exclusive)
82
+ def json_set(key, path, value, nx: false, xx: false, raw: false)
83
+ raise ArgumentError, "nx and xx are mutually exclusive" if nx && xx
84
+
85
+ value = ::JSON.generate(value) unless raw
86
+ args = [:"JSON.SET", key, path, value]
87
+ args << "NX" if nx
88
+ args << "XX" if xx
89
+
90
+ if nx || xx
91
+ send_command(args, &BoolifySet)
92
+ else
93
+ send_command(args)
94
+ end
95
+ end
96
+
97
+ # Get the JSON value(s) at one or more +paths+ in the document stored under +key+.
98
+ #
99
+ # By default the reply is parsed from JSON text into a Ruby object. Pass +raw: true+ to
100
+ # get the unparsed JSON string back instead (no JSON.parse).
101
+ #
102
+ # @example
103
+ # redis.json_get("doc")
104
+ # # => { "a" => 1, "nested" => { "b" => 2 } }
105
+ #
106
+ # @example raw JSON string
107
+ # redis.json_get("doc", raw: true)
108
+ # # => '{"a":1,"nested":{"b":2}}'
109
+ #
110
+ # @param [String] key
111
+ # @param [Array<String>] paths zero or more JSONPath expressions; with none the whole
112
+ # document is returned
113
+ # @param [Boolean] raw return the unparsed JSON string instead of a parsed Ruby object
114
+ # @return [Object, String, nil] the parsed JSON value (or the raw JSON string when +raw+
115
+ # is true), or nil if the key does not exist
116
+ def json_get(key, *paths, raw: false)
117
+ send_command([:"JSON.GET", key, *paths]) do |reply|
118
+ if reply.nil? || raw
119
+ reply
120
+ else
121
+ ::JSON.parse(reply)
122
+ end
123
+ end
124
+ end
125
+
126
+ # Set one or more JSON values atomically, one per +key+/+path+/+value+ triplet. Either all
127
+ # of the writes are applied or none are. For a key that does not yet exist the +path+ must
128
+ # be the root ("$").
129
+ #
130
+ # By default each value is a Ruby object serialized with JSON.generate; pass +raw: true+ to
131
+ # send already-encoded JSON strings through untouched.
132
+ #
133
+ # @example
134
+ # redis.json_mset("doc1", "$", { "a" => 1 }, "doc2", "$", { "b" => 2 })
135
+ # # => "OK"
136
+ #
137
+ # @param [Array] args a flat list of key, path, value triplets
138
+ # @param [Boolean] raw treat each value as an already-encoded JSON string
139
+ # @return [String] the raw "OK" reply
140
+ # @raise [ArgumentError] unless +args+ is a non-empty list of complete triplets
141
+ def json_mset(*args, raw: false)
142
+ raise ArgumentError, "wrong number of arguments (expected key/path/value triplets)" \
143
+ if args.empty? || !(args.size % 3).zero?
144
+
145
+ command = [:"JSON.MSET"]
146
+ args.each_slice(3) do |key, path, value|
147
+ command << key << path << (raw ? value : ::JSON.generate(value))
148
+ end
149
+ send_command(command)
150
+ end
151
+
152
+ # Get the values at a single +path+ from one or more +keys+.
153
+ #
154
+ # Returns one element per key, in order, parsed from JSON text into a Ruby object (or nil
155
+ # when the key or path does not exist). Pass +raw: true+ to get the unparsed JSON strings.
156
+ #
157
+ # @example
158
+ # redis.json_mget("doc1", "doc2", "$.a")
159
+ # # => [[1], [2]]
160
+ #
161
+ # @param [Array<String>] keys one or more keys to read
162
+ # @param [String] path a single JSONPath applied to every key
163
+ # @param [Boolean] raw return the unparsed JSON strings instead of parsed Ruby objects
164
+ # @return [Array] one value per key (nil for a missing key/path)
165
+ def json_mget(*keys, path, raw: false)
166
+ keys.flatten!(1)
167
+ send_command([:"JSON.MGET", *keys, path]) do |reply|
168
+ if reply.nil?
169
+ reply
170
+ else
171
+ reply.map { |value| value.nil? || raw ? value : ::JSON.parse(value) }
172
+ end
173
+ end
174
+ end
175
+
176
+ # Delete the value(s) at +path+ in the document stored under +key+. When +path+ is omitted
177
+ # it defaults to the root, so deleting the root removes the whole key.
178
+ #
179
+ # @example
180
+ # redis.json_del("doc", "$.a")
181
+ # # => 1
182
+ #
183
+ # @param [String] key
184
+ # @param [String] path an optional JSONPath (defaults to the root "$")
185
+ # @return [Integer] the number of values deleted
186
+ def json_del(key, path = nil)
187
+ args = [:"JSON.DEL", key]
188
+ args << path if path
189
+ send_command(args)
190
+ end
191
+
192
+ # Delete the value(s) at +path+ in the document stored under +key+. Alias of {#json_del}.
193
+ #
194
+ # @example
195
+ # redis.json_forget("doc", "$.a")
196
+ # # => 1
197
+ #
198
+ # @param [String] key
199
+ # @param [String] path an optional JSONPath (defaults to the root "$")
200
+ # @return [Integer] the number of values deleted
201
+ def json_forget(key, path = nil)
202
+ args = [:"JSON.FORGET", key]
203
+ args << path if path
204
+ send_command(args)
205
+ end
206
+
207
+ # Clear the container and numeric value(s) at +path+: arrays and objects are emptied and
208
+ # numbers are set to 0. Strings, booleans and null are left unchanged. When +path+ is
209
+ # omitted it defaults to the root.
210
+ #
211
+ # @example
212
+ # redis.json_clear("doc", "$.arr")
213
+ # # => 1
214
+ #
215
+ # @param [String] key
216
+ # @param [String] path an optional JSONPath (defaults to the root "$")
217
+ # @return [Integer] the number of values cleared
218
+ def json_clear(key, path = nil)
219
+ args = [:"JSON.CLEAR", key]
220
+ args << path if path
221
+ send_command(args)
222
+ end
223
+
224
+ # Merge +value+ into the document stored under +key+ at +path+, following RFC 7396 (JSON
225
+ # Merge Patch): a null value deletes a key, a non-null value creates or updates it, and any
226
+ # value merged into an existing array replaces the whole array. For a key that does not yet
227
+ # exist the +path+ must be the root ("$").
228
+ #
229
+ # By default +value+ is a Ruby object serialized with JSON.generate; pass +raw: true+ to
230
+ # send an already-encoded JSON string through untouched.
231
+ #
232
+ # @example
233
+ # redis.json_merge("doc", "$.b", 8)
234
+ # # => "OK"
235
+ #
236
+ # @param [String] key
237
+ # @param [String] path a JSONPath (must be "$" when creating a new key)
238
+ # @param [Object] value a JSON-serializable Ruby object, or a pre-encoded JSON string when
239
+ # +raw+ is true
240
+ # @param [Boolean] raw treat +value+ as an already-encoded JSON string and send it as-is
241
+ # @return [String] the raw "OK" reply
242
+ def json_merge(key, path, value, raw: false)
243
+ value = ::JSON.generate(value) unless raw
244
+ send_command([:"JSON.MERGE", key, path, value])
245
+ end
246
+
247
+ # Append one or more values to the array at +path+ in the document stored under +key+.
248
+ #
249
+ # By default each value is a Ruby object serialized with JSON.generate; pass +raw: true+ to
250
+ # send already-encoded JSON strings through untouched.
251
+ #
252
+ # @example
253
+ # redis.json_arrappend("doc", "$.colors", "blue")
254
+ # # => [3]
255
+ #
256
+ # @param [String] key
257
+ # @param [String] path a JSONPath to the target array
258
+ # @param [Array<Object>] values one or more JSON values to append
259
+ # @param [Boolean] raw treat each value as an already-encoded JSON string
260
+ # @return [Array<Integer>, Integer] the new array length(s); an Array for a JSONPath,
261
+ # a single Integer for a legacy path (nil for a match that is not an array)
262
+ def json_arrappend(key, path, *values, raw: false)
263
+ values = values.map { |value| ::JSON.generate(value) } unless raw
264
+ send_command([:"JSON.ARRAPPEND", key, path, *values])
265
+ end
266
+
267
+ # Return the index of the first occurrence of a scalar +value+ in the array at +path+. The
268
+ # optional +start+ (inclusive) and +stop+ (exclusive) bound the search.
269
+ #
270
+ # @example
271
+ # redis.json_arrindex("doc", "$.colors", "silver")
272
+ # # => [1]
273
+ #
274
+ # @param [String] key
275
+ # @param [String] path a JSONPath to the target array
276
+ # @param [Object] value the scalar JSON value to search for
277
+ # @param [Integer] start optional inclusive start index
278
+ # @param [Integer] stop optional exclusive stop index
279
+ # @param [Boolean] raw treat +value+ as an already-encoded JSON string
280
+ # @return [Array<Integer>, Integer] the index/indices of the first match (-1 if not found)
281
+ def json_arrindex(key, path, value, start: nil, stop: nil, raw: false)
282
+ value = ::JSON.generate(value) unless raw
283
+ args = [:"JSON.ARRINDEX", key, path, value]
284
+ unless start.nil? && stop.nil?
285
+ args << Integer(start || 0)
286
+ args << Integer(stop) unless stop.nil?
287
+ end
288
+ send_command(args)
289
+ end
290
+
291
+ # Insert one or more values into the array at +path+ before +index+ (existing elements at
292
+ # and after +index+ shift right; 0 prepends, negative counts from the end).
293
+ #
294
+ # By default each value is a Ruby object serialized with JSON.generate; pass +raw: true+ to
295
+ # send already-encoded JSON strings through untouched.
296
+ #
297
+ # @example
298
+ # redis.json_arrinsert("doc", "$.colors", 1, "gold")
299
+ # # => [4]
300
+ #
301
+ # @param [String] key
302
+ # @param [String] path a JSONPath to the target array
303
+ # @param [Integer] index the position to insert before
304
+ # @param [Array<Object>] values one or more JSON values to insert
305
+ # @param [Boolean] raw treat each value as an already-encoded JSON string
306
+ # @return [Array<Integer>, Integer] the new array length(s)
307
+ def json_arrinsert(key, path, index, *values, raw: false)
308
+ values = values.map { |value| ::JSON.generate(value) } unless raw
309
+ send_command([:"JSON.ARRINSERT", key, path, Integer(index), *values])
310
+ end
311
+
312
+ # Return the length of the array at +path+. When +path+ is omitted it defaults to the root.
313
+ #
314
+ # @example
315
+ # redis.json_arrlen("doc", "$.colors")
316
+ # # => [2]
317
+ #
318
+ # @param [String] key
319
+ # @param [String] path an optional JSONPath (defaults to the root "$")
320
+ # @return [Array<Integer>, Integer, nil] the array length(s); nil for a match that is not an
321
+ # array, or when the key/path does not exist
322
+ def json_arrlen(key, path = nil)
323
+ args = [:"JSON.ARRLEN", key]
324
+ args << path if path
325
+ send_command(args)
326
+ end
327
+
328
+ # Remove and return an element from the array at +path+. When +path+ is omitted it defaults
329
+ # to the root; when +index+ is omitted it defaults to -1 (the last element).
330
+ #
331
+ # The popped element is returned as parsed JSON (a Ruby object), or as the unparsed JSON
332
+ # string when +raw: true+.
333
+ #
334
+ # @example
335
+ # redis.json_arrpop("doc", "$.colors", 0)
336
+ # # => ["black"]
337
+ #
338
+ # @param [String] key
339
+ # @param [String] path an optional JSONPath to the target array (defaults to the root "$")
340
+ # @param [Integer] index an optional position to pop from (defaults to -1, the last element)
341
+ # @param [Boolean] raw return the unparsed JSON string(s) instead of parsed Ruby objects
342
+ # @return [Array, Object, nil] the popped value(s); an Array for a JSONPath, a single value
343
+ # for a legacy path, nil for an empty array or a non-array match
344
+ # @raise [ArgumentError] if +index+ is given without a +path+
345
+ def json_arrpop(key, path = nil, index = nil, raw: false)
346
+ raise ArgumentError, "index requires a path" if !index.nil? && path.nil?
347
+
348
+ args = [:"JSON.ARRPOP", key]
349
+ if path
350
+ args << path
351
+ args << Integer(index) unless index.nil?
352
+ end
353
+
354
+ send_command(args) do |reply|
355
+ if reply.nil? || raw
356
+ reply
357
+ elsif reply.is_a?(Array)
358
+ reply.map { |value| value.nil? ? nil : ::JSON.parse(value) }
359
+ else
360
+ ::JSON.parse(reply)
361
+ end
362
+ end
363
+ end
364
+
365
+ # Trim the array at +path+ so it keeps only the inclusive range of elements from +start+ to
366
+ # +stop+ (negative indices count from the end).
367
+ #
368
+ # @example
369
+ # redis.json_arrtrim("doc", "$.colors", 0, 1)
370
+ # # => [2]
371
+ #
372
+ # @param [String] key
373
+ # @param [String] path a JSONPath to the target array
374
+ # @param [Integer] start inclusive index of the first element to keep
375
+ # @param [Integer] stop inclusive index of the last element to keep
376
+ # @return [Array<Integer>, Integer] the new array length(s)
377
+ def json_arrtrim(key, path, start, stop)
378
+ send_command([:"JSON.ARRTRIM", key, path, Integer(start), Integer(stop)])
379
+ end
380
+
381
+ # Increment the numeric value(s) at +path+ in the document stored under +key+ by +number+.
382
+ #
383
+ # @example
384
+ # redis.json_numincrby("doc", "$.a", 2)
385
+ # # => [3]
386
+ #
387
+ # @param [String] key
388
+ # @param [String] path a JSONPath to the numeric value(s)
389
+ # @param [Numeric] number the amount to add
390
+ # @return [Array<Numeric>, Numeric, nil] the new value(s): an Array for a JSONPath, a single
391
+ # number for a legacy path; nil (or nil element) for a non-numeric match
392
+ def json_numincrby(key, path, number)
393
+ jsonpath = json_path?(path)
394
+ send_command([:"JSON.NUMINCRBY", key, path, number]) do |reply|
395
+ NumincrbyNormalize.call(reply, jsonpath)
396
+ end
397
+ end
398
+
399
+ # Return the type name of the value(s) at +path+ (e.g. "integer", "string", "object"). When
400
+ # +path+ is omitted it defaults to the root.
401
+ #
402
+ # @example
403
+ # redis.json_type("doc", "$.a")
404
+ # # => ["integer"]
405
+ #
406
+ # @param [String] key
407
+ # @param [String] path an optional JSONPath (defaults to the root)
408
+ # @return [Array<String>, String, nil] the type name(s): an Array for a JSONPath, a single
409
+ # string for a legacy path
410
+ def json_type(key, path = nil)
411
+ jsonpath = json_path?(path)
412
+ args = [:"JSON.TYPE", key]
413
+ args << path if path
414
+ send_command(args) do |reply|
415
+ TypeNormalize.call(reply, jsonpath)
416
+ end
417
+ end
418
+
419
+ # Return the key names of the JSON object(s) at +path+. When +path+ is omitted it defaults
420
+ # to the root.
421
+ #
422
+ # @example
423
+ # redis.json_objkeys("doc", "$.nested")
424
+ # # => [["b", "c"]]
425
+ #
426
+ # @param [String] key
427
+ # @param [String] path an optional JSONPath (defaults to the root)
428
+ # @return [Array] an array of key-name arrays (JSONPath) or a single array of key names
429
+ # (legacy path); nil for a non-object match
430
+ def json_objkeys(key, path = nil)
431
+ args = [:"JSON.OBJKEYS", key]
432
+ args << path if path
433
+ send_command(args)
434
+ end
435
+
436
+ # Return the number of keys in the JSON object(s) at +path+. When +path+ is omitted it
437
+ # defaults to the root.
438
+ #
439
+ # @example
440
+ # redis.json_objlen("doc", "$.nested")
441
+ # # => [2]
442
+ #
443
+ # @param [String] key
444
+ # @param [String] path an optional JSONPath (defaults to the root)
445
+ # @return [Array<Integer>, Integer, nil] the key count(s): an Array for a JSONPath, a single
446
+ # integer for a legacy path; nil for a non-object match
447
+ def json_objlen(key, path = nil)
448
+ args = [:"JSON.OBJLEN", key]
449
+ args << path if path
450
+ send_command(args)
451
+ end
452
+
453
+ # Return the length of the JSON string(s) at +path+. When +path+ is omitted it defaults to
454
+ # the root.
455
+ #
456
+ # @example
457
+ # redis.json_strlen("doc", "$.a")
458
+ # # => [3]
459
+ #
460
+ # @param [String] key
461
+ # @param [String] path an optional JSONPath (defaults to the root)
462
+ # @return [Array<Integer>, Integer, nil] the string length(s): an Array for a JSONPath, a
463
+ # single integer for a legacy path; nil for a non-string match
464
+ def json_strlen(key, path = nil)
465
+ args = [:"JSON.STRLEN", key]
466
+ args << path if path
467
+ send_command(args)
468
+ end
469
+
470
+ # Append +value+ to the JSON string(s) at +path+ in the document stored under +key+.
471
+ #
472
+ # By default +value+ is a Ruby string serialized with JSON.generate; pass +raw: true+ to
473
+ # send an already-encoded JSON string through untouched.
474
+ #
475
+ # @example
476
+ # redis.json_strappend("doc", "$.a", "bar")
477
+ # # => [6]
478
+ #
479
+ # @param [String] key
480
+ # @param [String] path a JSONPath to the target string(s)
481
+ # @param [String] value the string to append
482
+ # @param [Boolean] raw treat +value+ as an already-encoded JSON string and send it as-is
483
+ # @return [Array<Integer>, Integer, nil] the new string length(s): an Array for a JSONPath, a
484
+ # single integer for a legacy path; nil for a non-string match
485
+ def json_strappend(key, path, value, raw: false)
486
+ value = ::JSON.generate(value) unless raw
487
+ send_command([:"JSON.STRAPPEND", key, path, value])
488
+ end
489
+
490
+ # Toggle the boolean value(s) at +path+ in the document stored under +key+.
491
+ #
492
+ # @example
493
+ # redis.json_toggle("doc", "$.flag")
494
+ # # => [0]
495
+ #
496
+ # @param [String] key
497
+ # @param [String] path a JSONPath to the target boolean(s)
498
+ # @return [Array, Object, nil] +1+/+0+ for the new true/false value(s): an Array for a
499
+ # JSONPath, a single value for a legacy path; nil for a non-boolean match
500
+ def json_toggle(key, path)
501
+ send_command([:"JSON.TOGGLE", key, path])
502
+ end
503
+
504
+ # Report the size in bytes of the JSON value(s) at +path+ (the JSON.DEBUG MEMORY
505
+ # subcommand). When +path+ is omitted it defaults to the root.
506
+ #
507
+ # @example
508
+ # redis.json_debug_memory("doc")
509
+ # # => 264
510
+ #
511
+ # @param [String] key
512
+ # @param [String] path an optional JSONPath (defaults to the root)
513
+ # @return [Array<Integer>, Integer] the size(s) in bytes: an Array for a JSONPath, a single
514
+ # integer for a legacy path or when no path is given (0 for a missing key)
515
+ def json_debug_memory(key, path = nil)
516
+ args = [:"JSON.DEBUG", "MEMORY", key]
517
+ args << path if path
518
+ send_command(args)
519
+ end
520
+
521
+ private
522
+
523
+ # RedisJSON distinguishes JSONPath expressions (which start with "$") from legacy paths.
524
+ # JSONPath queries return an array of matches; legacy paths return a single value.
525
+ def json_path?(path)
526
+ path.to_s.start_with?("$")
527
+ end
528
+ end
529
+ end
530
+ end