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,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis/commands/modules/search/dialect"
4
+ require "redis/commands/modules/search/index_definition"
5
+ require "redis/commands/modules/search/field"
6
+ require "redis/commands/modules/search/schema"
7
+ require "redis/commands/modules/search/query"
8
+ require "redis/commands/modules/search/index"
9
+ require "redis/commands/modules/search/result"
10
+ require "redis/commands/modules/search/miscellaneous"
11
+ require "redis/commands/modules/search/hybrid"
12
+ require "redis/commands/modules/search/aggregation"
13
+
14
+ class Redis
15
+ module Commands
16
+ module Search
17
+ end
18
+ end
19
+ end
@@ -135,6 +135,26 @@ class Redis
135
135
  send_command([:sdiffstore, destination].concat(keys))
136
136
  end
137
137
 
138
+ # Get the number of members in the difference between the first set and
139
+ # all successive sets.
140
+ #
141
+ # @example
142
+ # redis.sadd("foo", ["s1", "s2", "s3"])
143
+ # redis.sadd("bar", "s3")
144
+ # redis.sdiffcard("foo", "bar")
145
+ # # => 2
146
+ #
147
+ # @param [String, Array<String>] keys keys pointing to sets to subtract
148
+ # @param [Integer] limit stop counting once the difference reaches `limit`
149
+ # members (`0` means unlimited)
150
+ # @return [Integer] number of members in the difference
151
+ def sdiffcard(*keys, limit: nil)
152
+ keys.flatten!(1)
153
+ args = [:sdiffcard, keys.size].concat(keys)
154
+ args << "LIMIT" << Integer(limit) if limit
155
+ send_command(args)
156
+ end
157
+
138
158
  # Intersect multiple sets.
139
159
  #
140
160
  # @param [String, Array<String>] keys keys pointing to sets to intersect
@@ -173,6 +193,27 @@ class Redis
173
193
  send_command([:sunionstore, destination].concat(keys))
174
194
  end
175
195
 
196
+ # Get the number of distinct members in the union of multiple sets.
197
+ #
198
+ # @example
199
+ # redis.sadd("foo", ["s1", "s2", "s3"])
200
+ # redis.sadd("bar", ["s3", "s4"])
201
+ # redis.sunioncard("foo", "bar")
202
+ # # => 4
203
+ #
204
+ # @param [String, Array<String>] keys keys pointing to sets to unify
205
+ # @param [Boolean] approx return an approximate cardinality computed with HyperLogLog
206
+ # @param [Integer] limit stop counting once the union reaches `limit`
207
+ # members (`0` means unlimited)
208
+ # @return [Integer] number of distinct members in the union
209
+ def sunioncard(*keys, approx: false, limit: nil)
210
+ keys.flatten!(1)
211
+ args = [:sunioncard, keys.size].concat(keys)
212
+ args << "APPROX" if approx
213
+ args << "LIMIT" << Integer(limit) if limit
214
+ send_command(args)
215
+ end
216
+
176
217
  # Scan a set
177
218
  #
178
219
  # @example Retrieve the first batch of keys in a set
@@ -366,7 +366,6 @@ class Redis
366
366
  # - when `:with_scores` is specified, an array with `[member, score]` pairs
367
367
  def zrange(key, start, stop, byscore: false, by_score: byscore, bylex: false, by_lex: bylex,
368
368
  rev: false, limit: nil, withscores: false, with_scores: withscores)
369
-
370
369
  if by_score && by_lex
371
370
  raise ArgumentError, "only one of :by_score or :by_lex can be specified"
372
371
  end
@@ -44,10 +44,11 @@ class Redis
44
44
  # @option opts [Integer] :maxlen max length of entries to keep
45
45
  # @option opts [Integer] :minid min id of entries to keep
46
46
  # @option opts [Boolean] :approximate whether to add `~` modifier of maxlen/minid or not
47
+ # @option opts [Integer] :limit maximum count of entries to be evicted, requires approximate trimming
47
48
  # @option opts [Boolean] :nomkstream whether to add NOMKSTREAM, default is not to add
48
49
  #
49
50
  # @return [String] the entry id
50
- def xadd(key, entry, approximate: nil, maxlen: nil, minid: nil, nomkstream: nil, id: '*')
51
+ def xadd(key, entry, approximate: nil, maxlen: nil, minid: nil, limit: nil, nomkstream: nil, id: '*')
51
52
  args = [:xadd, key]
52
53
  args << 'NOMKSTREAM' if nomkstream
53
54
  if maxlen
@@ -61,6 +62,7 @@ class Redis
61
62
  args << "~" if approximate
62
63
  args << minid
63
64
  end
65
+ args.concat(['LIMIT', limit]) if limit
64
66
  args << id
65
67
  args.concat(entry.flatten)
66
68
  send_command(args)
@@ -181,18 +183,28 @@ class Redis
181
183
  # redis.xread(%w[mystream1 mystream2], %w[0-0 0-0])
182
184
  # @example With count option
183
185
  # redis.xread('mystream', '0-0', count: 2)
186
+ # @example With max_count option
187
+ # redis.xread(%w[mystream1 mystream2], %w[0-0 0-0], max_count: 10)
184
188
  # @example With block option
185
189
  # redis.xread('mystream', '$', block: 1000)
186
190
  #
187
- # @param keys [Array<String>] one or multiple stream keys
188
- # @param ids [Array<String>] one or multiple entry ids
189
- # @param count [Integer] the number of entries as limit per stream
190
- # @param block [Integer] the number of milliseconds as blocking timeout
191
+ # @param keys [Array<String>] one or multiple stream keys
192
+ # @param ids [Array<String>] one or multiple entry ids
193
+ # @param count [Integer] the number of entries as limit per stream
194
+ # @param max_count [Integer] the total number of entries as limit across all streams
195
+ # combined, must be greater than or equal to `count` when both are given (Redis 8.10+).
196
+ # Defaults to unlimited.
197
+ # @param max_size [Integer] soft cap on the total reply size in bytes across all
198
+ # streams combined, measured on the serialized server reply including protocol overhead;
199
+ # a single oversized first entry can still be returned (Redis 8.10+). Defaults to unlimited.
200
+ # @param block [Integer] the number of milliseconds as blocking timeout
191
201
  #
192
202
  # @return [Hash{String => Hash{String => Hash}}] the entries
193
- def xread(keys, ids, count: nil, block: nil)
203
+ def xread(keys, ids, count: nil, max_count: nil, max_size: nil, block: nil)
194
204
  args = [:xread]
195
205
  args << 'COUNT' << count if count
206
+ args << 'MAXCOUNT' << Integer(max_count) if max_count
207
+ args << 'MAXSIZE' << Integer(max_size) if max_size
196
208
  args << 'BLOCK' << block.to_i if block
197
209
  _xread(args, keys, ids, block)
198
210
  end
@@ -243,14 +255,22 @@ class Redis
243
255
  # @param ids [Array<String>] one or multiple entry ids
244
256
  # @param opts [Hash] several options for `XREADGROUP` command
245
257
  #
246
- # @option opts [Integer] :count the number of entries as limit
258
+ # @option opts [Integer] :count the number of entries as limit per stream
259
+ # @option opts [Integer] :max_count the total number of entries as limit across all streams
260
+ # combined, must be greater than or equal to `count` when both are given (Redis 8.10+).
261
+ # Defaults to unlimited.
262
+ # @option opts [Integer] :max_size soft cap on the total reply size in bytes across all
263
+ # streams combined, measured on the serialized server reply including protocol overhead;
264
+ # a single oversized first entry can still be returned (Redis 8.10+). Defaults to unlimited.
247
265
  # @option opts [Integer] :block the number of milliseconds as blocking timeout
248
266
  # @option opts [Boolean] :noack whether message loss is acceptable or not
249
267
  #
250
268
  # @return [Hash{String => Hash{String => Hash}}] the entries
251
- def xreadgroup(group, consumer, keys, ids, count: nil, block: nil, noack: nil)
269
+ def xreadgroup(group, consumer, keys, ids, count: nil, max_count: nil, max_size: nil, block: nil, noack: nil)
252
270
  args = [:xreadgroup, 'GROUP', group, consumer]
253
271
  args << 'COUNT' << count if count
272
+ args << 'MAXCOUNT' << Integer(max_count) if max_count
273
+ args << 'MAXSIZE' << Integer(max_size) if max_size
254
274
  args << 'BLOCK' << block.to_i if block
255
275
  args << 'NOACK' if noack
256
276
  _xread(args, keys, ids, block)
@@ -8,8 +8,10 @@ require "redis/commands/hashes"
8
8
  require "redis/commands/hyper_log_log"
9
9
  require "redis/commands/keys"
10
10
  require "redis/commands/lists"
11
+ require "redis/commands/modules/json"
11
12
  require "redis/commands/pubsub"
12
13
  require "redis/commands/scripting"
14
+ require "redis/commands/modules/search"
13
15
  require "redis/commands/server"
14
16
  require "redis/commands/sets"
15
17
  require "redis/commands/sorted_sets"
@@ -27,8 +29,10 @@ class Redis
27
29
  include HyperLogLog
28
30
  include Keys
29
31
  include Lists
32
+ include Json
30
33
  include Pubsub
31
34
  include Scripting
35
+ include Search
32
36
  include Server
33
37
  include Sets
34
38
  include SortedSets
@@ -55,7 +59,9 @@ class Redis
55
59
  }
56
60
 
57
61
  Hashify = lambda { |value|
58
- if value.respond_to?(:each_slice)
62
+ if value.is_a?(Hash) # RESP3 already returns a map
63
+ value
64
+ elsif value.respond_to?(:each_slice) # RESP2 flat [k, v, k, v, ...]
59
65
  value.each_slice(2).to_h
60
66
  else
61
67
  value
@@ -63,10 +69,12 @@ class Redis
63
69
  }
64
70
 
65
71
  Pairify = lambda { |value|
66
- if value.respond_to?(:each_slice)
67
- value.each_slice(2).to_a
68
- else
72
+ return value unless value.respond_to?(:each_slice)
73
+
74
+ if value.first.is_a?(Array) # RESP3 already returns [[k, v], ...]
69
75
  value
76
+ else # RESP2 flat [k, v, k, v, ...]
77
+ value.each_slice(2).to_a
70
78
  end
71
79
  }
72
80
 
@@ -90,7 +98,14 @@ class Redis
90
98
  FloatifyPairs = lambda { |value|
91
99
  return value unless value.respond_to?(:each_slice)
92
100
 
93
- value.each_slice(2).map(&FloatifyPair)
101
+ if value.first.is_a?(Array) # RESP3 already returns [[member, score], ...]
102
+ # Scores arrive as native doubles, so the pairs are already in the final shape and
103
+ # re-mapping would only re-allocate identical arrays. Floatify only transforms Strings, so
104
+ # unless a score came back as one (it shouldn't under RESP3) return the parser's array as-is.
105
+ value.first.last.is_a?(String) ? value.map(&FloatifyPair) : value
106
+ else # RESP2 flat [member, score, member, score, ...]
107
+ value.each_slice(2).map(&FloatifyPair)
108
+ end
94
109
  }
95
110
 
96
111
  HashifyInfo = lambda { |reply|
@@ -217,14 +232,19 @@ class Redis
217
232
  when "get-master-addr-by-name"
218
233
  reply
219
234
  else
220
- if reply.is_a?(Array)
221
- if reply[0].is_a?(Array)
222
- reply.map(&Hashify)
235
+ case reply
236
+ when Array
237
+ if reply.empty?
238
+ reply # empty list (e.g. sentinels/slaves with no entries) stays []; don't Hashify to {}
223
239
  else
224
- Hashify.call(reply)
240
+ case reply[0]
241
+ when Array then reply.map(&Hashify) # RESP2: list of flat [k, v, ...] arrays
242
+ when Hash then reply # RESP3: list of maps (already hashes)
243
+ else Hashify.call(reply) # RESP2: a single flat [k, v, ...] array
244
+ end
225
245
  end
226
246
  else
227
- reply
247
+ reply # RESP3 single map, or a scalar reply
228
248
  end
229
249
  end
230
250
  end
@@ -22,6 +22,10 @@ class Redis
22
22
  @ring = options[:ring] || HashRing.new
23
23
  @node_configs = node_configs.map(&:dup)
24
24
  @default_options = options.dup
25
+ # Schemas registered via himport_prepare, replayed to nodes that join the ring later (see
26
+ # #add_node) so ring membership changes don't leave nodes without the fieldsets that
27
+ # key-routed himport_set calls expect. Initialized before the add_node loop below.
28
+ @himport_fieldsets = {}
25
29
  node_configs.each { |node_config| add_node(node_config) }
26
30
  @subscribed_node = nil
27
31
  @watch_key = nil
@@ -43,7 +47,12 @@ class Redis
43
47
  options = @default_options.merge(options)
44
48
  options.delete(:tag)
45
49
  options.delete(:ring)
46
- @ring.add_node Redis.new(options)
50
+ node = Redis.new(options)
51
+ # Replay registered fieldsets before the node can receive key-routed himport_set calls:
52
+ # a node joining after a himport_prepare fan-out has neither the server-side fieldset nor
53
+ # a populated registry to self-recover from "no such fieldset".
54
+ @himport_fieldsets.each { |name, fields| node.himport_prepare(name, fields) }
55
+ @ring.add_node node
47
56
  end
48
57
 
49
58
  # Change the selected database for the current connection.
@@ -340,6 +349,124 @@ class Redis
340
349
  node_for(key).getex(key, **options)
341
350
  end
342
351
 
352
+ # Set the JSON value at a path in the document stored under a key.
353
+ def json_set(key, path, value, **options)
354
+ node_for(key).json_set(key, path, value, **options)
355
+ end
356
+
357
+ # Get the JSON value(s) at one or more paths in the document stored under a key.
358
+ def json_get(key, *paths, **options)
359
+ node_for(key).json_get(key, *paths, **options)
360
+ end
361
+
362
+ # Set one or more JSON values. The keys may live on different nodes and the operation must be
363
+ # atomic, so it cannot be distributed.
364
+ def json_mset(*)
365
+ raise CannotDistribute, :json_mset
366
+ end
367
+
368
+ # Get the values at a path from several keys. Keys are grouped by node, queried per node, and
369
+ # reassembled in the original key order.
370
+ def json_mget(*keys, path, **options)
371
+ keys.flatten!(1)
372
+ values = keys.group_by { |key| node_for(key) }.each_with_object({}) do |(node, subkeys), acc|
373
+ node.json_mget(*subkeys, path, **options).each_with_index do |value, i|
374
+ acc[subkeys[i]] = value
375
+ end
376
+ end
377
+ keys.map { |key| values[key] }
378
+ end
379
+
380
+ # Delete the JSON value(s) at a path in the document stored under a key.
381
+ def json_del(key, path = nil)
382
+ node_for(key).json_del(key, path)
383
+ end
384
+
385
+ # Delete the JSON value(s) at a path in the document stored under a key (alias of json_del).
386
+ def json_forget(key, path = nil)
387
+ node_for(key).json_forget(key, path)
388
+ end
389
+
390
+ # Clear the container and numeric JSON value(s) at a path in the document stored under a key.
391
+ def json_clear(key, path = nil)
392
+ node_for(key).json_clear(key, path)
393
+ end
394
+
395
+ # Merge a JSON value into the document stored under a key at a path.
396
+ def json_merge(key, path, value, **options)
397
+ node_for(key).json_merge(key, path, value, **options)
398
+ end
399
+
400
+ # Append one or more values to the JSON array at a path in the document stored under a key.
401
+ def json_arrappend(key, path, *values, **options)
402
+ node_for(key).json_arrappend(key, path, *values, **options)
403
+ end
404
+
405
+ # Return the index of the first occurrence of a scalar in the JSON array at a path.
406
+ def json_arrindex(key, path, value, **options)
407
+ node_for(key).json_arrindex(key, path, value, **options)
408
+ end
409
+
410
+ # Insert one or more values into the JSON array at a path in the document stored under a key.
411
+ def json_arrinsert(key, path, index, *values, **options)
412
+ node_for(key).json_arrinsert(key, path, index, *values, **options)
413
+ end
414
+
415
+ # Return the length of the JSON array at a path in the document stored under a key.
416
+ def json_arrlen(key, path = nil)
417
+ node_for(key).json_arrlen(key, path)
418
+ end
419
+
420
+ # Remove and return an element from the JSON array at a path in the document stored under a key.
421
+ def json_arrpop(key, path = nil, index = nil, **options)
422
+ node_for(key).json_arrpop(key, path, index, **options)
423
+ end
424
+
425
+ # Trim the JSON array at a path in the document stored under a key to an inclusive range.
426
+ def json_arrtrim(key, path, start, stop)
427
+ node_for(key).json_arrtrim(key, path, start, stop)
428
+ end
429
+
430
+ # Increment the numeric JSON value(s) at a path in the document stored under a key.
431
+ def json_numincrby(key, path, number)
432
+ node_for(key).json_numincrby(key, path, number)
433
+ end
434
+
435
+ # Return the type name(s) of the JSON value(s) at a path in the document stored under a key.
436
+ def json_type(key, path = nil)
437
+ node_for(key).json_type(key, path)
438
+ end
439
+
440
+ # Return the key names of the JSON object(s) at a path in the document stored under a key.
441
+ def json_objkeys(key, path = nil)
442
+ node_for(key).json_objkeys(key, path)
443
+ end
444
+
445
+ # Return the number of keys in the JSON object(s) at a path in the document stored under a key.
446
+ def json_objlen(key, path = nil)
447
+ node_for(key).json_objlen(key, path)
448
+ end
449
+
450
+ # Return the length of the JSON string(s) at a path in the document stored under a key.
451
+ def json_strlen(key, path = nil)
452
+ node_for(key).json_strlen(key, path)
453
+ end
454
+
455
+ # Append a string to the JSON string(s) at a path in the document stored under a key.
456
+ def json_strappend(key, path, value, **options)
457
+ node_for(key).json_strappend(key, path, value, **options)
458
+ end
459
+
460
+ # Toggle the boolean JSON value(s) at a path in the document stored under a key.
461
+ def json_toggle(key, path)
462
+ node_for(key).json_toggle(key, path)
463
+ end
464
+
465
+ # Report the size in bytes of the JSON value(s) at a path in the document stored under a key.
466
+ def json_debug_memory(key, path = nil)
467
+ node_for(key).json_debug_memory(key, path)
468
+ end
469
+
343
470
  # Get the values of all the given keys as an Array.
344
471
  def mget(*keys)
345
472
  keys.flatten!(1)
@@ -427,6 +554,15 @@ class Redis
427
554
  end
428
555
  end
429
556
 
557
+ # Remove multiple elements from the head/tail of a list, append/prepend
558
+ # them to another list and return them.
559
+ def lmovem(source, destination, where_source, where_destination, count: nil, exactly: nil, order: nil)
560
+ ensure_same_node(:lmovem, [source, destination]) do |node|
561
+ node.lmovem(source, destination, where_source, where_destination,
562
+ count: count, exactly: exactly, order: order)
563
+ end
564
+ end
565
+
430
566
  # Remove the first/last element in a list and append/prepend it
431
567
  # to another list and return it, or block until one is available.
432
568
  def blmove(source, destination, where_source, where_destination, timeout: 0)
@@ -435,6 +571,17 @@ class Redis
435
571
  end
436
572
  end
437
573
 
574
+ # Remove multiple elements from the head/tail of a list, append/prepend
575
+ # them to another list and return them; or block until the request can
576
+ # be satisfied or the timeout expires.
577
+ def blmovem(source, destination, where_source, where_destination, timeout: 0, count: nil, exactly: nil,
578
+ order: nil)
579
+ ensure_same_node(:blmovem, [source, destination]) do |node|
580
+ node.blmovem(source, destination, where_source, where_destination,
581
+ timeout: timeout, count: count, exactly: exactly, order: order)
582
+ end
583
+ end
584
+
438
585
  # Prepend one or more values to a list.
439
586
  def lpush(key, value)
440
587
  node_for(key).lpush(key, value)
@@ -649,6 +796,15 @@ class Redis
649
796
  end
650
797
  end
651
798
 
799
+ # Get the number of members in the difference between the first set and
800
+ # all successive sets.
801
+ def sdiffcard(*keys, limit: nil)
802
+ keys.flatten!(1)
803
+ ensure_same_node(:sdiffcard, keys) do |node|
804
+ node.sdiffcard(keys, limit: limit)
805
+ end
806
+ end
807
+
652
808
  # Intersect multiple sets.
653
809
  def sinter(*keys)
654
810
  keys.flatten!(1)
@@ -681,6 +837,14 @@ class Redis
681
837
  end
682
838
  end
683
839
 
840
+ # Get the number of distinct members in the union of multiple sets.
841
+ def sunioncard(*keys, approx: false, limit: nil)
842
+ keys.flatten!(1)
843
+ ensure_same_node(:sunioncard, keys) do |node|
844
+ node.sunioncard(keys, approx: approx, limit: limit)
845
+ end
846
+ end
847
+
684
848
  # Get the number of members in a sorted set.
685
849
  def zcard(key)
686
850
  node_for(key).zcard(key)
@@ -918,6 +1082,112 @@ class Redis
918
1082
  node_for(key).hgetall(key)
919
1083
  end
920
1084
 
1085
+ def hexpire(key, ttl, *fields)
1086
+ node_for(key).hexpire(key, ttl, *fields)
1087
+ end
1088
+
1089
+ def httl(key, *fields)
1090
+ node_for(key).httl(key, *fields)
1091
+ end
1092
+
1093
+ # Scan a hash
1094
+ def hscan(key, cursor, **options)
1095
+ node_for(key).hscan(key, cursor, **options)
1096
+ end
1097
+
1098
+ # Scan a hash and return an enumerator
1099
+ def hscan_each(key, **options, &block)
1100
+ node_for(key).hscan_each(key, **options, &block)
1101
+ end
1102
+
1103
+ # Get the length of the value stored in a hash field
1104
+ def hstrlen(key, field)
1105
+ node_for(key).hstrlen(key, field)
1106
+ end
1107
+
1108
+ def hpexpire(key, ttl, *fields, nx: nil, xx: nil, gt: nil, lt: nil)
1109
+ node_for(key).hpexpire(key, ttl, *fields, nx: nx, xx: xx, gt: gt, lt: lt)
1110
+ end
1111
+
1112
+ def hpttl(key, *fields)
1113
+ node_for(key).hpttl(key, *fields)
1114
+ end
1115
+
1116
+ # Register a fieldset on every ring node. Fieldsets are scoped to a physical
1117
+ # connection, so every node that may receive an `himport_set` for a key it
1118
+ # owns needs the fieldset on its own connection.
1119
+ def himport_prepare(fieldset_name, *fields)
1120
+ fields.flatten!(1)
1121
+ results = on_each_node(:himport_prepare, fieldset_name, fields)
1122
+ @himport_fieldsets[fieldset_name.to_s] = fields.dup.freeze
1123
+ results
1124
+ end
1125
+
1126
+ # Create or fully replace a hash from a fieldset prepared on the key's node.
1127
+ def himport_set(key, fieldset_name, *values)
1128
+ values.flatten!(1)
1129
+ node_for(key).himport_set(key, fieldset_name, values)
1130
+ end
1131
+
1132
+ # Remove a fieldset from every ring node.
1133
+ def himport_discard(fieldset_name)
1134
+ results = on_each_node(:himport_discard, fieldset_name)
1135
+ @himport_fieldsets.delete(fieldset_name.to_s)
1136
+ results
1137
+ end
1138
+
1139
+ # Remove all fieldsets from every ring node.
1140
+ def himport_discard_all
1141
+ results = on_each_node(:himport_discard_all)
1142
+ @himport_fieldsets.clear
1143
+ results
1144
+ end
1145
+
1146
+ # Add one or more geospatial items to a sorted set.
1147
+ def geoadd(key, *member, **options)
1148
+ node_for(key).geoadd(key, *member, **options)
1149
+ end
1150
+
1151
+ # Get geohash strings representing the position of one or more members.
1152
+ def geohash(key, member)
1153
+ node_for(key).geohash(key, member)
1154
+ end
1155
+
1156
+ # Get longitude and latitude of one or more members of a geospatial index.
1157
+ def geopos(key, member)
1158
+ node_for(key).geopos(key, member)
1159
+ end
1160
+
1161
+ # Get the distance between two members of a geospatial index.
1162
+ def geodist(key, member1, member2, unit = 'm')
1163
+ node_for(key).geodist(key, member1, member2, unit)
1164
+ end
1165
+
1166
+ # Query a geospatial index for members within a given radius from a point.
1167
+ def georadius(*args, **geoptions)
1168
+ key = args.first
1169
+ node_for(key).georadius(*args, **geoptions)
1170
+ end
1171
+
1172
+ # Query a geospatial index for members within a given radius from an existing member.
1173
+ def georadiusbymember(*args, **geoptions)
1174
+ key = args.first
1175
+ node_for(key).georadiusbymember(*args, **geoptions)
1176
+ end
1177
+
1178
+ # Search a geospatial index for members within a given shape from a center point.
1179
+ def geosearch(key, **options)
1180
+ node_for(key).geosearch(key, **options)
1181
+ end
1182
+
1183
+ # Like #geosearch, but stores the result in a destination key.
1184
+ # Destination and source must hash to the same node; use a key tag to ensure that.
1185
+ def geosearchstore(destination, source, **options)
1186
+ ensure_same_node(:geosearchstore, [destination, source]) do |node|
1187
+ node.geosearchstore(destination, source, **options)
1188
+ end
1189
+ end
1190
+
921
1191
  # Post a message to a channel.
922
1192
  def publish(channel, message)
923
1193
  node_for(channel).publish(channel, message)
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis-client"
4
+ require "redis/version"
5
+
6
+ class Redis
7
+ # Reports `redis-rb` as the connecting library via `CLIENT SETINFO`.
8
+ #
9
+ # `redis-client` builds its own `LIB-NAME` as `redis-client(<driver_info>)` and always
10
+ # reports its own version as `LIB-VER`, which would identify `redis-client` rather than
11
+ # `redis-rb` to the server. We append a second pair of `CLIENT SETINFO` commands, which wins
12
+ # because the command is last-write-wins, so that `CLIENT LIST` / `CLIENT INFO` report
13
+ # `lib-name=redis-rb` and `lib-ver=<Redis::VERSION>`.
14
+ #
15
+ # Libraries built on top of `redis-rb` can still identify themselves by passing `driver_info`,
16
+ # in which case they are reported inside the parentheses, e.g. `lib-name=redis-rb(my-gem_v1.0.0)`.
17
+ # The official convention for such suffixes is `<name>_v<version>` (matching
18
+ # `(?<custom-name>[ -~]+)[ -~]v(?<custom-version>[\d\.]+)`), with `;` delimiting multiple
19
+ # suffixes and parentheses avoided inside them — see
20
+ # https://redis.io/docs/latest/commands/client-setinfo/. Passing `driver_info: false` disables
21
+ # `CLIENT SETINFO` entirely, for peers that can't tolerate unknown commands during the handshake
22
+ # (e.g. some proxies).
23
+ #
24
+ # Applications that use `redis-client` directly are left untouched: the override only applies
25
+ # to configurations whose `driver_info` was built by this module.
26
+ module LibIdentity
27
+ # Follows the official suffix convention, so that the transient pair `redis-client` itself
28
+ # builds from this value (`lib-name=redis-client(redis-rb_v<version>)`) is well-formed too.
29
+ MARKER = "redis-rb_v"
30
+ LIB_NAME = "redis-rb"
31
+ SEPARATOR = ";"
32
+ # Interpolated strings are not frozen by the magic comment; explicit freezes keep these
33
+ # constants Ractor-shareable (lazy module ivars here would raise Ractor::IsolationError).
34
+ OWN_INFO = "#{MARKER}#{Redis::VERSION}".freeze
35
+ OWN_PREFIX = "#{OWN_INFO}#{SEPARATOR}".freeze
36
+ # `CLIENT SETINFO` values must be printable ASCII with no spaces — the server rejects anything
37
+ # else, and `redis-client` swallows that error, which would silently leave `lib-name` empty.
38
+ # Parentheses are excluded as well: they delimit the suffix in `lib-name=redis-rb(<suffix>)`,
39
+ # so a suffix containing them would corrupt the reported name.
40
+ INVALID_CHARS = /(?:[^!-~]|[()])+/
41
+
42
+ class << self
43
+ # Combines redis-rb's own identity with the `driver_info` a downstream library passed in,
44
+ # so that both can be recovered when the connection prelude is built. `false` is passed
45
+ # through untouched and, being falsy, turns `CLIENT SETINFO` off wholesale (`redis-client`
46
+ # gates its own commands on `driver_info` too). Any other non-String, non-Array value is
47
+ # also passed through, leaving `redis-client` to reject it.
48
+ def driver_info(downstream = nil)
49
+ case downstream
50
+ when nil then OWN_INFO
51
+ when String then combine(downstream)
52
+ when Array then combine(downstream.join(SEPARATOR))
53
+ else downstream
54
+ end
55
+ end
56
+
57
+ # True only for values `driver_info` produced: exactly our own identity, or our identity
58
+ # followed by a downstream library's. Matching on the bare `MARKER` prefix instead would
59
+ # also capture a direct `redis-client` user whose own `driver_info` merely starts with
60
+ # `redis-rb_v` (say `redis-rb_viewer-1.0`) and would silently discard their identity.
61
+ def ours?(info)
62
+ info.is_a?(String) && (info == OWN_INFO || info.start_with?(OWN_PREFIX))
63
+ end
64
+
65
+ def setinfo_commands(info)
66
+ [
67
+ ["CLIENT", "SETINFO", "LIB-NAME", lib_name(info)].freeze,
68
+ ["CLIENT", "SETINFO", "LIB-VER", Redis::VERSION].freeze,
69
+ ].freeze
70
+ end
71
+
72
+ private
73
+
74
+ def combine(downstream)
75
+ downstream = downstream.scrub(" ") # invalid byte sequences would make gsub raise
76
+ .gsub(/\A#{INVALID_CHARS}|#{INVALID_CHARS}\z/o, "")
77
+ .gsub(INVALID_CHARS, "_")
78
+ downstream.empty? ? OWN_INFO : "#{OWN_PREFIX}#{downstream}"
79
+ end
80
+
81
+ def lib_name(info)
82
+ downstream = info == OWN_INFO ? nil : info.delete_prefix(OWN_PREFIX)
83
+ downstream.nil? || downstream.empty? ? LIB_NAME : "#{LIB_NAME}(#{downstream})".freeze
84
+ end
85
+ end
86
+
87
+ def connection_prelude
88
+ prelude = super
89
+ info = driver_info
90
+ return prelude unless LibIdentity.ours?(info)
91
+
92
+ # `CLIENT SETINFO` is last-write-wins, so appending after the commands `redis-client`
93
+ # already built overrides them, without having to recognise or remove them.
94
+ (prelude + LibIdentity.setinfo_commands(info)).freeze
95
+ end
96
+ end
97
+ end
98
+
99
+ # Prepended to the concrete config classes rather than to `RedisClient::Config::Common`, so that
100
+ # the two `redis-client` entry points are covered without altering the shared module for anything
101
+ # else that includes it. `RedisClient::SentinelConfig` is NOT a `RedisClient::Config` subclass, so
102
+ # both lines are required. `RedisClient::Cluster::Node::Config` does subclass `RedisClient::Config`
103
+ # and calls `super` in its own `connection_prelude`, so cluster nodes are covered by the first.
104
+ RedisClient::Config.prepend(Redis::LibIdentity)
105
+ RedisClient::SentinelConfig.prepend(Redis::LibIdentity)