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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module SearchEngine
6
+ # Builder for assembling labeled Relations into a federated multi-search.
7
+ #
8
+ # Pure collector: validates labels and relations, preserves insertion order,
9
+ # and produces per-search payload hashes ready for {SearchEngine::Client#multi_search}.
10
+ #
11
+ # Usage (via the module-level convenience):
12
+ # SearchEngine.multi_search(common: { query_by: SearchEngine.config.default_query_by }) do |m|
13
+ # m.add :products, Product.all.where(active: true).per(10)
14
+ # m.add :brands, Brand.all.where('name:~rud').per(5)
15
+ # end
16
+ class Multi
17
+ # Lightweight internal entry
18
+ Entry = Struct.new(:label, :key, :relation, :api_key, keyword_init: true)
19
+
20
+ # URL-level options that must never appear in request bodies.
21
+ URL_ONLY_KEYS = %i[use_cache cache_ttl].freeze
22
+
23
+ # Keys that must be present/retained for :only mode in multi-search payloads.
24
+ ESSENTIAL_MULTI_KEYS = %i[collection q page per_page preset].freeze
25
+
26
+ # Canonicalize a label into a case-insensitive Symbol key.
27
+ # @param label [String, Symbol]
28
+ # @return [Symbol]
29
+ # @raise [ArgumentError] when label is invalid
30
+ def self.canonicalize_label(label)
31
+ raise ArgumentError, 'label must be a Symbol or String' unless label.is_a?(String) || label.is_a?(Symbol)
32
+
33
+ s = label.to_s.strip
34
+ raise ArgumentError, 'label cannot be blank' if s.empty?
35
+
36
+ s.downcase.to_sym
37
+ end
38
+
39
+ # Create a new Multi builder.
40
+ # @return [void]
41
+ def initialize
42
+ @entries = []
43
+ @keys = Set.new
44
+ end
45
+
46
+ # Add a labeled search relation.
47
+ #
48
+ # @param label [String, Symbol] unique label (case-insensitive)
49
+ # @param relation [Object] object responding to +to_typesense_params+ and +klass+
50
+ # @param api_key [String, nil] per-search API key (unsupported; see below)
51
+ # @return [self]
52
+ # @raise [ArgumentError] when label is duplicate/invalid, relation is invalid, or api_key is provided
53
+ # @note Per-search api_key is not supported by the underlying Typesense client and will raise.
54
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/multi-search-guide`
55
+ def add(label, relation, api_key: nil)
56
+ key = Multi.canonicalize_label(label)
57
+ raise ArgumentError, "Multi#add: duplicate label #{label.inspect} (labels must be unique)." if @keys.include?(key)
58
+
59
+ validate_relation!(relation)
60
+ if api_key
61
+ raise ArgumentError,
62
+ 'Per-search api_key is not supported by the Typesense multi-search API; ' \
63
+ 'set a global API key in SearchEngine.config instead.'
64
+ end
65
+
66
+ @entries << Entry.new(label: label, key: key, relation: relation, api_key: nil)
67
+ @keys << key
68
+ self
69
+ end
70
+
71
+ # Ordered list of canonical labels (Symbols).
72
+ # @return [Array<Symbol>]
73
+ def labels
74
+ @entries.map(&:key)
75
+ end
76
+
77
+ # Build per-search payloads (order preserved), merging common params.
78
+ #
79
+ # For each entry, the payload is:
80
+ # { collection: <String>, **common_filtered, **per_search_params_filtered }
81
+ # Per-search values win over +common+ on shallow merge.
82
+ # URL-only options (:use_cache, :cache_ttl) are filtered from both sources.
83
+ # Empty values are omitted for cleanliness.
84
+ #
85
+ # Curation:
86
+ # - Reuses Relation#to_typesense_params to inject per-entry curation keys
87
+ # - Body-only: curation keys never appear in URL opts or top-level
88
+ # - Deterministic: preserves first-occurrence order for pinned IDs
89
+ #
90
+ # Presets:
91
+ # - Preset token lives inside each per-search payload under :preset (never top-level)
92
+ # - mode=:merge — pass through compiler output (includes :preset)
93
+ # - mode=:only — retain only ESSENTIAL_MULTI_KEYS (collection,q,page,per_page,preset) after common merge
94
+ # - mode=:lock — defensively drop any keys present in Config.presets.locked_domains from the merged payload
95
+ # (conflict recording remains in single-search compile)
96
+ #
97
+ # @param common [Hash] optional parameters applied to each per-search payload
98
+ # @return [Array<Hash>] array of request bodies suitable for Client#multi_search
99
+ # @raise [ArgumentError] when +common+ is not a Hash, when duplicate labels are detected,
100
+ # or when a relation is invalid / lacks a bound collection
101
+ # @example
102
+ # m = SearchEngine::Multi.new
103
+ # m.add(:products, Product.all.per(10))
104
+ # m.to_payloads(common: { query_by: SearchEngine.config.default_query_by })
105
+ # # => [{ collection: "products", q: "*", query_by: "name", per_page: 10 }]
106
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/multi-search-guide`
107
+ def to_payloads(common: {})
108
+ raise ArgumentError, 'common must be a Hash' unless common.is_a?(Hash)
109
+
110
+ filtered_common = filter_url_only_keys(common)
111
+
112
+ seen = Set.new
113
+ @entries.map do |e|
114
+ # Guard against external mutation that might have introduced duplicates
115
+ raise ArgumentError, "duplicate label: #{e.label.inspect}" if seen.include?(e.key)
116
+
117
+ seen << e.key
118
+
119
+ # Validate relation contract at compile time (in case of external mutation)
120
+ begin
121
+ validate_relation!(e.relation)
122
+ rescue ArgumentError
123
+ raise ArgumentError,
124
+ "invalid relation for label #{e.label.inspect}: expected a Relation with a bound collection"
125
+ end
126
+
127
+ per_search = SearchEngine::CompiledParams.from(e.relation.to_typesense_params).to_h
128
+ per_search = filter_url_only_keys(per_search)
129
+
130
+ collection = collection_name_for_relation(e.relation)
131
+
132
+ # Shallow-merge with per-search winning
133
+ merged = filtered_common.merge(per_search)
134
+ payload = { collection: collection }.merge(EssentialPruner.prune(merged, relation: e.relation))
135
+ prune_empty_values(payload)
136
+ end
137
+ end
138
+
139
+ # Ordered list of model classes bound to each entry.
140
+ # @return [Array<Class>]
141
+ def klasses
142
+ @entries.map { |e| e.relation.klass }
143
+ end
144
+
145
+ # ResultSet maps labels to {SearchEngine::Result} while preserving order.
146
+ class ResultSet
147
+ # @param pairs [Array<Array(Symbol, SearchEngine::Result)>>] ordered (label, result) pairs
148
+ def initialize(pairs)
149
+ @labels = []
150
+ @map = {}
151
+ pairs.each do |(label, result)|
152
+ key = Multi.canonicalize_label(label)
153
+ @labels << key
154
+ @map[key] = result
155
+ end
156
+ freeze
157
+ end
158
+
159
+ # Fetch a result by label (String or Symbol).
160
+ # @param label [String, Symbol]
161
+ # @return [SearchEngine::Result, nil]
162
+ def [](label)
163
+ @map[Multi.canonicalize_label(label)]
164
+ end
165
+
166
+ # Alias for {#[]} to support dig-like access.
167
+ # @param label [String, Symbol]
168
+ # @return [SearchEngine::Result, nil]
169
+ def dig(label)
170
+ self[label]
171
+ end
172
+
173
+ # Ordered label list (canonical Symbol keys).
174
+ # @return [Array<Symbol>]
175
+ def labels
176
+ @labels.dup
177
+ end
178
+
179
+ # Shallow Hash mapping labels to results.
180
+ # @return [Hash{Symbol=>SearchEngine::Result}]
181
+ def to_h
182
+ @map.dup
183
+ end
184
+
185
+ # Iterate over (label, result) in order.
186
+ # @yieldparam label [Symbol]
187
+ # @yieldparam result [SearchEngine::Result]
188
+ # @return [Enumerator]
189
+ def each_pair
190
+ return enum_for(:each_pair) unless block_given?
191
+
192
+ @labels.each { |l| yield(l, @map[l]) }
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ def validate_relation!(relation)
199
+ unless relation.respond_to?(:to_typesense_params) && relation.respond_to?(:klass)
200
+ raise ArgumentError, 'relation must respond to :to_typesense_params and :klass'
201
+ end
202
+
203
+ k = relation.klass
204
+ raise ArgumentError, 'relation.klass must be a Class' unless k.is_a?(Class)
205
+
206
+ # Ensure collection can be resolved early for clearer errors
207
+ collection_name_for_relation(relation)
208
+ end
209
+
210
+ def collection_name_for_relation(relation)
211
+ k = relation.klass
212
+ return k.collection if k.respond_to?(:collection) && k.collection && !k.collection.to_s.strip.empty?
213
+
214
+ # Fallback: reverse-lookup in registry (mirrors Relation#collection_name_for_klass)
215
+ begin
216
+ mapping = SearchEngine::Registry.mapping
217
+ found = mapping.find { |(_, cls)| cls == k }
218
+ return found.first if found
219
+ rescue StandardError
220
+ # ignore
221
+ end
222
+
223
+ name = k.respond_to?(:name) && k.name ? k.name : k.to_s
224
+ raise ArgumentError, "Unknown collection for #{name}"
225
+ end
226
+
227
+ # Remove URL-only options to avoid leaking them into request bodies.
228
+ # Returns a new Hash.
229
+ def filter_url_only_keys(hash)
230
+ return {} unless hash.is_a?(Hash)
231
+
232
+ hash.reject { |k, _| URL_ONLY_KEYS.include?(k.is_a?(Symbol) ? k : k.to_s.to_sym) }
233
+ end
234
+
235
+ # Remove empty/nil values from the payload for a clean body.
236
+ # Returns a new Hash preserving insertion order.
237
+ def prune_empty_values(hash)
238
+ out = {}
239
+ hash.each do |k, v|
240
+ next if v.nil?
241
+
242
+ if v.is_a?(String)
243
+ next if v.strip.empty?
244
+ elsif v.respond_to?(:empty?)
245
+ next if v.empty?
246
+ end
247
+ out[k] = v
248
+ end
249
+ out
250
+ end
251
+
252
+ # Internal helpers for applying preset-mode safeguards deterministically.
253
+ module EssentialPruner
254
+ module_function
255
+
256
+ def prune(merged_hash, relation:)
257
+ preset = relation.respond_to?(:preset_name) ? relation.preset_name : nil
258
+ return merged_hash unless preset
259
+
260
+ mode = relation.respond_to?(:preset_mode) ? relation.preset_mode : :merge
261
+ case mode
262
+ when :only
263
+ keep_only_essentials(merged_hash)
264
+ when :lock
265
+ drop_locked_domains(merged_hash)
266
+ else
267
+ merged_hash
268
+ end
269
+ end
270
+
271
+ def keep_only_essentials(hash)
272
+ out = {}
273
+ ESSENTIAL_MULTI_KEYS.each do |k|
274
+ out[k] = hash[k] if hash.key?(k)
275
+ end
276
+ out
277
+ end
278
+
279
+ def drop_locked_domains(hash)
280
+ locked = SearchEngine.config.presets.locked_domains_set
281
+ # Return a shallow copy with locked keys removed
282
+ hash.reject { |k, _| locked.include?(k) }
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # MultiResult wraps a Typesense multi-search response and exposes
5
+ # labeled Result objects while preserving insertion order.
6
+ #
7
+ # Labels are canonicalized using {SearchEngine::Multi.canonicalize_label}
8
+ # to case-insensitive Symbols. Accessors accept String or Symbol labels.
9
+ #
10
+ # Construction expects parallel arrays of labels and raw result items
11
+ # (as returned by Typesense under 'results'). An optional mapping of
12
+ # model classes may be provided either as an Array (parallel to labels)
13
+ # or as a Hash keyed by label. When a class is not provided, hydration
14
+ # falls back to the collection registry if the raw result exposes a
15
+ # collection name; otherwise OpenStruct is used.
16
+ #
17
+ # Network calls are performed only once by the caller (e.g.,
18
+ # {SearchEngine.multi_search_result}). This wrapper operates purely
19
+ # in-memory and will never make HTTP requests.
20
+ #
21
+ # @example
22
+ # mr = SearchEngine::MultiResult.new(
23
+ # labels: [:products, :brands],
24
+ # raw_results: [raw_a, raw_b],
25
+ # klasses: [Product, Brand]
26
+ # )
27
+ # mr[:products].found
28
+ # mr.dig('brands').to_a
29
+ # mr.labels #=> [:products, :brands]
30
+ class MultiResult
31
+ # Build a new MultiResult.
32
+ #
33
+ # @param labels [Array<String, Symbol>] ordered labels
34
+ # @param raw_results [Array<Hash>] ordered raw result items (one per label)
35
+ # @param klasses [Array<Class>, Hash{(String,Symbol)=>Class}, nil] optional model classes
36
+ # @raise [ArgumentError] when sizes mismatch, labels invalid/duplicate, or inputs malformed
37
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/multi-search-guide`
38
+ def initialize(labels:, raw_results:, klasses: nil)
39
+ @labels = canonicalize_labels(labels)
40
+ @map = {}
41
+
42
+ # Store the raw array privately to document the one-time network call and hydration
43
+ @raw_results = Array(raw_results).freeze
44
+
45
+ validate_sizes!(@labels, @raw_results, klasses)
46
+
47
+ klass_by_index = build_klass_index(@labels, klasses)
48
+
49
+ @labels.each_with_index do |label, index|
50
+ raw = @raw_results[index]
51
+ kls = klass_by_index[index] || infer_klass_from_raw(raw)
52
+ @map[label] = SearchEngine::Result.new(raw, klass: kls)
53
+ end
54
+
55
+ freeze
56
+ end
57
+
58
+ # Return a shallow copy of labels to preserve immutability.
59
+ # @return [Array<Symbol>]
60
+ def labels
61
+ @labels.dup
62
+ end
63
+
64
+ # Fetch a Result by label.
65
+ # @param label [String, Symbol]
66
+ # @return [SearchEngine::Result, nil]
67
+ def [](label)
68
+ @map[SearchEngine::Multi.canonicalize_label(label)]
69
+ rescue ArgumentError
70
+ nil
71
+ end
72
+
73
+ # Alias for {#[]} to support dig-like ergonomics.
74
+ # @param label [String, Symbol]
75
+ # @return [SearchEngine::Result, nil]
76
+ def dig(label)
77
+ self[label]
78
+ end
79
+
80
+ # Hash-like alias for {#labels}.
81
+ # @return [Array<Symbol>]
82
+ def keys
83
+ labels
84
+ end
85
+
86
+ # Shallow Hash mapping labels to results.
87
+ # Pure helper: returns a shallow copy and never performs HTTP.
88
+ # @return [Hash{Symbol=>SearchEngine::Result}]
89
+ def to_h
90
+ @map.dup
91
+ end
92
+
93
+ # Iterate over (label, result) in insertion order.
94
+ # Yields a two-element array to support destructuring via `|(label, result)|`.
95
+ # Pure helper: operates on cached hydration and never performs HTTP.
96
+ # @yieldparam pair [Array(Symbol, SearchEngine::Result)]
97
+ # @return [Enumerator] when no block is given
98
+ def each_label
99
+ return enum_for(:each_label) unless block_given?
100
+
101
+ @labels.each { |l| yield([l, @map[l]]) }
102
+ end
103
+
104
+ # Convenience to map over (label, result) pairs.
105
+ # Implemented via {#each_label}, purely in-memory.
106
+ # @yieldparam label [Symbol]
107
+ # @yieldparam result [SearchEngine::Result]
108
+ # @return [Enumerator, Array] Enumerator when no block is given; otherwise the mapped Array
109
+ # @example
110
+ # mr.map_labels { |label, result| [label, result.found] }
111
+ def map_labels
112
+ return each_label.map unless block_given?
113
+
114
+ each_label.map { |(label, result)| yield(label, result) }
115
+ end
116
+
117
+ private
118
+
119
+ def canonicalize_labels(labels)
120
+ unless labels.is_a?(Array) && labels.all? { |l| l.is_a?(String) || l.is_a?(Symbol) }
121
+ raise ArgumentError, 'labels must be an Array of String/Symbol'
122
+ end
123
+
124
+ out = []
125
+ seen = {}
126
+ labels.each do |l|
127
+ key = SearchEngine::Multi.canonicalize_label(l)
128
+ raise ArgumentError, "duplicate label: #{l.inspect}" if seen[key]
129
+
130
+ seen[key] = true
131
+ out << key
132
+ end
133
+ out
134
+ end
135
+
136
+ def validate_sizes!(labels, raw_results, klasses)
137
+ raise ArgumentError, 'raw_results must be an Array' unless raw_results.is_a?(Array)
138
+
139
+ if labels.size != raw_results.size
140
+ raise ArgumentError,
141
+ [
142
+ "labels count (#{labels.size}) does not match raw_results count (#{raw_results.size}).",
143
+ 'Verify builder vs. client mapping by index.'
144
+ ].join(' ')
145
+ end
146
+
147
+ return unless klasses.is_a?(Array) && klasses.size != labels.size
148
+
149
+ raise ArgumentError, "klasses count (#{klasses.size}) does not match labels count (#{labels.size})."
150
+ end
151
+
152
+ def build_klass_index(labels, klasses)
153
+ return {} if klasses.nil?
154
+
155
+ if klasses.is_a?(Array)
156
+ return klasses.each_with_index.to_h { |kls, idx| [idx, (kls if kls.is_a?(Class))] }
157
+ end
158
+
159
+ if klasses.is_a?(Hash)
160
+ index = {}
161
+ labels.each_with_index do |label, idx|
162
+ k = klasses[label] || klasses[label.to_s] || klasses[label.to_sym]
163
+ index[idx] = k if k.is_a?(Class)
164
+ end
165
+ return index
166
+ end
167
+
168
+ raise ArgumentError, 'klasses must be an Array, a Hash, or nil'
169
+ end
170
+
171
+ def infer_klass_from_raw(raw)
172
+ # Some Typesense clients may include `collection` alongside each result item
173
+ # in multi-search responses. Resolve via registry when present; otherwise nil.
174
+ name = begin
175
+ raw && (raw['collection'] || raw[:collection])
176
+ rescue StandardError
177
+ nil
178
+ end
179
+ return nil if name.nil?
180
+
181
+ SearchEngine.collection_for(name)
182
+ rescue ArgumentError
183
+ nil
184
+ end
185
+ end
186
+ end