search-engine-for-typesense 1.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.
Files changed (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,582 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module DSL
5
+ # Safe parser that converts Relation#where inputs into validated AST nodes.
6
+ #
7
+ # Supported inputs:
8
+ # - Hash: { field => value } (scalar => Eq, Array => In)
9
+ # - Raw String: full Typesense fragment (escape hatch) => Raw
10
+ # - Fragment + args: ["price > ?", 100] or ["brand_id IN ?", [1,2,3]]
11
+ # - Joined Hash: { assoc => { field => value } } => LHS "$assoc.field"
12
+ #
13
+ # Validation/coercion:
14
+ # - Field names validated against model attributes (when available)
15
+ # - Booleans coerced from "true"/"false" strings when attribute type is boolean
16
+ # - Date/DateTime coerced to Time.utc; Arrays flattened one level and compacted
17
+ # - When using joined fields, association names are validated via klass.join_for
18
+ # and (optionally) required to be present in relation joins when +joins+ is provided.
19
+ module Parser
20
+ module_function
21
+
22
+ # Accepted hints that should be treated as time-like for coercion purposes.
23
+ TIME_LIKE_HINTS = [
24
+ :time, 'time', :datetime, 'datetime',
25
+ :time_string, 'time_string', :datetime_string, 'datetime_string',
26
+ Time, Date, DateTime
27
+ ].freeze
28
+
29
+ # Parse a where input into AST nodes.
30
+ #
31
+ # Return convention:
32
+ # - Single predicate => a single AST node
33
+ # - Hash with multiple keys or list-of-inputs => Array<AST::Node>
34
+ #
35
+ # @param input [Hash, String, Array]
36
+ # @param args [Array] optional, used only when +input+ is a template String
37
+ # @param klass [Class] SearchEngine::Base subclass used for attribute validation
38
+ # @param joins [Array<Symbol>, nil] Optional list of applied join names on the relation
39
+ # @return [SearchEngine::AST::Node, Array<SearchEngine::AST::Node>]
40
+ # @raise [SearchEngine::Errors::InvalidField, SearchEngine::Errors::InvalidOperator, SearchEngine::Errors::InvalidType]
41
+ def parse(input, klass:, args: [], joins: nil)
42
+ case input
43
+ when Hash
44
+ parse_hash(input, klass: klass, joins: joins)
45
+ when String
46
+ if placeholders?(input)
47
+ needed = count_placeholders(input)
48
+ ensure_placeholder_arity!(needed, args.length, input)
49
+ parse_template(input, args, klass: klass)
50
+ else
51
+ parse_raw(input)
52
+ end
53
+ when Array
54
+ parse_array_input(input, klass: klass, joins: joins)
55
+ when Symbol
56
+ # Back-compat: treat symbol as raw fragment name
57
+ parse_raw(input.to_s)
58
+ else
59
+ raise ArgumentError, "Parser: unsupported input #{input.class}"
60
+ end
61
+ end
62
+
63
+ # Parse a list of heterogenous where arguments (as passed to Relation#where).
64
+ # @param list [Array]
65
+ # @param klass [Class]
66
+ # @param joins [Array<Symbol>, nil]
67
+ # @return [Array<SearchEngine::AST::Node>]
68
+ def parse_list(list, klass:, joins: nil)
69
+ items = Array(list).flatten.compact
70
+ return [] if items.empty?
71
+
72
+ nodes = []
73
+ i = 0
74
+ while i < items.length
75
+ entry = items[i]
76
+ case entry
77
+ when Hash
78
+ nodes.concat(Array(parse_hash(entry, klass: klass, joins: joins)))
79
+ i += 1
80
+ when String
81
+ if placeholders?(entry)
82
+ needed = count_placeholders(entry)
83
+ args_for_template = items[(i + 1)..(i + needed)] || []
84
+ ensure_placeholder_arity!(needed, args_for_template.length, entry)
85
+ nodes << parse_template(entry, args_for_template, klass: klass)
86
+ i += 1 + needed
87
+ else
88
+ nodes << parse_raw(entry)
89
+ i += 1
90
+ end
91
+ when Symbol
92
+ nodes << parse_raw(entry.to_s)
93
+ i += 1
94
+ when Array
95
+ nodes << parse_array_entry(entry, klass: klass, joins: joins)
96
+ i += 1
97
+ else
98
+ raise ArgumentError, "Parser: unsupported where argument #{entry.class}"
99
+ end
100
+ end
101
+ nodes
102
+ end
103
+
104
+ # --- Internals -------------------------------------------------------
105
+
106
+ def parse_array_entry(entry, klass:, joins: nil)
107
+ return parse_raw(entry.to_s) unless entry.first.is_a?(String)
108
+ return parse_list(entry, klass: klass, joins: joins) unless placeholders?(entry.first)
109
+
110
+ template = entry.first
111
+ args_list = if entry.length == 2 && entry[1].is_a?(Array)
112
+ [entry[1]]
113
+ else
114
+ entry[1..]
115
+ end
116
+ needed = count_placeholders(template)
117
+ ensure_placeholder_arity!(needed, Array(args_list).length, template)
118
+ parse_template(template, Array(args_list), klass: klass)
119
+ end
120
+
121
+ def parse_hash(hash, klass:, joins: nil)
122
+ raise ArgumentError, 'Parser: hash input must be a Hash' unless hash.is_a?(Hash)
123
+
124
+ attrs = safe_attributes_map(klass)
125
+ validate_hash_keys!(hash, attrs, klass)
126
+
127
+ pairs = []
128
+
129
+ hash.each do |k, v|
130
+ key_sym = k.to_sym
131
+ value = v
132
+
133
+ if value.is_a?(Hash)
134
+ # assoc => { field => value }
135
+ validate_assoc_and_join!(klass, key_sym, joins)
136
+
137
+ value.each do |inner_field, inner_value|
138
+ field_sym = inner_field.to_sym
139
+ path = "$#{key_sym}.#{field_sym}"
140
+ SearchEngine::Joins::Guard.ensure_single_hop_path!(path)
141
+
142
+ # Resolve target klass for joined-field type coercion and validate field best-effort
143
+ cfg = klass.join_for(key_sym)
144
+ target_klass = begin
145
+ SearchEngine.collection_for(cfg[:collection])
146
+ rescue StandardError
147
+ nil
148
+ end
149
+ begin
150
+ SearchEngine::Joins::Guard.validate_joined_field!(cfg, field_sym)
151
+ rescue StandardError
152
+ # skip strictly when registry/attributes missing or join missing
153
+ end
154
+
155
+ coercion_klass = target_klass || klass
156
+ if array_like?(inner_value)
157
+ values = normalize_array_values(inner_value, field: field_sym, klass: coercion_klass)
158
+ ensure_non_empty_values!(values, field: field_sym, klass: coercion_klass)
159
+ pairs << SearchEngine::AST.in_(path, values)
160
+ else
161
+ coerced = coerce_value_for_field(inner_value, field: field_sym, klass: coercion_klass)
162
+ pairs << SearchEngine::AST.eq(path, coerced)
163
+ end
164
+ end
165
+ else
166
+ field = key_sym
167
+
168
+ if array_like?(value)
169
+ values = normalize_array_values(value, field: field, klass: klass)
170
+ ensure_non_empty_values!(values, field: field, klass: klass)
171
+ pairs << SearchEngine::AST.in_(field, values)
172
+ else
173
+ coerced = coerce_value_for_field(value, field: field, klass: klass)
174
+ pairs << SearchEngine::AST.eq(field, coerced)
175
+ end
176
+ end
177
+ end
178
+
179
+ return pairs.first if pairs.length == 1
180
+
181
+ pairs
182
+ end
183
+
184
+ def parse_template(template, args, klass:)
185
+ raise ArgumentError, 'Parser: template must be a String' unless template.is_a?(String)
186
+
187
+ args = Array(args)
188
+ m = template.match(/\A\s*([A-Za-z_][A-Za-z0-9_]*)\s*(=|!=|>=|<=|>|<|IN|NOT\s+IN|MATCHES|PREFIX)\s*\?\s*\z/i)
189
+ unless m
190
+ raise SearchEngine::Errors::InvalidOperator.new(
191
+ "invalid template '#{template}'. Supported: =, !=, >, >=, <, <=, IN, NOT IN, MATCHES, PREFIX",
192
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
193
+ details: { template: template }
194
+ )
195
+ end
196
+
197
+ field_raw = m[1]
198
+ op = m[2].upcase.gsub(/\s+/, ' ')
199
+
200
+ field_sym = field_raw.to_sym
201
+ attrs = safe_attributes_map(klass)
202
+ validate_field!(field_sym, attrs, klass)
203
+
204
+ case op
205
+ when '='
206
+ SearchEngine::AST.eq(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
207
+ when '!='
208
+ SearchEngine::AST.not_eq(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
209
+ when '>'
210
+ SearchEngine::AST.gt(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
211
+ when '>='
212
+ SearchEngine::AST.gte(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
213
+ when '<'
214
+ SearchEngine::AST.lt(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
215
+ when '<='
216
+ SearchEngine::AST.lte(field_sym, coerce_value_for_field(args.first, field: field_sym, klass: klass))
217
+ when 'IN'
218
+ values = normalize_array_values(args.first, field: field_sym, klass: klass)
219
+ ensure_non_empty_values!(values, field: field_sym, klass: klass)
220
+ SearchEngine::AST.in_(field_sym, values)
221
+ when 'NOT IN'
222
+ values = normalize_array_values(args.first, field: field_sym, klass: klass)
223
+ ensure_non_empty_values!(values, field: field_sym, klass: klass)
224
+ SearchEngine::AST.not_in(field_sym, values)
225
+ when 'MATCHES'
226
+ SearchEngine::AST.matches(field_sym, args.first)
227
+ when 'PREFIX'
228
+ SearchEngine::AST.prefix(field_sym, String(args.first))
229
+ else
230
+ raise SearchEngine::Errors::InvalidOperator, "unsupported operator '#{op}'"
231
+ end
232
+ end
233
+
234
+ def parse_raw(fragment)
235
+ SearchEngine::AST.raw(String(fragment))
236
+ end
237
+
238
+ # Heuristic: treat an Array input as a template+args when it starts with a
239
+ # String that has placeholders; otherwise treat as list-of-inputs.
240
+ def parse_array_input(arr, klass:, joins: nil)
241
+ return [] if arr.nil?
242
+
243
+ arr = Array(arr)
244
+
245
+ if arr.first.is_a?(String) && placeholders?(arr.first) && arr.length >= 2
246
+ template = arr.first
247
+ args_list = if arr.length == 2 && arr[1].is_a?(Array)
248
+ [arr[1]]
249
+ else
250
+ arr[1..]
251
+ end
252
+ needed = count_placeholders(template)
253
+ ensure_placeholder_arity!(needed, Array(args_list).length, template)
254
+ parse_template(template, Array(args_list), klass: klass)
255
+ else
256
+ parse_list(arr, klass: klass, joins: joins)
257
+ end
258
+ end
259
+
260
+ # --- Utilities -------------------------------------------------------
261
+
262
+ def placeholders?(str)
263
+ str.is_a?(String) && SearchEngine::Filters::Sanitizer.count_placeholders(str).positive?
264
+ end
265
+
266
+ def count_placeholders(str)
267
+ SearchEngine::Filters::Sanitizer.count_placeholders(str)
268
+ end
269
+
270
+ def ensure_placeholder_arity!(needed, provided, template)
271
+ return if needed == provided
272
+
273
+ raise SearchEngine::Errors::InvalidOperator.new(
274
+ "expected #{needed} args for #{needed} placeholders in template '#{template}', got #{provided}.",
275
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
276
+ details: { needed: needed, provided: provided, template: template }
277
+ )
278
+ end
279
+
280
+ def safe_attributes_map(klass)
281
+ if klass.respond_to?(:attributes)
282
+ base = klass.attributes || {}
283
+ return base if base.key?(:id)
284
+
285
+ # Infer implicit id type:
286
+ # - When identify_by is declared as :id, keep integer to preserve numeric coercions in tests
287
+ # - Otherwise, default to :string (composed or custom ids)
288
+ if klass.instance_variable_defined?(:@__identify_by_kind__) &&
289
+ klass.instance_variable_get(:@__identify_by_kind__) == :symbol &&
290
+ klass.instance_variable_defined?(:@__identify_by_symbol__) &&
291
+ klass.instance_variable_get(:@__identify_by_symbol__) == :id
292
+ base.merge(id: :integer)
293
+ else
294
+ base.merge(id: :string)
295
+ end
296
+ else
297
+ { id: :string }
298
+ end
299
+ end
300
+
301
+ def validate_hash_keys!(hash, attributes_map, klass)
302
+ return if hash.nil? || hash.empty?
303
+
304
+ known = attributes_map.keys.map(&:to_sym)
305
+ # Exclude keys whose values are Hash (treated as assoc => { ... })
306
+ candidate_keys = hash.reject { |_, v| v.is_a?(Hash) }.keys
307
+ unknown = candidate_keys.map(&:to_sym) - known
308
+ return if unknown.empty?
309
+
310
+ return unless strict_fields?
311
+
312
+ raise build_invalid_field_error(unknown.first, known, klass)
313
+ end
314
+
315
+ def validate_field!(field, attributes_map, klass)
316
+ return if attributes_map.nil? || attributes_map.empty?
317
+ return unless strict_fields?
318
+
319
+ sym = field.to_sym
320
+ return if attributes_map.key?(sym)
321
+
322
+ known = attributes_map.keys.map(&:to_sym)
323
+ raise build_invalid_field_error(sym, known, klass)
324
+ end
325
+
326
+ def array_like?(value)
327
+ value.is_a?(Array)
328
+ end
329
+
330
+ def normalize_array_values(value, field:, klass:)
331
+ arr = Array(value).flatten(1).compact
332
+ arr.map { |v| coerce_value_for_field(v, field: field, klass: klass) }
333
+ end
334
+
335
+ def ensure_non_empty_values!(values, field:, klass:)
336
+ return if values.is_a?(Array) && !values.empty?
337
+
338
+ raise SearchEngine::Errors::InvalidType.new(
339
+ invalid_type_message(field: field, klass: klass, expectation: 'a non-empty Array', got: values),
340
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
341
+ details: { field: field, got_class: values.class.name }
342
+ )
343
+ end
344
+
345
+ def coerce_value_for_field(value, field:, klass:)
346
+ # Unwrap single-element array types to their inner hint (e.g., [:string] -> :string)
347
+ type_hint = begin
348
+ t = safe_attributes_map(klass)[field.to_sym]
349
+ t.is_a?(Array) && t.size == 1 ? t.first : t
350
+ rescue StandardError
351
+ nil
352
+ end
353
+ coerced = coerce_value(value, type_hint: type_hint, field: field, klass: klass)
354
+ return coerced unless coerced.equal?(:__no_coercion__)
355
+
356
+ # If generic coercion returned :__no_coercion__, try to coerce based on type hint
357
+ case type_hint
358
+ when :boolean
359
+ coerce_boolean(value, type_hint)
360
+ when :time, :datetime, :time_string, :datetime_string
361
+ coerce_time(value, type_hint, field: field, klass: klass)
362
+
363
+ when :integer
364
+ coerce_numeric(value, type_hint, field: field, klass: klass)
365
+ when :float, :decimal
366
+ coerce_numeric(value, type_hint, field: field, klass: klass)
367
+ when :string
368
+ # Ensure string-typed fields compare as strings (e.g., 1070 -> "1070")
369
+ value.to_s
370
+ else
371
+ value # No specific coercion for this type hint
372
+ end
373
+ end
374
+
375
+ def coerce_value(value, type_hint: nil, field: nil, klass: nil)
376
+ coerced_bool = coerce_boolean(value, type_hint)
377
+ return coerced_bool unless coerced_bool.equal?(:__no_coercion__)
378
+
379
+ coerced_time = coerce_time(value, type_hint, field: field, klass: klass)
380
+ return coerced_time unless coerced_time.equal?(:__no_coercion__)
381
+
382
+ coerced_number = coerce_numeric(value, type_hint, field: field, klass: klass)
383
+ return coerced_number unless coerced_number.equal?(:__no_coercion__)
384
+
385
+ value
386
+ end
387
+
388
+ def coerce_boolean(value, type_hint)
389
+ return :__no_coercion__ unless type_boolean?(type_hint) && value.is_a?(String)
390
+
391
+ lc = value.strip.downcase
392
+ return true if lc == 'true'
393
+ return false if lc == 'false'
394
+
395
+ raise SearchEngine::Errors::InvalidType.new(
396
+ invalid_type_message(field: nil, klass: nil, expectation: 'boolean', got: value),
397
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
398
+ details: { got: value }
399
+ )
400
+ end
401
+ private_class_method :coerce_boolean
402
+
403
+ def coerce_time(value, type_hint, field:, klass:)
404
+ # Direct time-like Ruby objects
405
+ return value.to_time.utc if value.is_a?(DateTime) || value.is_a?(Date)
406
+ return (value.utc? ? value : value.utc) if value.is_a?(Time)
407
+
408
+ # Only attempt further coercion when hint is time-like
409
+ return :__no_coercion__ unless type_time?(type_hint)
410
+
411
+ case value
412
+ when Integer
413
+ Time.at(value).utc
414
+ when Numeric
415
+ Time.at(value.to_i).utc
416
+ when String
417
+ if value.match?(/^\d+$/)
418
+ Time.at(Integer(value, 10)).utc
419
+ else
420
+ require 'time'
421
+ Time.parse(value).utc
422
+ end
423
+ else
424
+ :__no_coercion__
425
+ end
426
+ rescue StandardError
427
+ raise SearchEngine::Errors::InvalidType.new(
428
+ invalid_type_message(field: field, klass: klass, expectation: 'time', got: value),
429
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
430
+ details: { field: field, got: value }
431
+ )
432
+ end
433
+ private_class_method :coerce_time
434
+
435
+ def coerce_numeric(value, type_hint, field:, klass:)
436
+ if type_integer?(type_hint)
437
+ return value if value.is_a?(Integer)
438
+
439
+ if value.is_a?(String)
440
+ begin
441
+ int_val = Integer(value, 10)
442
+ return int_val
443
+ rescue StandardError
444
+ raise SearchEngine::Errors::InvalidType.new(
445
+ invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
446
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
447
+ details: { field: field, got: value }
448
+ )
449
+ end
450
+ end
451
+
452
+ if value.is_a?(Numeric)
453
+ raise SearchEngine::Errors::InvalidType.new(
454
+ invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
455
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
456
+ details: { field: field, got: value }
457
+ )
458
+ end
459
+
460
+ return :__no_coercion__
461
+ end
462
+
463
+ if type_float?(type_hint)
464
+ return value if value.is_a?(Integer)
465
+ return value.to_f if value.is_a?(Numeric)
466
+
467
+ if value.is_a?(String)
468
+ begin
469
+ Float(value)
470
+ rescue StandardError
471
+ raise SearchEngine::Errors::InvalidType.new(
472
+ invalid_type_message(field: field, klass: klass, expectation: 'numeric', got: value),
473
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/query-dsl#troubleshooting',
474
+ details: { field: field, got: value }
475
+ )
476
+ end
477
+ return value.to_f
478
+ end
479
+ end
480
+
481
+ :__no_coercion__
482
+ end
483
+ private_class_method :coerce_numeric
484
+
485
+ def type_boolean?(hint)
486
+ case hint
487
+ when :boolean, 'boolean', TrueClass, FalseClass then true
488
+ else false
489
+ end
490
+ end
491
+
492
+ def type_integer?(hint)
493
+ case hint
494
+ when :integer, 'integer', Integer then true
495
+ else false
496
+ end
497
+ end
498
+
499
+ def type_float?(hint)
500
+ case hint
501
+ when :float, 'float', :decimal, 'decimal', Float then true
502
+ else false
503
+ end
504
+ end
505
+
506
+ def type_time?(hint)
507
+ TIME_LIKE_HINTS.include?(hint)
508
+ end
509
+
510
+ def strict_fields?
511
+ begin
512
+ cfg = SearchEngine.config
513
+ val = cfg.respond_to?(:strict_fields) ? cfg.strict_fields : nil
514
+ return !!val unless val.nil?
515
+ rescue StandardError
516
+ # default below
517
+ end
518
+ true
519
+ end
520
+
521
+ def build_invalid_field_error(field, known, klass)
522
+ klass_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
523
+ suggestion = did_you_mean(field, known)
524
+ msg = "unknown field #{field.inspect} for #{klass_name}"
525
+ msg += " (did you mean #{suggestion.inspect}?)" if suggestion
526
+ SearchEngine::Errors::InvalidField.new(msg)
527
+ end
528
+
529
+ def did_you_mean(field, known)
530
+ return nil if known.nil? || known.empty?
531
+
532
+ begin
533
+ require 'did_you_mean'
534
+ require 'did_you_mean/levenshtein'
535
+ rescue StandardError
536
+ return nil
537
+ end
538
+
539
+ candidates = known.map(&:to_s)
540
+ input = field.to_s
541
+
542
+ # Compute minimal Levenshtein distance deterministically
543
+ distances = candidates.each_with_object({}) do |cand, acc|
544
+ acc[cand] = DidYouMean::Levenshtein.distance(input, cand)
545
+ end
546
+ min = distances.values.min
547
+ return nil if min.nil? || min > 2
548
+
549
+ best = distances.select { |_, d| d == min }.keys
550
+ return nil unless best.length == 1
551
+
552
+ best.first.to_sym
553
+ end
554
+
555
+ def invalid_type_message(field:, klass:, expectation:, got:)
556
+ klass_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
557
+ %(expected #{field.inspect} to be #{expectation} for #{klass_name} (got #{got.class}: #{got.inspect}))
558
+ end
559
+
560
+ def validate_assoc_and_join!(klass, assoc_name, joins)
561
+ # Validate association exists and config completeness
562
+ begin
563
+ SearchEngine::Joins::Guard.ensure_config_complete!(klass, assoc_name)
564
+ rescue SearchEngine::Errors::InvalidJoin
565
+ # Provide high-level guidance with suggestions if available
566
+ model_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
567
+ msg = "association :#{assoc_name} is not declared on #{model_name} (declare with `join :#{assoc_name}, ...`)"
568
+ raise SearchEngine::Errors::InvalidJoin, msg
569
+ end
570
+
571
+ # When enforcing applied joins, ensure relation has the association
572
+ return if joins.nil? || Array(joins).include?(assoc_name)
573
+
574
+ raise SearchEngine::Errors::JoinNotApplied.new(
575
+ "Call .joins(:#{assoc_name}) before filtering/sorting on #{assoc_name} fields",
576
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#troubleshooting',
577
+ details: { assoc: assoc_name, used_for: 'filtering' }
578
+ )
579
+ end
580
+ end
581
+ end
582
+ end