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,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
|