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,623 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Immutable, chainable query relation bound to a model class.
5
+ #
6
+ # Facade wiring that composes State, Options, DSL, Compiler, and Materializers.
7
+ class Relation
8
+ # Limited set of Array/Enumerable methods we intentionally delegate to the
9
+ # materialized results for convenience. Restricting this list prevents
10
+ # accidental network calls during reflection/printing in non‑interactive contexts.
11
+ ARRAY_DELEGATED_METHODS = %i[
12
+ to_a each map collect select filter reject find find_all detect any? all? none? one? empty?
13
+ size length include? first last take drop [] at slice reduce inject sum uniq
14
+ compact flatten each_with_index each_with_object index_by group_by partition grep grep_v flat_map collect_concat
15
+ each_slice each_cons reverse_each sort sort_by min max min_by max_by minmax minmax_by
16
+ take_while drop_while chunk chunk_while slice_when slice_before slice_after lazy
17
+ find_index index rindex values_at sample shuffle rotate
18
+ ].freeze
19
+ # Keys considered essential for :only preset mode.
20
+ ESSENTIAL_PARAM_KEYS = %i[q page per_page].freeze
21
+
22
+ # @return [Class] bound model class (typically a SearchEngine::Base subclass)
23
+ attr_reader :klass
24
+
25
+ # Modules are required explicitly to keep require graph stable
26
+ require 'search_engine/relation/state'
27
+ require 'search_engine/relation/options'
28
+ require 'search_engine/relation/dsl'
29
+ require 'search_engine/relation/compiler'
30
+ require 'search_engine/relation/deletion'
31
+ require 'search_engine/relation/updating'
32
+ require 'search_engine/relation/materializers'
33
+
34
+ include State
35
+ include Options
36
+ include DSL
37
+ include Compiler
38
+ include Deletion
39
+ include Updating
40
+ include Materializers
41
+
42
+ # Convenience conversion to compiled body params as a plain Hash.
43
+ def to_h
44
+ v = to_typesense_params
45
+ v.respond_to?(:to_h) ? v.to_h : v
46
+ end
47
+
48
+ # Return compiled Typesense filter_by string for this relation.
49
+ # Pure and deterministic; delegates to the compiler without I/O.
50
+ #
51
+ # @return [String, nil] the filter_by string or nil when absent
52
+ def filter_params
53
+ params = to_typesense_params
54
+ params[:filter_by]
55
+ end
56
+
57
+ # Return a Hash of simple base-field equality filters accumulated on this relation.
58
+ # Extracts only Eq/In predicates on base fields (no joins, no ranges, no negations, no ORs).
59
+ # When the predicate set contains constructs that cannot be represented as a flat Hash
60
+ # (e.g. OR, NOT, range comparisons, join predicates), returns an empty Hash to avoid
61
+ # misrepresenting the filter semantics.
62
+ #
63
+ # @return [Hash{Symbol=>Object}] symbolized base field => value(s) or {}
64
+ def filter_params_hash
65
+ nodes = Array(@state[:ast]).flatten.compact
66
+ return {} if nodes.empty?
67
+
68
+ result = {}
69
+ ambiguous = false
70
+
71
+ walker = lambda do |node|
72
+ return if ambiguous || node.nil?
73
+
74
+ case node
75
+ when SearchEngine::AST::And
76
+ Array(node.children).each { |child| walker.call(child) }
77
+ when SearchEngine::AST::Group
78
+ inner = Array(node.children).first
79
+ walker.call(inner)
80
+ when SearchEngine::AST::Eq
81
+ field = node.field.to_s
82
+ # Only base fields: exclude joins like "$assoc.field"
83
+ result[field.to_sym] = node.value unless join_field?(field)
84
+ when SearchEngine::AST::In
85
+ field = node.field.to_s
86
+ result[field.to_sym] = Array(node.values) unless join_field?(field)
87
+ else
88
+ # Any non-equality, negation, OR, range, or raw fragment makes this ambiguous
89
+ ambiguous ||= ambiguous_ast_node?(node)
90
+ end
91
+ end
92
+
93
+ nodes.each { |n| walker.call(n) }
94
+ return {} if ambiguous
95
+
96
+ result
97
+ end
98
+
99
+ private
100
+
101
+ # True when the field name refers to a joined field like "$assoc.field".
102
+ # @param field [String]
103
+ # @return [Boolean]
104
+ def join_field?(field)
105
+ field.start_with?('$') || field.include?('.')
106
+ end
107
+
108
+ # True when node type cannot be represented as a flat Hash of base Eq/In.
109
+ # @param node [SearchEngine::AST::Node]
110
+ # @return [Boolean]
111
+ def ambiguous_ast_node?(node)
112
+ node.is_a?(SearchEngine::AST::Or) ||
113
+ node.is_a?(SearchEngine::AST::NotEq) ||
114
+ node.is_a?(SearchEngine::AST::NotIn) ||
115
+ node.is_a?(SearchEngine::AST::Gt) ||
116
+ node.is_a?(SearchEngine::AST::Gte) ||
117
+ node.is_a?(SearchEngine::AST::Lt) ||
118
+ node.is_a?(SearchEngine::AST::Lte) ||
119
+ node.is_a?(SearchEngine::AST::Raw)
120
+ end
121
+
122
+ # Read-only access to accumulated predicate AST nodes.
123
+ # @return [Array<SearchEngine::AST::Node>] a frozen Array of AST nodes
124
+ def ast
125
+ nodes = Array(@state[:ast])
126
+ nodes.frozen? ? nodes : nodes.dup.freeze
127
+ end
128
+
129
+ # Return the effective preset mode when a preset is applied.
130
+ # Falls back to :merge when not explicitly set.
131
+ # @return [Symbol]
132
+ def preset_mode
133
+ (@state[:preset_mode] || :merge).to_sym
134
+ end
135
+
136
+ # Return the effective preset token (namespaced if configured) or nil.
137
+ # @return [String, nil]
138
+ def preset_name
139
+ @state[:preset_name]
140
+ end
141
+
142
+ public :ast, :preset_mode, :preset_name, :to_typesense_params
143
+
144
+ # Create a new Relation.
145
+ # @param klass [Class]
146
+ # @param state [Hash]
147
+ def initialize(klass, state = {})
148
+ @klass = klass
149
+ normalized = normalize_initial_state(state)
150
+ @state = DEFAULT_STATE.merge(normalized)
151
+ migrate_legacy_filters_to_ast!(@state)
152
+ deep_freeze_inplace(@state)
153
+ @__result_memo = nil
154
+ @__loaded = false
155
+ @__load_lock = Mutex.new
156
+ end
157
+
158
+ # Return self for AR-like parity.
159
+ # @return [SearchEngine::Relation]
160
+ def all
161
+ self
162
+ end
163
+
164
+ # True when the relation has no accumulated state beyond defaults.
165
+ # @return [Boolean]
166
+ def empty?
167
+ @state == DEFAULT_STATE
168
+ end
169
+
170
+ public
171
+
172
+ # Console-friendly inspect without network I/O.
173
+ # Always return a concise, stable summary to avoid surprises across consoles.
174
+ # @return [String]
175
+ def inspect
176
+ cfg = begin
177
+ SearchEngine.config
178
+ rescue StandardError
179
+ nil
180
+ end
181
+
182
+ materialize = cfg.respond_to?(:relation_print_materializes) ? cfg.relation_print_materializes : true
183
+ return summary_inspect_string unless materialize
184
+
185
+ preview_size = 11
186
+ begin
187
+ items = SearchEngine::Hydration::Materializers.preview(self, preview_size)
188
+ entries = Array(items).map { |obj| obj.respond_to?(:inspect) ? obj.inspect : obj.to_s }
189
+ entries[10] = '...' if entries.size == preview_size
190
+ +"#<#{self.class.name} [#{entries.join(', ')}]>"
191
+ rescue StandardError
192
+ # Defensive fallback to non-I/O summary when materialization fails
193
+ summary_inspect_string
194
+ end
195
+ end
196
+
197
+ # String form mirrors inspect to support printers that prefer to_s.
198
+ # @return [String]
199
+ def to_s
200
+ inspect
201
+ end
202
+
203
+ # Pry hooks into pretty_inspect in many cases. Keep it consistent with #inspect
204
+ # to avoid delegating to model class printers accidentally.
205
+ # @return [String]
206
+ def pretty_inspect
207
+ inspect
208
+ end
209
+
210
+ # Pretty print the concise, stable summary without network I/O.
211
+ # Avoid hydration during console pretty printing to keep behavior predictable.
212
+ # @param pp [PP]
213
+ # @return [void]
214
+ def pretty_print(pp)
215
+ cfg = begin
216
+ SearchEngine.config
217
+ rescue StandardError
218
+ nil
219
+ end
220
+
221
+ materialize = cfg.respond_to?(:relation_print_materializes) ? cfg.relation_print_materializes : true
222
+ unless materialize
223
+ pp.text(summary_inspect_string)
224
+ return
225
+ end
226
+
227
+ preview_size = 11
228
+ begin
229
+ items = SearchEngine::Hydration::Materializers.preview(self, preview_size)
230
+
231
+ pp.group(2, "#<#{self.class.name} [", ']>') do
232
+ items.each_with_index do |obj, idx|
233
+ if idx.positive?
234
+ pp.text(',')
235
+ pp.breakable ' '
236
+ end
237
+ pp.pp(obj)
238
+ end
239
+
240
+ if items.size == preview_size
241
+ pp.text(',') unless items.empty?
242
+ pp.breakable ' '
243
+ pp.text('...')
244
+ end
245
+ end
246
+ rescue StandardError
247
+ pp.text(summary_inspect_string)
248
+ end
249
+ end
250
+
251
+ public :ast, :preset_mode, :preset_name, :to_typesense_params
252
+
253
+ # Explain the current relation without performing any network calls.
254
+ # @return [String]
255
+ def explain(to: nil)
256
+ params = to_typesense_params
257
+
258
+ lines = []
259
+ header = "#{klass_name_for_inspect} Relation"
260
+ lines << header
261
+
262
+ append_preset_explain_line(lines, params)
263
+ append_curation_explain_lines(lines)
264
+ append_boolean_knobs_explain_lines(lines)
265
+ append_where_and_order_lines(lines, params)
266
+ append_grouping_explain_lines(lines)
267
+ append_selection_explain_lines(lines, params)
268
+ add_effective_selection_tokens!(lines)
269
+ add_pagination_line!(lines, params)
270
+
271
+ out = lines.join("\n")
272
+ puts(out) if to == :stdout
273
+ out
274
+ end
275
+
276
+ # Read-only list of join association names accumulated on this relation.
277
+ # @return [Array<Symbol>]
278
+ def joins_list
279
+ list = Array(@state[:joins])
280
+ list.frozen? ? list : list.dup.freeze
281
+ end
282
+
283
+ # Read-only grouping state for debugging/explain.
284
+ # @return [Hash, nil]
285
+ def grouping
286
+ g = @state[:grouping]
287
+ return nil if g.nil?
288
+
289
+ g.frozen? ? g : g.dup.freeze
290
+ end
291
+
292
+ # Read-only selected fields state for debugging (base + nested).
293
+ # @return [Hash]
294
+ def selected_fields_state
295
+ base = Array(@state[:select])
296
+ nested = @state[:select_nested] || {}
297
+ order = Array(@state[:select_nested_order])
298
+
299
+ {
300
+ base: base.dup.freeze,
301
+ nested: nested.transform_values { |arr| Array(arr).dup.freeze }.freeze,
302
+ nested_order: order.dup.freeze
303
+ }.freeze
304
+ end
305
+
306
+ # Programmatic accessor for preset conflicts in :lock mode.
307
+ # @return [Array<Hash{Symbol=>Symbol}>]
308
+ def preset_conflicts
309
+ params = to_typesense_params
310
+ keys = Array(params[:_preset_conflicts]).map { |k| k.respond_to?(:to_sym) ? k.to_sym : k }.grep(Symbol)
311
+ return [].freeze if keys.empty?
312
+
313
+ keys.sort.map { |k| { key: k, reason: :locked_by_preset } }.freeze
314
+ end
315
+
316
+ # Read-only hit limits state for debugging/explain.
317
+ # @return [Hash, nil]
318
+ def hit_limits
319
+ hl = @state[:hit_limits]
320
+ return nil if hl.nil?
321
+
322
+ hl.frozen? ? hl : hl.dup.freeze
323
+ end
324
+
325
+ # Handle unknown methods by delegating to the materialized Array.
326
+ # Allows callers to use enumerable helpers directly on Relation.
327
+ #
328
+ # @param method_name [Symbol]
329
+ # @param args [Array<Object>]
330
+ # @return [Object]
331
+ # @raise [NoMethodError] when the delegated Array doesn't support the method
332
+ def method_missing(method_name, *args, **kwargs, &block)
333
+ # Delegate to the model class first (AR-like behavior)
334
+ # Avoid delegating reflective/identity methods that console printers may use
335
+ # to derive labels; these should reflect the Relation itself.
336
+ blocked_class_delegations = %i[
337
+ name inspect pretty_inspect to_s to_str to_ary class object_id __id__
338
+ methods public_methods singleton_class respond_to? respond_to_missing?
339
+ ]
340
+
341
+ sym = method_name.to_sym
342
+ if @klass.respond_to?(method_name) && !blocked_class_delegations.include?(sym)
343
+ # If this is a SearchEngine model scope, apply it against the *current*
344
+ # relation (AR parity) rather than delegating to the class method which
345
+ # starts from `.all` and would drop current relation state.
346
+ scope_result = apply_model_scope(sym, args, kwargs)
347
+ return scope_result unless scope_result == :__se_no_scope
348
+
349
+ return @klass.public_send(method_name, *args, **kwargs, &block)
350
+ end
351
+
352
+ return super if blocked_class_delegations.include?(sym)
353
+
354
+ # Only delegate to the materialized Array for a conservative whitelist of methods.
355
+ unless ARRAY_DELEGATED_METHODS.include?(sym)
356
+ raise NoMethodError, %(undefined method `#{method_name}` for #{@klass}:Class)
357
+ end
358
+
359
+ arr = to_a
360
+ arr.public_send(method_name, *args, &block)
361
+ end
362
+
363
+ public :ast, :preset_mode, :preset_name, :to_typesense_params
364
+
365
+ # Ensure reflective APIs correctly report delegated methods on the
366
+ # materialized Array target. This keeps semantics consistent with
367
+ # method_missing above for interactive consoles and chaining.
368
+ # @param method_name [Symbol]
369
+ # @param include_private [Boolean]
370
+ # @return [Boolean]
371
+ def respond_to_missing?(method_name, include_private = false)
372
+ # Explicitly avoid implicit conversions that change object identity in consoles
373
+ return false if %i[to_ary to_str].include?(method_name.to_sym)
374
+
375
+ sym = method_name.to_sym
376
+ blocked_class_delegations = %i[
377
+ name inspect pretty_inspect to_s to_str to_ary class object_id __id__
378
+ methods public_methods singleton_class respond_to? respond_to_missing?
379
+ ]
380
+
381
+ if @klass.respond_to?(:__search_engine_scope_registry__) &&
382
+ @klass.__search_engine_scope_registry__.key?(sym) &&
383
+ !blocked_class_delegations.include?(sym)
384
+ return true
385
+ end
386
+
387
+ # Whitelist a conservative set of Enumerable-like methods for convenience.
388
+ ARRAY_DELEGATED_METHODS.include?(method_name.to_sym) || super
389
+ end
390
+
391
+ protected
392
+
393
+ # Spawn a new relation with a deep-duplicated mutable state.
394
+ # @yieldparam state [Hash]
395
+ # @return [SearchEngine::Relation]
396
+ def spawn
397
+ mutable_state = deep_dup(@state)
398
+ yield mutable_state
399
+ self.class.new(@klass, mutable_state)
400
+ end
401
+
402
+ private
403
+
404
+ # Apply a model scope against the current relation when present.
405
+ # Returns :__se_no_scope when no scope matches.
406
+ def apply_model_scope(sym, args, kwargs)
407
+ return :__se_no_scope unless @klass.respond_to?(:__search_engine_scope_registry__)
408
+
409
+ impl = @klass.__search_engine_scope_registry__[sym]
410
+ return :__se_no_scope unless impl
411
+
412
+ norm_args, norm_kwargs = normalize_scope_args_for(impl, args, kwargs)
413
+ result = instance_exec(*norm_args, **norm_kwargs, &impl)
414
+ return self if result.nil? || result.equal?(@klass)
415
+ return result if result.is_a?(SearchEngine::Relation)
416
+
417
+ raise ArgumentError,
418
+ "scope :#{sym} must return a SearchEngine::Relation (got #{result.class})"
419
+ end
420
+
421
+ def normalize_scope_args_for(impl, args, kwargs)
422
+ return [args, kwargs] unless @klass.respond_to?(:__se_normalize_scope_args, true)
423
+
424
+ @klass.__send__(:__se_normalize_scope_args, impl, args, kwargs)
425
+ end
426
+
427
+ # True when the relation has already executed and memoized the result.
428
+ # @return [Boolean]
429
+ def loaded?
430
+ @__loaded == true
431
+ end
432
+
433
+ def summary_inspect_string
434
+ parts = []
435
+ parts << "Model=#{klass_name_for_inspect}"
436
+
437
+ if (pn = @state[:preset_name])
438
+ pm = @state[:preset_mode] || :merge
439
+ parts << %(preset=#{pn}(mode=#{pm}))
440
+ end
441
+
442
+ filters = Array(@state[:filters])
443
+ parts << "filters=#{filters.length}" unless filters.empty?
444
+
445
+ ast_nodes = Array(@state[:ast])
446
+ parts << "ast=#{ast_nodes.length}" unless ast_nodes.empty?
447
+
448
+ compiled = begin
449
+ SearchEngine::CompiledParams.from(to_typesense_params)
450
+ rescue StandardError
451
+ {}
452
+ end
453
+
454
+ sort_str = compiled[:sort_by]
455
+ parts << %(sort="#{truncate_for_inspect(sort_str)}") if sort_str && !sort_str.to_s.empty?
456
+
457
+ append_selection_inspect_parts(parts, compiled)
458
+
459
+ if (g = @state[:grouping])
460
+ gparts = ["group_by=#{g[:field]}"]
461
+ gparts << "limit=#{g[:limit]}" if g[:limit]
462
+ gparts << 'missing_values=true' if g[:missing_values]
463
+ parts << gparts.join(' ')
464
+ end
465
+
466
+ parts << "page=#{compiled[:page]}" if compiled.key?(:page)
467
+ parts << "per=#{compiled[:per_page]}" if compiled.key?(:per_page)
468
+
469
+ "#<#{self.class.name} #{parts.join(' ')} >"
470
+ end
471
+
472
+ def interactive_console?
473
+ return true if defined?(Rails::Console)
474
+ return true if defined?(IRB) && $stdout.respond_to?(:tty?) && $stdout.tty?
475
+
476
+ # Pry detection (best-effort, without hard dependency)
477
+ return true if defined?(Pry) && (Pry.respond_to?(:active?) ? Pry.active? : true)
478
+
479
+ return true if $PROGRAM_NAME&.end_with?('console')
480
+
481
+ false
482
+ end
483
+
484
+ def klass_name_for_inspect
485
+ @klass.respond_to?(:name) && @klass.name ? @klass.name : @klass.to_s
486
+ end
487
+
488
+ def safe_attributes_map
489
+ if @klass.respond_to?(:attributes)
490
+ base = @klass.attributes || {}
491
+ return base if base.key?(:id)
492
+
493
+ # Mirror parser logic for implicit id type
494
+ if @klass.instance_variable_defined?(:@__identify_by_kind__) &&
495
+ @klass.instance_variable_get(:@__identify_by_kind__) == :symbol &&
496
+ @klass.instance_variable_defined?(:@__identify_by_symbol__) &&
497
+ @klass.instance_variable_get(:@__identify_by_symbol__) == :id
498
+ base.merge(id: :integer)
499
+ else
500
+ base.merge(id: :string)
501
+ end
502
+ else
503
+ { id: :string }
504
+ end
505
+ end
506
+
507
+ def validate_hash_keys!(hash, attributes_map)
508
+ return if hash.nil? || hash.empty?
509
+
510
+ known = attributes_map.keys.map(&:to_sym)
511
+ # Ignore association-style keys whose values are Hash (handled by DSL::Parser for joins)
512
+ candidate_keys = hash.reject { |_, v| v.is_a?(Hash) }.keys
513
+ unknown = candidate_keys.map(&:to_sym) - known
514
+ return if unknown.empty?
515
+
516
+ begin
517
+ cfg = SearchEngine.config
518
+ return unless cfg.respond_to?(:strict_fields) ? cfg.strict_fields : true
519
+ rescue StandardError
520
+ nil
521
+ end
522
+
523
+ klass_name = klass_name_for_inspect
524
+ known_list = known.map(&:to_s).sort.join(', ')
525
+ unknown_name = unknown.first.inspect
526
+ raise ArgumentError, "Unknown attribute #{unknown_name} for #{klass_name}. Known: #{known_list}"
527
+ end
528
+
529
+ def collection_name_for_klass
530
+ return @klass.collection if @klass.respond_to?(:collection) && @klass.collection
531
+
532
+ begin
533
+ mapping = SearchEngine::Registry.mapping
534
+ found = mapping.find { |(_, kls)| kls == @klass }
535
+ return found.first if found
536
+ rescue StandardError
537
+ nil
538
+ end
539
+
540
+ raise ArgumentError, "Unknown collection for #{klass_name_for_inspect}"
541
+ end
542
+
543
+ def client
544
+ # Prefer legacy ivar when explicitly set (tests or injected stubs), otherwise memoize with conventional name
545
+ return @__client if instance_variable_defined?(:@__client) && @__client
546
+
547
+ @client ||= SearchEngine.client
548
+ end
549
+
550
+ def build_url_opts
551
+ opts = @state[:options] || {}
552
+ url = {}
553
+ url[:use_cache] = option_value(opts, :use_cache) if opts.key?(:use_cache) || opts.key?('use_cache')
554
+ if opts.key?(:cache_ttl) || opts.key?('cache_ttl')
555
+ url[:cache_ttl] = begin
556
+ Integer(option_value(opts, :cache_ttl))
557
+ rescue StandardError
558
+ nil
559
+ end
560
+ end
561
+ url.compact
562
+ end
563
+
564
+ # pluck helpers reside in Materializers
565
+
566
+ def curated_indices_for_current_result
567
+ @__result_memo.to_a.each_with_index.select do |obj, _idx|
568
+ obj.respond_to?(:curated_hit?) && obj.curated_hit?
569
+ end.map(&:last)
570
+ end
571
+
572
+ def curation_filter_curated_hits?
573
+ @state[:curation] && @state[:curation][:filter_curated_hits]
574
+ end
575
+
576
+ def enforce_hit_validator_if_needed!(total_hits, collection: nil)
577
+ hl = @state[:hit_limits]
578
+ return unless hl && hl[:max]
579
+
580
+ th = total_hits.to_i
581
+ max = hl[:max].to_i
582
+ return unless th > max && max.positive?
583
+
584
+ coll = collection || begin
585
+ collection_name_for_klass
586
+ rescue StandardError
587
+ nil
588
+ end
589
+
590
+ msg = "HitLimitExceeded: #{th} results exceed max=#{max}"
591
+ raise SearchEngine::Errors::HitLimitExceeded.new(
592
+ msg,
593
+ hint: 'Increase `validate_hits!(max:)` or narrow filters. Prefer `limit_hits(n)` to avoid work when supported.',
594
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/hit-limits#validation',
595
+ details: { total_hits: th, max: max, collection: coll, relation_summary: inspect }
596
+ )
597
+ end
598
+
599
+ def append_boolean_knobs_explain_lines(lines)
600
+ lines << " use_synonyms: #{@state[:use_synonyms]}" if @state.key?(:use_synonyms) && !@state[:use_synonyms].nil?
601
+ return unless @state.key?(:use_stopwords) && !@state[:use_stopwords].nil?
602
+
603
+ lines << " use_stopwords: #{@state[:use_stopwords]} (maps to remove_stop_words=#{!@state[:use_stopwords]})"
604
+ end
605
+
606
+ def append_where_and_order_lines(lines, params)
607
+ if params[:filter_by] && !params[:filter_by].to_s.strip.empty?
608
+ where_str = friendly_where(params[:filter_by].to_s)
609
+ lines << " where: #{where_str}"
610
+ end
611
+ lines << " order: #{params[:sort_by]}" if params[:sort_by] && !params[:sort_by].to_s.strip.empty?
612
+ end
613
+
614
+ def append_grouping_explain_lines(lines)
615
+ if (g = @state[:grouping])
616
+ gparts = ["group_by=#{g[:field]}"]
617
+ gparts << "limit=#{g[:limit]}" if g[:limit]
618
+ gparts << 'missing_values=true' if g[:missing_values]
619
+ lines << " group: #{gparts.join(' ')}"
620
+ end
621
+ end
622
+ end
623
+ end