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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -1
- data/README.md +165 -12
- data/lib/redis/client.rb +42 -10
- data/lib/redis/commands/geo.rb +108 -6
- data/lib/redis/commands/hashes.rb +166 -2
- data/lib/redis/commands/keys.rb +5 -1
- data/lib/redis/commands/lists.rb +93 -0
- data/lib/redis/commands/modules/json.rb +530 -0
- data/lib/redis/commands/modules/search/aggregation.rb +418 -0
- data/lib/redis/commands/modules/search/dialect.rb +14 -0
- data/lib/redis/commands/modules/search/field.rb +306 -0
- data/lib/redis/commands/modules/search/hybrid.rb +359 -0
- data/lib/redis/commands/modules/search/index.rb +351 -0
- data/lib/redis/commands/modules/search/index_definition.rb +114 -0
- data/lib/redis/commands/modules/search/miscellaneous.rb +607 -0
- data/lib/redis/commands/modules/search/query.rb +738 -0
- data/lib/redis/commands/modules/search/result.rb +488 -0
- data/lib/redis/commands/modules/search/schema.rb +211 -0
- data/lib/redis/commands/modules/search.rb +19 -0
- data/lib/redis/commands/sets.rb +41 -0
- data/lib/redis/commands/sorted_sets.rb +0 -1
- data/lib/redis/commands/streams.rb +28 -8
- data/lib/redis/commands.rb +30 -10
- data/lib/redis/distributed.rb +271 -1
- data/lib/redis/lib_identity.rb +105 -0
- data/lib/redis/subscribe.rb +1 -1
- data/lib/redis/version.rb +1 -1
- data/lib/redis.rb +141 -11
- metadata +22 -9
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
class Redis
|
|
6
|
+
module Commands
|
|
7
|
+
module Search
|
|
8
|
+
# A single document returned by FT.SEARCH.
|
|
9
|
+
#
|
|
10
|
+
# Behaves like a read-only hash over the document's returned fields, while also
|
|
11
|
+
# exposing the document +id+ and, when requested, its +score+ and +payload+.
|
|
12
|
+
class Document
|
|
13
|
+
# @return [String] the document key/id
|
|
14
|
+
# @return [Float, Array, nil] the relevance score (present when WITHSCORES was set),
|
|
15
|
+
# normalized to a Float across RESP2/RESP3; with EXPLAINSCORE it is +[Float, explanation]+
|
|
16
|
+
# @return [String, nil] the document payload (present when WITHPAYLOADS was set)
|
|
17
|
+
# @return [Hash{String => Object}] the returned fields keyed by field name
|
|
18
|
+
attr_reader :id, :score, :payload, :attributes
|
|
19
|
+
|
|
20
|
+
# @param id [String] the document key/id
|
|
21
|
+
# @param attributes [Hash{String => Object}] the returned fields keyed by field name
|
|
22
|
+
# @param score [Float, Array, nil] the relevance score (Float; +[Float, explanation]+ with EXPLAINSCORE)
|
|
23
|
+
# @param payload [String, nil] the document payload
|
|
24
|
+
def initialize(id, attributes: {}, score: nil, payload: nil)
|
|
25
|
+
@id = id
|
|
26
|
+
@attributes = attributes
|
|
27
|
+
@score = score
|
|
28
|
+
@payload = payload
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Look up a returned field by name.
|
|
32
|
+
#
|
|
33
|
+
# @param key [String, Symbol] the field name
|
|
34
|
+
# @return [Object, nil] the field value, or nil when the field is absent
|
|
35
|
+
def [](key)
|
|
36
|
+
@attributes[key.to_s]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param key [String, Symbol] the field name
|
|
40
|
+
# @return [Boolean] whether the document has the given field
|
|
41
|
+
def key?(key)
|
|
42
|
+
@attributes.key?(key.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The document id plus its returned fields, as a flat hash.
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash{String => Object}] the attributes merged with +"id"+
|
|
48
|
+
def to_h
|
|
49
|
+
@attributes.merge("id" => @id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param other [Object] the object to compare against
|
|
53
|
+
# @return [Boolean] whether +other+ is a Document with equal id, attributes, score and payload
|
|
54
|
+
def ==(other)
|
|
55
|
+
other.is_a?(Document) &&
|
|
56
|
+
id == other.id &&
|
|
57
|
+
attributes == other.attributes &&
|
|
58
|
+
score == other.score &&
|
|
59
|
+
payload == other.payload
|
|
60
|
+
end
|
|
61
|
+
alias eql? ==
|
|
62
|
+
|
|
63
|
+
# @return [Integer] a hash derived from id, attributes, score and payload
|
|
64
|
+
def hash
|
|
65
|
+
[id, attributes, score, payload].hash
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Result of FT.SEARCH: the total number of matching documents (which may exceed the
|
|
70
|
+
# number returned because of paging) plus the documents on this page.
|
|
71
|
+
class SearchResult
|
|
72
|
+
include Enumerable
|
|
73
|
+
|
|
74
|
+
# @return [Integer] the total number of matching documents (may exceed +documents.size+
|
|
75
|
+
# because of paging)
|
|
76
|
+
# @return [Array<Document>] the documents on this page
|
|
77
|
+
# @return [Array<String>] any warnings returned by the server (e.g. a query timeout under
|
|
78
|
+
# the +return+ policy); only the RESP3 wire carries warnings on FT.SEARCH, so this is
|
|
79
|
+
# always empty on RESP2 connections
|
|
80
|
+
attr_reader :total, :documents, :warnings
|
|
81
|
+
|
|
82
|
+
# @param total [Integer] the total number of matching documents
|
|
83
|
+
# @param documents [Array<Document>] the documents on this page
|
|
84
|
+
# @param warnings [Array<String>] any warnings returned by the server
|
|
85
|
+
def initialize(total, documents, warnings: [])
|
|
86
|
+
@total = total
|
|
87
|
+
@documents = documents
|
|
88
|
+
@warnings = warnings
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Iterate over the documents on this page.
|
|
92
|
+
#
|
|
93
|
+
# @yieldparam document [Document]
|
|
94
|
+
# @return [Enumerator, Array<Document>]
|
|
95
|
+
def each(&block)
|
|
96
|
+
@documents.each(&block)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param index [Integer] the position within this page
|
|
100
|
+
# @return [Document, nil] the document at +index+, or nil when out of range
|
|
101
|
+
def [](index)
|
|
102
|
+
@documents[index]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Integer] the number of documents on this page
|
|
106
|
+
def size
|
|
107
|
+
@documents.size
|
|
108
|
+
end
|
|
109
|
+
alias length size
|
|
110
|
+
|
|
111
|
+
# @return [Boolean] whether this page has no documents
|
|
112
|
+
def empty?
|
|
113
|
+
@documents.empty?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Result of FT.AGGREGATE (and FT.CURSOR READ): the rows produced by the pipeline plus,
|
|
118
|
+
# when WITHCURSOR was requested, the cursor id to read the next batch with (0 when
|
|
119
|
+
# the cursor is exhausted).
|
|
120
|
+
class AggregateResult
|
|
121
|
+
include Enumerable
|
|
122
|
+
|
|
123
|
+
# @return [Array<Hash{String => Object}>] the rows produced by the pipeline, each a
|
|
124
|
+
# field => value hash
|
|
125
|
+
# @return [Integer, nil] the next cursor id (0 when exhausted), or nil when no cursor
|
|
126
|
+
attr_reader :rows, :cursor
|
|
127
|
+
|
|
128
|
+
# @param rows [Array<Hash{String => Object}>] the pipeline rows
|
|
129
|
+
# @param cursor [Integer, nil] the next cursor id, or nil when no cursor was requested
|
|
130
|
+
def initialize(rows, cursor: nil)
|
|
131
|
+
@rows = rows
|
|
132
|
+
@cursor = cursor
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Iterate over the rows.
|
|
136
|
+
#
|
|
137
|
+
# @yieldparam row [Hash{String => Object}]
|
|
138
|
+
# @return [Enumerator, Array<Hash>]
|
|
139
|
+
def each(&block)
|
|
140
|
+
@rows.each(&block)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @param index [Integer] the row position
|
|
144
|
+
# @return [Hash, nil] the row at +index+, or nil when out of range
|
|
145
|
+
def [](index)
|
|
146
|
+
@rows[index]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Integer] the number of rows
|
|
150
|
+
def size
|
|
151
|
+
@rows.size
|
|
152
|
+
end
|
|
153
|
+
alias length size
|
|
154
|
+
|
|
155
|
+
# @return [Boolean] whether there are no rows
|
|
156
|
+
def empty?
|
|
157
|
+
@rows.empty?
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Result of FT.HYBRID: the fused result rows (each a field => value hash, including the
|
|
162
|
+
# synthetic +"__key"+ and +"__score"+) plus the total, any warnings and the execution time.
|
|
163
|
+
#
|
|
164
|
+
# When the query used WITHCURSOR the server returns per-leg cursor ids instead of an inline
|
|
165
|
+
# page; those are exposed via {#search_cursor} / {#vsim_cursor} and {#cursor?} is true.
|
|
166
|
+
class HybridResult
|
|
167
|
+
include Enumerable
|
|
168
|
+
|
|
169
|
+
# @return [Array<Hash{String => Object}>] the fused result rows
|
|
170
|
+
# @return [Integer, nil] the total number of fused results
|
|
171
|
+
# @return [Array] any warnings returned by the server
|
|
172
|
+
# @return [Float, nil] the server-side execution time
|
|
173
|
+
# @return [Integer, nil] the SEARCH-leg cursor id (WITHCURSOR only)
|
|
174
|
+
# @return [Integer, nil] the VSIM-leg cursor id (WITHCURSOR only)
|
|
175
|
+
attr_reader :rows, :total, :warnings, :execution_time, :search_cursor, :vsim_cursor
|
|
176
|
+
|
|
177
|
+
def initialize(rows: [], total: nil, warnings: [], execution_time: nil,
|
|
178
|
+
search_cursor: nil, vsim_cursor: nil)
|
|
179
|
+
@rows = rows
|
|
180
|
+
@total = total
|
|
181
|
+
@warnings = warnings
|
|
182
|
+
@execution_time = execution_time
|
|
183
|
+
@search_cursor = search_cursor
|
|
184
|
+
@vsim_cursor = vsim_cursor
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Iterate over the result rows.
|
|
188
|
+
#
|
|
189
|
+
# @yieldparam row [Hash{String => Object}]
|
|
190
|
+
# @return [Enumerator, Array<Hash>]
|
|
191
|
+
def each(&block)
|
|
192
|
+
@rows.each(&block)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @param index [Integer] the row position
|
|
196
|
+
# @return [Hash, nil] the row at +index+, or nil when out of range
|
|
197
|
+
def [](index)
|
|
198
|
+
@rows[index]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [Integer] the number of rows
|
|
202
|
+
def size
|
|
203
|
+
@rows.size
|
|
204
|
+
end
|
|
205
|
+
alias length size
|
|
206
|
+
|
|
207
|
+
# @return [Boolean] whether there are no rows
|
|
208
|
+
def empty?
|
|
209
|
+
@rows.empty?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# @return [Boolean] whether this is a WITHCURSOR reply carrying cursor ids
|
|
213
|
+
def cursor?
|
|
214
|
+
!@search_cursor.nil? || !@vsim_cursor.nil?
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Reply reshaping for the Query Engine.
|
|
219
|
+
#
|
|
220
|
+
# Every parser normalises *both* RESP2 (flat arrays) and RESP3 (native maps) replies to
|
|
221
|
+
# the same Ruby objects, so the public API is stable regardless of the protocol the
|
|
222
|
+
# underlying connection negotiates. RESP3 is the default protocol; the RESP2 branches exist
|
|
223
|
+
# for +protocol: 2+ connections.
|
|
224
|
+
module ResultParser
|
|
225
|
+
module_function
|
|
226
|
+
|
|
227
|
+
# FT.SEARCH -> SearchResult
|
|
228
|
+
#
|
|
229
|
+
# @param reply [Array, Hash] RESP2 flat array or RESP3 map
|
|
230
|
+
# @param with_scores [Boolean] WITHSCORES was set (RESP2 only; RESP3 carries it inline)
|
|
231
|
+
# @param with_payloads [Boolean] WITHPAYLOADS was set (RESP2 only)
|
|
232
|
+
# @param no_content [Boolean] NOCONTENT was set
|
|
233
|
+
# @param decode_fields [Hash] field name => whether to JSON-decode its value
|
|
234
|
+
# @return [SearchResult, nil] the parsed result (nil when +reply+ is nil)
|
|
235
|
+
def search(reply, with_scores: false, with_payloads: false, no_content: false, decode_fields: {})
|
|
236
|
+
return reply if reply.nil?
|
|
237
|
+
|
|
238
|
+
if reply.is_a?(Hash) # RESP3
|
|
239
|
+
search_resp3(reply, decode_fields)
|
|
240
|
+
else # RESP2 flat array
|
|
241
|
+
search_resp2(reply, with_scores, with_payloads, no_content, decode_fields)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Parse a RESP2 flat-array FT.SEARCH reply into a {SearchResult}.
|
|
246
|
+
#
|
|
247
|
+
# @param reply [Array] the flat +[total, id, ..., fields, ...]+ array
|
|
248
|
+
# @param with_scores [Boolean] WITHSCORES was set
|
|
249
|
+
# @param with_payloads [Boolean] WITHPAYLOADS was set
|
|
250
|
+
# @param no_content [Boolean] NOCONTENT was set
|
|
251
|
+
# @param decode_fields [Hash] field name => whether to JSON-decode its value
|
|
252
|
+
# @return [SearchResult]
|
|
253
|
+
def search_resp2(reply, with_scores, with_payloads, no_content, decode_fields)
|
|
254
|
+
total = reply[0]
|
|
255
|
+
documents = []
|
|
256
|
+
index = 1
|
|
257
|
+
|
|
258
|
+
while index < reply.length
|
|
259
|
+
id = reply[index]
|
|
260
|
+
index += 1
|
|
261
|
+
|
|
262
|
+
score = nil
|
|
263
|
+
if with_scores
|
|
264
|
+
score = normalize_score(reply[index])
|
|
265
|
+
index += 1
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
payload = nil
|
|
269
|
+
if with_payloads
|
|
270
|
+
payload = reply[index]
|
|
271
|
+
index += 1
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
attributes = {}
|
|
275
|
+
unless no_content
|
|
276
|
+
attributes = hashify_fields(reply[index], decode_fields)
|
|
277
|
+
index += 1
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
documents << Document.new(id, attributes: attributes, score: score, payload: payload)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
SearchResult.new(total, documents)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Parse a RESP3 map FT.SEARCH reply into a {SearchResult}.
|
|
287
|
+
#
|
|
288
|
+
# @param reply [Hash] the native map carrying +"results"+ and +"total_results"+
|
|
289
|
+
# @param decode_fields [Hash] field name => whether to JSON-decode its value
|
|
290
|
+
# @return [SearchResult]
|
|
291
|
+
def search_resp3(reply, decode_fields)
|
|
292
|
+
documents = (reply["results"] || []).map do |row|
|
|
293
|
+
attributes = hashify_fields(row["extra_attributes"], decode_fields)
|
|
294
|
+
Document.new(row["id"], attributes: attributes, score: normalize_score(row["score"]),
|
|
295
|
+
payload: row["payload"])
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
SearchResult.new(reply["total_results"], documents, warnings: reply["warning"] || [])
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# FT.AGGREGATE / FT.CURSOR READ -> AggregateResult
|
|
302
|
+
#
|
|
303
|
+
# Without a cursor the reply is [num_rows, row, row, ...] (RESP2) or a map with
|
|
304
|
+
# "results" (RESP3). With WITHCURSOR the reply is [aggregate_reply, cursor_id].
|
|
305
|
+
#
|
|
306
|
+
# @param reply [Array, Hash] the FT.AGGREGATE / FT.CURSOR READ reply
|
|
307
|
+
# @return [AggregateResult, nil] the parsed result (nil when +reply+ is nil)
|
|
308
|
+
def aggregate(reply)
|
|
309
|
+
return reply if reply.nil?
|
|
310
|
+
|
|
311
|
+
cursor = nil
|
|
312
|
+
body = reply
|
|
313
|
+
# WITHCURSOR wraps the aggregate body and the cursor id in a 2-element array. The
|
|
314
|
+
# body is a flat array under RESP2 and a native map under RESP3.
|
|
315
|
+
if reply.is_a?(Array) && reply.length == 2 && reply[1].is_a?(Integer) &&
|
|
316
|
+
(reply[0].is_a?(Array) || reply[0].is_a?(Hash))
|
|
317
|
+
body = reply[0]
|
|
318
|
+
cursor = reply[1]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
rows =
|
|
322
|
+
if body.is_a?(Hash) # RESP3
|
|
323
|
+
(body["results"] || []).map { |row| hashify_fields(row["extra_attributes"] || row, {}) }
|
|
324
|
+
else # RESP2: first element is the row count, the rest are rows
|
|
325
|
+
body[1..-1].to_a.map { |row| hashify_fields(row, {}) }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
AggregateResult.new(rows.map { |row| normalize_aggregate_row(row) }, cursor: cursor)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A COLLECT reducer column is an array of entry-maps: +Array<Hash>+ under RESP3 but
|
|
332
|
+
# +Array<[k, v, ...]>+ (flat key/value arrays) under RESP2. Normalize both to
|
|
333
|
+
# +Array<Hash>+ so callers see a uniform type regardless of protocol. Scalar values and
|
|
334
|
+
# arrays of scalars (e.g. TOLIST) are left untouched, and the RESP3 case is idempotent.
|
|
335
|
+
#
|
|
336
|
+
# @param row [Hash] an already-hashified aggregate row
|
|
337
|
+
# @return [Hash] the row with any array-of-entries column reshaped to +Array<Hash>+
|
|
338
|
+
def normalize_aggregate_row(row)
|
|
339
|
+
row.each_with_object({}) do |(key, value), acc|
|
|
340
|
+
acc[key] =
|
|
341
|
+
if value.is_a?(Array) && !value.empty? && value.all? { |e| e.is_a?(Array) || e.is_a?(Hash) }
|
|
342
|
+
value.map { |entry| hashify_fields(entry, {}) }
|
|
343
|
+
else
|
|
344
|
+
value
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# FT.HYBRID -> HybridResult
|
|
350
|
+
#
|
|
351
|
+
# The reply is a native map under RESP3 and a flat [k, v, ...] array under RESP2. A
|
|
352
|
+
# WITHCURSOR reply carries per-leg cursor ids ("SEARCH"/"VSIM") instead of a result page.
|
|
353
|
+
#
|
|
354
|
+
# @param reply [Array, Hash] the FT.HYBRID reply
|
|
355
|
+
# @return [HybridResult, nil] the parsed result (nil when +reply+ is nil)
|
|
356
|
+
def hybrid(reply)
|
|
357
|
+
return reply if reply.nil?
|
|
358
|
+
|
|
359
|
+
map = reply.is_a?(Hash) ? reply : Hash[*reply]
|
|
360
|
+
|
|
361
|
+
if map.key?("SEARCH") || map.key?("VSIM")
|
|
362
|
+
return HybridResult.new(
|
|
363
|
+
warnings: map["warnings"] || [],
|
|
364
|
+
search_cursor: map["SEARCH"],
|
|
365
|
+
vsim_cursor: map["VSIM"]
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
rows = Array(map["results"]).map { |row| hashify_fields(row, {}) }
|
|
370
|
+
HybridResult.new(
|
|
371
|
+
rows: rows,
|
|
372
|
+
total: map["total_results"],
|
|
373
|
+
warnings: map["warnings"] || [],
|
|
374
|
+
execution_time: map["execution_time"]
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# FT.INFO / FT.CONFIG GET -> Hash. RESP2 returns a flat [k, v, ...] array; RESP3
|
|
379
|
+
# already returns a native map.
|
|
380
|
+
#
|
|
381
|
+
# @param reply [Array, Hash] the FT.INFO reply
|
|
382
|
+
# @return [Hash, nil] the info as a hash (nil when +reply+ is nil)
|
|
383
|
+
def hashify_info(reply)
|
|
384
|
+
return reply if reply.nil?
|
|
385
|
+
return reply if reply.is_a?(Hash)
|
|
386
|
+
|
|
387
|
+
Hash[*reply]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# FT.CONFIG GET -> Hash. RESP2 returns nested [[option, value], ...] pairs; RESP3 a map.
|
|
391
|
+
#
|
|
392
|
+
# @param reply [Array, Hash] the FT.CONFIG GET reply
|
|
393
|
+
# @return [Hash, nil] option => value (nil when +reply+ is nil)
|
|
394
|
+
def config_get(reply)
|
|
395
|
+
return reply if reply.nil?
|
|
396
|
+
return reply if reply.is_a?(Hash)
|
|
397
|
+
|
|
398
|
+
reply.to_h
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# FT.SYNDUMP -> { term => [group_id, ...] }. RESP2 is a flat [term, [ids], ...] array.
|
|
402
|
+
#
|
|
403
|
+
# @param reply [Array, Hash] the FT.SYNDUMP reply
|
|
404
|
+
# @return [Hash{String => Array}, nil] term => group ids (nil when +reply+ is nil)
|
|
405
|
+
def syndump(reply)
|
|
406
|
+
return reply if reply.nil?
|
|
407
|
+
return reply if reply.is_a?(Hash)
|
|
408
|
+
|
|
409
|
+
Hash[*reply]
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# FT.SPELLCHECK -> { term => [{ "suggestion" => ..., "score" => ... }, ...] }
|
|
413
|
+
#
|
|
414
|
+
# @param reply [Array, Hash] the FT.SPELLCHECK reply
|
|
415
|
+
# @return [Hash{String => Array<Hash>}, nil] term => suggestions (nil when +reply+ is nil)
|
|
416
|
+
def spellcheck(reply)
|
|
417
|
+
return reply if reply.nil?
|
|
418
|
+
|
|
419
|
+
# RESP3: { "results" => { term => [{ suggestion => score }, ...] } }
|
|
420
|
+
if reply.is_a?(Hash)
|
|
421
|
+
results = reply["results"] || {}
|
|
422
|
+
return results.each_with_object({}) do |(term, suggestions), acc|
|
|
423
|
+
acc[term] = suggestions.flat_map do |entry|
|
|
424
|
+
entry.map { |suggestion, score| { "suggestion" => suggestion, "score" => score } }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# RESP2: [["TERM", term, [[score, suggestion], ...]], ...]
|
|
430
|
+
parsed = {}
|
|
431
|
+
reply.each do |entry|
|
|
432
|
+
next unless entry[0] == "TERM"
|
|
433
|
+
|
|
434
|
+
parsed[entry[1]] = entry[2].map do |score, suggestion|
|
|
435
|
+
{ "suggestion" => suggestion, "score" => score }
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
parsed
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Turn a flat [field, value, ...] array (RESP2) or a native map (RESP3) of returned
|
|
442
|
+
# fields into a hash, JSON-decoding values whose field is flagged in +decode_fields+.
|
|
443
|
+
#
|
|
444
|
+
# @param fields [Array, Hash, nil] the field/value pairs
|
|
445
|
+
# @param decode_fields [Hash] field name => whether to JSON-decode its value
|
|
446
|
+
# @return [Hash{String => Object}] the fields as a hash (empty when +fields+ is nil)
|
|
447
|
+
# Normalize a WITHSCORES value to a Float so Document#score is the same type regardless of
|
|
448
|
+
# protocol (RESP2 returns the score as a bulk string, RESP3 as a native double). With
|
|
449
|
+
# EXPLAINSCORE the RESP2 value is +[score, explanation]+; coerce the score part and keep
|
|
450
|
+
# the explanation. Anything non-numeric is returned untouched.
|
|
451
|
+
def normalize_score(score)
|
|
452
|
+
case score
|
|
453
|
+
when nil then nil
|
|
454
|
+
when Array then [normalize_score(score[0]), *score[1..]]
|
|
455
|
+
else Float(score)
|
|
456
|
+
end
|
|
457
|
+
rescue ArgumentError, TypeError
|
|
458
|
+
score
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def hashify_fields(fields, decode_fields)
|
|
462
|
+
return {} if fields.nil?
|
|
463
|
+
|
|
464
|
+
# Reply field names are strings, but callers may key decode_fields with symbols
|
|
465
|
+
# (e.g. Query#return_field(:brand)). Normalize so decoding is caller-type-independent.
|
|
466
|
+
decode = decode_fields.transform_keys(&:to_s)
|
|
467
|
+
pairs = fields.is_a?(Hash) ? fields.to_a : fields.each_slice(2).to_a
|
|
468
|
+
|
|
469
|
+
pairs.each_with_object({}) do |(name, value), acc|
|
|
470
|
+
acc[name] = decode[name.to_s] ? decode_value(value) : value
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# JSON-decode a value, returning it unchanged when it is not a parseable JSON String.
|
|
475
|
+
#
|
|
476
|
+
# @param value [Object] the value to decode
|
|
477
|
+
# @return [Object] the parsed value, or +value+ when it is not a String or fails to parse
|
|
478
|
+
def decode_value(value)
|
|
479
|
+
return value unless value.is_a?(String)
|
|
480
|
+
|
|
481
|
+
::JSON.parse(value)
|
|
482
|
+
rescue ::JSON::ParserError
|
|
483
|
+
value
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Redis
|
|
4
|
+
module Commands
|
|
5
|
+
module Search
|
|
6
|
+
# A Redis Query Engine index schema: an ordered collection of {Field}
|
|
7
|
+
# objects rendered into the +SCHEMA+ section of an +FT.CREATE+ call.
|
|
8
|
+
class Schema
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
attr_reader :fields
|
|
12
|
+
|
|
13
|
+
# Build a schema from a list of fields.
|
|
14
|
+
#
|
|
15
|
+
# @param [Array<Field>] fields the fields that make up the schema
|
|
16
|
+
def initialize(fields = [])
|
|
17
|
+
@fields = fields
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Find a field by its name/path or its +AS+ alias, so e.g. a JSON field declared as
|
|
21
|
+
# "$.price" AS "price" is found by either "$.price" or "price".
|
|
22
|
+
#
|
|
23
|
+
# @param [String, Symbol] name the field name/path or alias to look up
|
|
24
|
+
# @return [Field, nil] the matching field, or +nil+ if none matches
|
|
25
|
+
def field(name)
|
|
26
|
+
name = name.to_s
|
|
27
|
+
@fields.find { |f| f.name.to_s == name || f.alias_name&.to_s == name }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Iterate over the schema's fields.
|
|
31
|
+
#
|
|
32
|
+
# @yieldparam [Field] field each field in order
|
|
33
|
+
# @return [Enumerator, Array<Field>] the fields when no block is given
|
|
34
|
+
def each(&block)
|
|
35
|
+
@fields.each(&block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Render the schema as the array of +FT.CREATE+ tokens.
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# schema.to_args # => ["SCHEMA", "title", "TEXT", "SORTABLE"]
|
|
42
|
+
#
|
|
43
|
+
# @return [Array] the +SCHEMA+ keyword followed by each field's tokens
|
|
44
|
+
def to_args
|
|
45
|
+
['SCHEMA'] + @fields.flat_map(&:to_args)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a schema using the block DSL evaluated in a {SchemaDefinition}.
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# Redis::Commands::Search::Schema.build do
|
|
52
|
+
# text_field "title", weight: 5.0, sortable: true
|
|
53
|
+
# numeric_field "price"
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# @return [Schema] the schema built from the block
|
|
57
|
+
# @raise [Redis::CommandError] if a field is given invalid options
|
|
58
|
+
def self.build(&block)
|
|
59
|
+
definition = SchemaDefinition.new
|
|
60
|
+
begin
|
|
61
|
+
definition.instance_eval(&block)
|
|
62
|
+
rescue ArgumentError => e
|
|
63
|
+
raise Redis::CommandError, e.message
|
|
64
|
+
end
|
|
65
|
+
new(definition.fields)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The block DSL used by {Schema.build} to declare fields. Each helper
|
|
70
|
+
# appends a {Field} subclass to {#fields}.
|
|
71
|
+
class SchemaDefinition
|
|
72
|
+
attr_reader :fields
|
|
73
|
+
|
|
74
|
+
def initialize
|
|
75
|
+
@fields = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Add a {TextField} to the schema.
|
|
79
|
+
#
|
|
80
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
81
|
+
# @option options [Numeric] :weight the field's scoring weight
|
|
82
|
+
# @option options [Boolean] :sortable allow sorting by the field
|
|
83
|
+
# @option options [Boolean] :no_index do not index the field
|
|
84
|
+
# @option options [String] :as an alias for the field
|
|
85
|
+
# @option options [String] :phonetic phonetic matcher
|
|
86
|
+
# @option options [Boolean] :no_stem disable stemming
|
|
87
|
+
# @option options [Boolean] :index_empty index empty values
|
|
88
|
+
# @option options [Boolean] :withsuffixtrie build a suffix trie
|
|
89
|
+
# @return [Array<Field>] the updated field list
|
|
90
|
+
# @raise [ArgumentError] if an unknown option key is given
|
|
91
|
+
def text_field(name, **options)
|
|
92
|
+
valid_options = %i[weight sortable no_index as phonetic no_stem index_empty index_missing withsuffixtrie]
|
|
93
|
+
invalid_options = options.keys - valid_options
|
|
94
|
+
if invalid_options.any?
|
|
95
|
+
raise ArgumentError, "Invalid options for text field: #{invalid_options.join(', ')}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@fields << TextField.new(name, **options)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Add a {NumericField} to the schema.
|
|
102
|
+
#
|
|
103
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
104
|
+
# @return [Array<Field>] the updated field list
|
|
105
|
+
def numeric_field(name, **options)
|
|
106
|
+
@fields << NumericField.new(name, **options)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add a {TagField} to the schema.
|
|
110
|
+
#
|
|
111
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
112
|
+
# @option options [Boolean] :sortable allow sorting by the field
|
|
113
|
+
# @option options [Boolean] :no_index do not index the field
|
|
114
|
+
# @option options [String] :as an alias for the field
|
|
115
|
+
# @option options [String] :separator the tag separator character
|
|
116
|
+
# @option options [Boolean] :case_sensitive keep tag casing
|
|
117
|
+
# @option options [Boolean] :index_empty index empty values
|
|
118
|
+
# @option options [Boolean] :index_missing index documents missing the field
|
|
119
|
+
# @option options [Boolean] :withsuffixtrie build a suffix trie
|
|
120
|
+
# @return [Array<Field>] the updated field list
|
|
121
|
+
# @raise [ArgumentError] if an unknown option key is given
|
|
122
|
+
def tag_field(name, **options)
|
|
123
|
+
valid_options = %i[sortable no_index as separator case_sensitive index_empty index_missing withsuffixtrie]
|
|
124
|
+
invalid_options = options.keys - valid_options
|
|
125
|
+
if invalid_options.any?
|
|
126
|
+
raise ArgumentError, "Invalid options for tag field: #{invalid_options.join(', ')}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@fields << TagField.new(name, **options)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Add a {GeoField} to the schema.
|
|
133
|
+
#
|
|
134
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
135
|
+
# @return [Array<Field>] the updated field list
|
|
136
|
+
def geo_field(name, **options)
|
|
137
|
+
@fields << GeoField.new(name, **options)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Add a {GeoShapeField} to the schema.
|
|
141
|
+
#
|
|
142
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
143
|
+
# @param [String, nil] coord_system the coordinate system, +FLAT+ or +SPHERICAL+;
|
|
144
|
+
# nil (the default) omits the token so the server default (+SPHERICAL+) applies
|
|
145
|
+
# @return [Array<Field>] the updated field list
|
|
146
|
+
def geoshape_field(name, coord_system = nil, **options)
|
|
147
|
+
@fields << GeoShapeField.new(name, coord_system, **options)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Add a {VectorField} to the schema.
|
|
151
|
+
#
|
|
152
|
+
# Field-level options (+:as+, +:sortable+, +:no_index+) are extracted from
|
|
153
|
+
# +attributes+; the remaining keys (e.g. +type:+, +dim:+, +distance_metric:+)
|
|
154
|
+
# become vector attributes. An optional block is evaluated in a
|
|
155
|
+
# {VectorFieldDefinition} for declaring attributes.
|
|
156
|
+
#
|
|
157
|
+
# @example
|
|
158
|
+
# vector_field "embedding", "HNSW", type: "FLOAT32", dim: 4, distance_metric: "L2"
|
|
159
|
+
#
|
|
160
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
161
|
+
# @param [String, Symbol] algorithm the indexing method (+FLAT+, +HNSW+, +SVS-VAMANA+)
|
|
162
|
+
# @param [Hash] attributes vector attributes and field-level options
|
|
163
|
+
# @yield an optional block evaluated in a {VectorFieldDefinition}
|
|
164
|
+
# @return [Array<Field>] the updated field list
|
|
165
|
+
def vector_field(name, algorithm, **attributes, &block)
|
|
166
|
+
# Extract field-level options (as, sortable, no_index) from attributes
|
|
167
|
+
field_options = {}
|
|
168
|
+
field_options[:as] = attributes.delete(:as) if attributes.key?(:as)
|
|
169
|
+
field_options[:sortable] = attributes.delete(:sortable) if attributes.key?(:sortable)
|
|
170
|
+
field_options[:no_index] = attributes.delete(:no_index) if attributes.key?(:no_index)
|
|
171
|
+
|
|
172
|
+
field = VectorField.new(name, algorithm, attributes, **field_options)
|
|
173
|
+
VectorFieldDefinition.new(field).instance_eval(&block) if block_given?
|
|
174
|
+
@fields << field
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The block DSL used by {SchemaDefinition#vector_field} to declare the
|
|
179
|
+
# attributes of a {VectorField}.
|
|
180
|
+
class VectorFieldDefinition
|
|
181
|
+
def initialize(field)
|
|
182
|
+
@field = field
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Set the vector element type attribute (e.g. +FLOAT32+).
|
|
186
|
+
#
|
|
187
|
+
# @param [Object] value the +TYPE+ attribute value
|
|
188
|
+
# @return [Object] the stored value
|
|
189
|
+
def type(value)
|
|
190
|
+
@field.add_attribute(:type, value)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Set the vector dimensionality attribute.
|
|
194
|
+
#
|
|
195
|
+
# @param [Object] value the +DIM+ attribute value
|
|
196
|
+
# @return [Object] the stored value
|
|
197
|
+
def dim(value)
|
|
198
|
+
@field.add_attribute(:dim, value)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Set the distance metric attribute (e.g. +L2+, +COSINE+).
|
|
202
|
+
#
|
|
203
|
+
# @param [Object] value the +DISTANCE_METRIC+ attribute value
|
|
204
|
+
# @return [Object] the stored value
|
|
205
|
+
def distance_metric(value)
|
|
206
|
+
@field.add_attribute(:distance_metric, value)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|