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,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # High-level handle to a search index: a {Schema}, an optional key prefix and a +Redis+
7
+ # connection bundled together. It is the most ergonomic entry point for HASH/JSON document
8
+ # workflows — it remembers the prefix (so document ids round-trip to the logical id passed to
9
+ # {#add}), validates field values against the schema, and accepts a {Query}, a query string,
10
+ # or a block in {#search}. Obtain one via +Redis#create_index+ or {Index.create}.
11
+ class Index
12
+ # @return [String] the index name
13
+ # @return [String, nil] the literal key prefix prepended to (and stripped from) document
14
+ # ids, e.g. "doc:"; nil when the index manages no single prefix
15
+ attr_reader :name, :prefix
16
+
17
+ # @param redis [Redis] the client used for all index operations
18
+ # @param name [String] the index name
19
+ # @param schema [Schema] the field schema
20
+ # @param storage_type [String] the indexed data type (+"hash"+ or +"json"+)
21
+ # @param prefix [String, nil] key prefix for documents
22
+ # @param stopwords [Array<String>, nil] custom stopword list
23
+ def initialize(redis, name, schema, storage_type, prefix: nil, stopwords: nil)
24
+ @redis = redis
25
+ @name = name
26
+ @prefix = prefix
27
+ @schema = schema
28
+ @storage_type = storage_type
29
+ @stopwords = stopwords
30
+ end
31
+
32
+ # Create the index on the server (runs +FT.CREATE+) and return a handle to it.
33
+ #
34
+ # @example
35
+ # Redis::Commands::Search::Index.create(redis, "idx", schema, "hash", prefix: "doc")
36
+ #
37
+ # @param redis [Redis] the client to use
38
+ # @param name [String] the index name
39
+ # @param schema [Schema] the field schema
40
+ # @param storage_type [String] the indexed data type (+"hash"+ or +"json"+)
41
+ # @param prefix [String, nil] key prefix for documents
42
+ # @param stopwords [Array<String>, nil] custom stopword list
43
+ # @param skip_initial_scan [Boolean] do not backfill existing keys (+SKIPINITIALSCAN+)
44
+ # @return [Index] the created index
45
+ # @raise [ArgumentError] if +schema+ is not a {Schema}
46
+ def self.create(
47
+ redis, name, schema, storage_type,
48
+ prefix: nil, stopwords: nil, skip_initial_scan: false, **options
49
+ )
50
+ raise ArgumentError, "Invalid schema" unless schema.is_a?(Schema)
51
+
52
+ redis.ft_create(
53
+ name, schema, storage_type,
54
+ prefix: prefix, stopwords: stopwords,
55
+ skip_initial_scan: skip_initial_scan, **options
56
+ )
57
+ # The Index stores the *literal* key prefix it prepends to (and strips from) document
58
+ # ids. A definition (which FT.CREATE prefers over the +prefix:+ keyword) carries its
59
+ # prefixes verbatim, e.g. "bicycle:"; the +prefix:+ keyword form appends the ":". It also
60
+ # records the resolved storage type (HASH/JSON) so #add writes documents the right way.
61
+ new(
62
+ redis, name, schema, resolve_storage_type(storage_type, options[:definition]),
63
+ prefix: key_prefix(prefix, options[:definition]), stopwords: stopwords
64
+ )
65
+ end
66
+
67
+ # The single literal key prefix the Index should manage, or nil when it can't be
68
+ # determined unambiguously (no prefix, or a definition with several prefixes).
69
+ def self.key_prefix(prefix, definition)
70
+ if definition.is_a?(IndexDefinition) && definition.prefixes.size == 1
71
+ definition.prefixes.first
72
+ elsif prefix
73
+ "#{prefix}:"
74
+ end
75
+ end
76
+
77
+ # The effective storage type, mirroring FT.CREATE: a definition's own +index_type+ wins
78
+ # when set, otherwise fall back to the +storage_type+ keyword. This keeps #add's write
79
+ # path (hset vs json_set) consistent with the ON clause, so a definition without a type
80
+ # plus +storage_type: :json+ still indexes as JSON.
81
+ def self.resolve_storage_type(storage_type, definition)
82
+ type = definition&.index_type || storage_type
83
+ type.to_s.casecmp?(IndexType::JSON) ? IndexType::JSON : IndexType::HASH
84
+ end
85
+
86
+ # Add (or replace) a document under the key +"<prefix><doc_id>"+ (or +doc_id+ when no
87
+ # prefix is set). The document is written to match the index's storage type: a HASH
88
+ # (+HSET+) for a HASH index, or a JSON document at the root path (+JSON.SET+) for a JSON
89
+ # index — so the index actually picks it up either way.
90
+ #
91
+ # For a JSON index the given +fields+ are written verbatim as the JSON document at the
92
+ # root path (+$+), so each key lands at a top-level attribute. This works when the schema
93
+ # indexes root-level JSONPaths (+$.brand AS brand+, so +add("1", brand: "x")+ stores
94
+ # +{"brand":"x"}+ which +$.brand+ matches). It does *not* build nested structure: a field
95
+ # over a nested path (+$.user.name AS name+) is not satisfied by a flat +name: "..."+
96
+ # write — pass the full document shape instead (+add("1", user: { name: "Ann" })+).
97
+ #
98
+ # @example
99
+ # index.add("1", title: "hello", price: 10)
100
+ #
101
+ # @param doc_id [String] the logical document id
102
+ # @param fields [Hash{Symbol,String => Object}] the field/value pairs to store
103
+ # @return [Integer, String] the +HSET+ reply (HASH index) or the +JSON.SET+ reply (JSON index)
104
+ # @raise [Redis::CommandError] if a value for a {NumericField} is not Numeric, or the write fails
105
+ def add(doc_id, **fields)
106
+ key = @prefix ? "#{@prefix}#{doc_id}" : doc_id
107
+
108
+ # Validate fields
109
+ fields.each do |field_name, value|
110
+ field = @schema.field(field_name)
111
+ if field.is_a?(NumericField) && !value.is_a?(Numeric)
112
+ raise Redis::CommandError, "Invalid value for numeric field '#{field_name}': #{value}"
113
+ end
114
+ end
115
+
116
+ begin
117
+ json? ? @redis.json_set(key, "$", fields) : @redis.hset(key, fields)
118
+ rescue Redis::CommandError => error
119
+ raise Redis::CommandError, "Error adding document: #{error.message}"
120
+ end
121
+ end
122
+
123
+ # Search the index.
124
+ #
125
+ # Accepts a {Query} object, a raw query string, or a block evaluated as a {Query}. Keyword
126
+ # arguments are applied on top of the query before it is sent. Document ids in the result
127
+ # have the index prefix stripped, so they match the ids passed to {#add}.
128
+ #
129
+ # @example with a Query object
130
+ # index.search(Redis::Commands::Search::Query.new("@title:hello").paging(0, 10))
131
+ #
132
+ # @example with a block
133
+ # index.search { text(:title).match("hello*") }
134
+ #
135
+ # @param query [Query, String, nil] the query (a {Query}, a query string, or nil when a
136
+ # block is given)
137
+ # @param query_params [Hash, Array, nil] query parameter substitutions (overrides +params+)
138
+ # @param params [Hash, Array, nil] query parameter substitutions (+PARAMS+)
139
+ # @param dialect [Integer, nil] query dialect version (+DIALECT+)
140
+ # @param nocontent [Boolean, nil] return ids only (+NOCONTENT+)
141
+ # @param verbatim [Boolean, nil] disable stemming (+VERBATIM+)
142
+ # @param no_stopwords [Boolean, nil] do not remove stopwords (+NOSTOPWORDS+)
143
+ # @param with_scores [Boolean, nil] include relevance scores (+WITHSCORES+)
144
+ # @param with_payloads [Boolean, nil] include payloads (+WITHPAYLOADS+)
145
+ # @param slop [Integer, nil] allowed term reordering distance (+SLOP+)
146
+ # @param in_order [Boolean, nil] require terms in query order (+INORDER+)
147
+ # @param language [String, nil] stemming language (+LANGUAGE+)
148
+ # @param return_fields [Array<String>, nil] only return these fields (+RETURN+)
149
+ # @param summarize [Hash, nil] +SUMMARIZE+ options
150
+ # @param highlight [Hash, nil] +HIGHLIGHT+ options
151
+ # @param sort_by [String, nil] sort field (+SORTBY+)
152
+ # @param asc [Boolean, nil] sort ascending (default) when +sort_by+ is given; +false+ for descending
153
+ # @param scorer [String, nil] scoring function name (+SCORER+)
154
+ # @param explain_score [Boolean, nil] include a score explanation (+EXPLAINSCORE+)
155
+ # @return [SearchResult] the total count and matching {Document}s (prefix stripped from ids)
156
+ # @raise [ArgumentError] if no usable query is provided
157
+ def search(query = nil, query_params: nil, params: nil, dialect: nil,
158
+ nocontent: nil, verbatim: nil, no_stopwords: nil, with_scores: nil,
159
+ with_payloads: nil, slop: nil, in_order: nil, language: nil,
160
+ return_fields: nil, summarize: nil, highlight: nil, sort_by: nil, asc: nil,
161
+ scorer: nil, explain_score: nil, &block)
162
+ if block_given?
163
+ query = Query.build(&block)
164
+ elsif query.is_a?(String)
165
+ query = Query.new(query)
166
+ end
167
+
168
+ raise ArgumentError, "Invalid query" unless query.is_a?(Query)
169
+
170
+ query_string = query.to_redis_args.first
171
+
172
+ sort_order = asc == false ? "DESC" : "ASC"
173
+ sortby = sort_by ? [sort_by, sort_order] : query.options[:sortby]
174
+ limit_ids = query.limit_ids_value
175
+ # INKEYS needs full Redis keys. Prepend the index prefix to logical ids (mirroring
176
+ # #add), but leave ids that already carry it alone so callers passing full keys don't
177
+ # get a doubled prefix that matches nothing.
178
+ if limit_ids && @prefix
179
+ limit_ids = limit_ids.map { |id| id.to_s.start_with?(@prefix) ? id : "#{@prefix}#{id}" }
180
+ end
181
+
182
+ # An empty RETURN list is not a meaningful per-call value (it would just omit RETURN and
183
+ # return all fields), so treat it as "unset" and fall back to the Query's RETURN list.
184
+ return_fields = nil if return_fields.is_a?(Array) && return_fields.empty?
185
+
186
+ # Build a fresh options hash per call. A per-call keyword argument wins when explicitly
187
+ # given (including +false+, to turn a flag off); +nil+ falls back to whatever the Query
188
+ # was built with. The Query is never mutated, so reusing one Query across searches never
189
+ # leaks options between calls and per-call flags can both enable and disable.
190
+ pick = ->(call_value, query_value) { call_value.nil? ? query_value : call_value }
191
+
192
+ options = {
193
+ filter: query.filters,
194
+ geo_filter: query.geo_filters,
195
+ limit_ids: limit_ids,
196
+ limit: query.options[:limit],
197
+ sortby: sortby,
198
+ dialect: pick.call(dialect, query.options[:dialect]),
199
+ return: pick.call(return_fields, query.return_fields),
200
+ decode_fields: query.return_fields_decode,
201
+ highlight: pick.call(highlight, query.highlight_options),
202
+ summarize: pick.call(summarize, query.summarize_options),
203
+ verbatim: pick.call(verbatim, query.verbatim_value),
204
+ no_stopwords: pick.call(no_stopwords, query.no_stopwords_value),
205
+ no_content: pick.call(nocontent, query.no_content_value),
206
+ with_scores: pick.call(with_scores, query.options[:withscores]),
207
+ scorer: pick.call(scorer, query.options[:scorer]),
208
+ explain_score: pick.call(explain_score, query.options[:explainscore]),
209
+ language: pick.call(language, query.language_value),
210
+ with_payloads: pick.call(with_payloads, query.with_payloads_value),
211
+ slop: pick.call(slop, query.slop_value),
212
+ in_order: pick.call(in_order, query.in_order_value),
213
+ timeout: query.timeout_value,
214
+ limit_fields: query.limit_fields_value,
215
+ expander: query.expander_value
216
+ }
217
+ substitutions = query_params || params
218
+ options[:params] = substitutions if substitutions
219
+
220
+ result = @redis.ft_search(@name, query_string, **options)
221
+
222
+ # Strip the index prefix from document IDs if one is set, so callers see the
223
+ # logical doc id they passed to #add rather than the underlying Redis key.
224
+ if @prefix && result.is_a?(SearchResult)
225
+ result.documents.map! do |doc|
226
+ next doc unless doc.id.is_a?(String) && doc.id.start_with?(@prefix)
227
+
228
+ Document.new(
229
+ doc.id.delete_prefix(@prefix),
230
+ attributes: doc.attributes, score: doc.score, payload: doc.payload
231
+ )
232
+ end
233
+ end
234
+
235
+ result
236
+ end
237
+
238
+ # Return information and statistics about the index (delegates to +FT.INFO+).
239
+ #
240
+ # @return [Hash] index metadata
241
+ def info
242
+ @redis.ft_info(@name)
243
+ end
244
+
245
+ # Drop the index (delegates to +FT.DROPINDEX+).
246
+ #
247
+ # @param delete_documents [Boolean] also delete the indexed documents (+DD+)
248
+ # @return [String] +"OK"+
249
+ def drop(delete_documents: false)
250
+ @redis.ft_dropindex(@name, delete_documents: delete_documents)
251
+ end
252
+
253
+ # Run an aggregation pipeline against the index (delegates to +FT.AGGREGATE+).
254
+ #
255
+ # @param query [AggregateRequest, Cursor, String] the aggregate request, a cursor, or a raw
256
+ # query string followed by raw pipeline +args+
257
+ # @param args [Array] raw pipeline tokens when +query+ is a String
258
+ # @return [AggregateResult] the result rows and cursor
259
+ def aggregate(query, *args)
260
+ @redis.ft_aggregate(@name, query, *args)
261
+ end
262
+
263
+ # Run a hybrid (lexical + vector) search against this index (delegates to +FT.HYBRID+).
264
+ #
265
+ # @param query [Search::HybridQuery] the combined SEARCH + VSIM query
266
+ # @param combine_method [Search::CombineResultsMethod, nil] the fusion strategy (RRF/LINEAR)
267
+ # @param post_processing [Search::HybridPostProcessingConfig, nil] a post-fusion pipeline
268
+ # @param params_substitution [Hash, nil] query parameter substitutions (+PARAMS+)
269
+ # @param timeout [Integer, nil] query timeout in milliseconds (+TIMEOUT+)
270
+ # @param cursor [Search::HybridCursorQuery, nil] cursor/pagination config (+WITHCURSOR+)
271
+ # @return [Search::HybridResult] the fused results (or per-leg cursor ids for WITHCURSOR)
272
+ def hybrid_search(query:, combine_method: nil, post_processing: nil,
273
+ params_substitution: nil, timeout: nil, cursor: nil)
274
+ @redis.ft_hybrid_search(
275
+ @name,
276
+ query: query, combine_method: combine_method, post_processing: post_processing,
277
+ params_substitution: params_substitution, timeout: timeout, cursor: cursor
278
+ )
279
+ end
280
+
281
+ # Return the execution plan for a query without running it (delegates to +FT.EXPLAIN+).
282
+ #
283
+ # @param query [String] the query string
284
+ # @return [String] the query plan
285
+ def explain(query)
286
+ @redis.ft_explain(@name, query)
287
+ end
288
+
289
+ # Add a field to the index schema (delegates to +FT.ALTER+).
290
+ #
291
+ # @param field_or_args [Search::Field, Array] a {Search::Field} (rendered via +#to_args+)
292
+ # or a raw token array describing the field to add
293
+ # @return [String] +"OK"+
294
+ def alter(field_or_args)
295
+ @redis.ft_alter(@name, field_or_args)
296
+ end
297
+
298
+ # Perform spelling correction over a query against the index (delegates to +FT.SPELLCHECK+).
299
+ #
300
+ # @param query [String] the query whose terms are checked
301
+ # @param distance [Integer, nil] maximum Levenshtein distance for suggestions (+DISTANCE+)
302
+ # @param include [String, nil] a custom dictionary to include terms from (+TERMS INCLUDE+)
303
+ # @param exclude [String, nil] a custom dictionary to exclude terms from (+TERMS EXCLUDE+)
304
+ # @return [Hash{String=>Array<Hash>}] misspelled terms mapped to suggestions
305
+ def spellcheck(query, distance: nil, include: nil, exclude: nil)
306
+ @redis.ft_spellcheck(@name, query, distance: distance, include: include, exclude: exclude)
307
+ end
308
+
309
+ # Add or update a synonym group on the index (delegates to +FT.SYNUPDATE+).
310
+ #
311
+ # @param group_id [String] the synonym group id
312
+ # @param terms [Array<String>] the terms to add to the group
313
+ # @param skip_initial_scan [Boolean] do not re-scan existing documents (+SKIPINITIALSCAN+)
314
+ # @return [String] +"OK"+
315
+ def synupdate(group_id, *terms, skip_initial_scan: false)
316
+ @redis.ft_synupdate(@name, group_id, *terms, skip_initial_scan: skip_initial_scan)
317
+ end
318
+
319
+ # Dump the synonym groups of the index (delegates to +FT.SYNDUMP+).
320
+ #
321
+ # @return [Hash{String=>Array<String>}] each term mapped to its synonym group ids
322
+ def syndump
323
+ @redis.ft_syndump(@name)
324
+ end
325
+
326
+ # List the distinct values of a TAG field (delegates to +FT.TAGVALS+).
327
+ #
328
+ # @param field_name [String] the TAG field name
329
+ # @return [Array<String>] the distinct tag values
330
+ def tagvals(field_name)
331
+ @redis.ft_tagvals(@name, field_name)
332
+ end
333
+
334
+ # Profile the execution of a query (delegates to +FT.PROFILE+).
335
+ #
336
+ # @param args [Array] the profile arguments
337
+ # @return [Array] the raw profile reply
338
+ def profile(*args)
339
+ @redis.ft_profile(@name, *args)
340
+ end
341
+
342
+ private
343
+
344
+ # Whether this index is built over JSON documents (vs. HASH keys).
345
+ def json?
346
+ @storage_type.to_s.casecmp?(IndexType::JSON)
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # The data structure an index is built over, passed as IndexDefinition(index_type:).
7
+ module IndexType
8
+ HASH = "HASH"
9
+ JSON = "JSON"
10
+ end
11
+
12
+ # The definition portion of an +FT.CREATE+ call: which keyspace the index
13
+ # is built over and how documents are scored and filtered. Renders into a
14
+ # token array exposed via {#args}.
15
+ class IndexDefinition
16
+ # @return [Array] the rendered +FT.CREATE+ definition tokens
17
+ # @return [Array<String>] the literal key prefixes the index applies to
18
+ # @return [String, nil] the indexed data structure ({IndexType::HASH}/{IndexType::JSON}), or nil
19
+ attr_reader :args, :prefixes, :index_type
20
+
21
+ # Build an index definition and pre-render its +FT.CREATE+ tokens.
22
+ #
23
+ # @example
24
+ # Redis::Commands::Search::IndexDefinition.new(prefix: ["doc:"], index_type: IndexType::JSON).args
25
+ # # => ["ON", "JSON", "PREFIX", 1, "doc:", "SCORE", 1.0]
26
+ #
27
+ # @param [Array<String>, String, nil] prefix key prefix(es) the index applies to (+PREFIX+);
28
+ # a single String is wrapped into a one-element list and nil means "no prefix"
29
+ # @param [String, nil] filter a filter expression (+FILTER+)
30
+ # @param [String, nil] language_field the field holding each document's language (+LANGUAGE_FIELD+)
31
+ # @param [String, nil] language the default document language (+LANGUAGE+)
32
+ # @param [String, nil] score_field the field holding each document's score (+SCORE_FIELD+)
33
+ # @param [Numeric] score the default document score (+SCORE+)
34
+ # @param [String, nil] payload_field the field holding each document's payload (+PAYLOAD_FIELD+)
35
+ # @param [String, nil] index_type the indexed data structure, {IndexType::HASH} or {IndexType::JSON} (+ON+)
36
+ # @raise [ArgumentError] if +index_type+ is given but is not {IndexType::HASH} or {IndexType::JSON}
37
+ def initialize(
38
+ prefix: [], filter: nil, language_field: nil, language: nil,
39
+ score_field: nil, score: 1.0, payload_field: nil, index_type: nil
40
+ )
41
+ @args = []
42
+ @index_type = index_type
43
+ # Array() makes the prefix handling nil-safe (nil -> []) and wraps a lone String prefix
44
+ # into a one-element list, so PREFIX always emits the correct count. dup detaches us from
45
+ # the caller's list (Array() returns it unchanged when it's already an Array) so later
46
+ # external mutations can't alter this definition.
47
+ @prefixes = Array(prefix).dup
48
+ append_index_type(index_type)
49
+ append_prefix(@prefixes)
50
+ append_filter(filter)
51
+ append_language(language_field, language)
52
+ append_score(score_field, score)
53
+ append_payload(payload_field)
54
+ end
55
+
56
+ private
57
+
58
+ def append_index_type(index_type)
59
+ if index_type == IndexType::HASH
60
+ @args += ["ON", "HASH"]
61
+ elsif index_type == IndexType::JSON
62
+ @args += ["ON", "JSON"]
63
+ elsif !index_type.nil?
64
+ raise ArgumentError, "index_type must be IndexType::HASH or IndexType::JSON"
65
+ end
66
+ end
67
+
68
+ def append_prefix(prefix)
69
+ unless prefix.empty?
70
+ @args << "PREFIX"
71
+ @args << prefix.length
72
+ prefix.each { |p| @args << p }
73
+ end
74
+ end
75
+
76
+ def append_filter(filter)
77
+ if filter
78
+ @args << "FILTER"
79
+ @args << filter
80
+ end
81
+ end
82
+
83
+ def append_language(language_field, language)
84
+ if language_field
85
+ @args << "LANGUAGE_FIELD"
86
+ @args << language_field
87
+ end
88
+ if language
89
+ @args << "LANGUAGE"
90
+ @args << language
91
+ end
92
+ end
93
+
94
+ def append_score(score_field, score)
95
+ if score_field
96
+ @args << "SCORE_FIELD"
97
+ @args << score_field
98
+ end
99
+ if score
100
+ @args << "SCORE"
101
+ @args << score
102
+ end
103
+ end
104
+
105
+ def append_payload(payload_field)
106
+ if payload_field
107
+ @args << "PAYLOAD_FIELD"
108
+ @args << payload_field
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end