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,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ class Redis
6
+ module Commands
7
+ module Search
8
+ # A single document returned by FT.SEARCH.
9
+ #
10
+ # Behaves like a read-only hash over the document's returned fields, while also
11
+ # exposing the document +id+ and, when requested, its +score+ and +payload+.
12
+ class Document
13
+ # @return [String] the document key/id
14
+ # @return [Float, Array, nil] the relevance score (present when WITHSCORES was set),
15
+ # normalized to a Float across RESP2/RESP3; with EXPLAINSCORE it is +[Float, explanation]+
16
+ # @return [String, nil] the document payload (present when WITHPAYLOADS was set)
17
+ # @return [Hash{String => Object}] the returned fields keyed by field name
18
+ attr_reader :id, :score, :payload, :attributes
19
+
20
+ # @param id [String] the document key/id
21
+ # @param attributes [Hash{String => Object}] the returned fields keyed by field name
22
+ # @param score [Float, Array, nil] the relevance score (Float; +[Float, explanation]+ with EXPLAINSCORE)
23
+ # @param payload [String, nil] the document payload
24
+ def initialize(id, attributes: {}, score: nil, payload: nil)
25
+ @id = id
26
+ @attributes = attributes
27
+ @score = score
28
+ @payload = payload
29
+ end
30
+
31
+ # Look up a returned field by name.
32
+ #
33
+ # @param key [String, Symbol] the field name
34
+ # @return [Object, nil] the field value, or nil when the field is absent
35
+ def [](key)
36
+ @attributes[key.to_s]
37
+ end
38
+
39
+ # @param key [String, Symbol] the field name
40
+ # @return [Boolean] whether the document has the given field
41
+ def key?(key)
42
+ @attributes.key?(key.to_s)
43
+ end
44
+
45
+ # The document id plus its returned fields, as a flat hash.
46
+ #
47
+ # @return [Hash{String => Object}] the attributes merged with +"id"+
48
+ def to_h
49
+ @attributes.merge("id" => @id)
50
+ end
51
+
52
+ # @param other [Object] the object to compare against
53
+ # @return [Boolean] whether +other+ is a Document with equal id, attributes, score and payload
54
+ def ==(other)
55
+ other.is_a?(Document) &&
56
+ id == other.id &&
57
+ attributes == other.attributes &&
58
+ score == other.score &&
59
+ payload == other.payload
60
+ end
61
+ alias eql? ==
62
+
63
+ # @return [Integer] a hash derived from id, attributes, score and payload
64
+ def hash
65
+ [id, attributes, score, payload].hash
66
+ end
67
+ end
68
+
69
+ # Result of FT.SEARCH: the total number of matching documents (which may exceed the
70
+ # number returned because of paging) plus the documents on this page.
71
+ class SearchResult
72
+ include Enumerable
73
+
74
+ # @return [Integer] the total number of matching documents (may exceed +documents.size+
75
+ # because of paging)
76
+ # @return [Array<Document>] the documents on this page
77
+ # @return [Array<String>] any warnings returned by the server (e.g. a query timeout under
78
+ # the +return+ policy); only the RESP3 wire carries warnings on FT.SEARCH, so this is
79
+ # always empty on RESP2 connections
80
+ attr_reader :total, :documents, :warnings
81
+
82
+ # @param total [Integer] the total number of matching documents
83
+ # @param documents [Array<Document>] the documents on this page
84
+ # @param warnings [Array<String>] any warnings returned by the server
85
+ def initialize(total, documents, warnings: [])
86
+ @total = total
87
+ @documents = documents
88
+ @warnings = warnings
89
+ end
90
+
91
+ # Iterate over the documents on this page.
92
+ #
93
+ # @yieldparam document [Document]
94
+ # @return [Enumerator, Array<Document>]
95
+ def each(&block)
96
+ @documents.each(&block)
97
+ end
98
+
99
+ # @param index [Integer] the position within this page
100
+ # @return [Document, nil] the document at +index+, or nil when out of range
101
+ def [](index)
102
+ @documents[index]
103
+ end
104
+
105
+ # @return [Integer] the number of documents on this page
106
+ def size
107
+ @documents.size
108
+ end
109
+ alias length size
110
+
111
+ # @return [Boolean] whether this page has no documents
112
+ def empty?
113
+ @documents.empty?
114
+ end
115
+ end
116
+
117
+ # Result of FT.AGGREGATE (and FT.CURSOR READ): the rows produced by the pipeline plus,
118
+ # when WITHCURSOR was requested, the cursor id to read the next batch with (0 when
119
+ # the cursor is exhausted).
120
+ class AggregateResult
121
+ include Enumerable
122
+
123
+ # @return [Array<Hash{String => Object}>] the rows produced by the pipeline, each a
124
+ # field => value hash
125
+ # @return [Integer, nil] the next cursor id (0 when exhausted), or nil when no cursor
126
+ attr_reader :rows, :cursor
127
+
128
+ # @param rows [Array<Hash{String => Object}>] the pipeline rows
129
+ # @param cursor [Integer, nil] the next cursor id, or nil when no cursor was requested
130
+ def initialize(rows, cursor: nil)
131
+ @rows = rows
132
+ @cursor = cursor
133
+ end
134
+
135
+ # Iterate over the rows.
136
+ #
137
+ # @yieldparam row [Hash{String => Object}]
138
+ # @return [Enumerator, Array<Hash>]
139
+ def each(&block)
140
+ @rows.each(&block)
141
+ end
142
+
143
+ # @param index [Integer] the row position
144
+ # @return [Hash, nil] the row at +index+, or nil when out of range
145
+ def [](index)
146
+ @rows[index]
147
+ end
148
+
149
+ # @return [Integer] the number of rows
150
+ def size
151
+ @rows.size
152
+ end
153
+ alias length size
154
+
155
+ # @return [Boolean] whether there are no rows
156
+ def empty?
157
+ @rows.empty?
158
+ end
159
+ end
160
+
161
+ # Result of FT.HYBRID: the fused result rows (each a field => value hash, including the
162
+ # synthetic +"__key"+ and +"__score"+) plus the total, any warnings and the execution time.
163
+ #
164
+ # When the query used WITHCURSOR the server returns per-leg cursor ids instead of an inline
165
+ # page; those are exposed via {#search_cursor} / {#vsim_cursor} and {#cursor?} is true.
166
+ class HybridResult
167
+ include Enumerable
168
+
169
+ # @return [Array<Hash{String => Object}>] the fused result rows
170
+ # @return [Integer, nil] the total number of fused results
171
+ # @return [Array] any warnings returned by the server
172
+ # @return [Float, nil] the server-side execution time
173
+ # @return [Integer, nil] the SEARCH-leg cursor id (WITHCURSOR only)
174
+ # @return [Integer, nil] the VSIM-leg cursor id (WITHCURSOR only)
175
+ attr_reader :rows, :total, :warnings, :execution_time, :search_cursor, :vsim_cursor
176
+
177
+ def initialize(rows: [], total: nil, warnings: [], execution_time: nil,
178
+ search_cursor: nil, vsim_cursor: nil)
179
+ @rows = rows
180
+ @total = total
181
+ @warnings = warnings
182
+ @execution_time = execution_time
183
+ @search_cursor = search_cursor
184
+ @vsim_cursor = vsim_cursor
185
+ end
186
+
187
+ # Iterate over the result rows.
188
+ #
189
+ # @yieldparam row [Hash{String => Object}]
190
+ # @return [Enumerator, Array<Hash>]
191
+ def each(&block)
192
+ @rows.each(&block)
193
+ end
194
+
195
+ # @param index [Integer] the row position
196
+ # @return [Hash, nil] the row at +index+, or nil when out of range
197
+ def [](index)
198
+ @rows[index]
199
+ end
200
+
201
+ # @return [Integer] the number of rows
202
+ def size
203
+ @rows.size
204
+ end
205
+ alias length size
206
+
207
+ # @return [Boolean] whether there are no rows
208
+ def empty?
209
+ @rows.empty?
210
+ end
211
+
212
+ # @return [Boolean] whether this is a WITHCURSOR reply carrying cursor ids
213
+ def cursor?
214
+ !@search_cursor.nil? || !@vsim_cursor.nil?
215
+ end
216
+ end
217
+
218
+ # Reply reshaping for the Query Engine.
219
+ #
220
+ # Every parser normalises *both* RESP2 (flat arrays) and RESP3 (native maps) replies to
221
+ # the same Ruby objects, so the public API is stable regardless of the protocol the
222
+ # underlying connection negotiates. RESP3 is the default protocol; the RESP2 branches exist
223
+ # for +protocol: 2+ connections.
224
+ module ResultParser
225
+ module_function
226
+
227
+ # FT.SEARCH -> SearchResult
228
+ #
229
+ # @param reply [Array, Hash] RESP2 flat array or RESP3 map
230
+ # @param with_scores [Boolean] WITHSCORES was set (RESP2 only; RESP3 carries it inline)
231
+ # @param with_payloads [Boolean] WITHPAYLOADS was set (RESP2 only)
232
+ # @param no_content [Boolean] NOCONTENT was set
233
+ # @param decode_fields [Hash] field name => whether to JSON-decode its value
234
+ # @return [SearchResult, nil] the parsed result (nil when +reply+ is nil)
235
+ def search(reply, with_scores: false, with_payloads: false, no_content: false, decode_fields: {})
236
+ return reply if reply.nil?
237
+
238
+ if reply.is_a?(Hash) # RESP3
239
+ search_resp3(reply, decode_fields)
240
+ else # RESP2 flat array
241
+ search_resp2(reply, with_scores, with_payloads, no_content, decode_fields)
242
+ end
243
+ end
244
+
245
+ # Parse a RESP2 flat-array FT.SEARCH reply into a {SearchResult}.
246
+ #
247
+ # @param reply [Array] the flat +[total, id, ..., fields, ...]+ array
248
+ # @param with_scores [Boolean] WITHSCORES was set
249
+ # @param with_payloads [Boolean] WITHPAYLOADS was set
250
+ # @param no_content [Boolean] NOCONTENT was set
251
+ # @param decode_fields [Hash] field name => whether to JSON-decode its value
252
+ # @return [SearchResult]
253
+ def search_resp2(reply, with_scores, with_payloads, no_content, decode_fields)
254
+ total = reply[0]
255
+ documents = []
256
+ index = 1
257
+
258
+ while index < reply.length
259
+ id = reply[index]
260
+ index += 1
261
+
262
+ score = nil
263
+ if with_scores
264
+ score = normalize_score(reply[index])
265
+ index += 1
266
+ end
267
+
268
+ payload = nil
269
+ if with_payloads
270
+ payload = reply[index]
271
+ index += 1
272
+ end
273
+
274
+ attributes = {}
275
+ unless no_content
276
+ attributes = hashify_fields(reply[index], decode_fields)
277
+ index += 1
278
+ end
279
+
280
+ documents << Document.new(id, attributes: attributes, score: score, payload: payload)
281
+ end
282
+
283
+ SearchResult.new(total, documents)
284
+ end
285
+
286
+ # Parse a RESP3 map FT.SEARCH reply into a {SearchResult}.
287
+ #
288
+ # @param reply [Hash] the native map carrying +"results"+ and +"total_results"+
289
+ # @param decode_fields [Hash] field name => whether to JSON-decode its value
290
+ # @return [SearchResult]
291
+ def search_resp3(reply, decode_fields)
292
+ documents = (reply["results"] || []).map do |row|
293
+ attributes = hashify_fields(row["extra_attributes"], decode_fields)
294
+ Document.new(row["id"], attributes: attributes, score: normalize_score(row["score"]),
295
+ payload: row["payload"])
296
+ end
297
+
298
+ SearchResult.new(reply["total_results"], documents, warnings: reply["warning"] || [])
299
+ end
300
+
301
+ # FT.AGGREGATE / FT.CURSOR READ -> AggregateResult
302
+ #
303
+ # Without a cursor the reply is [num_rows, row, row, ...] (RESP2) or a map with
304
+ # "results" (RESP3). With WITHCURSOR the reply is [aggregate_reply, cursor_id].
305
+ #
306
+ # @param reply [Array, Hash] the FT.AGGREGATE / FT.CURSOR READ reply
307
+ # @return [AggregateResult, nil] the parsed result (nil when +reply+ is nil)
308
+ def aggregate(reply)
309
+ return reply if reply.nil?
310
+
311
+ cursor = nil
312
+ body = reply
313
+ # WITHCURSOR wraps the aggregate body and the cursor id in a 2-element array. The
314
+ # body is a flat array under RESP2 and a native map under RESP3.
315
+ if reply.is_a?(Array) && reply.length == 2 && reply[1].is_a?(Integer) &&
316
+ (reply[0].is_a?(Array) || reply[0].is_a?(Hash))
317
+ body = reply[0]
318
+ cursor = reply[1]
319
+ end
320
+
321
+ rows =
322
+ if body.is_a?(Hash) # RESP3
323
+ (body["results"] || []).map { |row| hashify_fields(row["extra_attributes"] || row, {}) }
324
+ else # RESP2: first element is the row count, the rest are rows
325
+ body[1..-1].to_a.map { |row| hashify_fields(row, {}) }
326
+ end
327
+
328
+ AggregateResult.new(rows.map { |row| normalize_aggregate_row(row) }, cursor: cursor)
329
+ end
330
+
331
+ # A COLLECT reducer column is an array of entry-maps: +Array<Hash>+ under RESP3 but
332
+ # +Array<[k, v, ...]>+ (flat key/value arrays) under RESP2. Normalize both to
333
+ # +Array<Hash>+ so callers see a uniform type regardless of protocol. Scalar values and
334
+ # arrays of scalars (e.g. TOLIST) are left untouched, and the RESP3 case is idempotent.
335
+ #
336
+ # @param row [Hash] an already-hashified aggregate row
337
+ # @return [Hash] the row with any array-of-entries column reshaped to +Array<Hash>+
338
+ def normalize_aggregate_row(row)
339
+ row.each_with_object({}) do |(key, value), acc|
340
+ acc[key] =
341
+ if value.is_a?(Array) && !value.empty? && value.all? { |e| e.is_a?(Array) || e.is_a?(Hash) }
342
+ value.map { |entry| hashify_fields(entry, {}) }
343
+ else
344
+ value
345
+ end
346
+ end
347
+ end
348
+
349
+ # FT.HYBRID -> HybridResult
350
+ #
351
+ # The reply is a native map under RESP3 and a flat [k, v, ...] array under RESP2. A
352
+ # WITHCURSOR reply carries per-leg cursor ids ("SEARCH"/"VSIM") instead of a result page.
353
+ #
354
+ # @param reply [Array, Hash] the FT.HYBRID reply
355
+ # @return [HybridResult, nil] the parsed result (nil when +reply+ is nil)
356
+ def hybrid(reply)
357
+ return reply if reply.nil?
358
+
359
+ map = reply.is_a?(Hash) ? reply : Hash[*reply]
360
+
361
+ if map.key?("SEARCH") || map.key?("VSIM")
362
+ return HybridResult.new(
363
+ warnings: map["warnings"] || [],
364
+ search_cursor: map["SEARCH"],
365
+ vsim_cursor: map["VSIM"]
366
+ )
367
+ end
368
+
369
+ rows = Array(map["results"]).map { |row| hashify_fields(row, {}) }
370
+ HybridResult.new(
371
+ rows: rows,
372
+ total: map["total_results"],
373
+ warnings: map["warnings"] || [],
374
+ execution_time: map["execution_time"]
375
+ )
376
+ end
377
+
378
+ # FT.INFO / FT.CONFIG GET -> Hash. RESP2 returns a flat [k, v, ...] array; RESP3
379
+ # already returns a native map.
380
+ #
381
+ # @param reply [Array, Hash] the FT.INFO reply
382
+ # @return [Hash, nil] the info as a hash (nil when +reply+ is nil)
383
+ def hashify_info(reply)
384
+ return reply if reply.nil?
385
+ return reply if reply.is_a?(Hash)
386
+
387
+ Hash[*reply]
388
+ end
389
+
390
+ # FT.CONFIG GET -> Hash. RESP2 returns nested [[option, value], ...] pairs; RESP3 a map.
391
+ #
392
+ # @param reply [Array, Hash] the FT.CONFIG GET reply
393
+ # @return [Hash, nil] option => value (nil when +reply+ is nil)
394
+ def config_get(reply)
395
+ return reply if reply.nil?
396
+ return reply if reply.is_a?(Hash)
397
+
398
+ reply.to_h
399
+ end
400
+
401
+ # FT.SYNDUMP -> { term => [group_id, ...] }. RESP2 is a flat [term, [ids], ...] array.
402
+ #
403
+ # @param reply [Array, Hash] the FT.SYNDUMP reply
404
+ # @return [Hash{String => Array}, nil] term => group ids (nil when +reply+ is nil)
405
+ def syndump(reply)
406
+ return reply if reply.nil?
407
+ return reply if reply.is_a?(Hash)
408
+
409
+ Hash[*reply]
410
+ end
411
+
412
+ # FT.SPELLCHECK -> { term => [{ "suggestion" => ..., "score" => ... }, ...] }
413
+ #
414
+ # @param reply [Array, Hash] the FT.SPELLCHECK reply
415
+ # @return [Hash{String => Array<Hash>}, nil] term => suggestions (nil when +reply+ is nil)
416
+ def spellcheck(reply)
417
+ return reply if reply.nil?
418
+
419
+ # RESP3: { "results" => { term => [{ suggestion => score }, ...] } }
420
+ if reply.is_a?(Hash)
421
+ results = reply["results"] || {}
422
+ return results.each_with_object({}) do |(term, suggestions), acc|
423
+ acc[term] = suggestions.flat_map do |entry|
424
+ entry.map { |suggestion, score| { "suggestion" => suggestion, "score" => score } }
425
+ end
426
+ end
427
+ end
428
+
429
+ # RESP2: [["TERM", term, [[score, suggestion], ...]], ...]
430
+ parsed = {}
431
+ reply.each do |entry|
432
+ next unless entry[0] == "TERM"
433
+
434
+ parsed[entry[1]] = entry[2].map do |score, suggestion|
435
+ { "suggestion" => suggestion, "score" => score }
436
+ end
437
+ end
438
+ parsed
439
+ end
440
+
441
+ # Turn a flat [field, value, ...] array (RESP2) or a native map (RESP3) of returned
442
+ # fields into a hash, JSON-decoding values whose field is flagged in +decode_fields+.
443
+ #
444
+ # @param fields [Array, Hash, nil] the field/value pairs
445
+ # @param decode_fields [Hash] field name => whether to JSON-decode its value
446
+ # @return [Hash{String => Object}] the fields as a hash (empty when +fields+ is nil)
447
+ # Normalize a WITHSCORES value to a Float so Document#score is the same type regardless of
448
+ # protocol (RESP2 returns the score as a bulk string, RESP3 as a native double). With
449
+ # EXPLAINSCORE the RESP2 value is +[score, explanation]+; coerce the score part and keep
450
+ # the explanation. Anything non-numeric is returned untouched.
451
+ def normalize_score(score)
452
+ case score
453
+ when nil then nil
454
+ when Array then [normalize_score(score[0]), *score[1..]]
455
+ else Float(score)
456
+ end
457
+ rescue ArgumentError, TypeError
458
+ score
459
+ end
460
+
461
+ def hashify_fields(fields, decode_fields)
462
+ return {} if fields.nil?
463
+
464
+ # Reply field names are strings, but callers may key decode_fields with symbols
465
+ # (e.g. Query#return_field(:brand)). Normalize so decoding is caller-type-independent.
466
+ decode = decode_fields.transform_keys(&:to_s)
467
+ pairs = fields.is_a?(Hash) ? fields.to_a : fields.each_slice(2).to_a
468
+
469
+ pairs.each_with_object({}) do |(name, value), acc|
470
+ acc[name] = decode[name.to_s] ? decode_value(value) : value
471
+ end
472
+ end
473
+
474
+ # JSON-decode a value, returning it unchanged when it is not a parseable JSON String.
475
+ #
476
+ # @param value [Object] the value to decode
477
+ # @return [Object] the parsed value, or +value+ when it is not a String or fails to parse
478
+ def decode_value(value)
479
+ return value unless value.is_a?(String)
480
+
481
+ ::JSON.parse(value)
482
+ rescue ::JSON::ParserError
483
+ value
484
+ end
485
+ end
486
+ end
487
+ end
488
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Redis
4
+ module Commands
5
+ module Search
6
+ # A Redis Query Engine index schema: an ordered collection of {Field}
7
+ # objects rendered into the +SCHEMA+ section of an +FT.CREATE+ call.
8
+ class Schema
9
+ include Enumerable
10
+
11
+ attr_reader :fields
12
+
13
+ # Build a schema from a list of fields.
14
+ #
15
+ # @param [Array<Field>] fields the fields that make up the schema
16
+ def initialize(fields = [])
17
+ @fields = fields
18
+ end
19
+
20
+ # Find a field by its name/path or its +AS+ alias, so e.g. a JSON field declared as
21
+ # "$.price" AS "price" is found by either "$.price" or "price".
22
+ #
23
+ # @param [String, Symbol] name the field name/path or alias to look up
24
+ # @return [Field, nil] the matching field, or +nil+ if none matches
25
+ def field(name)
26
+ name = name.to_s
27
+ @fields.find { |f| f.name.to_s == name || f.alias_name&.to_s == name }
28
+ end
29
+
30
+ # Iterate over the schema's fields.
31
+ #
32
+ # @yieldparam [Field] field each field in order
33
+ # @return [Enumerator, Array<Field>] the fields when no block is given
34
+ def each(&block)
35
+ @fields.each(&block)
36
+ end
37
+
38
+ # Render the schema as the array of +FT.CREATE+ tokens.
39
+ #
40
+ # @example
41
+ # schema.to_args # => ["SCHEMA", "title", "TEXT", "SORTABLE"]
42
+ #
43
+ # @return [Array] the +SCHEMA+ keyword followed by each field's tokens
44
+ def to_args
45
+ ['SCHEMA'] + @fields.flat_map(&:to_args)
46
+ end
47
+
48
+ # Build a schema using the block DSL evaluated in a {SchemaDefinition}.
49
+ #
50
+ # @example
51
+ # Redis::Commands::Search::Schema.build do
52
+ # text_field "title", weight: 5.0, sortable: true
53
+ # numeric_field "price"
54
+ # end
55
+ #
56
+ # @return [Schema] the schema built from the block
57
+ # @raise [Redis::CommandError] if a field is given invalid options
58
+ def self.build(&block)
59
+ definition = SchemaDefinition.new
60
+ begin
61
+ definition.instance_eval(&block)
62
+ rescue ArgumentError => e
63
+ raise Redis::CommandError, e.message
64
+ end
65
+ new(definition.fields)
66
+ end
67
+ end
68
+
69
+ # The block DSL used by {Schema.build} to declare fields. Each helper
70
+ # appends a {Field} subclass to {#fields}.
71
+ class SchemaDefinition
72
+ attr_reader :fields
73
+
74
+ def initialize
75
+ @fields = []
76
+ end
77
+
78
+ # Add a {TextField} to the schema.
79
+ #
80
+ # @param [String, Symbol] name the document attribute the field indexes
81
+ # @option options [Numeric] :weight the field's scoring weight
82
+ # @option options [Boolean] :sortable allow sorting by the field
83
+ # @option options [Boolean] :no_index do not index the field
84
+ # @option options [String] :as an alias for the field
85
+ # @option options [String] :phonetic phonetic matcher
86
+ # @option options [Boolean] :no_stem disable stemming
87
+ # @option options [Boolean] :index_empty index empty values
88
+ # @option options [Boolean] :withsuffixtrie build a suffix trie
89
+ # @return [Array<Field>] the updated field list
90
+ # @raise [ArgumentError] if an unknown option key is given
91
+ def text_field(name, **options)
92
+ valid_options = %i[weight sortable no_index as phonetic no_stem index_empty index_missing withsuffixtrie]
93
+ invalid_options = options.keys - valid_options
94
+ if invalid_options.any?
95
+ raise ArgumentError, "Invalid options for text field: #{invalid_options.join(', ')}"
96
+ end
97
+
98
+ @fields << TextField.new(name, **options)
99
+ end
100
+
101
+ # Add a {NumericField} to the schema.
102
+ #
103
+ # @param [String, Symbol] name the document attribute the field indexes
104
+ # @return [Array<Field>] the updated field list
105
+ def numeric_field(name, **options)
106
+ @fields << NumericField.new(name, **options)
107
+ end
108
+
109
+ # Add a {TagField} to the schema.
110
+ #
111
+ # @param [String, Symbol] name the document attribute the field indexes
112
+ # @option options [Boolean] :sortable allow sorting by the field
113
+ # @option options [Boolean] :no_index do not index the field
114
+ # @option options [String] :as an alias for the field
115
+ # @option options [String] :separator the tag separator character
116
+ # @option options [Boolean] :case_sensitive keep tag casing
117
+ # @option options [Boolean] :index_empty index empty values
118
+ # @option options [Boolean] :index_missing index documents missing the field
119
+ # @option options [Boolean] :withsuffixtrie build a suffix trie
120
+ # @return [Array<Field>] the updated field list
121
+ # @raise [ArgumentError] if an unknown option key is given
122
+ def tag_field(name, **options)
123
+ valid_options = %i[sortable no_index as separator case_sensitive index_empty index_missing withsuffixtrie]
124
+ invalid_options = options.keys - valid_options
125
+ if invalid_options.any?
126
+ raise ArgumentError, "Invalid options for tag field: #{invalid_options.join(', ')}"
127
+ end
128
+
129
+ @fields << TagField.new(name, **options)
130
+ end
131
+
132
+ # Add a {GeoField} to the schema.
133
+ #
134
+ # @param [String, Symbol] name the document attribute the field indexes
135
+ # @return [Array<Field>] the updated field list
136
+ def geo_field(name, **options)
137
+ @fields << GeoField.new(name, **options)
138
+ end
139
+
140
+ # Add a {GeoShapeField} to the schema.
141
+ #
142
+ # @param [String, Symbol] name the document attribute the field indexes
143
+ # @param [String, nil] coord_system the coordinate system, +FLAT+ or +SPHERICAL+;
144
+ # nil (the default) omits the token so the server default (+SPHERICAL+) applies
145
+ # @return [Array<Field>] the updated field list
146
+ def geoshape_field(name, coord_system = nil, **options)
147
+ @fields << GeoShapeField.new(name, coord_system, **options)
148
+ end
149
+
150
+ # Add a {VectorField} to the schema.
151
+ #
152
+ # Field-level options (+:as+, +:sortable+, +:no_index+) are extracted from
153
+ # +attributes+; the remaining keys (e.g. +type:+, +dim:+, +distance_metric:+)
154
+ # become vector attributes. An optional block is evaluated in a
155
+ # {VectorFieldDefinition} for declaring attributes.
156
+ #
157
+ # @example
158
+ # vector_field "embedding", "HNSW", type: "FLOAT32", dim: 4, distance_metric: "L2"
159
+ #
160
+ # @param [String, Symbol] name the document attribute the field indexes
161
+ # @param [String, Symbol] algorithm the indexing method (+FLAT+, +HNSW+, +SVS-VAMANA+)
162
+ # @param [Hash] attributes vector attributes and field-level options
163
+ # @yield an optional block evaluated in a {VectorFieldDefinition}
164
+ # @return [Array<Field>] the updated field list
165
+ def vector_field(name, algorithm, **attributes, &block)
166
+ # Extract field-level options (as, sortable, no_index) from attributes
167
+ field_options = {}
168
+ field_options[:as] = attributes.delete(:as) if attributes.key?(:as)
169
+ field_options[:sortable] = attributes.delete(:sortable) if attributes.key?(:sortable)
170
+ field_options[:no_index] = attributes.delete(:no_index) if attributes.key?(:no_index)
171
+
172
+ field = VectorField.new(name, algorithm, attributes, **field_options)
173
+ VectorFieldDefinition.new(field).instance_eval(&block) if block_given?
174
+ @fields << field
175
+ end
176
+ end
177
+
178
+ # The block DSL used by {SchemaDefinition#vector_field} to declare the
179
+ # attributes of a {VectorField}.
180
+ class VectorFieldDefinition
181
+ def initialize(field)
182
+ @field = field
183
+ end
184
+
185
+ # Set the vector element type attribute (e.g. +FLOAT32+).
186
+ #
187
+ # @param [Object] value the +TYPE+ attribute value
188
+ # @return [Object] the stored value
189
+ def type(value)
190
+ @field.add_attribute(:type, value)
191
+ end
192
+
193
+ # Set the vector dimensionality attribute.
194
+ #
195
+ # @param [Object] value the +DIM+ attribute value
196
+ # @return [Object] the stored value
197
+ def dim(value)
198
+ @field.add_attribute(:dim, value)
199
+ end
200
+
201
+ # Set the distance metric attribute (e.g. +L2+, +COSINE+).
202
+ #
203
+ # @param [Object] value the +DISTANCE_METRIC+ attribute value
204
+ # @return [Object] the stored value
205
+ def distance_metric(value)
206
+ @field.add_attribute(:distance_metric, value)
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end