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