search-engine-for-typesense 30.9.2 → 30.9.3

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +47 -13
  4. data/lib/search_engine/active_record_syncable.rb +85 -197
  5. data/lib/search_engine/ast/node.rb +3 -6
  6. data/lib/search_engine/async_partition_coordinator.rb +5 -3
  7. data/lib/search_engine/client/services/search.rb +2 -0
  8. data/lib/search_engine/client.rb +9 -3
  9. data/lib/search_engine/compiler.rb +33 -128
  10. data/lib/search_engine/deletion.rb +12 -9
  11. data/lib/search_engine/dsl/parser.rb +51 -71
  12. data/lib/search_engine/engine.rb +25 -16
  13. data/lib/search_engine/filters/sanitizer.rb +162 -126
  14. data/lib/search_engine/hydration/materializers.rb +53 -118
  15. data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
  16. data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
  17. data/lib/search_engine/indexing_run_store.rb +10 -0
  18. data/lib/search_engine/joins/guard.rb +14 -8
  19. data/lib/search_engine/multi.rb +92 -6
  20. data/lib/search_engine/multi_result.rb +40 -23
  21. data/lib/search_engine/notifications/compact_logger.rb +2 -2
  22. data/lib/search_engine/observability.rb +6 -6
  23. data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
  24. data/lib/search_engine/postgres_outbox/event.rb +12 -0
  25. data/lib/search_engine/postgres_outbox/repository.rb +48 -102
  26. data/lib/search_engine/postgres_outbox.rb +1 -0
  27. data/lib/search_engine/registry.rb +27 -28
  28. data/lib/search_engine/relation/compiler.rb +18 -33
  29. data/lib/search_engine/relation/dsl/filters.rb +71 -175
  30. data/lib/search_engine/relation/dx.rb +3 -1
  31. data/lib/search_engine/relation/materializers.rb +6 -3
  32. data/lib/search_engine/relation/options.rb +5 -26
  33. data/lib/search_engine/relation/state.rb +3 -2
  34. data/lib/search_engine/relation.rb +5 -4
  35. data/lib/search_engine/result.rb +33 -2
  36. data/lib/search_engine/schema.rb +23 -24
  37. data/lib/search_engine/test/offline_client.rb +2 -2
  38. data/lib/search_engine/test/stub_client.rb +9 -16
  39. data/lib/search_engine/update.rb +1 -1
  40. data/lib/search_engine/version.rb +1 -1
  41. data/lib/search_engine.rb +11 -149
  42. data/lib/tasks/search_engine.rake +3 -5
  43. metadata +17 -3
  44. /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
@@ -70,26 +70,12 @@ module SearchEngine
70
70
 
71
71
  def compile_node(node, parent_prec:, klass: nil)
72
72
  case node
73
- when SearchEngine::AST::Eq
74
- compile_binary(node.field, ':=', node.value, klass: klass)
75
- when SearchEngine::AST::NotEq
76
- compile_binary(node.field, ':!=', node.value, klass: klass)
77
- when SearchEngine::AST::Gt
78
- compile_binary(node.field, ':>', node.value, klass: klass)
79
- when SearchEngine::AST::Gte
80
- compile_binary(node.field, ':>=', node.value, klass: klass)
81
- when SearchEngine::AST::Lt
82
- compile_binary(node.field, ':<', node.value, klass: klass)
83
- when SearchEngine::AST::Lte
84
- compile_binary(node.field, ':<=', node.value, klass: klass)
85
- when SearchEngine::AST::In
86
- compile_binary(node.field, ':=', node.values, klass: klass)
87
- when SearchEngine::AST::NotIn
88
- compile_binary(node.field, ':!=', node.values, klass: klass)
73
+ when SearchEngine::AST::BinaryOp
74
+ compile_binary(node, klass: klass)
89
75
  when SearchEngine::AST::And
90
76
  compile_boolean(node.children, ' && ', parent_prec: parent_prec, my_prec: precedence(:and), klass: klass)
91
77
  when SearchEngine::AST::Or
92
- compile_or(node, parent_prec, klass: klass)
78
+ compile_boolean(node.children, ' || ', parent_prec: parent_prec, my_prec: precedence(:or), klass: klass)
93
79
  when SearchEngine::AST::Group
94
80
  "(#{compile_node(node.children.first, parent_prec: 0, klass: klass)})"
95
81
  when SearchEngine::AST::Raw
@@ -104,61 +90,24 @@ module SearchEngine
104
90
  end
105
91
  private_class_method :compile_node
106
92
 
107
- def compile_or(node, parent_prec, klass: nil)
108
- compiled = compile_boolean(
109
- node.children,
110
- ' || ',
111
- parent_prec: parent_prec,
112
- my_prec: precedence(:or),
113
- klass: klass
114
- )
115
- if node.children.length == 2 && node.children.last.is_a?(SearchEngine::AST::And)
116
- left_str, right_str = compiled.split(' || ', 2)
117
- compiled = "#{left_str} || (#{right_str})"
93
+ def compile_binary(node, klass:)
94
+ if (joined = extract_join_inner_binary(node, klass: klass))
95
+ assoc, inner = joined
96
+ return "$#{assoc}(#{inner})"
118
97
  end
119
- compiled
120
- end
121
- private_class_method :compile_or
122
-
123
- def compile_binary(field, op, value, klass: nil)
124
- fstr = field.to_s
125
- if (m = fstr.match(/^\$(\w+)\.(.+)$/))
126
- assoc = m[1]
127
- inner = m[2]
128
- # Render joined field as $assoc(inner OP value) per expected Typesense join filter syntax
129
- target_klass = begin
130
- cfg = klass.respond_to?(:join_for) ? klass.join_for(assoc.to_sym) : nil
131
- coll = cfg ? cfg[:collection] : nil
132
- coll ? SearchEngine.collection_for(coll) : nil
133
- rescue StandardError
134
- nil
135
- end
136
- rhs = quote(value, field: inner, klass: target_klass || klass)
137
- "$#{assoc}(#{inner}#{op}#{rhs})"
138
- else
139
- rhs = quote(value, field: fstr, klass: klass)
140
- binary(field, op, rhs)
141
- end
142
- end
143
- private_class_method :compile_binary
144
98
 
145
- def binary(field, op, rhs)
146
- "#{field}#{op}#{rhs}"
99
+ "#{node.field}#{op_for(node)}#{quote(node.right, field: node.field, klass: klass)}"
147
100
  end
148
- private_class_method :binary
101
+ private_class_method :compile_binary
149
102
 
150
103
  def compile_boolean(children, joiner, parent_prec:, my_prec:, klass: nil)
151
104
  # For conjunctions only, merge multiple $assoc(inner ...) predicates targeting
152
105
  # the same association into a single $assoc(inner && inner ...) expression
153
106
  # to satisfy Typesense join filter rules.
154
107
  if joiner == ' && '
155
- merged = merge_join_predicates(children, parent_prec: parent_prec, my_prec: my_prec, klass: klass,
156
- joiner: joiner
157
- )
158
- return merged if merged
108
+ return merge_join_predicates(children, parent_prec: parent_prec, my_prec: my_prec, klass: klass)
159
109
  end
160
110
 
161
- # Fallback/default behavior
162
111
  parts = children.map do |child|
163
112
  cstr = compile_node(child, parent_prec: my_prec, klass: klass)
164
113
  if needs_parentheses?(child, parent_prec: my_prec)
@@ -188,40 +137,26 @@ joiner: joiner
188
137
  # @param parent_prec [Integer] parent operator precedence
189
138
  # @param my_prec [Integer] current operator precedence
190
139
  # @param klass [Class, nil] model class for context
191
- # @param joiner [String] operator joiner (' && ' or ' || ')
192
- # @return [String, nil] merged expression string, or nil if no joins to merge
193
- def merge_join_predicates(children, parent_prec:, my_prec:, klass:, joiner:)
194
- items = [] # [{ pos:, str: }]
195
- assoc_first_pos = {}
196
- assoc_inner_map = {} # { assoc => [inner_str, ...] }
197
-
198
- children.each_with_index do |child, idx|
199
- inner = extract_join_inner_binary(child, klass: klass)
200
- if inner
201
- assoc, inner_expr = inner
202
- assoc_first_pos[assoc] = idx unless assoc_first_pos.key?(assoc)
203
- assoc_inner_map[assoc] ||= []
204
- assoc_inner_map[assoc] << inner_expr
140
+ # @return [String] compiled conjunction
141
+ def merge_join_predicates(children, parent_prec:, my_prec:, klass:)
142
+ groups = {}
143
+ parts = children.map.with_index do |child, position|
144
+ if (joined = extract_join_inner_binary(child, klass: klass))
145
+ assoc, predicate = joined
146
+ group = groups[assoc] ||= { position: position, predicates: [] }
147
+ group[:predicates] << predicate
205
148
  next
206
149
  end
207
150
 
208
- cstr = compile_node(child, parent_prec: my_prec, klass: klass)
209
- cstr = "(#{cstr})" if needs_parentheses?(child, parent_prec: my_prec)
210
- items << { pos: idx, str: cstr }
151
+ expression = compile_node(child, parent_prec: my_prec, klass: klass)
152
+ needs_parentheses?(child, parent_prec: my_prec) ? "(#{expression})" : expression
211
153
  end
212
154
 
213
- return nil if assoc_inner_map.empty?
214
-
215
- # Emit one consolidated token per assoc at the first position it appeared
216
- assoc_first_pos.sort_by { |_a, pos| pos }.each do |assoc, pos|
217
- inners = Array(assoc_inner_map[assoc]).flatten.compact
218
- next if inners.empty?
219
-
220
- token = "$#{assoc}(#{inners.join(' && ')})"
221
- items << { pos: pos, str: token }
155
+ groups.each do |assoc, group|
156
+ parts[group[:position]] = "$#{assoc}(#{group[:predicates].join(' && ')})"
222
157
  end
223
158
 
224
- inner = items.sort_by { |it| it[:pos] }.map { |it| it[:str] }.join(joiner)
159
+ inner = parts.compact.join(' && ')
225
160
  parent_prec > my_prec ? "(#{inner})" : inner
226
161
  end
227
162
  private_class_method :merge_join_predicates
@@ -229,37 +164,12 @@ joiner: joiner
229
164
  # Try to extract join association and compiled inner expression for a binary node
230
165
  # with a joined field like "$assoc.field". Returns [assoc(String), inner(String)] or nil.
231
166
  def extract_join_inner_binary(node, klass: nil)
232
- case node
233
- when SearchEngine::AST::Eq, SearchEngine::AST::NotEq,
234
- SearchEngine::AST::Gt, SearchEngine::AST::Gte,
235
- SearchEngine::AST::Lt, SearchEngine::AST::Lte,
236
- SearchEngine::AST::In, SearchEngine::AST::NotIn
237
- field = node.respond_to?(:field) ? node.field.to_s : nil
238
- return nil unless field&.start_with?('$')
239
-
240
- m = field.match(/^\$(\w+)\.(.+)$/)
241
- return nil unless m
242
-
243
- assoc = m[1]
244
- inner_field = m[2]
245
- op = op_for(node)
246
- target_klass = begin
247
- cfg = klass.respond_to?(:join_for) ? klass.join_for(assoc.to_sym) : nil
248
- coll = cfg ? cfg[:collection] : nil
249
- coll ? SearchEngine.collection_for(coll) : nil
250
- rescue StandardError
251
- nil
252
- end
253
- rhs = if node.respond_to?(:value)
254
- quote(node.value, field: inner_field, klass: target_klass || klass)
255
- elsif node.respond_to?(:values)
256
- quote(node.values, field: inner_field, klass: target_klass || klass)
257
- else
258
- return nil
259
- end
260
-
261
- [assoc, "#{inner_field}#{op}#{rhs}"]
262
- end
167
+ return unless node.is_a?(SearchEngine::AST::BinaryOp) && node.field.start_with?('$')
168
+
169
+ assoc, field = node.field.delete_prefix('$').split('.', 2)
170
+ target = SearchEngine::Joins::Guard.target_model(klass, assoc)
171
+ rhs = quote(node.right, field: field, klass: target, joined: true)
172
+ [assoc, "#{field}#{op_for(node)}#{rhs}"]
263
173
  end
264
174
  private_class_method :extract_join_inner_binary
265
175
 
@@ -274,12 +184,12 @@ joiner: joiner
274
184
  when SearchEngine::AST::In then ':='
275
185
  when SearchEngine::AST::NotIn then ':!='
276
186
  else
277
- raise Error, "Unknown binary node for join extraction: #{node.class}"
187
+ raise Error, "Compiler: unsupported binary predicate #{node.class}"
278
188
  end
279
189
  end
280
190
  private_class_method :op_for
281
191
 
282
- def quote(value, field: nil, klass: nil)
192
+ def quote(value, field:, klass:, joined: false)
283
193
  # Typesense requires reference fields to use string values, even if the field type is int64
284
194
  converted_value = if klass && field && field_reference?(klass, field)
285
195
  convert_for_reference_field(value)
@@ -287,12 +197,7 @@ joiner: joiner
287
197
  value
288
198
  end
289
199
 
290
- # Use conditional scalar quoting for scalars; preserve array element quoting rules
291
- if converted_value.is_a?(Array)
292
- SearchEngine::Filters::Sanitizer.quote(converted_value)
293
- else
294
- SearchEngine::Filters::Sanitizer.quote_scalar_for_filter(converted_value)
295
- end
200
+ SearchEngine::Filters::Sanitizer.quote(converted_value, field: field, klass: klass, joined: joined)
296
201
  end
297
202
  private_class_method :quote
298
203
 
@@ -333,7 +238,7 @@ joiner: joiner
333
238
  def needs_parentheses?(child, parent_prec:)
334
239
  child_prec = precedence(child)
335
240
  # Parenthesize when child binds looser than parent
336
- child_prec < parent_prec
241
+ child_prec < parent_prec || (child.is_a?(SearchEngine::AST::And) && parent_prec == precedence(:or))
337
242
  end
338
243
  private_class_method :needs_parentheses?
339
244
 
@@ -17,7 +17,7 @@ module SearchEngine
17
17
  # @param timeout_ms [Integer, nil] optional read timeout override in ms
18
18
  # @return [Integer] number of deleted documents as reported by Typesense
19
19
  def delete_by(klass:, filter: nil, hash: nil, into: nil, partition: nil, timeout_ms: nil)
20
- filter_str = build_filter(filter, hash)
20
+ filter_str = build_filter(filter, hash, klass: klass)
21
21
  collection = resolve_into(klass: klass, partition: partition, into: into)
22
22
 
23
23
  effective_timeout = if timeout_ms&.to_i&.positive?
@@ -42,15 +42,18 @@ module SearchEngine
42
42
  # @param filter [String, nil]
43
43
  # @param hash [Hash, nil]
44
44
  # @return [String]
45
- def build_filter(filter, hash)
46
- if filter && !filter.to_s.strip.empty?
47
- filter.to_s
48
- elsif hash.is_a?(Hash) && !hash.empty?
49
- fragments = SearchEngine::Filters::Sanitizer.build_from_hash(hash)
50
- fragments.join(' && ')
51
- else
52
- raise ArgumentError, 'delete_by requires a filter string or a non-empty hash'
45
+ def build_filter(filter, hash, klass: nil)
46
+ return filter if filter.is_a?(String) && !filter.strip.empty?
47
+ unless filter.nil? || filter.is_a?(String) || filter.is_a?(Hash)
48
+ raise ArgumentError, 'expected a filter string or hash'
53
49
  end
50
+
51
+ raise ArgumentError, 'provide filter fields in a single hash' if filter.is_a?(Hash) && hash && !hash.empty?
52
+
53
+ fields = filter.is_a?(Hash) ? filter : hash
54
+ raise ArgumentError, 'expected a non-empty filter hash' unless fields.is_a?(Hash) && !fields.empty?
55
+
56
+ SearchEngine::Filters::Sanitizer.build_from_hash(fields, klass: klass).join(' && ')
54
57
  end
55
58
 
56
59
  # Resolve the physical collection name using the same logic as the indexer.
@@ -47,7 +47,7 @@ module SearchEngine
47
47
  when String
48
48
  if placeholders?(input)
49
49
  needed = count_placeholders(input)
50
- ensure_placeholder_arity!(needed, args.length, input)
50
+ ensure_placeholder_arity!(needed, args.length)
51
51
  parse_template(input, args, klass: klass)
52
52
  else
53
53
  parse_raw(input)
@@ -70,7 +70,7 @@ module SearchEngine
70
70
  def parse_list(list, klass:, joins: nil)
71
71
  # Preserve input shape so template bind arrays stay intact:
72
72
  # parse_list(['brand_id IN ?', [1, 2]]) should keep [1, 2] as one bind arg.
73
- items = Array(list).compact
73
+ items = Array(list)
74
74
  return [] if items.empty?
75
75
 
76
76
  nodes = []
@@ -78,6 +78,8 @@ module SearchEngine
78
78
  while i < items.length
79
79
  entry = items[i]
80
80
  case entry
81
+ when nil
82
+ i += 1
81
83
  when Hash
82
84
  nodes.concat(Array(parse_hash(entry, klass: klass, joins: joins)))
83
85
  i += 1
@@ -85,7 +87,7 @@ module SearchEngine
85
87
  if placeholders?(entry)
86
88
  needed = count_placeholders(entry)
87
89
  args_for_template = items[(i + 1)..(i + needed)] || []
88
- ensure_placeholder_arity!(needed, args_for_template.length, entry)
90
+ ensure_placeholder_arity!(needed, args_for_template.length)
89
91
  nodes << parse_template(entry, args_for_template, klass: klass)
90
92
  i += 1 + needed
91
93
  else
@@ -130,7 +132,7 @@ module SearchEngine
130
132
  entry[1..]
131
133
  end
132
134
  needed = count_placeholders(template)
133
- ensure_placeholder_arity!(needed, Array(args_list).length, template)
135
+ ensure_placeholder_arity!(needed, Array(args_list).length)
134
136
  parse_template(template, Array(args_list), klass: klass)
135
137
  end
136
138
 
@@ -150,31 +152,25 @@ module SearchEngine
150
152
  # assoc => { field => value }
151
153
  validate_assoc_and_join!(klass, key_sym, joins)
152
154
 
155
+ cfg = klass.join_for(key_sym)
156
+ target_klass = SearchEngine::Joins::Guard.target_model(klass, key_sym)
153
157
  value.each do |inner_field, inner_value|
154
158
  field_sym = inner_field.to_sym
155
159
  path = "$#{key_sym}.#{field_sym}"
156
160
  SearchEngine::Joins::Guard.ensure_single_hop_path!(path)
157
161
 
158
- # Resolve target klass for joined-field type coercion and validate field best-effort
159
- cfg = klass.join_for(key_sym)
160
- target_klass = begin
161
- SearchEngine.collection_for(cfg[:collection])
162
- rescue StandardError
163
- nil
164
- end
165
- begin
166
- SearchEngine::Joins::Guard.validate_joined_field!(cfg, field_sym)
167
- rescue StandardError
168
- # skip strictly when registry/attributes missing or join missing
162
+ if target_klass && strict_fields?
163
+ SearchEngine::Joins::Guard.validate_joined_field!(
164
+ cfg, field_sym, source_klass: klass, target_klass: target_klass
165
+ )
169
166
  end
170
167
 
171
- coercion_klass = target_klass || klass
172
168
  if array_like?(inner_value)
173
- values = normalize_array_values(inner_value, field: field_sym, klass: coercion_klass)
174
- ensure_non_empty_values!(values, field: field_sym, klass: coercion_klass)
169
+ values = normalize_array_values(inner_value, field: field_sym, klass: target_klass)
170
+ ensure_non_empty_values!(values, field: field_sym, klass: target_klass)
175
171
  pairs << SearchEngine::AST.in_(path, values)
176
172
  else
177
- coerced = coerce_value_for_field(inner_value, field: field_sym, klass: coercion_klass)
173
+ coerced = coerce_value_for_field(inner_value, field: field_sym, klass: target_klass)
178
174
  pairs << SearchEngine::AST.eq(path, coerced)
179
175
  end
180
176
  end
@@ -204,9 +200,8 @@ module SearchEngine
204
200
  m = template.match(/\A\s*([A-Za-z_][A-Za-z0-9_]*)\s*(=|!=|>=|<=|>|<|IN|NOT\s+IN|MATCHES|PREFIX)\s*\?\s*\z/i)
205
201
  unless m
206
202
  raise SearchEngine::Errors::InvalidOperator.new(
207
- "invalid template '#{template}'. Supported: =, !=, >, >=, <, <=, IN, NOT IN, MATCHES, PREFIX",
208
- doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
209
- details: { template: template }
203
+ 'invalid filter template. Supported operators: =, !=, >, >=, <, <=, IN, NOT IN, MATCHES, PREFIX',
204
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting'
210
205
  )
211
206
  end
212
207
 
@@ -266,7 +261,7 @@ module SearchEngine
266
261
  arr[1..]
267
262
  end
268
263
  needed = count_placeholders(template)
269
- ensure_placeholder_arity!(needed, Array(args_list).length, template)
264
+ ensure_placeholder_arity!(needed, Array(args_list).length)
270
265
  parse_template(template, Array(args_list), klass: klass)
271
266
  else
272
267
  parse_list(arr, klass: klass, joins: joins)
@@ -283,13 +278,13 @@ module SearchEngine
283
278
  SearchEngine::Filters::Sanitizer.count_placeholders(str)
284
279
  end
285
280
 
286
- def ensure_placeholder_arity!(needed, provided, template)
281
+ def ensure_placeholder_arity!(needed, provided)
287
282
  return if needed == provided
288
283
 
289
284
  raise SearchEngine::Errors::InvalidOperator.new(
290
- "expected #{needed} args for #{needed} placeholders in template '#{template}', got #{provided}.",
285
+ "expected #{needed} args for #{needed} placeholders, got #{provided}.",
291
286
  doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
292
- details: { needed: needed, provided: provided, template: template }
287
+ details: { needed: needed, provided: provided }
293
288
  )
294
289
  end
295
290
 
@@ -352,7 +347,7 @@ module SearchEngine
352
347
  return if values.is_a?(Array) && !values.empty?
353
348
 
354
349
  raise SearchEngine::Errors::InvalidType.new(
355
- invalid_type_message(field: field, klass: klass, expectation: 'a non-empty Array', got: values),
350
+ "expected #{field.inspect} to be a non-empty Array for #{klass} (got #{values.class})",
356
351
  doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
357
352
  details: { field: field, got_class: values.class.name }
358
353
  )
@@ -372,7 +367,7 @@ module SearchEngine
372
367
  # If generic coercion returned :__no_coercion__, try to coerce based on type hint
373
368
  case type_hint
374
369
  when :boolean
375
- coerce_boolean(value, type_hint)
370
+ coerce_boolean(value, type_hint, field: field, klass: klass)
376
371
  when :time, :datetime, :time_string, :datetime_string
377
372
  coerce_time(value, type_hint, field: field, klass: klass)
378
373
 
@@ -389,7 +384,7 @@ module SearchEngine
389
384
  end
390
385
 
391
386
  def coerce_value(value, type_hint: nil, field: nil, klass: nil)
392
- coerced_bool = coerce_boolean(value, type_hint)
387
+ coerced_bool = coerce_boolean(value, type_hint, field: field, klass: klass)
393
388
  return coerced_bool unless coerced_bool.equal?(:__no_coercion__)
394
389
 
395
390
  coerced_time = coerce_time(value, type_hint, field: field, klass: klass)
@@ -401,18 +396,14 @@ module SearchEngine
401
396
  value
402
397
  end
403
398
 
404
- def coerce_boolean(value, type_hint)
399
+ def coerce_boolean(value, type_hint, field:, klass:)
405
400
  return :__no_coercion__ unless type_boolean?(type_hint) && value.is_a?(String)
406
401
 
407
402
  lc = value.strip.downcase
408
403
  return true if lc == 'true'
409
404
  return false if lc == 'false'
410
405
 
411
- raise SearchEngine::Errors::InvalidType.new(
412
- invalid_type_message(field: nil, klass: nil, expectation: 'boolean', got: value),
413
- doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
414
- details: { got: value }
415
- )
406
+ raise invalid_type(field, klass, 'boolean', value)
416
407
  end
417
408
  private_class_method :coerce_boolean
418
409
 
@@ -439,12 +430,9 @@ module SearchEngine
439
430
  else
440
431
  :__no_coercion__
441
432
  end
442
- rescue StandardError
443
- raise SearchEngine::Errors::InvalidType.new(
444
- invalid_type_message(field: field, klass: klass, expectation: 'time', got: value),
445
- doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
446
- details: { field: field, got: value }
447
- )
433
+ rescue ArgumentError, TypeError, RangeError
434
+ # Time.parse includes the input in its exception, which error reporters also traverse.
435
+ raise invalid_type(field, klass, 'time', value), cause: nil
448
436
  end
449
437
  private_class_method :coerce_time
450
438
 
@@ -453,26 +441,17 @@ module SearchEngine
453
441
  return value if value.is_a?(Integer)
454
442
 
455
443
  if value.is_a?(String)
456
- begin
457
- int_val = Integer(value, 10)
458
- return int_val
459
- rescue StandardError
460
- raise SearchEngine::Errors::InvalidType.new(
461
- invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
462
- doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
463
- details: { field: field, got: value }
464
- )
465
- end
466
- end
444
+ number = Integer(value, 10, exception: false)
445
+ raise invalid_type(field, klass, 'integer', value) unless number
446
+
447
+ # Typesense compares document IDs byte-for-byte. Converting "0042" would target "42".
448
+ return value if field.to_s == 'id' && value != number.to_s
467
449
 
468
- if value.is_a?(Numeric)
469
- raise SearchEngine::Errors::InvalidType.new(
470
- invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
471
- doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
472
- details: { field: field, got: value }
473
- )
450
+ return number
474
451
  end
475
452
 
453
+ raise invalid_type(field, klass, 'integer', value) if value.is_a?(Numeric)
454
+
476
455
  return :__no_coercion__
477
456
  end
478
457
 
@@ -481,16 +460,10 @@ module SearchEngine
481
460
  return value.to_f if value.is_a?(Numeric)
482
461
 
483
462
  if value.is_a?(String)
484
- begin
485
- Float(value)
486
- rescue StandardError
487
- raise SearchEngine::Errors::InvalidType.new(
488
- invalid_type_message(field: field, klass: klass, expectation: 'numeric', got: value),
489
- doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
490
- details: { field: field, got: value }
491
- )
492
- end
493
- return value.to_f
463
+ number = Float(value, exception: false)
464
+ raise invalid_type(field, klass, 'numeric', value) unless number
465
+
466
+ return number
494
467
  end
495
468
  end
496
469
 
@@ -568,10 +541,17 @@ module SearchEngine
568
541
  best.first.to_sym
569
542
  end
570
543
 
571
- def invalid_type_message(field:, klass:, expectation:, got:)
572
- klass_name = klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
573
- %(expected #{field.inspect} to be #{expectation} for #{klass_name} (got #{got.class}: #{got.inspect}))
544
+ def invalid_type(field, klass, expectation, value)
545
+ subject = field ? field.inspect : 'filter value'
546
+ model = " for #{klass}" if klass
547
+ actual = value.class.name
548
+ SearchEngine::Errors::InvalidType.new(
549
+ "expected #{subject} to be #{expectation}#{model} (got #{actual})",
550
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
551
+ details: { field: field, expected: expectation, got: actual }
552
+ )
574
553
  end
554
+ private_class_method :invalid_type
575
555
 
576
556
  def validate_assoc_and_join!(klass, assoc_name, joins)
577
557
  # Validate association exists and config completeness
@@ -35,13 +35,7 @@ module SearchEngine
35
35
 
36
36
  # Also ensure Rails global autoloaders ignore these files, since the engine
37
37
  # adds lib/ to autoload paths and the main/once loaders may scan it.
38
- if defined?(Rails) && Rails.respond_to?(:autoloaders)
39
- al = Rails.autoloaders
40
- al.main.ignore(shim) if al.respond_to?(:main)
41
- al.once.ignore(shim) if al.respond_to?(:once)
42
- al.main.ignore(cli_file) if al.respond_to?(:main)
43
- al.once.ignore(cli_file) if al.respond_to?(:once)
44
- end
38
+ Rails.autoloaders.each { |autoloader| autoloader.ignore(shim, cli_file) }
45
39
  end
46
40
 
47
41
  initializer 'search_engine.observability' do
@@ -111,6 +105,26 @@ module SearchEngine
111
105
  end
112
106
  end
113
107
 
108
+ # Rails-managed mappers may declare a collection unrelated to their constant name.
109
+ # Register these mappings before a callback can interpret a miss as a bare collection.
110
+ initializer 'search_engine.rails_model_mappings' do |app|
111
+ app.reloader.to_prepare do
112
+ models = app.paths['app/models'].existent
113
+ if Rails.autoloaders.zeitwerk_enabled?
114
+ Rails.autoloaders.each do |loader|
115
+ (loader.dirs & models).each do |root|
116
+ namespace_dir = File.join(root, 'search_engine')
117
+ loader.eager_load_dir(namespace_dir) if File.directory?(namespace_dir)
118
+ end
119
+ end
120
+ else
121
+ models.each do |root|
122
+ Dir[File.join(root, 'search_engine', '**', '*.rb')].sort.each { |file| require_dependency(file) }
123
+ end
124
+ end
125
+ end
126
+ end
127
+
114
128
  # Manage a dedicated Zeitwerk loader for host app SearchEngine models.
115
129
  # Loads after Rails so application models/constants are available.
116
130
  initializer 'search_engine.models_loader' do
@@ -126,22 +140,17 @@ module SearchEngine
126
140
  next unless File.directory?(path_s)
127
141
 
128
142
  # Ensure Rails' autoloaders do not also manage this directory.
129
- if defined?(Rails) && Rails.respond_to?(:autoloaders)
130
- al = Rails.autoloaders
131
- %i[main once].each do |key|
132
- al.public_send(key).ignore(path_s) if al.respond_to?(key)
133
- end
134
- end
143
+ Rails.autoloaders.each { |autoloader| autoloader.ignore(path_s) }
135
144
 
136
145
  # Create or reuse a dedicated loader under SearchEngine namespace.
137
146
  loader = SearchEngine.instance_variable_get(:@_models_loader)
138
147
  unless loader
148
+ require 'zeitwerk'
139
149
  loader = Zeitwerk::Loader.new
140
150
  loader.tag = 'search_engine.models'
141
151
  # Reuse Rails' inflector for consistent constantization rules.
142
- if defined?(Rails) && Rails.respond_to?(:autoloaders) && Rails.autoloaders.respond_to?(:main)
143
- loader.inflector = Rails.autoloaders.main.inflector
144
- end
152
+ main = Rails.autoloaders.main
153
+ loader.inflector = main.inflector if main
145
154
  loader.push_dir(path_s, namespace: SearchEngine)
146
155
  loader.enable_reloading if defined?(Rails) && Rails.env.development?
147
156
  SearchEngine.instance_variable_set(:@_models_loader, loader)