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,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # A fluent builder for FT.AGGREGATE requests.
7
+ #
8
+ # Aggregation steps (+group_by+, +sort_by+, +apply+, +filter+, +limit+, +load+) are
9
+ # chained on; each returns +self+. {#to_redis_args} renders them into the argument array
10
+ # passed to +FT.AGGREGATE+, emitting +LOAD+ steps before +DIALECT+ and the remaining steps
11
+ # after it.
12
+ #
13
+ # @example
14
+ # req = Redis::Commands::Search::AggregateRequest.new("*")
15
+ # .group_by("@category", Redis::Commands::Search::Reducers.count(alias_name: "n"))
16
+ class AggregateRequest
17
+ # @param query [String] the aggregation query string (defaults to "*")
18
+ # @param with_cursor [Boolean] emit +WITHCURSOR+ to page results with a cursor
19
+ # @param cursor_count [Integer, nil] the cursor +COUNT+ (batch size)
20
+ # @param cursor_max_idle [Integer, nil] the cursor +MAXIDLE+ in milliseconds
21
+ # @param dialect [Integer] the query +DIALECT+ version (defaults to {DEFAULT_DIALECT})
22
+ def initialize(query = "*", with_cursor: false, cursor_count: nil, cursor_max_idle: nil,
23
+ dialect: DEFAULT_DIALECT)
24
+ @query = query
25
+ @with_cursor = with_cursor
26
+ @cursor_count = cursor_count
27
+ @cursor_max_idle = cursor_max_idle
28
+ @dialect = dialect
29
+ @steps = []
30
+ end
31
+
32
+ # Add a +GROUPBY+ step with optional +REDUCE+ functions.
33
+ #
34
+ # @param fields [String, Array<String>] the field(s) to group by
35
+ # @param reducers [Array<Reducers>] the reducers applied to each group
36
+ # @return [self]
37
+ def group_by(fields, *reducers)
38
+ step = ["GROUPBY", Array(fields).size.to_s, *Array(fields)]
39
+ reducers.each do |reducer|
40
+ step.concat(["REDUCE", reducer.name, reducer.args.size.to_s])
41
+ step.concat(reducer.args)
42
+ step.concat(["AS", reducer.alias_name]) if reducer.alias_name
43
+ end
44
+ @steps << step.flatten
45
+ self
46
+ end
47
+
48
+ # Add a +SORTBY+ step.
49
+ #
50
+ # @param sort_by_fields [Array<Asc, Desc, String>] the fields to sort by; +Asc+/+Desc+
51
+ # wrappers carry an explicit direction, plain strings sort with the server default
52
+ # @param max [Integer, nil] limit the sort to the top +MAX+ results
53
+ # @return [self]
54
+ def sort_by(*sort_by_fields, max: nil)
55
+ # Count total arguments (field + order for each)
56
+ nargs = sort_by_fields.sum do |field|
57
+ field.is_a?(Asc) || field.is_a?(Desc) ? 2 : 1
58
+ end
59
+
60
+ step = ["SORTBY", nargs]
61
+ sort_by_fields.each do |field|
62
+ if field.is_a?(Asc) || field.is_a?(Desc)
63
+ step << field.name << field.order
64
+ else
65
+ step << field
66
+ end
67
+ end
68
+ step << "MAX" << max if max
69
+ @steps << step
70
+ self
71
+ end
72
+
73
+ # Add one +APPLY+ step per expression.
74
+ #
75
+ # @param expressions [Hash{String, Symbol => String}] map of result alias => expression
76
+ # @return [self]
77
+ def apply(expressions)
78
+ expressions.each do |as, expression|
79
+ @steps << ["APPLY", expression, "AS", as.to_s]
80
+ end
81
+ self
82
+ end
83
+
84
+ # Add a +LIMIT+ (paging) step.
85
+ #
86
+ # @param offset [Integer] the index of the first row to return
87
+ # @param num [Integer] the maximum number of rows to return
88
+ # @return [self]
89
+ def limit(offset, num)
90
+ @steps << ["LIMIT", offset, num]
91
+ self
92
+ end
93
+
94
+ # Add a +FILTER+ step that keeps rows matching +expression+.
95
+ #
96
+ # @param expression [String] the filter expression
97
+ # @return [self]
98
+ def filter(expression)
99
+ @steps << ["FILTER", expression]
100
+ self
101
+ end
102
+
103
+ # Add a +LOAD+ step. With no fields, loads all attributes (+LOAD *+).
104
+ #
105
+ # @param fields [Array<String>] the fields to load (empty loads all)
106
+ # @return [self]
107
+ def load(*fields)
108
+ @steps << if fields.empty?
109
+ ["LOAD", "*"]
110
+ else
111
+ ["LOAD", fields.size, *fields.flatten]
112
+ end
113
+ self
114
+ end
115
+
116
+ # Include the document scores in the aggregation (+ADDSCORES+).
117
+ #
118
+ # @param add_scores [Boolean] whether to emit +ADDSCORES+
119
+ # @return [self]
120
+ def add_scores(add_scores: true)
121
+ @add_scores = add_scores
122
+ self
123
+ end
124
+
125
+ # Set the query +DIALECT+ version.
126
+ #
127
+ # @param dialect_version [Integer] the dialect version
128
+ # @return [self]
129
+ def dialect(dialect_version)
130
+ @dialect = dialect_version
131
+ self
132
+ end
133
+
134
+ # Render the request into the +FT.AGGREGATE+ argument array.
135
+ #
136
+ # +LOAD+ steps are emitted before +DIALECT+ and all other steps after it.
137
+ #
138
+ # @return [Array] the argument token array (query string first)
139
+ def to_redis_args
140
+ args = [@query]
141
+ if @with_cursor
142
+ args << "WITHCURSOR"
143
+ args << "COUNT" << @cursor_count if @cursor_count
144
+ args << "MAXIDLE" << @cursor_max_idle if @cursor_max_idle
145
+ end
146
+ args << "ADDSCORES" if @add_scores
147
+ # Add LOAD steps first (before DIALECT)
148
+ load_steps = @steps.select { |step| step[0] == "LOAD" }
149
+ load_steps.each { |step| args.concat(step) }
150
+
151
+ args << "DIALECT" << @dialect if @dialect
152
+
153
+ # Add remaining steps (after DIALECT)
154
+ other_steps = @steps.reject { |step| step[0] == "LOAD" }
155
+ other_steps.each { |step| args.concat(step) }
156
+ args
157
+ end
158
+ end
159
+
160
+ # Wraps a field name with an ascending (+ASC+) sort order for {AggregateRequest#sort_by}.
161
+ class Asc
162
+ # @return [String] the field name
163
+ # @return [String] the order keyword, always "ASC"
164
+ attr_reader :name, :order
165
+
166
+ # @param name [String] the field name to sort ascending
167
+ def initialize(name)
168
+ @name = name
169
+ @order = 'ASC'
170
+ end
171
+ end
172
+
173
+ # Wraps a field name with a descending (+DESC+) sort order for {AggregateRequest#sort_by}.
174
+ class Desc
175
+ # @return [String] the field name
176
+ # @return [String] the order keyword, always "DESC"
177
+ attr_reader :name, :order
178
+
179
+ # @param name [String] the field name to sort descending
180
+ def initialize(name)
181
+ @name = name
182
+ @order = 'DESC'
183
+ end
184
+ end
185
+
186
+ # Factory for the +REDUCE+ functions used inside {AggregateRequest#group_by}.
187
+ #
188
+ # Each class method returns a +Reducers+ instance carrying the function name, its
189
+ # arguments, and an optional result alias.
190
+ #
191
+ # @example
192
+ # Redis::Commands::Search::Reducers.sum("@price", alias_name: "total")
193
+ class Reducers
194
+ # Build a +COUNT+ reducer.
195
+ #
196
+ # @param alias_name [String, nil] the result alias
197
+ # @return [Reducers]
198
+ def self.count(alias_name: nil)
199
+ new("COUNT", alias_name: alias_name)
200
+ end
201
+
202
+ # Build a +COUNT_DISTINCT+ reducer.
203
+ #
204
+ # @param property [String] the property to count distinct values of
205
+ # @param alias_name [String, nil] the result alias
206
+ # @return [Reducers]
207
+ def self.count_distinct(property, alias_name: nil)
208
+ new("COUNT_DISTINCT", property, alias_name: alias_name)
209
+ end
210
+
211
+ # Build a +COUNT_DISTINCTISH+ (approximate distinct count) reducer.
212
+ #
213
+ # @param property [String] the property to count distinct values of
214
+ # @param alias_name [String, nil] the result alias
215
+ # @return [Reducers]
216
+ def self.count_distinctish(property, alias_name: nil)
217
+ new("COUNT_DISTINCTISH", property, alias_name: alias_name)
218
+ end
219
+
220
+ # Build a +SUM+ reducer.
221
+ #
222
+ # @param property [String] the property to sum
223
+ # @param alias_name [String, nil] the result alias
224
+ # @return [Reducers]
225
+ def self.sum(property, alias_name: nil)
226
+ new("SUM", property, alias_name: alias_name)
227
+ end
228
+
229
+ # Build a +MIN+ reducer.
230
+ #
231
+ # @param property [String] the property to take the minimum of
232
+ # @param alias_name [String, nil] the result alias
233
+ # @return [Reducers]
234
+ def self.min(property, alias_name: nil)
235
+ new("MIN", property, alias_name: alias_name)
236
+ end
237
+
238
+ # Build a +MAX+ reducer.
239
+ #
240
+ # @param property [String] the property to take the maximum of
241
+ # @param alias_name [String, nil] the result alias
242
+ # @return [Reducers]
243
+ def self.max(property, alias_name: nil)
244
+ new("MAX", property, alias_name: alias_name)
245
+ end
246
+
247
+ # Build an +AVG+ reducer.
248
+ #
249
+ # @param property [String] the property to average
250
+ # @param alias_name [String, nil] the result alias
251
+ # @return [Reducers]
252
+ def self.avg(property, alias_name: nil)
253
+ new("AVG", property, alias_name: alias_name)
254
+ end
255
+
256
+ # Build a +STDDEV+ (standard deviation) reducer.
257
+ #
258
+ # @param property [String] the property to compute the standard deviation of
259
+ # @param alias_name [String, nil] the result alias
260
+ # @return [Reducers]
261
+ def self.stddev(property, alias_name: nil)
262
+ new("STDDEV", property, alias_name: alias_name)
263
+ end
264
+
265
+ # Build a +QUANTILE+ reducer.
266
+ #
267
+ # @param property [String] the property to compute the quantile of
268
+ # @param quantile [Float] the quantile in the range 0..1
269
+ # @param alias_name [String, nil] the result alias
270
+ # @return [Reducers]
271
+ def self.quantile(property, quantile, alias_name: nil)
272
+ new("QUANTILE", property, quantile, alias_name: alias_name)
273
+ end
274
+
275
+ # Build a +TOLIST+ reducer (collects distinct values into a list).
276
+ #
277
+ # @param property [String] the property to collect
278
+ # @param alias_name [String, nil] the result alias
279
+ # @return [Reducers]
280
+ def self.tolist(property, alias_name: nil)
281
+ new("TOLIST", property, alias_name: alias_name)
282
+ end
283
+
284
+ # Build a +FIRST_VALUE+ reducer, optionally ordered by another property.
285
+ #
286
+ # @param property [String] the property whose first value is taken
287
+ # @param alias_name [String, nil] the result alias
288
+ # @param sort_by [String, nil] the property to order by before taking the first value
289
+ # @param sort_order [Symbol, String, nil] the sort direction ("ASC"/"DESC", default "ASC")
290
+ # @return [Reducers]
291
+ def self.first_value(property, alias_name: nil, sort_by: nil, sort_order: nil)
292
+ args = [property]
293
+ if sort_by
294
+ args << "BY" << sort_by
295
+ args << (sort_order || "ASC").to_s.upcase
296
+ end
297
+ new("FIRST_VALUE", *args, alias_name: alias_name)
298
+ end
299
+
300
+ # Build a +RANDOM_SAMPLE+ reducer.
301
+ #
302
+ # @param property [String] the property to sample
303
+ # @param sample_size [Integer] the number of values to sample
304
+ # @param alias_name [String, nil] the result alias
305
+ # @return [Reducers]
306
+ def self.random_sample(property, sample_size, alias_name: nil)
307
+ new("RANDOM_SAMPLE", property, sample_size, alias_name: alias_name)
308
+ end
309
+
310
+ # Build a +COLLECT+ reducer, which gathers each group's rows, projects a chosen set of
311
+ # fields, optionally deduplicates, sorts and limits them, and emits them as an array of
312
+ # per-entry maps under the reducer alias.
313
+ #
314
+ # Field and sort-key names must carry an +@+ prefix on the wire (like the other reducers,
315
+ # the caller supplies it); output map keys are the same names with the +@+ stripped.
316
+ #
317
+ # @example top 5 per group by rating
318
+ # Reducers.collect(
319
+ # fields: ["@title", "@rating"],
320
+ # sort_by: [Desc.new("@rating")],
321
+ # limit: [0, 5],
322
+ # alias_name: "top"
323
+ # )
324
+ #
325
+ # @param fields [Symbol, Array<String>] +:all+ emits +FIELDS *+ (projects the fields the
326
+ # pipeline has materialized at this stage — not a whole-document fetch); an Array emits
327
+ # +FIELDS <count> <@field> ...+
328
+ # @param distinct [Boolean] emit +DISTINCT+ to deduplicate entries. NOTE: not yet
329
+ # implemented server-side; sending it currently fails parsing. Kept for forward
330
+ # compatibility — do not rely on it until the server ships it.
331
+ # @param sort_by [Array<String, Asc, Desc>, nil] sort keys; a plain String sorts ascending
332
+ # (the default), an +Asc+/+Desc+ wrapper sets the direction explicitly. NOTE: +SORTBY+
333
+ # without +limit+ returns at most 10 entries per group (the server default).
334
+ # @param limit [Array(Integer, Integer), nil] a +[offset, count]+ pair emitting
335
+ # +LIMIT <offset> <count>+; with +sort_by+ this is a bounded top-N selection
336
+ # @param alias_name [String, nil] the reducer output column name (+AS+)
337
+ # @return [Reducers]
338
+ # @raise [ArgumentError] if +fields+ is an empty list and not +:all+
339
+ def self.collect(fields:, distinct: false, sort_by: nil, limit: nil, alias_name: nil)
340
+ tokens = collect_fields_tokens(fields)
341
+ tokens << "DISTINCT" if distinct
342
+ tokens.concat(collect_sortby_tokens(sort_by))
343
+ tokens.concat(["LIMIT", *limit]) if limit
344
+
345
+ new("COLLECT", *tokens, alias_name: alias_name)
346
+ end
347
+
348
+ # Render the +FIELDS+ clause tokens for {collect}.
349
+ def self.collect_fields_tokens(fields)
350
+ return ["FIELDS", "*"] if fields == :all
351
+
352
+ fields = Array(fields)
353
+ raise ArgumentError, "collect fields must be :all or a non-empty list" if fields.empty?
354
+
355
+ ["FIELDS", fields.size.to_s, *fields]
356
+ end
357
+ private_class_method :collect_fields_tokens
358
+
359
+ # Render the optional +SORTBY+ clause tokens for {collect}. Each Asc/Desc wrapper emits a
360
+ # name and its direction (2 tokens); a plain String emits just the name (ASC is implicit).
361
+ def self.collect_sortby_tokens(sort_by)
362
+ return [] if sort_by.nil? || sort_by.empty?
363
+
364
+ sort_tokens = sort_by.flat_map do |field|
365
+ field.is_a?(Asc) || field.is_a?(Desc) ? [field.name, field.order] : [field]
366
+ end
367
+ ["SORTBY", sort_tokens.size.to_s, *sort_tokens]
368
+ end
369
+ private_class_method :collect_sortby_tokens
370
+
371
+ # @return [String] the reducer function name
372
+ # @return [Array] the reducer arguments
373
+ # @return [String, nil] the result alias
374
+ attr_reader :name, :args, :alias_name
375
+
376
+ # @param name [String] the reducer function name
377
+ # @param args [Array] the reducer arguments
378
+ # @param alias_name [String, nil] the result alias
379
+ def initialize(name, *args, alias_name: nil)
380
+ @name = name
381
+ @args = args
382
+ @alias_name = alias_name
383
+ end
384
+
385
+ # Set the result alias (+AS+) for this reducer.
386
+ #
387
+ # @param alias_name [String] the result alias
388
+ # @return [self]
389
+ def as(alias_name)
390
+ @alias_name = alias_name
391
+ self
392
+ end
393
+ end
394
+
395
+ # Represents an +FT.CURSOR+ read for paging through aggregation results.
396
+ class Cursor
397
+ # @return [Integer] the cursor id
398
+ # @return [Integer] the +COUNT+ (batch size, 0 to omit)
399
+ attr_accessor :cid, :count
400
+
401
+ # @param cid [Integer] the cursor id returned by a prior aggregation
402
+ def initialize(cid)
403
+ @cid = cid
404
+ @count = 0
405
+ end
406
+
407
+ # Render the cursor arguments for +FT.CURSOR READ+.
408
+ #
409
+ # @return [Array<String>] the argument token array
410
+ def build_args
411
+ args = [@cid.to_s]
412
+ args.concat(["COUNT", @count.to_s]) if @count > 0
413
+ args
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # Default query dialect used for FT.SEARCH and FT.AGGREGATE
7
+ # +DEFAULT_DIALECT+. Dialect 2 is the recommended baseline: it supports modern query syntax
8
+ # such as vector (KNN) and geoshape predicates, whereas the server's built-in default is
9
+ # dialect 1. Override per query via {Query#dialect} / {AggregateRequest#dialect} or the
10
+ # +dialect:+ option to +ft_search+/+ft_aggregate+.
11
+ DEFAULT_DIALECT = 2
12
+ end
13
+ end
14
+ end