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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # Base class for a single field in a Redis Query Engine +SCHEMA+ (the
7
+ # field list passed to +FT.CREATE+). Subclasses describe the field type
8
+ # (+TEXT+, +TAG+, +NUMERIC+, +GEO+, +GEOSHAPE+, +VECTOR+).
9
+ class Field
10
+ attr_reader :name, :type, :options, :alias_name
11
+ attr_accessor :query
12
+
13
+ # Build a field definition.
14
+ #
15
+ # @example
16
+ # Redis::Commands::Search::Field.new("title", :text, weight: 5.0, sortable: true)
17
+ #
18
+ # @param [String, Symbol] name the document attribute the field indexes
19
+ # @param [Symbol] type the field type, e.g. +:text+, +:tag+, +:numeric+
20
+ # @param [Query, nil] query a query the field is bound to, enabling predicate helpers
21
+ # @option options [String] :as an alias for the field, rendered as +AS <alias>+
22
+ # @option options [Boolean] :no_index do not index the field (+NOINDEX+)
23
+ # @option options [Boolean] :index_missing index documents missing the field (+INDEXMISSING+)
24
+ # @option options [Boolean] :index_empty index empty values (+INDEXEMPTY+)
25
+ # @option options [Boolean] :sortable allow sorting by the field (+SORTABLE+)
26
+ # @option options [Boolean] :withsuffixtrie build a suffix trie (+WITHSUFFIXTRIE+)
27
+ def initialize(name, type, query = nil, **options)
28
+ @name = name.to_s
29
+ @type = type
30
+ @query = query
31
+ @options = options
32
+ @alias_name = options.delete(:as)
33
+ end
34
+
35
+ # Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
36
+ #
37
+ # @example
38
+ # Redis::Commands::Search::Field.new("title", :text, weight: 5.0, sortable: true).to_args
39
+ # # => ["title", "TEXT", "WEIGHT", "5.0", "SORTABLE"]
40
+ #
41
+ # @return [Array] the schema tokens for this field
42
+ def to_args
43
+ args = [@name]
44
+ args << "AS" << @alias_name if @alias_name
45
+ args << @type.to_s.upcase
46
+
47
+ # Add type-specific options first (separator, casesensitive for TAG)
48
+ if @type == :tag
49
+ args << "SEPARATOR" << @options[:separator] if @options[:separator]
50
+ args << "CASESENSITIVE" if @options[:case_sensitive]
51
+ end
52
+
53
+ # Add suffix options in specific order: no_index, index_missing, index_empty, sortable, withsuffixtrie
54
+ args << "NOINDEX" if @options[:no_index]
55
+ args << "INDEXMISSING" if @options[:index_missing]
56
+ args << "INDEXEMPTY" if @options[:index_empty]
57
+ args << "SORTABLE" if @options[:sortable]
58
+ args << "WITHSUFFIXTRIE" if @options[:withsuffixtrie]
59
+
60
+ args
61
+ end
62
+ end
63
+
64
+ # A +TAG+ field, indexing one or more delimited tag tokens.
65
+ class TagField < Field
66
+ # Build a +TAG+ field.
67
+ #
68
+ # @param [String, Symbol] name the document attribute the field indexes
69
+ # @param [Query, nil] query a query the field is bound to, enabling {#eq}
70
+ # @option options [String] :separator the tag separator character (+SEPARATOR+)
71
+ # @option options [Boolean] :case_sensitive keep tag casing (+CASESENSITIVE+)
72
+ def initialize(name, query = nil, **options)
73
+ super(name, :tag, query, **options)
74
+ end
75
+
76
+ # Add a tag-equality predicate (+@field:{value}+) to the bound query.
77
+ #
78
+ # @param [String] value the tag to match
79
+ # @return [Query] the bound query with the predicate added
80
+ def eq(value)
81
+ query.add_predicate(TagEqualityPredicate.new(@alias_name || name, value))
82
+ end
83
+ end
84
+
85
+ # A +TEXT+ field, indexing full-text searchable content.
86
+ class TextField < Field
87
+ # Build a +TEXT+ field.
88
+ #
89
+ # @param [String, Symbol] name the document attribute the field indexes
90
+ # @param [Query, nil] query a query the field is bound to, enabling {#match}
91
+ # @option options [Numeric] :weight the field's scoring weight (+WEIGHT+)
92
+ # @option options [String] :phonetic phonetic matcher, one of +dm:en+, +dm:fr+, +dm:pt+, +dm:es+ (+PHONETIC+)
93
+ # @option options [Boolean] :no_stem disable stemming (+NOSTEM+)
94
+ # @raise [ArgumentError] if +:phonetic+ is not a supported matcher
95
+ def initialize(name, query = nil, **options)
96
+ super(name, :text, query, **options)
97
+ if options[:phonetic]
98
+ valid_matchers = ['dm:en', 'dm:fr', 'dm:pt', 'dm:es']
99
+ unless valid_matchers.include?(options[:phonetic])
100
+ raise ArgumentError, "Invalid phonetic matcher. Supported matchers are: #{valid_matchers.join(', ')}"
101
+ end
102
+ end
103
+ end
104
+
105
+ # Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
106
+ #
107
+ # @return [Array] the schema tokens for this field
108
+ def to_args
109
+ args = [@name]
110
+ args << "AS" << @alias_name if @alias_name
111
+ args << @type.to_s.upcase
112
+ args << "NOSTEM" if @options[:no_stem]
113
+ args << "WEIGHT" << @options[:weight].to_s if @options[:weight]
114
+ args << "PHONETIC" << @options[:phonetic] if @options[:phonetic]
115
+
116
+ # Add suffix options in specific order: no_index, index_missing, index_empty, sortable, withsuffixtrie
117
+ args << "NOINDEX" if @options[:no_index]
118
+ args << "INDEXMISSING" if @options[:index_missing]
119
+ args << "INDEXEMPTY" if @options[:index_empty]
120
+ args << "SORTABLE" if @options[:sortable]
121
+ args << "WITHSUFFIXTRIE" if @options[:withsuffixtrie]
122
+
123
+ args
124
+ end
125
+
126
+ # Add a text-match predicate (+@field:(pattern)+) to the bound query.
127
+ #
128
+ # @param [String] pattern the text pattern to match
129
+ # @param [Boolean] raw when +true+, treat +pattern+ as a raw RediSearch expression so
130
+ # operators such as wildcards (+*+) and OR (+|+) apply; when +false+ (default) the
131
+ # pattern is escaped and matched literally
132
+ # @return [Query] the bound query with the predicate added
133
+ def match(pattern, raw: false)
134
+ query.add_predicate(TextMatchPredicate.new(@alias_name || name, pattern, raw: raw))
135
+ end
136
+ end
137
+
138
+ # A +NUMERIC+ field, indexing numeric values for range queries.
139
+ class NumericField < Field
140
+ # Build a +NUMERIC+ field.
141
+ #
142
+ # @param [String, Symbol] name the document attribute the field indexes
143
+ # @param [Query, nil] query a query the field is bound to, enabling {#gt}, {#lt}, {#between}
144
+ def initialize(name, query = nil, **options)
145
+ super(name, :numeric, query, **options)
146
+ end
147
+
148
+ # Add a greater-than range predicate to the bound query.
149
+ #
150
+ # @param [Numeric] value the exclusive lower bound
151
+ # @return [Query] the bound query with the predicate added
152
+ # @raise [ArgumentError] if +value+ is not numeric
153
+ def gt(value)
154
+ query.add_predicate(RangePredicate.new(@alias_name || name, "(#{Float(value)}", "+inf"))
155
+ end
156
+
157
+ # Add a less-than range predicate to the bound query.
158
+ #
159
+ # @param [Numeric] value the exclusive upper bound
160
+ # @return [Query] the bound query with the predicate added
161
+ # @raise [ArgumentError] if +value+ is not numeric
162
+ def lt(value)
163
+ query.add_predicate(RangePredicate.new(@alias_name || name, "-inf", "(#{Float(value)}"))
164
+ end
165
+
166
+ # Add an inclusive range predicate to the bound query.
167
+ #
168
+ # @param [Numeric] min the inclusive lower bound
169
+ # @param [Numeric] max the inclusive upper bound
170
+ # @return [Query] the bound query with the predicate added
171
+ # @raise [ArgumentError] if +min+ or +max+ is not numeric
172
+ def between(min, max)
173
+ # Coerce to numeric so a value can't inject range/query syntax. RangePredicate renders
174
+ # bounds verbatim, so the field boundary is where untrusted input must be validated.
175
+ query.add_predicate(RangePredicate.new(@alias_name || name, Float(min), Float(max)))
176
+ end
177
+ end
178
+
179
+ # A +GEO+ field, indexing longitude/latitude pairs for geo queries.
180
+ class GeoField < Field
181
+ # Build a +GEO+ field.
182
+ #
183
+ # @param [String, Symbol] name the document attribute the field indexes
184
+ # @param [Query, nil] query a query the field is bound to
185
+ def initialize(name, query = nil, **options)
186
+ super(name, :geo, query, **options)
187
+ end
188
+ end
189
+
190
+ # A +GEOSHAPE+ field, indexing geometric shapes under a coordinate system.
191
+ class GeoShapeField < Field
192
+ FLAT = "FLAT"
193
+ SPHERICAL = "SPHERICAL"
194
+
195
+ # Build a +GEOSHAPE+ field.
196
+ #
197
+ # @param [String, Symbol] name the document attribute the field indexes
198
+ # @param [String, nil] coord_system the coordinate system, {FLAT} or {SPHERICAL};
199
+ # nil (the default) omits the token so the server default ({SPHERICAL}) applies
200
+ # @option options [Boolean] :no_index do not index the field (+NOINDEX+)
201
+ # @option options [Boolean] :index_missing index documents missing the field (+INDEXMISSING+)
202
+ # @option options [Boolean] :sortable allow sorting by the field (+SORTABLE+)
203
+ def initialize(name, coord_system = nil, **options)
204
+ super(name, :geoshape, nil, **options)
205
+ @coord_system = coord_system
206
+ end
207
+
208
+ # Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
209
+ #
210
+ # @return [Array] the schema tokens for this field
211
+ def to_args
212
+ args = [@name]
213
+ args << "AS" << @alias_name if @alias_name
214
+ args << @type.to_s.upcase
215
+ args << @coord_system if @coord_system
216
+
217
+ # Add suffix options (order mirrors the base Field: NOINDEX, INDEXMISSING, SORTABLE).
218
+ # GEOSHAPE supports INDEXMISSING but not INDEXEMPTY/WITHSUFFIXTRIE.
219
+ args << "NOINDEX" if @options[:no_index]
220
+ args << "INDEXMISSING" if @options[:index_missing]
221
+ args << "SORTABLE" if @options[:sortable]
222
+
223
+ args
224
+ end
225
+ end
226
+
227
+ # A +VECTOR+ field, indexing embeddings for approximate/exact nearest-neighbor search.
228
+ class VectorField < Field
229
+ attr_reader :algorithm, :attributes
230
+
231
+ # Build a +VECTOR+ field.
232
+ #
233
+ # @example
234
+ # Redis::Commands::Search::Field::VectorField.new(
235
+ # "embedding", "HNSW", { type: "FLOAT32", dim: 4, distance_metric: "L2" }
236
+ # )
237
+ #
238
+ # @param [String, Symbol] name the document attribute the field indexes
239
+ # @param [String, Symbol] algorithm the indexing method, one of +FLAT+, +HNSW+, +SVS-VAMANA+
240
+ # @param [Hash] attributes the vector attributes (e.g. +type+, +dim+, +distance_metric+)
241
+ # @option options [String] :as an alias for the field, rendered as +AS <alias>+
242
+ # @raise [ArgumentError] if +algorithm+ is not a supported indexing method
243
+ # @raise [Redis::CommandError] if +:sortable+ or +:no_index+ is given
244
+ def initialize(name, algorithm, attributes = {}, **options)
245
+ # Validate algorithm
246
+ unless ['FLAT', 'HNSW', 'SVS-VAMANA'].include?(algorithm.to_s.upcase)
247
+ raise ArgumentError,
248
+ "Realtime vector indexing supporting 3 Indexing Methods: 'FLAT', 'HNSW', and 'SVS-VAMANA'"
249
+ end
250
+
251
+ # Validate that sortable and no_index are not used with vector fields
252
+ if options[:sortable]
253
+ raise Redis::CommandError, "Vector fields cannot be sortable"
254
+ end
255
+ if options[:no_index]
256
+ raise Redis::CommandError, "Vector fields cannot have no_index option"
257
+ end
258
+
259
+ super(name, :vector, **options)
260
+ @algorithm = algorithm.to_s.upcase
261
+ @attributes = attributes.transform_keys { |k| k.to_s.upcase }.transform_values { |v| v.to_s.upcase }
262
+ end
263
+
264
+ # Set or override a single vector attribute.
265
+ #
266
+ # @param [String, Symbol] key the attribute name (upcased internally)
267
+ # @param [Object] value the attribute value
268
+ # @return [Object] the stored value
269
+ def add_attribute(key, value)
270
+ # Normalize like #initialize does for the kwargs form, so block-DSL attributes
271
+ # (vector_field(...) { type "float32" }) reach FT.CREATE with the expected casing.
272
+ @attributes[key.to_s.upcase] = value.to_s.upcase
273
+ end
274
+
275
+ # Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
276
+ #
277
+ # @return [Array] the schema tokens for this field, including the +VECTOR+ clause
278
+ def to_args
279
+ args = [name]
280
+ args << "AS" << @alias_name if @alias_name
281
+ args += field_args
282
+ args
283
+ end
284
+
285
+ # Returns field-specific args (without name/alias) for compatibility with tests
286
+ #
287
+ # @return [Array] the +VECTOR+ clause tokens (without name/alias)
288
+ def args
289
+ field_args
290
+ end
291
+
292
+ private
293
+
294
+ def field_args
295
+ args = ['VECTOR']
296
+ args << @algorithm
297
+ args << @attributes.size * 2
298
+ @attributes.each do |k, v|
299
+ args << k << v
300
+ end
301
+ args
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # The textual +SEARCH+ leg of an FT.HYBRID query.
7
+ #
8
+ # Chainable setters (+scorer+, +yield_score_as+) return +self+; {#args} renders the leg
9
+ # into its argument tokens, beginning with "SEARCH".
10
+ class HybridSearchQuery
11
+ # @return [String] the search query string
12
+ attr_reader :query_string
13
+
14
+ # @param query_string [String] the search query string
15
+ # @param scorer [String, nil] the scoring function
16
+ # @param yield_score_as [String, nil] the alias to expose the search score under
17
+ def initialize(query_string, scorer: nil, yield_score_as: nil)
18
+ @query_string = query_string
19
+ @scorer = scorer
20
+ @yield_score_as = yield_score_as
21
+ end
22
+
23
+ # Set the scoring function (+SCORER+).
24
+ #
25
+ # @param scorer [String] the scorer name
26
+ # @return [self]
27
+ def scorer(scorer)
28
+ @scorer = scorer
29
+ self
30
+ end
31
+
32
+ # Expose the search score under an alias (+YIELD_SCORE_AS+).
33
+ #
34
+ # @param alias_name [String] the alias for the score
35
+ # @return [self]
36
+ def yield_score_as(alias_name)
37
+ @yield_score_as = alias_name
38
+ self
39
+ end
40
+
41
+ # @return [Array] the +SEARCH+ leg argument tokens, beginning with "SEARCH"
42
+ def args
43
+ result = ["SEARCH", @query_string]
44
+ result << "SCORER" << @scorer if @scorer
45
+ result << "YIELD_SCORE_AS" << @yield_score_as if @yield_score_as
46
+ result
47
+ end
48
+ end
49
+
50
+ # The vector-similarity +VSIM+ leg of an FT.HYBRID query.
51
+ #
52
+ # Chainable setters (+vsim_method_params+, +filter+, +yield_score_as+) return +self+;
53
+ # {#args} renders the leg into its argument tokens, beginning with "VSIM".
54
+ class HybridVsimQuery
55
+ # @return [String] the vector field name
56
+ # @return [String] the query vector data (blob)
57
+ attr_reader :vector_field, :vector_data
58
+
59
+ # @param vector_field_name [String] the vector field to search
60
+ # @param vector_data [String] the query vector blob
61
+ # @param vsim_search_method [Symbol, String, nil] the search method, e.g. +:knn+ or +:range+
62
+ # @param vsim_search_method_params [Hash, nil] the search-method parameters
63
+ # @param filter [HybridFilter, nil] an optional filter applied to the vector leg
64
+ # @param yield_score_as [String, nil] the alias to expose the vector score under
65
+ def initialize(
66
+ vector_field_name:, vector_data:,
67
+ vsim_search_method: nil, vsim_search_method_params: nil,
68
+ filter: nil, yield_score_as: nil
69
+ )
70
+ @vector_field = vector_field_name
71
+ @vector_data = vector_data
72
+ @vsim_method_params = nil
73
+ @filter = filter
74
+ @yield_score_as = yield_score_as
75
+
76
+ if vsim_search_method && vsim_search_method_params
77
+ vsim_method_params(vsim_search_method, **vsim_search_method_params)
78
+ end
79
+ end
80
+
81
+ # Set the vector search method and its parameters (e.g. +KNN+ / +RANGE+).
82
+ #
83
+ # @param method [Symbol, String] the search method (upcased into a token)
84
+ # @param kwargs [Hash] the method parameters, emitted as upcased key/value pairs
85
+ # @return [self]
86
+ def vsim_method_params(method, **kwargs)
87
+ @vsim_method_params = [method.to_s.upcase]
88
+ if kwargs.any?
89
+ @vsim_method_params << kwargs.size * 2
90
+ kwargs.each do |key, value|
91
+ @vsim_method_params << key.to_s.upcase << value
92
+ end
93
+ end
94
+ self
95
+ end
96
+
97
+ # Set a filter applied to the vector leg (+FILTER+).
98
+ #
99
+ # @param flt [HybridFilter] the filter to apply
100
+ # @return [self]
101
+ def filter(flt)
102
+ @filter = flt
103
+ self
104
+ end
105
+
106
+ # Expose the vector score under an alias (+YIELD_SCORE_AS+).
107
+ #
108
+ # @param alias_name [String] the alias for the score
109
+ # @return [self]
110
+ def yield_score_as(alias_name)
111
+ @yield_score_as = alias_name
112
+ self
113
+ end
114
+
115
+ # @return [Array] the +VSIM+ leg argument tokens, beginning with "VSIM"
116
+ def args
117
+ result = ["VSIM", @vector_field, @vector_data]
118
+ result.concat(@vsim_method_params) if @vsim_method_params
119
+ result.concat(@filter.args) if @filter
120
+ result << "YIELD_SCORE_AS" << @yield_score_as if @yield_score_as
121
+ result
122
+ end
123
+ end
124
+
125
+ # An FT.HYBRID query combining a textual {HybridSearchQuery} leg and a
126
+ # {HybridVsimQuery} vector leg.
127
+ class HybridQuery
128
+ # @param search_query [HybridSearchQuery] the textual search leg
129
+ # @param vector_similarity_query [HybridVsimQuery] the vector-similarity leg
130
+ def initialize(search_query, vector_similarity_query)
131
+ @search_query = search_query
132
+ @vector_similarity_query = vector_similarity_query
133
+ end
134
+
135
+ # @return [Array] the concatenated argument tokens of both legs (SEARCH then VSIM)
136
+ def args
137
+ result = []
138
+ result.concat(@search_query.args)
139
+ result.concat(@vector_similarity_query.args)
140
+ result
141
+ end
142
+ end
143
+
144
+ # Constants for the result-combination methods used by {CombineResultsMethod}.
145
+ module CombinationMethods
146
+ # Reciprocal Rank Fusion.
147
+ RRF = "RRF"
148
+ # Linear combination of leg scores.
149
+ LINEAR = "LINEAR"
150
+ end
151
+
152
+ # Constants for the vector search methods used by {HybridVsimQuery}.
153
+ module VectorSearchMethods
154
+ # K-nearest-neighbours search.
155
+ KNN = "KNN"
156
+ # Range search.
157
+ RANGE = "RANGE"
158
+ end
159
+
160
+ # The +COMBINE+ clause of an FT.HYBRID query, describing how the two legs' results are fused.
161
+ class CombineResultsMethod
162
+ # @param method [String] the combination method (see {CombinationMethods})
163
+ # @param kwargs [Hash] the method parameters, emitted as upcased key/value pairs
164
+ def initialize(method, **kwargs)
165
+ @method = method
166
+ @kwargs = kwargs
167
+ end
168
+
169
+ # @return [Array<String>] the +COMBINE+ argument tokens, beginning with "COMBINE"
170
+ def args
171
+ result = ["COMBINE", @method, (@kwargs.size * 2).to_s]
172
+ @kwargs.each do |key, value|
173
+ result << key.to_s.upcase << value.to_s
174
+ end
175
+ result
176
+ end
177
+
178
+ # Build a Reciprocal Rank Fusion (+RRF+) combine method.
179
+ #
180
+ # @param window [Integer, nil] the +WINDOW+ size
181
+ # @param constant [Numeric, nil] the +CONSTANT+ value
182
+ # @return [CombineResultsMethod]
183
+ def self.rrf(window: nil, constant: nil)
184
+ # Guard on nil (not truthiness): 0 is a legitimate value and, unlike some languages,
185
+ # truthy in Ruby — but nil-checking keeps "was it provided?" explicit and correct.
186
+ kwargs = {}
187
+ kwargs[:window] = window unless window.nil?
188
+ kwargs[:constant] = constant unless constant.nil?
189
+ new(CombinationMethods::RRF, **kwargs)
190
+ end
191
+
192
+ # Build a +LINEAR+ combine method.
193
+ #
194
+ # @param alpha [Numeric, nil] the weight of the first leg
195
+ # @param beta [Numeric, nil] the weight of the second leg
196
+ # @return [CombineResultsMethod]
197
+ def self.linear(alpha: nil, beta: nil)
198
+ # Guard on nil (not truthiness) so alpha/beta of 0 are still emitted.
199
+ kwargs = {}
200
+ kwargs[:alpha] = alpha unless alpha.nil?
201
+ kwargs[:beta] = beta unless beta.nil?
202
+ new(CombinationMethods::LINEAR, **kwargs)
203
+ end
204
+ end
205
+
206
+ # A +FILTER+ clause used within a hybrid query leg.
207
+ class HybridFilter
208
+ # @return [Array] the +FILTER+ argument tokens, +["FILTER", conditions]+
209
+ attr_reader :args
210
+
211
+ # @param conditions [String] the filter expression
212
+ def initialize(conditions)
213
+ @args = ["FILTER", conditions]
214
+ end
215
+ end
216
+
217
+ # Builder for the post-processing pipeline applied to FT.HYBRID results.
218
+ #
219
+ # Chainable setters (+load+, +group_by+, +apply+, +sort_by+, +filter+, +limit+) return
220
+ # +self+; {#build_args} renders the configured steps into argument tokens.
221
+ class HybridPostProcessingConfig
222
+ def initialize
223
+ @load_statements = []
224
+ @apply_statements = []
225
+ @groupby_statements = []
226
+ @sortby_fields = []
227
+ @filter = nil
228
+ @limit = nil
229
+ end
230
+
231
+ # Add a +LOAD+ step loading the given fields.
232
+ #
233
+ # @param fields [Array<String>] the fields to load (space-separated strings are split)
234
+ # @return [self]
235
+ def load(*fields)
236
+ unless fields.empty?
237
+ fields_str = fields.join(" ")
238
+ fields_list = fields_str.split(" ")
239
+ @load_statements.concat(["LOAD", fields_list.size, *fields_list])
240
+ end
241
+ self
242
+ end
243
+
244
+ # Add a +GROUPBY+ step with optional +REDUCE+ functions.
245
+ #
246
+ # @param fields [String, Array<String>] the field(s) to group by
247
+ # @param reducers [Array<Reducers>] the reducers applied to each group
248
+ # @return [self]
249
+ def group_by(fields, *reducers)
250
+ ret = ["GROUPBY", Array(fields).size.to_s, *Array(fields)]
251
+ reducers.each do |reducer|
252
+ ret.concat(["REDUCE", reducer.name, reducer.args.size.to_s])
253
+ ret.concat(reducer.args)
254
+ ret.concat(["AS", reducer.alias_name]) if reducer.alias_name
255
+ end
256
+ @groupby_statements.concat(ret)
257
+ self
258
+ end
259
+
260
+ # Add one +APPLY+ step per expression.
261
+ #
262
+ # @param kwexpr [Hash{Symbol => String}] map of result alias => expression
263
+ # @return [self]
264
+ def apply(**kwexpr)
265
+ apply_args = []
266
+ kwexpr.each do |alias_name, expr|
267
+ ret = ["APPLY", expr]
268
+ ret.concat(["AS", alias_name.to_s]) if alias_name
269
+ apply_args.concat(ret)
270
+ end
271
+ @apply_statements.concat(apply_args)
272
+ self
273
+ end
274
+
275
+ # Add a +SORTBY+ step.
276
+ #
277
+ # @param sortby_fields [Array<SortbyField>] the fields to sort by
278
+ # @return [self]
279
+ def sort_by(*sortby_fields)
280
+ @sortby_fields = sortby_fields
281
+ self
282
+ end
283
+
284
+ # Add a +FILTER+ step.
285
+ #
286
+ # @param flt [HybridFilter] the filter to apply
287
+ # @return [self]
288
+ def filter(flt)
289
+ @filter = flt
290
+ self
291
+ end
292
+
293
+ # Add a +LIMIT+ (paging) step.
294
+ #
295
+ # @param offset [Integer] the index of the first row to return
296
+ # @param num [Integer] the maximum number of rows to return
297
+ # @return [self]
298
+ def limit(offset, num)
299
+ @limit = { offset: offset, num: num }
300
+ self
301
+ end
302
+
303
+ # Render the post-processing pipeline into its argument tokens.
304
+ #
305
+ # Steps are emitted in order: LOAD, GROUPBY, APPLY, SORTBY, FILTER, LIMIT.
306
+ #
307
+ # @return [Array] the argument token array
308
+ def build_args
309
+ args = []
310
+ args.concat(@load_statements) if @load_statements.any?
311
+ args.concat(@groupby_statements) if @groupby_statements.any?
312
+ args.concat(@apply_statements) if @apply_statements.any?
313
+ if @sortby_fields.any?
314
+ sortby_args = []
315
+ @sortby_fields.each do |f|
316
+ sortby_args.concat(f.args)
317
+ end
318
+ args.concat(["SORTBY", sortby_args.size, *sortby_args])
319
+ end
320
+ args.concat(@filter.args) if @filter
321
+ args.concat(["LIMIT", @limit[:offset], @limit[:num]]) if @limit
322
+ args
323
+ end
324
+ end
325
+
326
+ # The +WITHCURSOR+ clause for paging through FT.HYBRID results.
327
+ class HybridCursorQuery
328
+ # @param count [Integer] the cursor +COUNT+ (batch size, 0 to omit)
329
+ # @param max_idle [Integer] the cursor +MAXIDLE+ in milliseconds (0 to omit)
330
+ def initialize(count: 0, max_idle: 0)
331
+ @count = count
332
+ @max_idle = max_idle
333
+ end
334
+
335
+ # Render the cursor arguments.
336
+ #
337
+ # @return [Array<String>] the argument tokens, beginning with "WITHCURSOR"
338
+ def build_args
339
+ args = ["WITHCURSOR"]
340
+ args.concat(["COUNT", @count.to_s]) if @count > 0
341
+ args.concat(["MAXIDLE", @max_idle.to_s]) if @max_idle > 0
342
+ args
343
+ end
344
+ end
345
+
346
+ # A single field/direction pair for hybrid-search +SORTBY+.
347
+ class SortbyField
348
+ # @return [Array<String>] the argument tokens, +[field, "ASC"|"DESC"]+
349
+ attr_reader :args
350
+
351
+ # @param field [String] the field to sort by
352
+ # @param asc [Boolean] sort ascending when true, descending otherwise
353
+ def initialize(field, asc: true)
354
+ @args = [field, asc ? "ASC" : "DESC"]
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end