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,808 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module Hydration
5
+ # Centralized executors for materialization: to_a/each/count/ids/pluck/pick.
6
+ # Accepts a Relation instance; never mutates it; performs at most one HTTP call
7
+ # per relation instance by reusing its internal memoization lock.
8
+ module Materializers
9
+ module_function
10
+
11
+ # Execute the relation and return a Result, memoizing on the relation.
12
+ # @param relation [SearchEngine::Relation]
13
+ # @return [SearchEngine::Result]
14
+ def execute(relation)
15
+ loaded = relation.instance_variable_get(:@__loaded)
16
+ memo = relation.instance_variable_get(:@__result_memo)
17
+ return memo if loaded && memo
18
+
19
+ load_lock = relation.instance_variable_get(:@__load_lock)
20
+ load_lock.synchronize do
21
+ loaded = relation.instance_variable_get(:@__loaded)
22
+ memo = relation.instance_variable_get(:@__result_memo)
23
+ return memo if loaded && memo
24
+
25
+ collection = relation.send(:collection_name_for_klass)
26
+ params = SearchEngine::CompiledParams.from(relation.to_typesense_params)
27
+ url_opts = relation.send(:build_url_opts)
28
+
29
+ raw_result = nil
30
+
31
+ # Preflight client-side fallback (extracted for readability)
32
+ preflight_raw, relation, params = preflight_join_fallback_if_needed(relation, params)
33
+
34
+ begin
35
+ raw_result = preflight_raw || relation.send(:client).search(
36
+ collection: collection,
37
+ params: params,
38
+ url_opts: url_opts
39
+ )
40
+ rescue SearchEngine::Errors::Api => error
41
+ # Graceful empty fallback for infix/prefix configuration errors
42
+ if infix_missing_error?(error)
43
+ empty = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
44
+ raw_result = SearchEngine::Result.new(empty, klass: relation.klass)
45
+ else
46
+ # Client-side join fallback: handle missing Typesense reference for joined filters
47
+ raise unless join_reference_missing_error?(error) && Array(relation.joins_list).any?
48
+
49
+ fallback_rel = build_client_side_join_fallback_relation(relation)
50
+ if fallback_rel.equal?(:__empty__)
51
+ # Short-circuit: no matches
52
+ empty = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
53
+ raw_result = SearchEngine::Result.new(empty, klass: relation.klass)
54
+ else
55
+ # Retry with rewritten base relation
56
+ new_params = SearchEngine::CompiledParams.from(fallback_rel.to_typesense_params)
57
+ raw_result = relation.send(:client).search(collection: collection, params: new_params,
58
+ url_opts: url_opts
59
+ )
60
+ instrument_client_side_fallback(relation)
61
+ # Replace relation for selection/facets context below
62
+ relation = fallback_rel
63
+ end
64
+ end
65
+ end
66
+
67
+ selection_ctx = SearchEngine::Hydration::SelectionContext.build(relation)
68
+ facets_ctx = build_facets_context_from_state(relation)
69
+
70
+ result = if selection_ctx || facets_ctx
71
+ SearchEngine::Result.new(
72
+ raw_result.raw,
73
+ klass: relation.klass,
74
+ selection: selection_ctx,
75
+ facets: facets_ctx
76
+ )
77
+ else
78
+ raw_result
79
+ end
80
+
81
+ relation.send(:enforce_hit_validator_if_needed!, result.found, collection: collection)
82
+ relation.instance_variable_set(:@__result_memo, result)
83
+ relation.instance_variable_set(:@__loaded, true)
84
+ result
85
+ end
86
+ end
87
+
88
+ # --- Public materializers (delegation targets) ------------------------
89
+
90
+ # Return a shallow copy of hydrated hits.
91
+ # @return [Array<Object>]
92
+ def to_a(relation)
93
+ result = execute(relation)
94
+ result.to_a
95
+ end
96
+
97
+ # Lightweight preview for console rendering; fetches up to the given limit
98
+ # without memoizing as the relation’s main result. Does not mutate relation state.
99
+ # @param relation [SearchEngine::Relation]
100
+ # @param limit [Integer]
101
+ # @return [Array<Object>]
102
+ def preview(relation, limit)
103
+ limit = limit.to_i
104
+ return [] unless limit.positive?
105
+
106
+ if relation.send(:loaded?)
107
+ memo = relation.instance_variable_get(:@__result_memo)
108
+ return [] unless memo
109
+
110
+ array = memo.respond_to?(:to_a) ? memo.to_a : Array(memo)
111
+ return array.first(limit)
112
+ end
113
+
114
+ if relation.instance_variable_defined?(:@__preview_memo)
115
+ cached = relation.instance_variable_get(:@__preview_memo)
116
+ return Array(cached).first(limit)
117
+ end
118
+
119
+ # Use the relation's effective per_page for the underlying request to keep
120
+ # ordering consistent with to_a; slice to the preview limit locally.
121
+ per_for_request = effective_per_page(relation)
122
+ per_for_request = limit if per_for_request.to_i <= 0
123
+
124
+ preview_relation = relation.send(:spawn) do |state|
125
+ state[:page] = 1
126
+ state[:per_page] = per_for_request
127
+ end
128
+
129
+ collection = preview_relation.send(:collection_name_for_klass)
130
+ params = SearchEngine::CompiledParams.from(preview_relation.to_typesense_params)
131
+ url_opts = preview_relation.send(:build_url_opts)
132
+
133
+ raw_result = perform_preview_search_with_fallback(preview_relation, collection, params, url_opts)
134
+
135
+ selection_ctx = SearchEngine::Hydration::SelectionContext.build(preview_relation)
136
+ facets_ctx = build_facets_context_from_state(preview_relation)
137
+
138
+ result = if selection_ctx || facets_ctx
139
+ SearchEngine::Result.new(
140
+ raw_result.raw,
141
+ klass: preview_relation.klass,
142
+ selection: selection_ctx,
143
+ facets: facets_ctx
144
+ )
145
+ else
146
+ raw_result
147
+ end
148
+
149
+ array = Array(result.respond_to?(:to_a) ? result.to_a : result).first(limit)
150
+ relation.instance_variable_set(:@__preview_memo, array)
151
+ array
152
+ end
153
+
154
+ # Internal: perform preview search, applying client-side fallback when Typesense
155
+ # reports a missing reference for joined filters.
156
+ # @param preview_relation [SearchEngine::Relation]
157
+ # @param collection [String]
158
+ # @param params [SearchEngine::CompiledParams]
159
+ # @param url_opts [Hash]
160
+ # @return [Object] raw result from client
161
+ def perform_preview_search_with_fallback(preview_relation, collection, params, url_opts)
162
+ preview_relation.send(:client).search(
163
+ collection: collection,
164
+ params: params,
165
+ url_opts: url_opts
166
+ )
167
+ rescue SearchEngine::Errors::Api => error
168
+ # Graceful empty fallback for infix/prefix configuration errors
169
+ if infix_missing_error?(error)
170
+ empty_raw = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
171
+ return SearchEngine::Result.new(empty_raw, klass: preview_relation.klass)
172
+ end
173
+ raise unless join_reference_missing_error?(error) && Array(preview_relation.joins_list).any?
174
+
175
+ fallback_rel = build_client_side_join_fallback_relation(preview_relation)
176
+ if fallback_rel.equal?(:__empty__)
177
+ empty_raw = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
178
+ return SearchEngine::Result.new(empty_raw, klass: preview_relation.klass)
179
+ end
180
+
181
+ new_params = SearchEngine::CompiledParams.from(fallback_rel.to_typesense_params)
182
+ res = preview_relation.send(:client).search(
183
+ collection: collection,
184
+ params: new_params,
185
+ url_opts: url_opts
186
+ )
187
+ instrument_client_side_fallback(preview_relation)
188
+ res
189
+ end
190
+
191
+ def each(relation, &block)
192
+ arr = to_a(relation)
193
+ block_given? ? arr.each(&block) : arr.each
194
+ end
195
+
196
+ def first(relation, n = nil)
197
+ # Fast path: when relation has a single equality predicate on id and n=nil, use document GET
198
+ if n.nil?
199
+ id_value = detect_equality_id_predicate_value(relation)
200
+ if id_value
201
+ doc = retrieve_document_by_id(relation, id_value)
202
+ return doc unless doc.nil?
203
+ end
204
+ end
205
+
206
+ arr = to_a(relation)
207
+ return arr.first if n.nil?
208
+
209
+ arr.first(n)
210
+ end
211
+
212
+ def last(relation, n = nil)
213
+ arr = to_a(relation)
214
+ return arr.last if n.nil?
215
+
216
+ arr.last(n)
217
+ end
218
+
219
+ def take(relation, n = 1)
220
+ arr = to_a(relation)
221
+ return arr.first if n == 1
222
+
223
+ arr.first(n)
224
+ end
225
+
226
+ def ids(relation)
227
+ pluck(relation, :id)
228
+ end
229
+
230
+ def pick(relation, *fields)
231
+ raise ArgumentError, 'pick requires at least one field' if fields.nil? || fields.empty?
232
+
233
+ loaded = relation.instance_variable_get(:@__loaded)
234
+ return pluck(relation, *fields).first if loaded
235
+
236
+ state = relation.instance_variable_get(:@state) || {}
237
+ relation_for_pick =
238
+ if state[:page] || state[:per_page]
239
+ relation
240
+ else
241
+ relation.limit(1)
242
+ end
243
+ pluck(relation_for_pick, *fields).first
244
+ end
245
+
246
+ def pluck(relation, *fields)
247
+ raise ArgumentError, 'pluck requires at least one field' if fields.nil? || fields.empty?
248
+
249
+ names = coerce_pluck_field_names(fields)
250
+ validate_pluck_fields_allowed!(relation, names)
251
+
252
+ result = execute(relation)
253
+ raw_hits = Array(result.raw['hits'])
254
+ objects = result.to_a
255
+
256
+ enforce_strict_for_pluck_row = lambda do |doc, requested|
257
+ present_keys = doc.keys.map(&:to_s)
258
+ if result.respond_to?(:send)
259
+ ctx = result.instance_variable_get(:@selection_ctx) || {}
260
+ if ctx[:strict_missing] == true
261
+ result.send(:enforce_strict_missing_if_needed!, present_keys, requested_override: requested)
262
+ end
263
+ end
264
+ end
265
+
266
+ if names.length == 1
267
+ field = names.first
268
+ return objects.each_with_index.map do |obj, idx|
269
+ doc = (raw_hits[idx] && raw_hits[idx]['document']) || {}
270
+ # Enforce strict missing for the requested field against present keys
271
+ enforce_strict_for_pluck_row.call(doc, [field])
272
+ if obj.respond_to?(field)
273
+ obj.public_send(field)
274
+ else
275
+ doc[field]
276
+ end
277
+ end
278
+ end
279
+
280
+ objects.each_with_index.map do |obj, idx|
281
+ doc = (raw_hits[idx] && raw_hits[idx]['document']) || {}
282
+ # Enforce strict missing for all requested fields against present keys
283
+ enforce_strict_for_pluck_row.call(doc, names)
284
+ names.map do |field|
285
+ if obj.respond_to?(field)
286
+ obj.public_send(field)
287
+ else
288
+ doc[field]
289
+ end
290
+ end
291
+ end
292
+ end
293
+
294
+ def exists?(relation)
295
+ loaded = relation.instance_variable_get(:@__loaded)
296
+ memo = relation.instance_variable_get(:@__result_memo)
297
+ return memo.found.to_i.positive? if loaded && memo
298
+
299
+ fetch_found_only(relation).positive?
300
+ end
301
+
302
+ def count(relation)
303
+ if relation.send(:curation_filter_curated_hits?)
304
+ to_a(relation)
305
+ return relation.send(:curated_indices_for_current_result).size
306
+ end
307
+
308
+ loaded = relation.instance_variable_get(:@__loaded)
309
+ memo = relation.instance_variable_get(:@__result_memo)
310
+ return memo.found.to_i if loaded && memo
311
+
312
+ fetch_found_only(relation)
313
+ end
314
+
315
+ # Compute number of result pages based on total hits and per-page value.
316
+ # @return [Integer]
317
+ def pages_count(relation)
318
+ total_hits = count(relation).to_i
319
+ return 0 if total_hits <= 0
320
+
321
+ per_page = effective_per_page(relation)
322
+ return total_hits if per_page <= 0
323
+
324
+ (total_hits.to_f / per_page).ceil
325
+ end
326
+
327
+ # --- internals --------------------------------------------------------
328
+
329
+ def fetch_found_only(relation)
330
+ collection = relation.send(:collection_name_for_klass)
331
+ base = SearchEngine::CompiledParams.from(relation.to_typesense_params).to_h
332
+
333
+ minimal = base.dup
334
+ minimal[:per_page] = 1
335
+ minimal[:page] = 1
336
+ minimal[:include_fields] = 'id'
337
+
338
+ url_opts = relation.send(:build_url_opts)
339
+ begin
340
+ result = relation.send(:client).search(collection: collection, params: minimal, url_opts: url_opts)
341
+ rescue SearchEngine::Errors::Api => error
342
+ return 0 if infix_missing_error?(error)
343
+ # Client-side join fallback: handle missing Typesense reference for joined filters in count path
344
+ raise unless join_reference_missing_error?(error) && Array(relation.joins_list).any?
345
+
346
+ fallback_rel = build_client_side_join_fallback_relation(relation)
347
+ return 0 if fallback_rel.equal?(:__empty__)
348
+
349
+ new_params = SearchEngine::CompiledParams.from(fallback_rel.to_typesense_params).to_h
350
+ new_minimal = new_params.dup
351
+ new_minimal[:per_page] = 1
352
+ new_minimal[:page] = 1
353
+ new_minimal[:include_fields] = 'id'
354
+
355
+ result = relation.send(:client).search(collection: collection, params: new_minimal, url_opts: url_opts)
356
+ instrument_client_side_fallback(relation)
357
+ end
358
+
359
+ count = result.found.to_i
360
+ relation.send(:enforce_hit_validator_if_needed!, count, collection: collection)
361
+ count
362
+ end
363
+ module_function :fetch_found_only
364
+
365
+ # Detect Typesense 400 errors caused by missing infix/prefix configuration
366
+ # e.g., "Could not find `name` in the infix index. Make sure to enable infix search by specifying `infix: true` in the schema."
367
+ def infix_missing_error?(error)
368
+ return false unless error.is_a?(SearchEngine::Errors::Api)
369
+
370
+ status = error.status.to_i
371
+ return false unless status == 400
372
+
373
+ body = error.body
374
+ msg = error.message.to_s
375
+ # Match common phrasing from Typesense for missing infix/prefix index
376
+ need_infix = 'infix index'
377
+ enable_infix = 'enable infix'
378
+ could_not_find = 'Could not find'
379
+ missing_prefix = 'prefix index'
380
+ (
381
+ body.is_a?(String) && (body.include?(need_infix) ||
382
+ body.include?(enable_infix) || body.include?(missing_prefix))
383
+ ) ||
384
+ msg.include?(need_infix) || msg.include?(enable_infix) ||
385
+ msg.include?(missing_prefix) || msg.include?(could_not_find)
386
+ rescue StandardError
387
+ false
388
+ end
389
+
390
+ # --- client-side join fallback helpers ---------------------------------
391
+
392
+ # True when the relation uses joined fields in filters and the base
393
+ # collection schema lacks a matching reference for at least one of
394
+ # those associations.
395
+ def join_fallback_preflight_required?(relation)
396
+ state = relation.instance_variable_get(:@state) || {}
397
+ ast_nodes = Array(state[:ast]).flatten.compact
398
+ assocs = extract_join_assocs_from_ast(ast_nodes)
399
+ return false if assocs.empty?
400
+
401
+ base_klass = relation.klass
402
+ compiled = SearchEngine::Schema.compile(base_klass)
403
+ fields = Array(compiled[:fields])
404
+ by_name = {}
405
+ fields.each do |f|
406
+ name = (f[:name] || f['name']).to_s
407
+ by_name[name] = f
408
+ end
409
+
410
+ assocs.any? do |assoc|
411
+ begin
412
+ cfg = base_klass.join_for(assoc)
413
+ lk = (cfg[:local_key] || '').to_s
414
+ fk = (cfg[:foreign_key] || '').to_s
415
+ coll = (cfg[:collection] || '').to_s
416
+ expected = "#{coll}.#{fk}"
417
+ entry = by_name[lk]
418
+ actual = entry && (entry[:reference] || entry['reference'])
419
+ actual_str = actual.to_s
420
+ # Accept async suffix on actual
421
+ next true if actual_str.empty? || !actual_str.start_with?(expected)
422
+ rescue StandardError
423
+ next true
424
+ end
425
+ false
426
+ end
427
+ rescue StandardError
428
+ false
429
+ end
430
+
431
+ # Walk AST nodes and collect association names used via "$assoc.field".
432
+ def extract_join_assocs_from_ast(nodes)
433
+ list = Array(nodes).flatten.compact
434
+ return [] if list.empty?
435
+
436
+ seen = []
437
+ walker = lambda do |node|
438
+ return unless node.is_a?(SearchEngine::AST::Node)
439
+
440
+ if node.respond_to?(:field)
441
+ field = node.field.to_s
442
+ if field.start_with?('$')
443
+ m = field.match(/^\$(\w+)\./)
444
+ if m
445
+ name = m[1].to_sym
446
+ seen << name unless seen.include?(name)
447
+ end
448
+ end
449
+ end
450
+
451
+ Array(node.children).each { |child| walker.call(child) }
452
+ end
453
+ list.each { |n| walker.call(n) }
454
+ seen
455
+ end
456
+
457
+ # Attempt a client-side fallback rewrite before making a request when
458
+ # joined filters are present but the base schema lacks the needed reference.
459
+ # Returns [raw_result_or_nil, relation, params]
460
+ def preflight_join_fallback_if_needed(relation, params)
461
+ raw_result = nil
462
+ try_fallback = begin
463
+ join_fallback_preflight_required?(relation)
464
+ rescue StandardError
465
+ false
466
+ end
467
+
468
+ if try_fallback
469
+ begin
470
+ fallback_rel = build_client_side_join_fallback_relation(relation)
471
+ if fallback_rel.equal?(:__empty__)
472
+ empty = { 'hits' => [], 'found' => 0, 'out_of' => 0 }
473
+ raw_result = SearchEngine::Result.new(empty, klass: relation.klass)
474
+ else
475
+ relation = fallback_rel
476
+ params = SearchEngine::CompiledParams.from(relation.to_typesense_params)
477
+ instrument_client_side_fallback(relation)
478
+ end
479
+ rescue StandardError
480
+ # ignore and proceed
481
+ end
482
+ end
483
+ [raw_result, relation, params]
484
+ end
485
+
486
+ def join_reference_missing_error?(error)
487
+ return false unless error.is_a?(SearchEngine::Errors::Api)
488
+
489
+ body = error.body
490
+ msg = error.message.to_s
491
+ needle = 'No reference field found'
492
+ (body.is_a?(String) && body.include?(needle)) || msg.include?(needle)
493
+ rescue StandardError
494
+ false
495
+ end
496
+
497
+ def build_client_side_join_fallback_relation(relation)
498
+ state = relation.instance_variable_get(:@state) || {}
499
+ ast_nodes = Array(state[:ast]).flatten.compact
500
+ joins = Array(state[:joins]).flatten.compact
501
+ return relation if joins.empty?
502
+
503
+ # Guard: sorting or selection on joined fields not supported in v1
504
+ orders = Array(state[:orders]).map(&:to_s)
505
+ if orders.any? { |o| o.start_with?('$') }
506
+ raise SearchEngine::Errors::InvalidOption.new(
507
+ 'Sorting by joined fields is not supported by client-side join fallback',
508
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#client-side-fallback'
509
+ )
510
+ end
511
+ include_str = begin
512
+ relation.send(:compile_include_fields_string)
513
+ rescue StandardError
514
+ nil
515
+ end
516
+ if include_str&.split(',')&.any? { |seg| seg.strip.start_with?('$') }
517
+ raise SearchEngine::Errors::InvalidOption.new(
518
+ 'Selecting joined fields is not supported by client-side join fallback',
519
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#client-side-fallback'
520
+ )
521
+ end
522
+
523
+ # For each applied assoc, collect inner predicates (Eq/In only)
524
+ per_assoc_inners = extract_join_inners(ast_nodes)
525
+ return relation if per_assoc_inners.empty?
526
+
527
+ # Resolve keys and fetch key sets via pre-query
528
+ key_sets = {}
529
+ base_klass = relation.klass
530
+ joins.each do |assoc|
531
+ cfg = base_klass.join_for(assoc)
532
+ inners = per_assoc_inners[assoc] || []
533
+ next if inners.empty?
534
+
535
+ keys = fetch_keys_for_assoc(base_klass, cfg, inners)
536
+ key_sets[assoc] = keys
537
+ end
538
+
539
+ # If any assoc produced no keys, the AND semantics imply empty
540
+ return :__empty__ if key_sets.values.any? { |arr| Array(arr).empty? }
541
+
542
+ # Rewrite AST: remove joined nodes and add base IN(local_key, keys) per assoc
543
+ rewritten_ast = rewrite_ast_with_key_sets(ast_nodes, key_sets, base_klass)
544
+
545
+ relation.send(:spawn) do |s|
546
+ s[:ast] = rewritten_ast
547
+ # NOTE: s[:filters] retained (base fragments only); joins preserved for DX
548
+ end
549
+ end
550
+
551
+ def extract_join_inners(ast_nodes)
552
+ map = {}
553
+ walker = lambda do |node|
554
+ return unless node.is_a?(SearchEngine::AST::Node)
555
+
556
+ node.children.each { |ch| walker.call(ch) } if node.respond_to?(:children) && node.children
557
+
558
+ if node.respond_to?(:field)
559
+ field = node.field.to_s
560
+ if (m = field.match(/^\$(\w+)\.(.+)$/))
561
+ assoc = m[1].to_sym
562
+ inner_field = m[2]
563
+ case node
564
+ when SearchEngine::AST::Eq
565
+ (map[assoc] ||= []) << [:eq, inner_field, node.value]
566
+ when SearchEngine::AST::In
567
+ (map[assoc] ||= []) << [:in, inner_field, node.values]
568
+ else
569
+ # Unsupported node type for fallback (e.g., ranges, not_eq, etc.)
570
+ raise SearchEngine::Errors::InvalidOption.new(
571
+ 'Only equality and IN predicates on joined fields are supported by client-side join fallback',
572
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#client-side-fallback'
573
+ )
574
+ end
575
+ end
576
+ end
577
+ end
578
+
579
+ Array(ast_nodes).each { |n| walker.call(n) }
580
+ map
581
+ end
582
+
583
+ def fetch_keys_for_assoc(base_klass, assoc_cfg, inners)
584
+ require 'search_engine/joins/resolver'
585
+ keys = SearchEngine::Joins::Resolver.resolve_keys(base_klass, assoc_cfg)
586
+ collection = assoc_cfg[:collection]
587
+ target_klass = SearchEngine.collection_for(collection)
588
+
589
+ # Build target relation by AND-ing inner predicates
590
+ rel = target_klass.all
591
+ inners.each do |(op, field, value)|
592
+ case op
593
+ when :eq
594
+ rel = rel.where(field.to_s => value)
595
+ when :in
596
+ rel = rel.where(field.to_s => Array(value))
597
+ end
598
+ end
599
+
600
+ vals = rel.pluck(keys[:foreign_key])
601
+ Array(vals).flatten.compact.uniq
602
+ end
603
+
604
+ def rewrite_ast_with_key_sets(ast_nodes, key_sets, base_klass)
605
+ # Remove joined predicates and append base IN(local_key, keys) for each assoc
606
+ stripped = strip_join_nodes(ast_nodes)
607
+ added = []
608
+ key_sets.each do |assoc, keys|
609
+ cfg = base_klass.join_for(assoc)
610
+ require 'search_engine/joins/resolver'
611
+ lk = SearchEngine::Joins::Resolver.resolve_keys(base_klass, cfg)[:local_key]
612
+ added << SearchEngine::AST.in_(lk.to_sym, keys)
613
+ end
614
+ (Array(stripped) + added).flatten.compact
615
+ end
616
+
617
+ def strip_join_nodes(nodes)
618
+ out = []
619
+ Array(nodes).each do |node|
620
+ next unless node.is_a?(SearchEngine::AST::Node)
621
+
622
+ case node
623
+ when SearchEngine::AST::And
624
+ children = strip_join_nodes(node.children)
625
+ out.concat(Array(children))
626
+ when SearchEngine::AST::Or
627
+ # Fallback does not support OR with joined nodes; reject early
628
+ raise SearchEngine::Errors::InvalidOption.new(
629
+ 'OR with joined predicates is not supported by client-side join fallback',
630
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/joins#client-side-fallback'
631
+ )
632
+ else
633
+ if node.respond_to?(:field)
634
+ f = node.field.to_s
635
+ next if f.start_with?('$')
636
+ end
637
+ out << node
638
+ end
639
+ end
640
+ out
641
+ end
642
+
643
+ def instrument_client_side_fallback(relation)
644
+ return unless defined?(SearchEngine::Instrumentation)
645
+
646
+ SearchEngine::Instrumentation.instrument(
647
+ 'search_engine.joins.client_side_fallback',
648
+ collection: (relation.klass.respond_to?(:collection) ? relation.klass.collection : nil),
649
+ joins: Array(relation.joins_list).map(&:to_s)
650
+ )
651
+ rescue StandardError
652
+ nil
653
+ end
654
+
655
+ def effective_per_page(relation)
656
+ state = relation.instance_variable_get(:@state) || {}
657
+ per_page = state[:per_page]
658
+ return per_page.to_i if per_page.to_i.positive?
659
+
660
+ loaded = relation.instance_variable_get(:@__loaded)
661
+ memo = relation.instance_variable_get(:@__result_memo)
662
+ if loaded && memo
663
+ request_params = memo.raw['request_params'] || memo.raw[:request_params]
664
+ request_params ||= memo.raw['search_params'] || memo.raw[:search_params]
665
+ if request_params
666
+ per = request_params['per_page'] || request_params[:per_page]
667
+ return per.to_i if per.to_i.positive?
668
+ end
669
+
670
+ return memo.hits.size if memo.respond_to?(:hits)
671
+ end
672
+
673
+ 10
674
+ end
675
+ module_function :effective_per_page
676
+
677
+ # Retrieve and hydrate all matching records by paging via multi-search.
678
+ # Uses maximum per_page=250 to minimize requests and chunks batches under multi_search_limit.
679
+ # @param relation [SearchEngine::Relation]
680
+ # @return [Array<Object>]
681
+ def all!(relation)
682
+ total = count(relation)
683
+ return [] if total.to_i <= 0
684
+
685
+ per = 250
686
+ pages = (total.to_f / per).ceil
687
+
688
+ # Fast path for a single page
689
+ return relation.page(1).per(per).to_a if pages <= 1
690
+
691
+ limit = begin
692
+ SearchEngine.config.multi_search_limit.to_i
693
+ rescue StandardError
694
+ 50
695
+ end
696
+ limit = 1 if limit <= 0
697
+
698
+ page_numbers = (1..pages).to_a
699
+ out = []
700
+
701
+ page_numbers.each_slice(limit) do |batch|
702
+ mr = SearchEngine.multi_search_result do |m|
703
+ batch.each do |p|
704
+ m.add("p#{p}", relation.page(p).per(per))
705
+ end
706
+ end
707
+
708
+ batch.each do |p|
709
+ res = mr["p#{p}"]
710
+ out.concat(Array(res&.to_a))
711
+ end
712
+ end
713
+
714
+ out
715
+ end
716
+
717
+ def coerce_pluck_field_names(fields)
718
+ Array(fields).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?)
719
+ end
720
+ module_function :coerce_pluck_field_names
721
+
722
+ def validate_pluck_fields_allowed!(relation, names)
723
+ state = relation.instance_variable_get(:@state) || {}
724
+ include_base = Array(state[:select]).map(&:to_s)
725
+ exclude_base = Array(state[:exclude]).map(&:to_s)
726
+
727
+ missing = if include_base.empty?
728
+ names & exclude_base
729
+ else
730
+ allowed = include_base - exclude_base
731
+ names - allowed
732
+ end
733
+
734
+ return if missing.empty?
735
+
736
+ msg = build_invalid_selection_message_for_pluck(
737
+ missing: missing,
738
+ requested: names,
739
+ include_base: include_base,
740
+ exclude_base: exclude_base
741
+ )
742
+ field = missing.map(&:to_s).min
743
+ hint = exclude_base.include?(field) ? "Remove exclude(:#{field})." : nil
744
+ raise SearchEngine::Errors::InvalidSelection.new(
745
+ msg,
746
+ hint: hint,
747
+ doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/field-selection#guardrails--errors',
748
+ details: { requested: names, include_base: include_base, exclude_base: exclude_base }
749
+ )
750
+ end
751
+ module_function :validate_pluck_fields_allowed!
752
+
753
+ def build_invalid_selection_message_for_pluck(missing:, requested:, include_base:, exclude_base:)
754
+ field = missing.map(&:to_s).min
755
+ if exclude_base.include?(field)
756
+ "InvalidSelection: field :#{field} not in effective selection. Remove exclude(:#{field})."
757
+ else
758
+ suggestion_fields = include_base.dup
759
+ requested.each { |f| suggestion_fields << f unless suggestion_fields.include?(f) }
760
+ symbols = suggestion_fields.map { |t| ":#{t}" }.join(',')
761
+ "InvalidSelection: field :#{field} not in effective selection. Use `reselect(#{symbols})`."
762
+ end
763
+ end
764
+ module_function :build_invalid_selection_message_for_pluck
765
+
766
+ def build_facets_context_from_state(relation)
767
+ state = relation.instance_variable_get(:@state) || {}
768
+ fields = Array(state[:facet_fields]).map(&:to_s)
769
+ queries = Array(state[:facet_queries]).map do |q|
770
+ h = { field: q[:field].to_s, expr: q[:expr].to_s }
771
+ h[:label] = q[:label].to_s if q[:label]
772
+ h
773
+ end
774
+ return nil if fields.empty? && queries.empty?
775
+
776
+ { fields: fields.freeze, queries: queries.freeze }.freeze
777
+ end
778
+ module_function :build_facets_context_from_state
779
+
780
+ # Detect a simple AST eq(:id, value) predicate with no other filters.
781
+ def detect_equality_id_predicate_value(relation)
782
+ state = relation.instance_variable_get(:@state) || {}
783
+ ast_nodes = Array(state[:ast]).flatten.compact
784
+ return nil unless ast_nodes.size == 1
785
+
786
+ node = ast_nodes.first
787
+ return nil unless node.is_a?(SearchEngine::AST::Eq)
788
+ return nil unless node.field.to_s == 'id'
789
+
790
+ node.value
791
+ rescue StandardError
792
+ nil
793
+ end
794
+
795
+ # Use the Typesense retrieve-by-id endpoint and hydrate the document.
796
+ def retrieve_document_by_id(relation, id_value)
797
+ collection = relation.send(:collection_name_for_klass)
798
+ client = relation.send(:client)
799
+ raw = client.retrieve_document(collection: collection, id: id_value)
800
+ return nil unless raw.is_a?(Hash)
801
+
802
+ SearchEngine::Base::Creation::Helpers.hydrate_from_document(relation.klass, raw)
803
+ rescue StandardError
804
+ nil
805
+ end
806
+ end
807
+ end
808
+ end