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,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'search_engine/filters/sanitizer'
4
+ require 'search_engine/ast'
5
+
6
+ module SearchEngine
7
+ # Compiler for turning Predicate AST into Typesense `filter_by` strings.
8
+ #
9
+ # Pure and deterministic: no side effects, no I/O. Uses
10
+ # `SearchEngine::Filters::Sanitizer` for all quoting/escaping to ensure
11
+ # consistent rendering with the `where` DSL.
12
+ module Compiler
13
+ class Error < StandardError; end
14
+ class UnsupportedNode < Error; end
15
+
16
+ module_function
17
+
18
+ # Compile an AST node or an array of nodes (implicit AND) into a Typesense
19
+ # `filter_by` string.
20
+ #
21
+ # - Nil or empty input returns an empty String
22
+ # - Arrays at the top-level are treated as implicit AND
23
+ # - Group nodes are always parenthesized
24
+ # - Raw fragments are passed-through
25
+ #
26
+ # @param ast [SearchEngine::AST::Node, Array<SearchEngine::AST::Node>, nil]
27
+ # @param klass [Class] optional model class for context (used for observability)
28
+ # @return [String]
29
+ # @note Emits "search_engine.compile" via ActiveSupport::Notifications with
30
+ # payload: { collection, klass, node_count, duration_ms, source: :ast }
31
+ def compile(ast, klass: nil)
32
+ root = coerce_root(ast)
33
+ return '' unless root
34
+
35
+ compiled = nil
36
+ if defined?(ActiveSupport::Notifications)
37
+ payload = {
38
+ collection: safe_collection_for_klass(klass),
39
+ klass: safe_klass_name(klass),
40
+ node_count: count_nodes(root),
41
+ source: :ast
42
+ }
43
+ SearchEngine::Instrumentation.instrument('search_engine.compile', payload) do |_ctx|
44
+ compiled = compile_node(root, parent_prec: 0, klass: klass)
45
+ end
46
+ compiled
47
+ else
48
+ compile_node(root, parent_prec: 0, klass: klass)
49
+ end
50
+ end
51
+
52
+ # --- Internals ---------------------------------------------------------
53
+
54
+ def coerce_root(ast)
55
+ return nil if ast.nil?
56
+
57
+ if ast.is_a?(Array)
58
+ nodes = ast.flatten.compact.select { |n| n.is_a?(SearchEngine::AST::Node) }
59
+ return nil if nodes.empty?
60
+ return nodes.first if nodes.length == 1
61
+
62
+ return SearchEngine::AST.and_(*nodes)
63
+ end
64
+
65
+ return ast if ast.is_a?(SearchEngine::AST::Node)
66
+
67
+ raise Error, "Compiler: unsupported root input #{ast.class}"
68
+ end
69
+ private_class_method :coerce_root
70
+
71
+ def compile_node(node, parent_prec:, klass: nil)
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)
89
+ when SearchEngine::AST::And
90
+ compile_boolean(node.children, ' && ', parent_prec: parent_prec, my_prec: precedence(:and), klass: klass)
91
+ when SearchEngine::AST::Or
92
+ compile_or(node, parent_prec, klass: klass)
93
+ when SearchEngine::AST::Group
94
+ "(#{compile_node(node.children.first, parent_prec: 0, klass: klass)})"
95
+ when SearchEngine::AST::Raw
96
+ node.fragment
97
+ when SearchEngine::AST::Matches
98
+ raise UnsupportedNode, 'Typesense filter_by does not support MATCHES; use AST::Raw if needed.'
99
+ when SearchEngine::AST::Prefix
100
+ raise UnsupportedNode, 'Typesense filter_by does not support PREFIX; use AST::Raw if needed.'
101
+ else
102
+ raise Error, "Compiler: unknown node #{node.class}"
103
+ end
104
+ end
105
+ private_class_method :compile_node
106
+
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})"
118
+ 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
+
145
+ def binary(field, op, rhs)
146
+ "#{field}#{op}#{rhs}"
147
+ end
148
+ private_class_method :binary
149
+
150
+ def compile_boolean(children, joiner, parent_prec:, my_prec:, klass: nil)
151
+ # For conjunctions only, merge multiple $assoc(inner ...) predicates targeting
152
+ # the same association into a single $assoc(inner && inner ...) expression
153
+ # to satisfy Typesense join filter rules.
154
+ 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
159
+ end
160
+
161
+ # Fallback/default behavior
162
+ parts = children.map do |child|
163
+ cstr = compile_node(child, parent_prec: my_prec, klass: klass)
164
+ if needs_parentheses?(child, parent_prec: my_prec)
165
+ "(#{cstr})"
166
+ else
167
+ cstr
168
+ end
169
+ end
170
+ inner = parts.join(joiner)
171
+
172
+ # Parent adds parens if its precedence is greater than ours
173
+ if parent_prec > my_prec
174
+ "(#{inner})"
175
+ else
176
+ inner
177
+ end
178
+ end
179
+ private_class_method :compile_boolean
180
+
181
+ # Merge multiple join predicates targeting the same association into consolidated expressions.
182
+ #
183
+ # For AND operations, collects all predicates for each association and merges them
184
+ # into a single $assoc(inner && inner ...) expression at the first position where
185
+ # that association appeared.
186
+ #
187
+ # @param children [Array<SearchEngine::AST::Node>] child nodes to process
188
+ # @param parent_prec [Integer] parent operator precedence
189
+ # @param my_prec [Integer] current operator precedence
190
+ # @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
205
+ next
206
+ end
207
+
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 }
211
+ end
212
+
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 }
222
+ end
223
+
224
+ inner = items.sort_by { |it| it[:pos] }.map { |it| it[:str] }.join(joiner)
225
+ parent_prec > my_prec ? "(#{inner})" : inner
226
+ end
227
+ private_class_method :merge_join_predicates
228
+
229
+ # Try to extract join association and compiled inner expression for a binary node
230
+ # with a joined field like "$assoc.field". Returns [assoc(String), inner(String)] or nil.
231
+ 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
263
+ end
264
+ private_class_method :extract_join_inner_binary
265
+
266
+ def op_for(node)
267
+ case node
268
+ when SearchEngine::AST::Eq then ':='
269
+ when SearchEngine::AST::NotEq then ':!='
270
+ when SearchEngine::AST::Gt then ':>'
271
+ when SearchEngine::AST::Gte then ':>='
272
+ when SearchEngine::AST::Lt then ':<'
273
+ when SearchEngine::AST::Lte then ':<='
274
+ when SearchEngine::AST::In then ':='
275
+ when SearchEngine::AST::NotIn then ':!='
276
+ else
277
+ raise Error, "Unknown binary node for join extraction: #{node.class}"
278
+ end
279
+ end
280
+ private_class_method :op_for
281
+
282
+ def quote(value, field: nil, klass: nil)
283
+ # Typesense requires reference fields to use string values, even if the field type is int64
284
+ converted_value = if klass && field && field_reference?(klass, field)
285
+ convert_for_reference_field(value)
286
+ else
287
+ value
288
+ end
289
+
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
296
+ end
297
+ private_class_method :quote
298
+
299
+ # Check if a field has a reference attribute in the schema.
300
+ # Uses the same logic as Schema.build_references_by_local_key to ensure consistency.
301
+ def field_reference?(klass, field_name)
302
+ return false unless klass
303
+ return false unless klass.respond_to?(:joins_config)
304
+
305
+ configs = klass.joins_config || {}
306
+ configs.each_value do |cfg|
307
+ # Only belongs_to/belongs_to_many contribute references to schema
308
+ kind = (cfg[:kind] || :belongs_to).to_sym
309
+ next if %i[has_one has_many].include?(kind)
310
+
311
+ lk = cfg[:local_key]
312
+ next if lk.nil?
313
+
314
+ return true if lk.to_sym == field_name.to_sym
315
+ end
316
+
317
+ false
318
+ end
319
+ private_class_method :field_reference?
320
+
321
+ # Convert numeric values to strings for reference fields
322
+ def convert_for_reference_field(value)
323
+ if value.is_a?(Array)
324
+ value.map { |v| convert_for_reference_field(v) }
325
+ elsif value.is_a?(Numeric)
326
+ value.to_s
327
+ else
328
+ value
329
+ end
330
+ end
331
+ private_class_method :convert_for_reference_field
332
+
333
+ def needs_parentheses?(child, parent_prec:)
334
+ child_prec = precedence(child)
335
+ # Parenthesize when child binds looser than parent
336
+ child_prec < parent_prec
337
+ end
338
+ private_class_method :needs_parentheses?
339
+
340
+ def precedence(node)
341
+ case node
342
+ when Symbol
343
+ return 20 if node == :and
344
+ return 10 if node == :or
345
+
346
+ 100
347
+ when SearchEngine::AST::And
348
+ 20
349
+ when SearchEngine::AST::Or
350
+ 10
351
+ when SearchEngine::AST::Group
352
+ # Group is a special case; caller always wraps it explicitly
353
+ 100
354
+ else
355
+ # Leaves (Eq, In, etc.) bind tight
356
+ 100
357
+ end
358
+ end
359
+ private_class_method :precedence
360
+
361
+ def count_nodes(node)
362
+ return 0 unless node.is_a?(SearchEngine::AST::Node)
363
+
364
+ 1 + Array(node.children).sum { |child| count_nodes(child) }
365
+ end
366
+ private_class_method :count_nodes
367
+
368
+ def safe_klass_name(klass)
369
+ return nil unless klass
370
+
371
+ klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
372
+ end
373
+ private_class_method :safe_klass_name
374
+
375
+ def safe_collection_for_klass(klass)
376
+ return nil unless klass
377
+ return klass.collection if klass.respond_to?(:collection) && klass.collection
378
+
379
+ nil
380
+ end
381
+ private_class_method :safe_collection_for_klass
382
+ end
383
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Config
5
+ # Observability and structured logging configuration.
6
+ class Observability
7
+ # @return [Boolean] enable the compact logging subscriber automatically
8
+ attr_accessor :enabled
9
+ # @return [Symbol] :kv or :json
10
+ attr_accessor :log_format
11
+ # @return [Integer] maximum message length for error samples in logs
12
+ attr_accessor :max_message_length
13
+ # @return [Boolean] include short error messages in logs for batch/stale events
14
+ attr_accessor :include_error_messages
15
+ # @return [Boolean] also emit legacy event aliases where applicable
16
+ attr_accessor :emit_legacy_event_aliases
17
+
18
+ def initialize
19
+ @enabled = true
20
+ @log_format = :kv
21
+ @max_message_length = 200
22
+ @include_error_messages = false
23
+ @emit_legacy_event_aliases = true
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module SearchEngine
6
+ class Config
7
+ # Default presets resolution configuration.
8
+ # Controls namespacing and enablement.
9
+ class Presets
10
+ # @return [Boolean] when false, namespace is ignored but declared tokens remain usable
11
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets
12
+ attr_accessor :enabled
13
+ # @return [String, nil] optional namespace prepended to preset names when enabled
14
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets
15
+ attr_accessor :namespace
16
+
17
+ def initialize
18
+ @enabled = true
19
+ @namespace = nil
20
+ @locked_domains = %i[filter_by sort_by include_fields exclude_fields]
21
+ @locked_domains_set = nil
22
+ end
23
+
24
+ # Normalize a Boolean-like value.
25
+ # Accepts true/false, or common String forms ("true","false","1","0","yes","no","on","off").
26
+ # @param value [Object]
27
+ # @return [Boolean]
28
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#config-default-preset
29
+ def self.normalize_enabled(value)
30
+ return true if value == true
31
+ return false if value == false
32
+
33
+ if value.is_a?(String)
34
+ v = value.strip.downcase
35
+ return true if %w[1 true yes on].include?(v)
36
+ return false if %w[0 false no off].include?(v)
37
+ end
38
+
39
+ value
40
+ end
41
+
42
+ # Normalize namespace to a non-empty String or return original for validation.
43
+ # @param value [Object]
44
+ # @return [String, nil, Object]
45
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#config-default-preset
46
+ def self.normalize_namespace(value)
47
+ return nil if value.nil?
48
+
49
+ if value.is_a?(String)
50
+ ns = value.strip
51
+ return nil if ns.empty?
52
+
53
+ return ns
54
+ end
55
+
56
+ value
57
+ end
58
+
59
+ # Assign locked domains; accepts Array/Set or a single value. Values are
60
+ # normalized to Symbols. Internal membership checks use a frozen Set.
61
+ # @param value [Array<#to_sym>, Set<#to_sym>, #to_sym, nil]
62
+ # @return [void]
63
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#strategies-merge-only-lock
64
+ def locked_domains=(value)
65
+ list =
66
+ case value
67
+ when nil then []
68
+ when Set then value.to_a
69
+ when Array then value
70
+ else Array(value)
71
+ end
72
+ syms = list.compact.map { |k| k.respond_to?(:to_sym) ? k.to_sym : k }.grep(Symbol)
73
+ @locked_domains = syms
74
+ @locked_domains_set = syms.to_set.freeze
75
+ end
76
+
77
+ # Return the locked domains as an Array of Symbols.
78
+ # @return [Array<Symbol>]
79
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#strategies-merge-only-lock
80
+ def locked_domains
81
+ Array(@locked_domains).map(&:to_sym)
82
+ end
83
+
84
+ # Return a frozen Set of locked domains for fast membership checks.
85
+ # @return [Set<Symbol>]
86
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#strategies-merge-only-lock
87
+ def locked_domains_set
88
+ @locked_domains_set ||= locked_domains.to_set.freeze
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Config
5
+ # Selection/hydration configuration.
6
+ # Controls strictness of missing attributes during hydration.
7
+ class Selection
8
+ # @return [Boolean] when true, missing requested fields raise MissingField
9
+ attr_accessor :strict_missing
10
+
11
+ def initialize
12
+ @strict_missing = false
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Config
5
+ # Typesense transport/client configuration.
6
+ # Holds connection details, timeouts, and retry knobs.
7
+ # Public fields mirror the facade to preserve API compatibility via forwarders.
8
+ #
9
+ # @example
10
+ # ts = SearchEngine::Config::Typesense.new
11
+ # ts.host = 'localhost'
12
+ # ts.port = 8108
13
+ class Typesense
14
+ # @return [String, nil] secret Typesense API key (redacted separately)
15
+ attr_accessor :api_key
16
+ # @return [String] hostname of the Typesense server
17
+ attr_accessor :host
18
+ # @return [Integer] TCP port for the Typesense server
19
+ attr_accessor :port
20
+ # @return [String] one of "http" or "https"
21
+ attr_reader :protocol
22
+ # @return [Integer] request total timeout in milliseconds
23
+ attr_accessor :timeout_ms
24
+ # @return [Integer] connect/open timeout in milliseconds
25
+ attr_accessor :open_timeout_ms
26
+ # @return [Hash] retry policy with keys { attempts: Integer, backoff: Float or Range<Float> }
27
+ attr_accessor :retries
28
+
29
+ def initialize
30
+ @api_key = nil
31
+ @host = 'localhost'
32
+ @port = 8108
33
+ @protocol = 'http'
34
+ @timeout_ms = 3_600_000
35
+ @open_timeout_ms = 1_000
36
+ @retries = { attempts: 2, backoff: (10.0..60.0) }
37
+ end
38
+
39
+ # Normalize protocol assignment to default to 'http' when blank.
40
+ # @param value [String, nil]
41
+ # @return [void]
42
+ def protocol=(value)
43
+ s = value.to_s
44
+ @protocol = s.strip.present? ? s : 'http'
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Config
5
+ # Validation helpers for configuration.
6
+ # Keep messages identical to the previous inline implementations.
7
+ module Validators
8
+ module_function
9
+
10
+ def validate_protocol(protocol)
11
+ return [] if %w[http https].include?(protocol.to_s)
12
+
13
+ ['protocol must be "http" or "https"']
14
+ end
15
+
16
+ def validate_host(host)
17
+ return [] unless host.nil? || host.to_s.strip.empty?
18
+
19
+ ['host must be present']
20
+ end
21
+
22
+ def validate_port(port)
23
+ return [] if port.is_a?(Integer) && port.positive?
24
+
25
+ ['port must be a positive Integer']
26
+ end
27
+
28
+ def validate_timeouts(timeout_ms, open_timeout_ms)
29
+ errors = []
30
+ errors << 'timeout_ms must be a non-negative Integer' unless timeout_ms.is_a?(Integer) && !timeout_ms.negative?
31
+ unless open_timeout_ms.is_a?(Integer) && !open_timeout_ms.negative?
32
+ errors << 'open_timeout_ms must be a non-negative Integer'
33
+ end
34
+ errors
35
+ end
36
+
37
+ def validate_retries(retries)
38
+ return ['retries must be a Hash with keys :attempts and :backoff'] unless retries_valid_shape?(retries)
39
+
40
+ errors = []
41
+ attempts = retries[:attempts]
42
+ backoff = retries[:backoff]
43
+
44
+ unless attempts.is_a?(Integer) && !attempts.negative?
45
+ errors << 'retries[:attempts] must be a non-negative Integer'
46
+ end
47
+
48
+ if backoff.is_a?(Numeric)
49
+ if backoff.negative?
50
+ errors << 'retries[:backoff] must be a non-negative Float or Range of non-negative Floats'
51
+ end
52
+ elsif backoff.is_a?(Range)
53
+ b = backoff.begin
54
+ e = backoff.end
55
+ valid = b.is_a?(Numeric) && e.is_a?(Numeric) && b >= 0 && e >= 0 && b <= e
56
+ errors << 'retries[:backoff] must be a non-negative Float or Range of non-negative Floats' unless valid
57
+ else
58
+ errors << 'retries[:backoff] must be a non-negative Float or Range of non-negative Floats'
59
+ end
60
+
61
+ errors
62
+ end
63
+
64
+ def retries_valid_shape?(retries)
65
+ retries.is_a?(Hash) && retries.key?(:attempts) && retries.key?(:backoff)
66
+ end
67
+
68
+ def validate_cache(cache_ttl_s)
69
+ return [] if cache_ttl_s.is_a?(Integer) && !cache_ttl_s.negative?
70
+
71
+ ['cache_ttl_s must be a non-negative Integer']
72
+ end
73
+
74
+ def validate_multi_search_limit(limit)
75
+ return [] if limit.is_a?(Integer) && !limit.negative?
76
+
77
+ ['multi_search_limit must be a non-negative Integer']
78
+ end
79
+
80
+ def validate_presets(presets)
81
+ errors = []
82
+ en = presets.enabled
83
+ ns = presets.namespace
84
+ ld = Array(presets.locked_domains)
85
+
86
+ errors << 'presets.enabled must be a Boolean' unless [true, false].include?(en)
87
+ unless ns.nil? || (ns.is_a?(String) && !ns.strip.empty?)
88
+ errors << 'presets.namespace must be a non-empty String or nil'
89
+ end
90
+ unless ld.is_a?(Array) && ld.all? { |k| k.is_a?(Symbol) }
91
+ errors << 'presets.locked_domains must be an Array of Symbols'
92
+ end
93
+ errors
94
+ end
95
+ end
96
+ end
97
+ end