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,738 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Redis
|
|
4
|
+
module Commands
|
|
5
|
+
module Search
|
|
6
|
+
# A fluent builder for FT.SEARCH query strings and their options.
|
|
7
|
+
#
|
|
8
|
+
# Chainable setter methods (+filter+, +paging+, +sort_by+, ...) return +self+ so calls can
|
|
9
|
+
# be strung together, and a predicate DSL (+tag+/+text+/+numeric+ combined with +and_+/+or_+)
|
|
10
|
+
# builds the query string itself. {#to_redis_args} renders the whole thing into the argument
|
|
11
|
+
# array passed to +FT.SEARCH+.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# query = Redis::Commands::Search::Query.new("hello")
|
|
15
|
+
# .paging(0, 10)
|
|
16
|
+
# .sort_by("price", asc: false)
|
|
17
|
+
# query.to_redis_args # => ["hello", "SORTBY", "price", "DESC", "LIMIT", 0, 10, "DIALECT", 2]
|
|
18
|
+
class Query
|
|
19
|
+
# @return [Hash] the accumulated query options (dialect, limit, sortby, scorer, ...)
|
|
20
|
+
# @return [Hash] map of return-field name => whether its value should be JSON-decoded
|
|
21
|
+
# @return [Array<Array>] the +FILTER+ clauses as +[field, min, max]+ triples
|
|
22
|
+
# @return [Array<Array>, nil] the +GEOFILTER+ clauses as +[field, lon, lat, radius, unit]+ tuples
|
|
23
|
+
attr_reader :options, :return_fields_decode, :filters, :geo_filters
|
|
24
|
+
# @return [Array] the fields to RETURN
|
|
25
|
+
# @return [Hash, nil] the HIGHLIGHT options
|
|
26
|
+
# @return [Hash, nil] the SUMMARIZE options
|
|
27
|
+
attr_accessor :return_fields, :highlight_options, :summarize_options
|
|
28
|
+
|
|
29
|
+
# @param base [String, nil] an optional base query string used when no predicates are added
|
|
30
|
+
def initialize(base = nil)
|
|
31
|
+
@base = base
|
|
32
|
+
@predicate_collection = [PredicateCollection.new(:and)]
|
|
33
|
+
@filters = []
|
|
34
|
+
@options = { dialect: DEFAULT_DIALECT } # Default dialect, matching redis-py
|
|
35
|
+
@return_fields = []
|
|
36
|
+
@return_fields_decode = {}
|
|
37
|
+
@summarize_options = nil
|
|
38
|
+
@highlight_options = nil
|
|
39
|
+
@language = nil
|
|
40
|
+
@verbatim = false
|
|
41
|
+
@no_stopwords = false
|
|
42
|
+
@with_payloads = false
|
|
43
|
+
@slop = nil
|
|
44
|
+
@in_order = false
|
|
45
|
+
@no_content = false
|
|
46
|
+
@limit_ids = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build a Query by evaluating +block+ in the context of a fresh instance.
|
|
50
|
+
#
|
|
51
|
+
# @example
|
|
52
|
+
# Redis::Commands::Search::Query.build { paging(0, 5); sort_by("name") }
|
|
53
|
+
#
|
|
54
|
+
# @yield evaluated via +instance_eval+ against the new Query
|
|
55
|
+
# @return [Query] the populated query
|
|
56
|
+
def self.build(&block)
|
|
57
|
+
instance = new
|
|
58
|
+
instance.instance_eval(&block)
|
|
59
|
+
instance
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Add a numeric +FILTER+ clause.
|
|
63
|
+
#
|
|
64
|
+
# Bounds are validated so a malformed or untrusted value can't inject query syntax, the
|
|
65
|
+
# same guarantee the numeric-predicate DSL gives. Beyond plain numbers, the RediSearch
|
|
66
|
+
# bound forms +inf+/+-inf+ and the exclusive +(+ prefix (e.g. +"(10"+) are accepted.
|
|
67
|
+
#
|
|
68
|
+
# @param field [String] the numeric field name
|
|
69
|
+
# @param min [Numeric, String] the lower bound (also used as upper bound when +max+ is nil)
|
|
70
|
+
# @param max [Numeric, String, nil] the upper bound
|
|
71
|
+
# @return [self]
|
|
72
|
+
# @raise [ArgumentError] if a bound is neither numeric nor a valid RediSearch bound token
|
|
73
|
+
def filter(field, min, max = nil)
|
|
74
|
+
max ||= min
|
|
75
|
+
@filters << [field, coerce_filter_bound(min), coerce_filter_bound(max)]
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Add a +GEOFILTER+ clause restricting results to a radius around a point.
|
|
80
|
+
#
|
|
81
|
+
# @param field [String] the geo field name
|
|
82
|
+
# @param lon [Numeric] the longitude of the center
|
|
83
|
+
# @param lat [Numeric] the latitude of the center
|
|
84
|
+
# @param radius [Numeric] the radius
|
|
85
|
+
# @param unit [String] the radius unit ("m", "km", "mi", "ft")
|
|
86
|
+
# @return [self]
|
|
87
|
+
def geo_filter(field, lon, lat, radius, unit = 'km')
|
|
88
|
+
@geo_filters ||= []
|
|
89
|
+
@geo_filters << [field, lon, lat, radius, unit]
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Restrict the search to the given document ids (+INKEYS+).
|
|
94
|
+
#
|
|
95
|
+
# @param ids [Array<String>] the document ids to limit to
|
|
96
|
+
# @return [self]
|
|
97
|
+
def limit_ids(*ids)
|
|
98
|
+
@limit_ids = ids
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Set the +LIMIT+ (paging) for the result set.
|
|
103
|
+
#
|
|
104
|
+
# @param offset [Integer] the index of the first result to return
|
|
105
|
+
# @param limit [Integer] the maximum number of results to return
|
|
106
|
+
# @return [self]
|
|
107
|
+
def paging(offset, limit)
|
|
108
|
+
@options[:limit] = [offset, limit]
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Set the +SORTBY+ field and direction.
|
|
113
|
+
#
|
|
114
|
+
# Supports both styles: a positional +order+ ("ASC"/"DESC" symbol or string) and the
|
|
115
|
+
# +asc:+ keyword. When +order+ is given it takes precedence over +asc:+.
|
|
116
|
+
#
|
|
117
|
+
# @param field [String] the field to sort by
|
|
118
|
+
# @param order [Symbol, String, nil] explicit order, e.g. +:asc+ or "DESC"
|
|
119
|
+
# @param asc [Boolean] sort ascending when true, descending otherwise (used when +order+ is nil)
|
|
120
|
+
# @return [self]
|
|
121
|
+
def sort_by(field, order = nil, asc: true)
|
|
122
|
+
# Support both old style (order as symbol/string) and new style (asc as boolean)
|
|
123
|
+
direction = if order.nil?
|
|
124
|
+
# New style: use asc parameter
|
|
125
|
+
asc ? "ASC" : "DESC"
|
|
126
|
+
else
|
|
127
|
+
# Old style: order is a symbol or string
|
|
128
|
+
order.to_s.upcase
|
|
129
|
+
end
|
|
130
|
+
@options[:sortby] = [field, direction]
|
|
131
|
+
self
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Set the fields to +RETURN+, replacing any previously configured ones.
|
|
135
|
+
#
|
|
136
|
+
# @param fields [Array<String>] the field names to return
|
|
137
|
+
# @return [self]
|
|
138
|
+
def return(*fields)
|
|
139
|
+
@return_fields = fields
|
|
140
|
+
# Replacing the RETURN list drops any decode flags set via #return_field; otherwise a
|
|
141
|
+
# stale flag would keep decoding an overlapping field name in the new list.
|
|
142
|
+
@return_fields_decode = {}
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Append a single field to +RETURN+, optionally aliased and/or JSON-decoded.
|
|
147
|
+
#
|
|
148
|
+
# @param field [String] the field name to return
|
|
149
|
+
# @param as_field [String, nil] an alias to expose the field under (+AS+)
|
|
150
|
+
# @param decode_field [Boolean] whether the returned value should be JSON-decoded in results
|
|
151
|
+
# @return [self]
|
|
152
|
+
def return_field(field, as_field: nil, decode_field: true)
|
|
153
|
+
@return_fields ||= []
|
|
154
|
+
@return_fields_decode ||= {}
|
|
155
|
+
|
|
156
|
+
@return_fields << field
|
|
157
|
+
# Results are keyed by the alias when one is given (Redis returns the value under AS),
|
|
158
|
+
# so the decode flag must be keyed the same way or hashify_fields won't match it.
|
|
159
|
+
@return_fields_decode[as_field || field] = decode_field
|
|
160
|
+
|
|
161
|
+
if as_field
|
|
162
|
+
@return_fields << "AS" << as_field
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Set the query +LANGUAGE+ used for stemming.
|
|
169
|
+
#
|
|
170
|
+
# @param lang [String] the language name, e.g. "english"
|
|
171
|
+
# @return [self]
|
|
172
|
+
def language(lang)
|
|
173
|
+
@language = lang
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Disable stemming for the query (+VERBATIM+).
|
|
178
|
+
#
|
|
179
|
+
# @return [self]
|
|
180
|
+
def verbatim
|
|
181
|
+
@verbatim = true
|
|
182
|
+
self
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Set the query +EXPANDER+ (custom query expander).
|
|
186
|
+
#
|
|
187
|
+
# @param expander_name [String] the expander name
|
|
188
|
+
# @return [self]
|
|
189
|
+
def expander(expander_name)
|
|
190
|
+
@expander = expander_name
|
|
191
|
+
self
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Do not filter out stopwords (+NOSTOPWORDS+).
|
|
195
|
+
#
|
|
196
|
+
# @return [self]
|
|
197
|
+
def no_stopwords
|
|
198
|
+
@no_stopwords = true
|
|
199
|
+
self
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Return the relevance score of each document (+WITHSCORES+).
|
|
203
|
+
#
|
|
204
|
+
# @return [self]
|
|
205
|
+
def with_scores
|
|
206
|
+
@options[:withscores] = true
|
|
207
|
+
self
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Set the scoring function (+SCORER+).
|
|
211
|
+
#
|
|
212
|
+
# @param scorer_name [String] the scorer name, e.g. "BM25"
|
|
213
|
+
# @return [self]
|
|
214
|
+
def scorer(scorer_name)
|
|
215
|
+
@options[:scorer] = scorer_name
|
|
216
|
+
self
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Return the payload attached to each document (+WITHPAYLOADS+).
|
|
220
|
+
#
|
|
221
|
+
# @return [self]
|
|
222
|
+
def with_payloads
|
|
223
|
+
@with_payloads = true
|
|
224
|
+
self
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Set the allowed +SLOP+ (number of intervening terms) for phrase matching.
|
|
228
|
+
#
|
|
229
|
+
# @param value [Integer] the slop value
|
|
230
|
+
# @return [self]
|
|
231
|
+
def slop(value)
|
|
232
|
+
@slop = value
|
|
233
|
+
self
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Require query terms to appear in the same order as in the query (+INORDER+).
|
|
237
|
+
#
|
|
238
|
+
# @return [self]
|
|
239
|
+
def in_order
|
|
240
|
+
@in_order = true
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Set the query +TIMEOUT+.
|
|
245
|
+
#
|
|
246
|
+
# @param milliseconds [Integer] the timeout in milliseconds
|
|
247
|
+
# @return [self]
|
|
248
|
+
def timeout(milliseconds)
|
|
249
|
+
@timeout = milliseconds
|
|
250
|
+
self
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Return only document ids, without their contents (+NOCONTENT+).
|
|
254
|
+
#
|
|
255
|
+
# @return [self]
|
|
256
|
+
def no_content
|
|
257
|
+
@no_content = true
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Restrict the search to the given fields (+INFIELDS+).
|
|
262
|
+
#
|
|
263
|
+
# @param fields [Array<String>] the field names to search within
|
|
264
|
+
# @return [self]
|
|
265
|
+
def limit_fields(*fields)
|
|
266
|
+
@limit_fields = fields
|
|
267
|
+
self
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Configure result +HIGHLIGHT+ing.
|
|
271
|
+
#
|
|
272
|
+
# @param fields [String, Array<String>, nil] the fields to highlight (all when nil)
|
|
273
|
+
# @param tags [Array<String>] the opening and closing tags wrapped around matches
|
|
274
|
+
# @return [self]
|
|
275
|
+
def highlight(fields: nil, tags: ["<b>", "</b>"])
|
|
276
|
+
@highlight_options = {
|
|
277
|
+
fields: Array(fields),
|
|
278
|
+
tags: tags
|
|
279
|
+
}
|
|
280
|
+
self
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Configure result +SUMMARIZE+ation (fragment extraction around matches).
|
|
284
|
+
#
|
|
285
|
+
# @param fields [String, Array<String>, nil] the fields to summarize (all when nil)
|
|
286
|
+
# @param separator [String] the string placed between fragments
|
|
287
|
+
# @param len [Integer] the number of words per fragment
|
|
288
|
+
# @param frags [Integer] the number of fragments to extract
|
|
289
|
+
# @return [self]
|
|
290
|
+
def summarize(fields: nil, separator: "...", len: 20, frags: 3)
|
|
291
|
+
@summarize_options = {
|
|
292
|
+
fields: Array(fields),
|
|
293
|
+
separator: separator,
|
|
294
|
+
len: len,
|
|
295
|
+
frags: frags
|
|
296
|
+
}
|
|
297
|
+
self
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Set the query +DIALECT+ version.
|
|
301
|
+
#
|
|
302
|
+
# @param dialect_version [Integer] the dialect version (defaults to 2)
|
|
303
|
+
# @return [self]
|
|
304
|
+
def dialect(dialect_version)
|
|
305
|
+
@options[:dialect] = dialect_version
|
|
306
|
+
self
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Return an explanation of the score of each document (+EXPLAINSCORE+).
|
|
310
|
+
#
|
|
311
|
+
# @return [self]
|
|
312
|
+
def explain_score
|
|
313
|
+
@options[:explainscore] = true
|
|
314
|
+
self
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Internal getters used by Index#search to read accumulated state - unlike the
|
|
318
|
+
# chainable setters above, these return the stored value rather than +self+.
|
|
319
|
+
# :nodoc:
|
|
320
|
+
def limit_ids_value
|
|
321
|
+
@limit_ids
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# :nodoc:
|
|
325
|
+
def language_value
|
|
326
|
+
@language
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# :nodoc:
|
|
330
|
+
def verbatim_value
|
|
331
|
+
@verbatim
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# :nodoc:
|
|
335
|
+
def no_stopwords_value
|
|
336
|
+
@no_stopwords
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# :nodoc:
|
|
340
|
+
def no_content_value
|
|
341
|
+
@no_content
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# :nodoc:
|
|
345
|
+
def with_payloads_value
|
|
346
|
+
@with_payloads
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# :nodoc:
|
|
350
|
+
def slop_value
|
|
351
|
+
@slop
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# :nodoc:
|
|
355
|
+
def in_order_value
|
|
356
|
+
@in_order
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# :nodoc:
|
|
360
|
+
def timeout_value
|
|
361
|
+
@timeout
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# :nodoc:
|
|
365
|
+
def limit_fields_value
|
|
366
|
+
@limit_fields
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# :nodoc:
|
|
370
|
+
def expander_value
|
|
371
|
+
@expander
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
## -------------
|
|
375
|
+
## query builder
|
|
376
|
+
## -------------
|
|
377
|
+
|
|
378
|
+
# Open an OR group: predicates added inside +block+ are joined with +|+.
|
|
379
|
+
#
|
|
380
|
+
# @yield builds predicates that are combined with OR
|
|
381
|
+
# @return [self]
|
|
382
|
+
def or_(&block)
|
|
383
|
+
new_collection(:or, &block)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Open an AND group: predicates added inside +block+ are joined with a space.
|
|
387
|
+
#
|
|
388
|
+
# @yield builds predicates that are combined with AND
|
|
389
|
+
# @return [self]
|
|
390
|
+
def and_(&block)
|
|
391
|
+
new_collection(:and, &block)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Add a built predicate to the current collection.
|
|
395
|
+
#
|
|
396
|
+
# @param predicate [Predicate] the predicate to add
|
|
397
|
+
# @return [self]
|
|
398
|
+
def add_predicate(predicate)
|
|
399
|
+
@predicate_collection.last.add(predicate)
|
|
400
|
+
self
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Push a new predicate collection of +type+, evaluate the block against it, then fold it
|
|
404
|
+
# back into the parent collection.
|
|
405
|
+
#
|
|
406
|
+
# @param type [Symbol] +:and+ or +:or+
|
|
407
|
+
# @yield builds predicates inside the new collection
|
|
408
|
+
# @return [self]
|
|
409
|
+
def new_collection(type)
|
|
410
|
+
collection = PredicateCollection.new(type)
|
|
411
|
+
@predicate_collection << collection
|
|
412
|
+
yield if block_given?
|
|
413
|
+
@predicate_collection.pop
|
|
414
|
+
@predicate_collection.last.add(collection)
|
|
415
|
+
self
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Begin a tag-field predicate bound to this query (call +.eq+ on the result).
|
|
419
|
+
#
|
|
420
|
+
# @param field [String] the tag field name
|
|
421
|
+
# @return [TagField] a field bound to this query
|
|
422
|
+
def tag(field)
|
|
423
|
+
TagField.new(field, self)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Begin a text-field predicate bound to this query (call +.match+ on the result).
|
|
427
|
+
#
|
|
428
|
+
# @param field [String] the text field name
|
|
429
|
+
# @return [TextField] a field bound to this query
|
|
430
|
+
def text(field)
|
|
431
|
+
TextField.new(field, self)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Begin a numeric-field predicate bound to this query (call +.gt+/+.lt+/+.between+ on it).
|
|
435
|
+
#
|
|
436
|
+
# @param field [String] the numeric field name
|
|
437
|
+
# @return [NumericField] a field bound to this query
|
|
438
|
+
def numeric(field)
|
|
439
|
+
NumericField.new(field, self)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Render the query and all configured options into the +FT.SEARCH+ argument array.
|
|
443
|
+
#
|
|
444
|
+
# @return [Array] the argument array whose first element is the query string and whose
|
|
445
|
+
# remaining elements are option tokens
|
|
446
|
+
def to_redis_args
|
|
447
|
+
args = [query_string]
|
|
448
|
+
append_boolean_options(args)
|
|
449
|
+
append_scorer(args)
|
|
450
|
+
append_with_scores(args)
|
|
451
|
+
append_limit_fields(args)
|
|
452
|
+
append_limit_ids(args)
|
|
453
|
+
append_filters(args)
|
|
454
|
+
append_geo_filters(args)
|
|
455
|
+
append_return_fields(args)
|
|
456
|
+
append_summarize_options(args)
|
|
457
|
+
append_highlight_options(args)
|
|
458
|
+
append_slop(args)
|
|
459
|
+
append_timeout(args)
|
|
460
|
+
append_language(args)
|
|
461
|
+
append_expander(args)
|
|
462
|
+
append_in_order(args)
|
|
463
|
+
append_sort_by(args)
|
|
464
|
+
append_limit(args)
|
|
465
|
+
append_dialect(args)
|
|
466
|
+
args.flatten
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Evaluate +block+ against this query (used to populate it via the DSL).
|
|
470
|
+
#
|
|
471
|
+
# @yield evaluated via +instance_eval+ against this query
|
|
472
|
+
# @return [Object, nil] the block's return value, or nil when no block is given
|
|
473
|
+
def evaluate(&block)
|
|
474
|
+
if block_given?
|
|
475
|
+
instance_eval(&block)
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
private
|
|
480
|
+
|
|
481
|
+
def query_string
|
|
482
|
+
if @predicate_collection.first.predicates.empty?
|
|
483
|
+
@base || "*"
|
|
484
|
+
else
|
|
485
|
+
@predicate_collection.first.to_s
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def append_boolean_options(args)
|
|
490
|
+
args << "NOCONTENT" if @no_content
|
|
491
|
+
args << "VERBATIM" if @verbatim
|
|
492
|
+
args << "NOSTOPWORDS" if @no_stopwords
|
|
493
|
+
args << "WITHPAYLOADS" if @with_payloads
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def append_scorer(args)
|
|
497
|
+
args.concat(["SCORER", @options[:scorer]]) if @options[:scorer]
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def append_with_scores(args)
|
|
501
|
+
# EXPLAINSCORE is only valid alongside WITHSCORES, and requesting an explanation implies
|
|
502
|
+
# wanting the score, so emit WITHSCORES whenever either is set. This keeps #explain_score
|
|
503
|
+
# self-sufficient rather than producing an invalid EXPLAINSCORE-without-WITHSCORES command.
|
|
504
|
+
args << "WITHSCORES" if @options[:withscores] || @options[:explainscore]
|
|
505
|
+
args << "EXPLAINSCORE" if @options[:explainscore]
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def append_limit_fields(args)
|
|
509
|
+
if @limit_fields && !@limit_fields.empty?
|
|
510
|
+
args.concat(["INFIELDS", @limit_fields.size, *@limit_fields.map(&:to_s)])
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def append_limit_ids(args)
|
|
515
|
+
if @limit_ids && !@limit_ids.empty?
|
|
516
|
+
args.concat(["INKEYS", @limit_ids.size, *@limit_ids])
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def append_filters(args)
|
|
521
|
+
@filters.each do |field, min, max|
|
|
522
|
+
args.concat(["FILTER", field, min, max])
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Validate a FILTER bound. Numeric values pass through; strings must be a valid RediSearch
|
|
527
|
+
# numeric bound — an optional exclusive "(" prefix followed by a number or +inf/-inf.
|
|
528
|
+
# Anything else (e.g. injected query syntax) raises, matching the guarded predicate DSL.
|
|
529
|
+
def coerce_filter_bound(value)
|
|
530
|
+
return value if value.is_a?(Numeric)
|
|
531
|
+
|
|
532
|
+
token = value.to_s
|
|
533
|
+
body = token.start_with?("(") ? token[1..] : token
|
|
534
|
+
return token if body.match?(/\A[+-]?inf\z/i)
|
|
535
|
+
|
|
536
|
+
Float(body) # raises ArgumentError unless body is a plain number
|
|
537
|
+
token
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def append_geo_filters(args)
|
|
541
|
+
@geo_filters&.each do |field, lon, lat, radius, unit|
|
|
542
|
+
args.concat(["GEOFILTER", field, lon, lat, radius, unit])
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def append_return_fields(args)
|
|
547
|
+
if @return_fields && !@return_fields.empty?
|
|
548
|
+
args.concat(["RETURN", @return_fields.size, *@return_fields])
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def append_summarize_options(args)
|
|
553
|
+
if @summarize_options
|
|
554
|
+
args << "SUMMARIZE"
|
|
555
|
+
if @summarize_options[:fields].any?
|
|
556
|
+
args.concat(["FIELDS", @summarize_options[:fields].size, *@summarize_options[:fields].map(&:to_s)])
|
|
557
|
+
end
|
|
558
|
+
args.concat(["FRAGS", @summarize_options[:frags]])
|
|
559
|
+
args.concat(["LEN", @summarize_options[:len]])
|
|
560
|
+
args.concat(["SEPARATOR", @summarize_options[:separator]])
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def append_highlight_options(args)
|
|
565
|
+
if @highlight_options
|
|
566
|
+
args << "HIGHLIGHT"
|
|
567
|
+
if @highlight_options[:fields].any?
|
|
568
|
+
args.concat(["FIELDS", @highlight_options[:fields].size, *@highlight_options[:fields].map(&:to_s)])
|
|
569
|
+
end
|
|
570
|
+
args.concat(["TAGS", *@highlight_options[:tags]])
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def append_slop(args)
|
|
575
|
+
args.concat(["SLOP", @slop]) if @slop
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def append_timeout(args)
|
|
579
|
+
args.concat(["TIMEOUT", @timeout]) if @timeout
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def append_language(args)
|
|
583
|
+
args.concat(["LANGUAGE", @language]) if @language
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def append_expander(args)
|
|
587
|
+
args.concat(["EXPANDER", @expander]) if @expander
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def append_in_order(args)
|
|
591
|
+
args << "INORDER" if @in_order
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def append_sort_by(args)
|
|
595
|
+
if @options[:sortby]
|
|
596
|
+
args.concat(["SORTBY", @options[:sortby][0], @options[:sortby][1]])
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def append_limit(args)
|
|
601
|
+
if @options[:limit]
|
|
602
|
+
args.concat(["LIMIT", @options[:limit][0], @options[:limit][1]])
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def append_dialect(args)
|
|
607
|
+
args.concat(["DIALECT", @options[:dialect]]) if @options[:dialect]
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Abstract base for a single query-string predicate. Subclasses render themselves into a
|
|
612
|
+
# query-string fragment via +#to_s+.
|
|
613
|
+
class Predicate
|
|
614
|
+
# @return [String] the field the predicate applies to
|
|
615
|
+
attr_reader :field
|
|
616
|
+
|
|
617
|
+
# RediSearch query operators/metacharacters. Escaped with a backslash so an interpolated
|
|
618
|
+
# value is matched literally instead of altering (or breaking) the query.
|
|
619
|
+
TEXT_SPECIAL_CHARACTERS = [
|
|
620
|
+
',', '.', '<', '>', '{', '}', '[', ']', '"', "'", ':', ';', '!', '@',
|
|
621
|
+
'#', '$', '%', '^', '&', '*', '(', ')', '-', '+', '=', '~', '|', '/', '\\'
|
|
622
|
+
].freeze
|
|
623
|
+
# TAG values are single literal tokens, so whitespace is significant and must be escaped
|
|
624
|
+
# too. TEXT queries keep spaces, which separate terms (a multi-word match is an AND).
|
|
625
|
+
TAG_SPECIAL_CHARACTERS = (TEXT_SPECIAL_CHARACTERS + [' ']).freeze
|
|
626
|
+
TEXT_ESCAPE_PATTERN = Regexp.union(TEXT_SPECIAL_CHARACTERS)
|
|
627
|
+
TAG_ESCAPE_PATTERN = Regexp.union(TAG_SPECIAL_CHARACTERS)
|
|
628
|
+
|
|
629
|
+
# @param field [String] the field name
|
|
630
|
+
def initialize(field)
|
|
631
|
+
@field = field
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# @raise [NotImplementedError] always; subclasses must override
|
|
635
|
+
# @return [String] the query-string fragment
|
|
636
|
+
def to_s
|
|
637
|
+
raise NotImplementedError
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
private
|
|
641
|
+
|
|
642
|
+
# Escape query metacharacters in a literal text value, keeping spaces as term separators.
|
|
643
|
+
def escape_text(value)
|
|
644
|
+
value.to_s.gsub(TEXT_ESCAPE_PATTERN) { |char| "\\#{char}" }
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Escape query metacharacters in a literal tag value, including whitespace.
|
|
648
|
+
def escape_tag(value)
|
|
649
|
+
value.to_s.gsub(TAG_ESCAPE_PATTERN) { |char| "\\#{char}" }
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# A tag-equality predicate rendering +(@field:{value})+.
|
|
654
|
+
class TagEqualityPredicate < Predicate
|
|
655
|
+
# @param field [String] the tag field name
|
|
656
|
+
# @param value [String] the tag value to match
|
|
657
|
+
def initialize(field, value)
|
|
658
|
+
super(field)
|
|
659
|
+
@value = value
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# @return [String] the query-string fragment, e.g. +(@color:{red})+
|
|
663
|
+
def to_s
|
|
664
|
+
"(@#{@field}:{#{escape_tag(@value)}})"
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# A text-match predicate rendering +(@field:(pattern))+.
|
|
669
|
+
class TextMatchPredicate < Predicate
|
|
670
|
+
# @param field [String] the text field name
|
|
671
|
+
# @param pattern [String] the text pattern to match
|
|
672
|
+
# @param raw [Boolean] when +true+ the pattern is interpolated verbatim so RediSearch
|
|
673
|
+
# operators (wildcards, +|+ OR, ...) apply; when +false+ (default) metacharacters are
|
|
674
|
+
# escaped and the pattern is matched literally
|
|
675
|
+
def initialize(field, pattern, raw: false)
|
|
676
|
+
super(field)
|
|
677
|
+
@pattern = pattern
|
|
678
|
+
@raw = raw
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# @return [String] the query-string fragment, e.g. +(@title:(hello world))+
|
|
682
|
+
def to_s
|
|
683
|
+
# Group the pattern: RediSearch scopes "@field:" only to the term immediately after it,
|
|
684
|
+
# so a bare "@title:hello world" would match "hello" on the field but "world" globally
|
|
685
|
+
# (across all fields). Wrapping in parentheses scopes the whole pattern to the field;
|
|
686
|
+
# it is a no-op for single-term patterns. Unless raw, escape metacharacters so a value
|
|
687
|
+
# can't inject query syntax.
|
|
688
|
+
pattern = @raw ? @pattern : escape_text(@pattern)
|
|
689
|
+
"(@#{@field}:(#{pattern}))"
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# A numeric-range predicate rendering +(@field:[min max])+.
|
|
694
|
+
class RangePredicate < Predicate
|
|
695
|
+
# @param field [String] the numeric field name
|
|
696
|
+
# @param min [Numeric, String] the lower bound
|
|
697
|
+
# @param max [Numeric, String] the upper bound
|
|
698
|
+
def initialize(field, min, max)
|
|
699
|
+
super(field)
|
|
700
|
+
@min = min
|
|
701
|
+
@max = max
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# @return [String] the query-string fragment, e.g. +(@price:[10 20])+
|
|
705
|
+
def to_s
|
|
706
|
+
"(@#{@field}:[#{@min} #{@max}])"
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# An ordered collection of predicates joined with AND (a space) or OR (+ | +).
|
|
711
|
+
class PredicateCollection
|
|
712
|
+
# @return [Symbol] +:and+ or +:or+
|
|
713
|
+
# @return [Array<Predicate, PredicateCollection>] the contained predicates
|
|
714
|
+
attr_reader :type, :predicates
|
|
715
|
+
|
|
716
|
+
# @param type [Symbol] +:and+ to join with a space, +:or+ to join with +|+
|
|
717
|
+
def initialize(type)
|
|
718
|
+
@type = type
|
|
719
|
+
@predicates = []
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Add a predicate (or nested collection) to this collection.
|
|
723
|
+
#
|
|
724
|
+
# @param predicate [Predicate, PredicateCollection] the predicate to add
|
|
725
|
+
# @return [Array] the updated list of predicates
|
|
726
|
+
def add(predicate)
|
|
727
|
+
@predicates << predicate
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# @return [String] the parenthesised, joined query-string fragment
|
|
731
|
+
def to_s
|
|
732
|
+
joiner = @type == :or ? ' | ' : ' '
|
|
733
|
+
"(#{@predicates.join(joiner)})"
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|