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,607 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ # Redis Query Engine (RediSearch, the +FT.*+ command family). See
6
+ # {file:specs/query-engine.md} for the layered design and an abstractions-vs-raw-commands guide.
7
+ #
8
+ # Replies are reshaped by {Search::ResultParser} into {Search::SearchResult} /
9
+ # {Search::Document} (for +FT.SEARCH+), {Search::AggregateResult} (for +FT.AGGREGATE+ /
10
+ # +FT.CURSOR READ+), or plain Hashes/Arrays. Reshaping is identical under RESP2 and RESP3.
11
+ module Search
12
+ # Create a search index over HASH or JSON keys.
13
+ #
14
+ # @example
15
+ # schema = Redis::Commands::Search::Schema.build { text_field :title }
16
+ # redis.ft_create("idx", schema, prefix: "doc")
17
+ # # => "OK"
18
+ #
19
+ # @param index_name [String] the index name
20
+ # @param schema [Search::Schema] the field schema; the +SCHEMA+ clause is rendered from it
21
+ # @param storage_type [String, Symbol, nil] the data type to index, e.g. +"HASH"+ or +"JSON"+
22
+ # (emitted as +ON <type>+); ignored when +definition+ is given
23
+ # @param prefix [String, nil] index keys whose name starts with +"<prefix>:"+; ignored when
24
+ # +definition+ is given
25
+ # @param stopwords [Array<String>, nil] a custom stopword list (emits +STOPWORDS+)
26
+ # @param max_text_fields [Boolean] emit +MAXTEXTFIELDS+
27
+ # @param skip_initial_scan [Boolean] emit +SKIPINITIALSCAN+ (do not backfill existing keys)
28
+ # @param definition [Search::IndexDefinition, nil] a prebuilt +ON .../PREFIX/FILTER/...+ clause;
29
+ # when given it takes precedence over +storage_type+/+prefix+
30
+ # @param temporary [Integer, nil] index lifetime in seconds (emits +TEMPORARY <seconds>+)
31
+ # @param no_term_offsets [Boolean] emit +NOOFFSETS+
32
+ # @param no_highlight [Boolean] emit +NOHL+
33
+ # @param no_field_flags [Boolean] emit +NOFIELDS+
34
+ # @param no_term_frequencies [Boolean] emit +NOFREQS+
35
+ # @return [String] +"OK"+
36
+ # @raise [ArgumentError] if +schema+ is not a {Search::Schema}
37
+ def ft_create(
38
+ index_name, schema, storage_type = nil,
39
+ prefix: nil, stopwords: nil, max_text_fields: false,
40
+ skip_initial_scan: false, definition: nil, temporary: nil,
41
+ no_term_offsets: false, no_highlight: false,
42
+ no_field_flags: false, no_term_frequencies: false, **_options
43
+ )
44
+ raise ArgumentError, "schema must be a Schema object" unless schema.is_a?(Schema)
45
+
46
+ args = [index_name]
47
+
48
+ # Use IndexDefinition if provided, otherwise use legacy parameters
49
+ if definition
50
+ # The definition supplies the ON/PREFIX/FILTER clause. When it doesn't declare an index
51
+ # type, fall back to the storage_type keyword so e.g. a JSON index isn't silently created
52
+ # as HASH. Normalize to the uppercase HASH/JSON form some Query Engine versions require.
53
+ args += ["ON", storage_type.to_s.upcase] if definition.index_type.nil? && storage_type
54
+ args += definition.args
55
+ else
56
+ # Normalize the ON token to HASH/JSON; a lowercase value is rejected by some
57
+ # Query Engine versions (and IndexType/the docs use the uppercase form).
58
+ args += ["ON", storage_type.to_s.upcase] if storage_type
59
+ args += ["PREFIX", 1, "#{prefix}:"] if prefix
60
+ end
61
+
62
+ args << "MAXTEXTFIELDS" if max_text_fields
63
+
64
+ if temporary
65
+ args << "TEMPORARY"
66
+ args << temporary
67
+ end
68
+
69
+ args << "NOOFFSETS" if no_term_offsets
70
+ args << "NOHL" if no_highlight
71
+ args << "NOFIELDS" if no_field_flags
72
+ args << "NOFREQS" if no_term_frequencies
73
+ args << "SKIPINITIALSCAN" if skip_initial_scan
74
+
75
+ # Stopwords
76
+ if stopwords
77
+ args += ['STOPWORDS', stopwords.size]
78
+ stopwords.each do |stopword|
79
+ args << stopword
80
+ end
81
+ end
82
+
83
+ # Schema Fields
84
+ args += ["SCHEMA"]
85
+ schema.fields.each do |field|
86
+ args += field.to_args
87
+ end
88
+
89
+ send_command([:"FT.CREATE", *args])
90
+ end
91
+
92
+ # Create an index and return a high-level {Search::Index} bound to this client.
93
+ #
94
+ # Unlike {#ft_create} (which returns +"OK"+), this returns a stateful {Search::Index} that
95
+ # remembers the schema and prefix and exposes +#add+/+#search+/+#aggregate+/etc.
96
+ #
97
+ # @example
98
+ # index = redis.create_index("idx", schema, prefix: "doc")
99
+ # index.add("1", title: "hello")
100
+ # index.search("hello").total # => 1
101
+ #
102
+ # @param name [String] the index name
103
+ # @param schema [Search::Schema] the field schema
104
+ # @param storage_type [String] the data type to index (+"hash"+ or +"json"+)
105
+ # @param prefix [String, nil] key prefix for indexed/added documents
106
+ # @param stopwords [Array<String>, nil] custom stopword list
107
+ # @param max_text_fields [Boolean] emit +MAXTEXTFIELDS+
108
+ # @param skip_initial_scan [Boolean] emit +SKIPINITIALSCAN+
109
+ # @param definition [Search::IndexDefinition, nil] prebuilt index definition clause
110
+ # @return [Search::Index] the created index
111
+ # @raise [Redis::CommandError] if +schema+ is not a {Search::Schema} or creation fails
112
+ def create_index(
113
+ name, schema, storage_type: "hash",
114
+ prefix: nil, stopwords: nil, max_text_fields: false,
115
+ skip_initial_scan: false, definition: nil, **options
116
+ )
117
+ raise Redis::CommandError, "schema must be a Schema object" unless schema.is_a?(Schema)
118
+
119
+ begin
120
+ Index.create(
121
+ self, name, schema, storage_type,
122
+ prefix: prefix, stopwords: stopwords,
123
+ max_text_fields: max_text_fields,
124
+ skip_initial_scan: skip_initial_scan,
125
+ definition: definition, **options
126
+ )
127
+ rescue ArgumentError => e
128
+ raise Redis::CommandError, e.message
129
+ end
130
+ end
131
+
132
+ # Search an index.
133
+ #
134
+ # @example
135
+ # redis.ft_search("idx", "@title:hello", limit: [0, 10], with_scores: true)
136
+ # # => #<SearchResult total=1 ...>
137
+ #
138
+ # @example raw vector (KNN) query
139
+ # redis.ft_search("idx", "(*)=>[KNN 2 @vec $v]", params: { v: blob }, dialect: 2)
140
+ #
141
+ # @param index_name [String] the index (or alias) name
142
+ # @param query [String] the query string (use {Search::Query}+#to_redis_args.first+ to build one)
143
+ # @param options [Hash] search options translated to +FT.SEARCH+ tokens
144
+ # @option options [Boolean] :no_content return ids only (+NOCONTENT+); documents have no fields
145
+ # @option options [Boolean] :verbatim disable stemming (+VERBATIM+)
146
+ # @option options [Boolean] :no_stopwords do not remove stopwords (+NOSTOPWORDS+)
147
+ # @option options [Boolean] :with_scores include the relevance score on each document (+WITHSCORES+)
148
+ # @option options [Boolean] :with_payloads include each document's payload (+WITHPAYLOADS+)
149
+ # @option options [String] :scorer scoring function name (+SCORER+), e.g. +"BM25"+/+"TFIDF"+
150
+ # @option options [Boolean] :explain_score include a score explanation (+EXPLAINSCORE+)
151
+ # @option options [String] :language stemming language (+LANGUAGE+)
152
+ # @option options [Integer] :slop allowed term reordering distance (+SLOP+)
153
+ # @option options [Boolean] :in_order require terms in query order (+INORDER+)
154
+ # @option options [Integer] :timeout per-query timeout in milliseconds (+TIMEOUT+)
155
+ # @option options [String] :expander custom query expander (+EXPANDER+)
156
+ # @option options [Array<String>] :limit_fields restrict full-text matching to these fields (+INFIELDS+)
157
+ # @option options [Array(Integer, Integer)] :limit +[offset, count]+ paging (+LIMIT+)
158
+ # @option options [Array(String, String)] :sortby +[field, "ASC"|"DESC"]+ (+SORTBY+)
159
+ # @option options [String] :sort_by sort field (convenience; pair with +:asc+)
160
+ # @option options [Boolean] :asc sort ascending when +:sort_by+ is used (default; pass
161
+ # +false+ for descending)
162
+ # @option options [Array<Array>] :filter numeric filters, each +[field, min, max]+ (+FILTER+)
163
+ # @option options [Array<Array>] :geo_filter geo filters, each +[field, lon, lat, radius, unit]+
164
+ # @option options [Array<String>] :limit_ids restrict to these keys (+INKEYS+)
165
+ # @option options [Array<String>] :return only return these fields (+RETURN+)
166
+ # @option options [Hash] :summarize +SUMMARIZE+ options (+:fields+, +:frags+, +:len+, +:separator+)
167
+ # @option options [Hash] :highlight +HIGHLIGHT+ options (+:fields+, +:tags+)
168
+ # @option options [Hash, Array] :params query parameter substitutions (+PARAMS+)
169
+ # @option options [Integer] :dialect query dialect version (+DIALECT+)
170
+ # @option options [Hash{String=>Boolean}] :decode_fields field name => whether to JSON-decode its
171
+ # value in the returned {Search::Document}
172
+ # @return [Search::SearchResult] the total count and the matching {Search::Document}s
173
+ def ft_search(index_name, query, **options)
174
+ args = [index_name, query]
175
+
176
+ args << "NOCONTENT" if options[:no_content]
177
+ args << "VERBATIM" if options[:verbatim]
178
+ args << "NOSTOPWORDS" if options[:no_stopwords]
179
+ # EXPLAINSCORE requires WITHSCORES, so emit WITHSCORES whenever an explanation is asked for.
180
+ args << "WITHSCORES" if options[:with_scores] || options[:explain_score]
181
+ args << "WITHPAYLOADS" if options[:with_payloads]
182
+ args << "SCORER" << options[:scorer] if options[:scorer]
183
+ args << "EXPLAINSCORE" if options[:explain_score]
184
+ args << "LANGUAGE" << options[:language] if options[:language]
185
+ args << "SLOP" << options[:slop] if options[:slop]
186
+ args << "INORDER" if options[:in_order]
187
+ args << "TIMEOUT" << options[:timeout] if options[:timeout]
188
+ args << "EXPANDER" << options[:expander] if options[:expander]
189
+ if options[:limit_fields] && !options[:limit_fields].empty?
190
+ args << "INFIELDS" << options[:limit_fields].size
191
+ args.concat(options[:limit_fields].map(&:to_s))
192
+ end
193
+ args << "LIMIT" << options[:limit][0] << options[:limit][1] if options[:limit]
194
+
195
+ # Handle both :sortby (array format) and :sort_by (convenience format)
196
+ if options[:sortby]
197
+ args << "SORTBY" << options[:sortby][0] << options[:sortby][1]
198
+ elsif options[:sort_by]
199
+ # Default to ASC when :asc is omitted (matching Index#search, Query#sort_by and the
200
+ # server's own SORTBY default); only an explicit asc: false sorts descending.
201
+ direction = options[:asc] == false ? 'DESC' : 'ASC'
202
+ args << "SORTBY" << options[:sort_by] << direction
203
+ end
204
+
205
+ options[:filter]&.each do |field, min, max|
206
+ args << "FILTER" << field << min << max
207
+ end
208
+
209
+ options[:geo_filter]&.each do |field, lon, lat, radius, unit|
210
+ args << "GEOFILTER" << field << lon << lat << radius << unit
211
+ end
212
+
213
+ if options[:limit_ids] && !options[:limit_ids].empty?
214
+ args << "INKEYS" << options[:limit_ids].size
215
+ args.concat(options[:limit_ids])
216
+ end
217
+
218
+ if options[:return] && !options[:return].empty?
219
+ args << "RETURN" << options[:return].size
220
+ args.concat(options[:return])
221
+ end
222
+
223
+ if options[:summarize]
224
+ args << "SUMMARIZE"
225
+ if options[:summarize][:fields]&.any?
226
+ args << "FIELDS" << options[:summarize][:fields].size
227
+ args.concat(options[:summarize][:fields].map(&:to_s))
228
+ end
229
+ args << "FRAGS" << options[:summarize][:frags] if options[:summarize][:frags]
230
+ args << "LEN" << options[:summarize][:len] if options[:summarize][:len]
231
+ args << "SEPARATOR" << options[:summarize][:separator] if options[:summarize][:separator]
232
+ end
233
+
234
+ if options[:highlight]
235
+ args << "HIGHLIGHT"
236
+ if options[:highlight][:fields]&.any?
237
+ args << "FIELDS" << options[:highlight][:fields].size
238
+ args.concat(options[:highlight][:fields].map(&:to_s))
239
+ end
240
+ if options[:highlight][:tags]
241
+ args << "TAGS" << options[:highlight][:tags][0] << options[:highlight][:tags][1]
242
+ end
243
+ end
244
+
245
+ if options[:params]
246
+ if options[:params].is_a?(Hash)
247
+ args << "PARAMS" << (options[:params].length * 2)
248
+ options[:params].each do |k, v|
249
+ args << k.to_s << v
250
+ end
251
+ else
252
+ args << "PARAMS" << options[:params].length
253
+ args.concat(options[:params])
254
+ end
255
+ end
256
+
257
+ # Default to DIALECT 2 (matching redis-py) unless the caller specified one. An explicit
258
+ # dialect: nil (e.g. flowing from an unset Query option) falls back to the default too, so
259
+ # it behaves the same as omitting the keyword rather than deferring to the server default.
260
+ dialect = options[:dialect] || DEFAULT_DIALECT
261
+ args << "DIALECT" << dialect
262
+
263
+ # WITHSCORES is emitted for explain_score too (EXPLAINSCORE requires it), so the RESP2
264
+ # parser must expect a score column in the same cases or it mis-reads the reply layout.
265
+ with_scores = options[:with_scores] || options[:explain_score]
266
+ with_payloads = options[:with_payloads]
267
+ no_content = options[:no_content]
268
+ decode_fields = options[:decode_fields] || {}
269
+
270
+ send_command(["FT.SEARCH"] + args.flatten.compact) do |reply|
271
+ ResultParser.search(
272
+ reply,
273
+ with_scores: !!with_scores,
274
+ with_payloads: !!with_payloads,
275
+ no_content: !!no_content,
276
+ decode_fields: decode_fields
277
+ )
278
+ end
279
+ end
280
+
281
+ # Return information and statistics about an index.
282
+ #
283
+ # @example
284
+ # redis.ft_info("idx")["num_docs"] # => 42
285
+ #
286
+ # @param index_name [String] the index name
287
+ # @return [Hash] index metadata (e.g. +"index_name"+, +"num_docs"+, +"attributes"+)
288
+ def ft_info(index_name)
289
+ send_command(["FT.INFO", index_name]) { |reply| ResultParser.hashify_info(reply) }
290
+ end
291
+
292
+ # Drop an index.
293
+ #
294
+ # @param index_name [String] the index name
295
+ # @param delete_documents [Boolean] also delete the indexed documents (+DD+)
296
+ # @return [String] +"OK"+
297
+ def ft_dropindex(index_name, delete_documents: false)
298
+ args = ["FT.DROPINDEX", index_name]
299
+ args << 'DD' if delete_documents
300
+ send_command(args)
301
+ end
302
+
303
+ # Run an aggregation pipeline, or read the next batch of a cursor.
304
+ #
305
+ # @example with an AggregateRequest
306
+ # req = Redis::Commands::Search::AggregateRequest.new("*")
307
+ # .group_by("@category", Redis::Commands::Search::Reducers.count.as("n"))
308
+ # redis.ft_aggregate("idx", req) # => #<AggregateResult ...>
309
+ #
310
+ # @param index_name [String] the index name
311
+ # @param query [Search::AggregateRequest, Search::Cursor, String] an aggregate request, a
312
+ # cursor to read, or a raw query string followed by raw pipeline +args+
313
+ # @param args [Array] raw pipeline tokens when +query+ is a String
314
+ # (e.g. +"GROUPBY", 1, "@x", "REDUCE", "COUNT", 0, "AS", "n"+)
315
+ # @return [Search::AggregateResult] the result rows (each a Hash) and the cursor id (or nil)
316
+ def ft_aggregate(index_name, query, *args)
317
+ command =
318
+ case query
319
+ when AggregateRequest
320
+ ["FT.AGGREGATE", index_name] + query.to_redis_args
321
+ when Cursor
322
+ ["FT.CURSOR", "READ", index_name] + query.build_args
323
+ else
324
+ # Raw query string: default to DIALECT 2 (matching redis-py) unless one was passed.
325
+ base = ["FT.AGGREGATE", index_name, query, *args]
326
+ base += ["DIALECT", DEFAULT_DIALECT] unless args.flatten.map(&:to_s).include?("DIALECT")
327
+ base
328
+ end
329
+
330
+ send_command(command) { |reply| ResultParser.aggregate(reply) }
331
+ end
332
+
333
+ # Return the execution plan for a query without running it.
334
+ #
335
+ # @param index_name [String] the index name
336
+ # @param query [String] the query string
337
+ # @return [String] a human-readable description of the query plan
338
+ def ft_explain(index_name, query)
339
+ send_command(["FT.EXPLAIN", index_name, query])
340
+ end
341
+
342
+ # Add a field to an existing index's schema (+FT.ALTER ... SCHEMA ADD+).
343
+ #
344
+ # @param index_name [String] the index name
345
+ # @param field_or_args [Search::Field, Array] a {Search::Field} (rendered via +#to_args+) or a
346
+ # raw token array
347
+ # @return [String] +"OK"+
348
+ # @raise [ArgumentError] if +field_or_args+ is neither a Field nor an Array
349
+ def ft_alter(index_name, field_or_args)
350
+ args = [index_name, "SCHEMA", "ADD"]
351
+ if field_or_args.respond_to?(:to_args)
352
+ args += field_or_args.to_args
353
+ elsif field_or_args.is_a?(Array)
354
+ args += field_or_args
355
+ else
356
+ raise ArgumentError, "field_or_args must be a Field object or an array"
357
+ end
358
+ send_command([:"FT.ALTER", *args])
359
+ end
360
+
361
+ # Read the next batch of results from an aggregation cursor.
362
+ #
363
+ # @param index_name [String] the index name
364
+ # @param cursor_id [Integer] the cursor id returned by a previous WITHCURSOR aggregation
365
+ # @return [Search::AggregateResult] the next rows, with +#cursor+ set to the next cursor id
366
+ # (+0+ when exhausted)
367
+ def ft_cursor_read(index_name, cursor_id)
368
+ send_command(["FT.CURSOR", "READ", index_name, cursor_id]) { |reply| ResultParser.aggregate(reply) }
369
+ end
370
+
371
+ # Discard an aggregation cursor.
372
+ #
373
+ # @param index_name [String] the index name
374
+ # @param cursor_id [Integer] the cursor id
375
+ # @return [String] +"OK"+
376
+ def ft_cursor_del(index_name, cursor_id)
377
+ send_command(["FT.CURSOR", "DEL", index_name, cursor_id])
378
+ end
379
+
380
+ # Profile the execution of a +SEARCH+ or +AGGREGATE+ query (timing/heuristics).
381
+ #
382
+ # @param index_name [String] the index name
383
+ # @param args [Array] the profile arguments, e.g. +"SEARCH", "QUERY", "<query>"+
384
+ # @return [Array] the raw profile reply (results plus a profiling tree)
385
+ def ft_profile(index_name, *args)
386
+ send_command(["FT.PROFILE", index_name] + args)
387
+ end
388
+
389
+ # Run a hybrid (lexical + vector) search.
390
+ #
391
+ # @param index_name [String] the index name
392
+ # @param query [Search::HybridQuery] the combined SEARCH + VSIM query
393
+ # @param combine_method [Search::CombineResultsMethod, nil] the fusion strategy (RRF/LINEAR)
394
+ # @param post_processing [Search::HybridPostProcessingConfig, nil] a post-fusion pipeline
395
+ # @param params_substitution [Hash, nil] query parameter substitutions (+PARAMS+)
396
+ # @param timeout [Integer, nil] query timeout in milliseconds (+TIMEOUT+)
397
+ # @param cursor [Search::HybridCursorQuery, nil] cursor/pagination config (+WITHCURSOR+)
398
+ # @return [Search::HybridResult] the fused result rows, total, warnings and execution time
399
+ # (or, for a WITHCURSOR query, the per-leg cursor ids)
400
+ def ft_hybrid_search(
401
+ index_name, query:, combine_method: nil, post_processing: nil,
402
+ params_substitution: nil, timeout: nil, cursor: nil, **_options
403
+ )
404
+ args = ["FT.HYBRID", index_name]
405
+ args.concat(query.args)
406
+ args.concat(combine_method.args) if combine_method
407
+ args.concat(post_processing.build_args) if post_processing
408
+ if params_substitution
409
+ args << "PARAMS" << params_substitution.size * 2
410
+ params_substitution.each do |key, value|
411
+ args << key.to_s << value
412
+ end
413
+ end
414
+ args.concat(["TIMEOUT", timeout]) if timeout
415
+ args.concat(cursor.build_args) if cursor
416
+ # NOTE: unlike FT.SEARCH/FT.AGGREGATE, FT.HYBRID does NOT accept a DIALECT token (the
417
+ # server rejects it: "DIALECT is not supported in FT.HYBRID or any of its subqueries").
418
+ # Its legs use the server's search-default-dialect config, so do not append DEFAULT_DIALECT.
419
+ send_command(args) { |reply| ResultParser.hybrid(reply) }
420
+ end
421
+
422
+ # Add a suggestion string to an auto-complete dictionary.
423
+ #
424
+ # @param key [String] the suggestion dictionary key
425
+ # @param string [String] the suggestion text
426
+ # @param score [Numeric] the suggestion weight
427
+ # @param options [Hash]
428
+ # @option options [Boolean] :incr increment the existing score instead of replacing it (+INCR+)
429
+ # @option options [String] :payload an opaque payload to store with the suggestion (+PAYLOAD+)
430
+ # @return [Integer] the current size of the suggestion dictionary
431
+ def ft_sugadd(key, string, score, options = {})
432
+ args = ["FT.SUGADD", key, string, score]
433
+ args << 'INCR' if options[:incr]
434
+ args << 'PAYLOAD' << options[:payload] if options[:payload]
435
+ send_command(args)
436
+ end
437
+
438
+ # Get auto-complete suggestions for a prefix.
439
+ #
440
+ # @param key [String] the suggestion dictionary key
441
+ # @param prefix [String] the prefix to complete
442
+ # @param options [Hash]
443
+ # @option options [Boolean] :fuzzy perform a fuzzy prefix match (+FUZZY+)
444
+ # @option options [Boolean] :with_scores also return each suggestion's score (+WITHSCORES+)
445
+ # @option options [Boolean] :with_payloads also return each suggestion's payload (+WITHPAYLOADS+)
446
+ # @option options [Integer] :max maximum number of suggestions (+MAX+)
447
+ # @return [Array<String>] the suggestions, interleaved with scores/payloads when requested
448
+ def ft_sugget(key, prefix, options = {})
449
+ args = ["FT.SUGGET", key, prefix]
450
+ args << 'FUZZY' if options[:fuzzy]
451
+ args << 'WITHSCORES' if options[:withscores] || options[:with_scores]
452
+ args << 'WITHPAYLOADS' if options[:withpayloads] || options[:with_payloads]
453
+ args << 'MAX' << options[:max] if options[:max]
454
+ send_command(args)
455
+ end
456
+
457
+ # Get the number of entries in a suggestion dictionary.
458
+ #
459
+ # @param key [String] the suggestion dictionary key
460
+ # @return [Integer] the number of suggestions
461
+ def ft_suglen(key)
462
+ send_command(["FT.SUGLEN", key])
463
+ end
464
+
465
+ # Delete a string from a suggestion dictionary.
466
+ #
467
+ # @param key [String] the suggestion dictionary key
468
+ # @param string [String] the suggestion text to delete
469
+ # @return [Integer] +1+ if the suggestion existed and was deleted, +0+ otherwise
470
+ def ft_sugdel(key, string)
471
+ send_command(["FT.SUGDEL", key, string])
472
+ end
473
+
474
+ # Perform spelling correction over a query against an index.
475
+ #
476
+ # @example
477
+ # redis.ft_spellcheck("idx", "hello wrld")
478
+ # # => { "wrld" => [{ "suggestion" => "world", "score" => 0.5 }] }
479
+ #
480
+ # @param index_name [String] the index name
481
+ # @param query [String] the query whose terms are checked
482
+ # @param distance [Integer, nil] maximum Levenshtein distance for suggestions (+DISTANCE+)
483
+ # @param include [String, nil] a custom dictionary to include terms from (+TERMS INCLUDE+)
484
+ # @param exclude [String, nil] a custom dictionary to exclude terms from (+TERMS EXCLUDE+)
485
+ # @return [Hash{String=>Array<Hash>}] each misspelled term mapped to an array of
486
+ # +{ "suggestion" => String, "score" => Numeric }+
487
+ def ft_spellcheck(index_name, query, distance: nil, include: nil, exclude: nil)
488
+ args = ["FT.SPELLCHECK", index_name, query]
489
+ args += ["DISTANCE", distance] if distance
490
+ args += ["TERMS", "INCLUDE", include] if include
491
+ args += ["TERMS", "EXCLUDE", exclude] if exclude
492
+
493
+ send_command(args) { |reply| ResultParser.spellcheck(reply) }
494
+ end
495
+
496
+ # Add or update a synonym group on an index.
497
+ #
498
+ # @param index_name [String] the index name
499
+ # @param group_id [String] the synonym group id
500
+ # @param terms [Array<String>] the terms to add to the group
501
+ # @param skip_initial_scan [Boolean] do not re-scan existing documents (+SKIPINITIALSCAN+)
502
+ # @return [String] +"OK"+
503
+ def ft_synupdate(index_name, group_id, *terms, skip_initial_scan: false)
504
+ args = [index_name, group_id]
505
+ args << "SKIPINITIALSCAN" if skip_initial_scan
506
+ args += terms
507
+ send_command(["FT.SYNUPDATE"] + args)
508
+ end
509
+
510
+ # Dump the synonym groups of an index.
511
+ #
512
+ # @param index_name [String] the index name
513
+ # @return [Hash{String=>Array<String>}] each term mapped to the synonym group ids it belongs to
514
+ def ft_syndump(index_name)
515
+ send_command(["FT.SYNDUMP", index_name]) { |reply| ResultParser.syndump(reply) }
516
+ end
517
+
518
+ # List the distinct values of a TAG field.
519
+ #
520
+ # @param index_name [String] the index name
521
+ # @param field_name [String] the TAG field name
522
+ # @return [Array<String>] the distinct tag values (normalized to lowercase unless the field is
523
+ # case-sensitive)
524
+ def ft_tagvals(index_name, field_name)
525
+ send_command(["FT.TAGVALS", index_name, field_name])
526
+ end
527
+
528
+ # Add an alias for an index.
529
+ #
530
+ # @param alias_name [String] the alias
531
+ # @param index_name [String] the index the alias points to
532
+ # @return [String] +"OK"+
533
+ def ft_aliasadd(alias_name, index_name)
534
+ send_command(["FT.ALIASADD", alias_name, index_name])
535
+ end
536
+
537
+ # Repoint an existing alias to a different index (or create it if absent).
538
+ #
539
+ # @param alias_name [String] the alias
540
+ # @param index_name [String] the index the alias should point to
541
+ # @return [String] +"OK"+
542
+ def ft_aliasupdate(alias_name, index_name)
543
+ send_command(["FT.ALIASUPDATE", alias_name, index_name])
544
+ end
545
+
546
+ # Remove an index alias.
547
+ #
548
+ # @param alias_name [String] the alias
549
+ # @return [String] +"OK"+
550
+ def ft_aliasdel(alias_name)
551
+ send_command(["FT.ALIASDEL", alias_name])
552
+ end
553
+
554
+ # List all aliases associated with an index (Redis 8.10+).
555
+ #
556
+ # @param index_name [String] the index name
557
+ # @return [Array<String>] the aliases pointing to the index (empty when none)
558
+ # @raise [Redis::CommandError] if the index does not exist
559
+ def ft_aliaslist(index_name)
560
+ send_command(["FT.ALIASLIST", index_name])
561
+ end
562
+
563
+ # Add terms to a custom dictionary.
564
+ #
565
+ # @param dict_name [String] the dictionary name
566
+ # @param terms [Array<String>] the terms to add
567
+ # @return [Integer] the number of new terms added
568
+ def ft_dictadd(dict_name, *terms)
569
+ send_command(["FT.DICTADD", dict_name] + terms)
570
+ end
571
+
572
+ # Remove terms from a custom dictionary.
573
+ #
574
+ # @param dict_name [String] the dictionary name
575
+ # @param terms [Array<String>] the terms to remove
576
+ # @return [Integer] the number of terms removed
577
+ def ft_dictdel(dict_name, *terms)
578
+ send_command(["FT.DICTDEL", dict_name] + terms)
579
+ end
580
+
581
+ # Dump all terms in a custom dictionary.
582
+ #
583
+ # @param dict_name [String] the dictionary name
584
+ # @return [Array<String>] the terms in the dictionary
585
+ def ft_dictdump(dict_name)
586
+ send_command(["FT.DICTDUMP", dict_name])
587
+ end
588
+
589
+ # Set a runtime Query Engine configuration option.
590
+ #
591
+ # @param option [String] the option name (or +"*"+)
592
+ # @param value [String, Integer] the value
593
+ # @return [String] +"OK"+
594
+ def ft_config_set(option, value)
595
+ send_command(["FT.CONFIG", "SET", option, value])
596
+ end
597
+
598
+ # Get a runtime Query Engine configuration option.
599
+ #
600
+ # @param option [String] the option name, or +"*"+ for all options
601
+ # @return [Hash] option name => value
602
+ def ft_config_get(option)
603
+ send_command(["FT.CONFIG", "GET", option]) { |reply| ResultParser.config_get(reply) }
604
+ end
605
+ end
606
+ end
607
+ end