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,479 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/inflector'
5
+
6
+ module SearchEngine
7
+ class Base
8
+ # Join declarations for server-side joins.
9
+ module Joins
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ # Declare a belongs_to association with auto-resolution of params.
14
+ #
15
+ # Defaults when options are omitted (association-name based):
16
+ # - collection: pluralized first argument (e.g., :brand -> "brands", :promotion_labels -> "promotion_labels")
17
+ # - local_key: singular(name) + "_ids" when the provided name is plural; otherwise singular(name) + "_id"
18
+ # (e.g., :promotion_labels -> :promotion_label_ids, :brand -> :brand_id)
19
+ # - foreign_key: singular(name) + "_id" (e.g., :promotion_labels -> :promotion_label_id)
20
+ #
21
+ # @param name [#to_sym]
22
+ # @param collection [#to_s, nil]
23
+ # @param local_key [#to_sym, nil]
24
+ # @param foreign_key [#to_sym, nil]
25
+ # @param async_ref [Boolean] when true, mark this reference as asynchronous in schema
26
+ # @param optional [Boolean, nil] when set, mark local_key as optional in schema
27
+ # @return [void]
28
+ def belongs_to(name, collection: nil, local_key: nil, foreign_key: nil, async_ref: nil, optional: nil)
29
+ assoc_name = name.to_sym
30
+ raise ArgumentError, 'belongs_to name must be non-empty' if assoc_name.to_s.strip.empty?
31
+
32
+ arg_str = assoc_name.to_s
33
+ arg_plural = ActiveSupport::Inflector.pluralize(arg_str) == arg_str
34
+
35
+ target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
36
+
37
+ assoc_singular = ActiveSupport::Inflector.singularize(arg_str)
38
+
39
+ lk = if local_key
40
+ local_key.to_sym
41
+ else
42
+ base = assoc_singular
43
+ (arg_plural ? "#{base}_ids" : "#{base}_id").to_sym
44
+ end
45
+
46
+ fk = if foreign_key
47
+ foreign_key.to_sym
48
+ else
49
+ "#{assoc_singular}_id".to_sym
50
+ end
51
+
52
+ normalized_async = nil
53
+ unless async_ref.nil?
54
+ normalized_async = async_ref ? true : false
55
+ end
56
+
57
+ __se_register_join!(
58
+ name: assoc_name,
59
+ collection: target_collection.to_s,
60
+ local_key: lk,
61
+ foreign_key: fk,
62
+ kind: :belongs_to,
63
+ async_ref: normalized_async
64
+ )
65
+
66
+ unless optional.nil?
67
+ type = (@attributes || {})[lk]
68
+ __se_update_attribute_options!(
69
+ lk,
70
+ type,
71
+ locale: nil,
72
+ optional: optional,
73
+ sort: nil,
74
+ infix: nil,
75
+ empty_filtering: nil,
76
+ facet: nil,
77
+ index: nil
78
+ )
79
+ end
80
+
81
+ # Define an instance-level association reader for AR-like access.
82
+ # Example: product.brand => returns single associated record or nil; product.brands => Relation
83
+ __se_define_assoc_reader_if_needed!(assoc_name)
84
+
85
+ nil
86
+ end
87
+ end
88
+
89
+ class_methods do
90
+ # Declare a belongs_to_many association with auto-resolution of params.
91
+ #
92
+ # Semantics mirror belongs_to for reference emission, but instance
93
+ # reader always returns a Relation (plural) regardless of local value
94
+ # multiplicity. Use when a single local key may refer to many target
95
+ # records via a shared foreign key.
96
+ #
97
+ # Defaults when options are omitted (association-name based):
98
+ # - collection: pluralized first argument (e.g., :calculated_product -> "calculated_products")
99
+ # - local_key: singular(name) + "_ids" when the provided name is plural; otherwise singular(name) + "_id"
100
+ # - foreign_key: singular(name) + "_id"
101
+ #
102
+ # @param name [#to_sym]
103
+ # @param collection [#to_s, nil]
104
+ # @param local_key [#to_sym, nil]
105
+ # @param foreign_key [#to_sym, nil]
106
+ # @param async_ref [Boolean] when true, mark this reference as asynchronous in schema
107
+ # @return [void]
108
+ def belongs_to_many(name, collection: nil, local_key: nil, foreign_key: nil, async_ref: nil)
109
+ assoc_name = name.to_sym
110
+ raise ArgumentError, 'belongs_to_many name must be non-empty' if assoc_name.to_s.strip.empty?
111
+
112
+ arg_str = assoc_name.to_s
113
+ arg_plural = ActiveSupport::Inflector.pluralize(arg_str) == arg_str
114
+
115
+ target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
116
+
117
+ assoc_singular = ActiveSupport::Inflector.singularize(arg_str)
118
+
119
+ lk = if local_key
120
+ local_key.to_sym
121
+ else
122
+ base = assoc_singular
123
+ (arg_plural ? "#{base}_ids" : "#{base}_id").to_sym
124
+ end
125
+
126
+ fk = if foreign_key
127
+ foreign_key.to_sym
128
+ else
129
+ "#{assoc_singular}_id".to_sym
130
+ end
131
+
132
+ normalized_async = nil
133
+ unless async_ref.nil?
134
+ normalized_async = async_ref ? true : false
135
+ end
136
+
137
+ __se_register_join!(
138
+ name: assoc_name,
139
+ collection: target_collection.to_s,
140
+ local_key: lk,
141
+ foreign_key: fk,
142
+ kind: :belongs_to_many,
143
+ async_ref: normalized_async
144
+ )
145
+
146
+ __se_define_assoc_reader_if_needed!(assoc_name)
147
+
148
+ nil
149
+ end
150
+ end
151
+
152
+ class_methods do
153
+ # Declare a has_one association with deterministic defaults (no name-based plurality heuristics).
154
+ #
155
+ # Defaults when options are omitted:
156
+ # - collection: pluralized first argument
157
+ # - local_key: current collection's singular + "_id"
158
+ # - foreign_key: current collection's singular + "_id"
159
+ #
160
+ # Note: `has_one` does not contribute a schema reference; only `belongs_to`/`belongs_to_many` do.
161
+ #
162
+ # @param name [#to_sym]
163
+ # @param collection [#to_s, nil]
164
+ # @param local_key [#to_sym, nil]
165
+ # @param foreign_key [#to_sym, nil]
166
+ # @return [void]
167
+ # rubocop:disable Naming/PredicatePrefix
168
+ def has_one(name, collection: nil, local_key: nil, foreign_key: nil)
169
+ assoc_name = name.to_sym
170
+ raise ArgumentError, 'has_one name must be non-empty' if assoc_name.to_s.strip.empty?
171
+
172
+ arg_str = assoc_name.to_s
173
+ target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
174
+
175
+ current_collection = respond_to?(:collection) ? collection : nil
176
+ if current_collection.nil?
177
+ demod = self.name ? ActiveSupport::Inflector.demodulize(self.name) : ''
178
+ current_collection = ActiveSupport::Inflector.underscore(demod).to_s
179
+ end
180
+ current_singular = ActiveSupport::Inflector.singularize(current_collection.to_s)
181
+
182
+ lk = local_key ? local_key.to_sym : "#{current_singular}_id".to_sym
183
+ fk = foreign_key ? foreign_key.to_sym : "#{current_singular}_id".to_sym
184
+
185
+ __se_register_join!(
186
+ name: assoc_name,
187
+ collection: target_collection.to_s,
188
+ local_key: lk,
189
+ foreign_key: fk,
190
+ kind: :has_one
191
+ )
192
+
193
+ __se_define_assoc_reader_if_needed!(assoc_name)
194
+
195
+ nil
196
+ end
197
+ # rubocop:enable Naming/PredicatePrefix
198
+
199
+ # Declare a has_many association with deterministic defaults (no name-based plurality heuristics).
200
+ #
201
+ # Defaults when options are omitted:
202
+ # - collection: pluralized first argument
203
+ # - local_key: current collection's singular + "_id"
204
+ # - foreign_key: current collection's singular + "_ids"
205
+ #
206
+ # Note: `has_many` does not contribute a schema reference; only `belongs_to`/`belongs_to_many` do.
207
+ #
208
+ # @param name [#to_sym]
209
+ # @param collection [#to_s, nil]
210
+ # @param local_key [#to_sym, nil]
211
+ # @param foreign_key [#to_sym, nil]
212
+ # @return [void]
213
+ # rubocop:disable Naming/PredicatePrefix
214
+ def has_many(name, collection: nil, local_key: nil, foreign_key: nil)
215
+ assoc_name = name.to_sym
216
+ raise ArgumentError, 'has_many name must be non-empty' if assoc_name.to_s.strip.empty?
217
+
218
+ arg_str = assoc_name.to_s
219
+ target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
220
+
221
+ current_collection = respond_to?(:collection) ? collection : nil
222
+ if current_collection.nil?
223
+ demod = self.name ? ActiveSupport::Inflector.demodulize(self.name) : ''
224
+ current_collection = ActiveSupport::Inflector.underscore(demod).to_s
225
+ end
226
+ current_singular = ActiveSupport::Inflector.singularize(current_collection.to_s)
227
+
228
+ lk = local_key ? local_key.to_sym : "#{current_singular}_id".to_sym
229
+ fk = foreign_key ? foreign_key.to_sym : "#{current_singular}_ids".to_sym
230
+
231
+ __se_register_join!(
232
+ name: assoc_name,
233
+ collection: target_collection.to_s,
234
+ local_key: lk,
235
+ foreign_key: fk,
236
+ kind: :has_many
237
+ )
238
+
239
+ __se_define_assoc_reader_if_needed!(assoc_name)
240
+
241
+ nil
242
+ end
243
+ # rubocop:enable Naming/PredicatePrefix
244
+
245
+ # NOTE: legacy `has` DSL has been removed; no replacement shim is provided.
246
+ end
247
+
248
+ class_methods do
249
+ # Define an instance reader for a declared association that resolves
250
+ # the referenced records through the join config.
251
+ #
252
+ # The reader follows these rules:
253
+ # - has_many :name => returns Relation scoped by foreign_key
254
+ # - has_one :name => returns a single record or nil
255
+ # - belongs_to :singular => returns a single record or nil
256
+ # - belongs_to :plural (local_key array) => returns Relation
257
+ #
258
+ # For array-typed foreign keys on the target, a single local scalar
259
+ # value is wrapped into an Array to preserve IN semantics.
260
+ #
261
+ # @param assoc_name [Symbol]
262
+ # @return [void]
263
+ # rubocop:disable Metrics/PerceivedComplexity
264
+ def __se_define_assoc_reader_if_needed!(assoc_name)
265
+ return if method_defined?(assoc_name)
266
+
267
+ define_method(assoc_name) do
268
+ cfg = self.class.join_for(assoc_name)
269
+ target_klass = SearchEngine.collection_for(cfg[:collection])
270
+
271
+ local_key = cfg[:local_key].to_sym
272
+ foreign_key = cfg[:foreign_key].to_sym
273
+ kind = (cfg[:kind] || :belongs_to).to_sym
274
+
275
+ # Prefer reader when available; fall back to ivar set during hydration
276
+ local_value = if respond_to?(local_key)
277
+ public_send(local_key)
278
+ else
279
+ instance_variable_get("@#{local_key}")
280
+ end
281
+
282
+ # When foreign key on target is an array type, wrap scalar local values
283
+ begin
284
+ attrs = target_klass.respond_to?(:attributes) ? (target_klass.attributes || {}) : {}
285
+ fk_is_array = attrs[foreign_key].is_a?(Array) && attrs[foreign_key].size == 1
286
+ rescue StandardError
287
+ fk_is_array = false
288
+ end
289
+
290
+ normalized = local_value
291
+ normalized = [normalized] if fk_is_array && !normalized.nil? && !normalized.is_a?(Array)
292
+
293
+ case kind
294
+ when :belongs_to_many
295
+ # For nil local values, return an always-empty relation via id:nil
296
+ return target_klass.where(id: nil) if normalized.nil?
297
+
298
+ target_klass.where(foreign_key => normalized)
299
+ when :has_many
300
+ # For nil local values, return an always-empty relation via id:nil
301
+ return target_klass.where(id: nil) if normalized.nil?
302
+
303
+ target_klass.where(foreign_key => normalized)
304
+ when :has_one
305
+ # Singular has_one: nil => nil; Array => first match via find_by semantics
306
+ return nil if normalized.nil?
307
+
308
+ return target_klass.where(foreign_key => normalized).first if normalized.is_a?(Array)
309
+
310
+ target_klass.find_by(foreign_key => normalized)
311
+ else # :belongs_to
312
+ # Singular belongs_to: nil => nil; Array => Relation
313
+ return nil if normalized.nil?
314
+
315
+ if normalized.is_a?(Array)
316
+ # Empty local array => empty relation
317
+ return target_klass.where(id: nil) if normalized.empty?
318
+
319
+ return target_klass.where(foreign_key => normalized)
320
+ end
321
+ target_klass.find_by(foreign_key => normalized)
322
+ end
323
+ end
324
+ end
325
+ private :__se_define_assoc_reader_if_needed!
326
+ # rubocop:enable Metrics/PerceivedComplexity
327
+ end
328
+
329
+ class_methods do
330
+ # Internal: choose foreign_key for belongs_to when not specified.
331
+ # Prefers `<current>_ids` if declared on target; else `<current>_id`.
332
+ # If target has a matching back association referencing this model's collection,
333
+ # prefer `_ids` when that back association is `has_many`, and `_id` when it is `has_one`.
334
+ def __se_guess_fk_for_belongs_to!(target_collection, current_singular)
335
+ # Try to resolve target model to inspect declared attributes and has-configs.
336
+ target_klass = nil
337
+ begin
338
+ target_klass = SearchEngine.collection_for(target_collection)
339
+ rescue StandardError
340
+ target_klass = nil
341
+ end
342
+
343
+ ids_candidate = "#{current_singular}_ids".to_sym
344
+ id_candidate = "#{current_singular}_id".to_sym
345
+
346
+ if target_klass.respond_to?(:attributes)
347
+ attrs = target_klass.attributes || {}
348
+ return ids_candidate if attrs.key?(ids_candidate)
349
+ return id_candidate if attrs.key?(id_candidate)
350
+ end
351
+
352
+ # Inspect target joins to see if it declares a has_one/has_many back to our collection.
353
+ if target_klass.respond_to?(:joins_config)
354
+ begin
355
+ cfgs = target_klass.joins_config || {}
356
+ current_coll_name = respond_to?(:collection) ? (collection || '').to_s : ''
357
+ back = cfgs.values.find do |c|
358
+ c[:collection].to_s == current_coll_name && %w[has_one has_many].include?(c[:kind].to_s)
359
+ end
360
+ if back
361
+ k = back[:kind].to_s
362
+ case k
363
+ when 'has_many'
364
+ return ids_candidate
365
+ when 'has_one'
366
+ return id_candidate
367
+ end
368
+ end
369
+ rescue StandardError
370
+ # ignore
371
+ end
372
+ end
373
+
374
+ id_candidate
375
+ end
376
+ private :__se_guess_fk_for_belongs_to!
377
+ end
378
+
379
+ class_methods do
380
+ # Internal: common registrar used by belongs_to/has and legacy join.
381
+ # Validates, freezes, and stores config with copy-on-write.
382
+ def __se_register_join!(name:, collection:, local_key:, foreign_key:, kind: :belongs_to, async_ref: nil)
383
+ assoc_name = name.to_sym
384
+ raise ArgumentError, 'join name must be non-empty' if assoc_name.to_s.strip.empty?
385
+
386
+ coll = collection.to_s
387
+ raise ArgumentError, 'collection must be a non-empty String' if coll.strip.empty?
388
+
389
+ lk = local_key.to_sym
390
+ fk = foreign_key.to_sym
391
+
392
+ # Validate local_key against declared attributes when available (allow :id implicitly)
393
+ if instance_variable_defined?(:@attributes) && lk != :id && !(@attributes || {}).key?(lk)
394
+ raise SearchEngine::Errors::InvalidField,
395
+ "Unknown local_key :#{lk} for #{self}. Declare 'attribute :#{lk}, :integer' first."
396
+ end
397
+
398
+ rec = {
399
+ name: assoc_name,
400
+ collection: coll,
401
+ local_key: lk,
402
+ foreign_key: fk,
403
+ kind: kind.to_sym
404
+ }.freeze
405
+
406
+ # Extend with async_ref when provided (belongs_to only). Keep record frozen at the end.
407
+ unless async_ref.nil?
408
+ base = rec.dup
409
+ base[:async_ref] = async_ref ? true : false
410
+ rec = base.freeze
411
+ end
412
+
413
+ current = @joins_config || {}
414
+ if current.key?(assoc_name)
415
+ raise ArgumentError,
416
+ "Join :#{assoc_name} already defined for #{self}. " \
417
+ 'Use a different name or remove the previous declaration.'
418
+ end
419
+
420
+ # copy-on-write write path
421
+ new_map = current.dup
422
+ new_map[assoc_name] = rec
423
+ @joins_config = new_map.freeze
424
+
425
+ # lightweight instrumentation (no-op if AS::N is unavailable)
426
+ SearchEngine::Instrumentation.instrument(
427
+ 'search_engine.joins.declared',
428
+ model: self.name, name: assoc_name, collection: coll
429
+ )
430
+
431
+ nil
432
+ end
433
+ private :__se_register_join!
434
+ end
435
+
436
+ class_methods do
437
+ # Declare a joinable association for server-side joins.
438
+ # @param name [#to_sym]
439
+ # @param collection [#to_s]
440
+ # @param local_key [#to_sym]
441
+ # @param foreign_key [#to_sym]
442
+ # @return [void]
443
+ def join(name, collection:, local_key:, foreign_key:)
444
+ __se_register_join!(
445
+ name: name,
446
+ collection: collection,
447
+ local_key: local_key,
448
+ foreign_key: foreign_key,
449
+ kind: :belongs_to
450
+ )
451
+ end
452
+ end
453
+
454
+ class_methods do
455
+ # Read-only view of join declarations for this class.
456
+ # @return [Hash{Symbol=>Hash}]
457
+ def joins_config
458
+ (@joins_config || {}).dup.freeze
459
+ end
460
+ end
461
+
462
+ class_methods do
463
+ # Lookup a single join configuration by name.
464
+ # @param name [#to_sym]
465
+ # @return [Hash]
466
+ # @raise [SearchEngine::Errors::UnknownJoin]
467
+ def join_for(name)
468
+ key = name.to_sym
469
+ cfg = (@joins_config || {})[key]
470
+ return cfg if cfg
471
+
472
+ available = (@joins_config || {}).keys
473
+ raise SearchEngine::Errors::UnknownJoin,
474
+ "Unknown join :#{key} for #{self}. Available: #{available.inspect}."
475
+ end
476
+ end
477
+ end
478
+ end
479
+ end