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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/app/search_engine/search_engine/app_info.rb +11 -0
- data/app/search_engine/search_engine/index_partition_job.rb +170 -0
- data/lib/generators/search_engine/install/install_generator.rb +20 -0
- data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
- data/lib/generators/search_engine/model/model_generator.rb +86 -0
- data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
- data/lib/search-engine-for-typesense.rb +12 -0
- data/lib/search_engine/active_record_syncable.rb +247 -0
- data/lib/search_engine/admin/stopwords.rb +125 -0
- data/lib/search_engine/admin/synonyms.rb +125 -0
- data/lib/search_engine/admin.rb +12 -0
- data/lib/search_engine/ast/and.rb +52 -0
- data/lib/search_engine/ast/binary_op.rb +75 -0
- data/lib/search_engine/ast/eq.rb +19 -0
- data/lib/search_engine/ast/group.rb +18 -0
- data/lib/search_engine/ast/gt.rb +12 -0
- data/lib/search_engine/ast/gte.rb +12 -0
- data/lib/search_engine/ast/in.rb +28 -0
- data/lib/search_engine/ast/lt.rb +12 -0
- data/lib/search_engine/ast/lte.rb +12 -0
- data/lib/search_engine/ast/matches.rb +55 -0
- data/lib/search_engine/ast/node.rb +176 -0
- data/lib/search_engine/ast/not_eq.rb +13 -0
- data/lib/search_engine/ast/not_in.rb +24 -0
- data/lib/search_engine/ast/or.rb +52 -0
- data/lib/search_engine/ast/prefix.rb +51 -0
- data/lib/search_engine/ast/raw.rb +41 -0
- data/lib/search_engine/ast/unary_op.rb +43 -0
- data/lib/search_engine/ast.rb +101 -0
- data/lib/search_engine/base/creation.rb +727 -0
- data/lib/search_engine/base/deletion.rb +80 -0
- data/lib/search_engine/base/display_coercions.rb +36 -0
- data/lib/search_engine/base/hydration.rb +312 -0
- data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
- data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
- data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
- data/lib/search_engine/base/index_maintenance.rb +459 -0
- data/lib/search_engine/base/indexing_dsl.rb +255 -0
- data/lib/search_engine/base/joins.rb +479 -0
- data/lib/search_engine/base/model_dsl.rb +472 -0
- data/lib/search_engine/base/presets.rb +43 -0
- data/lib/search_engine/base/pretty_printer.rb +315 -0
- data/lib/search_engine/base/relation_delegation.rb +42 -0
- data/lib/search_engine/base/scopes.rb +113 -0
- data/lib/search_engine/base/updating.rb +92 -0
- data/lib/search_engine/base.rb +38 -0
- data/lib/search_engine/bulk.rb +284 -0
- data/lib/search_engine/cache.rb +33 -0
- data/lib/search_engine/cascade.rb +531 -0
- data/lib/search_engine/cli/doctor.rb +631 -0
- data/lib/search_engine/cli/support.rb +217 -0
- data/lib/search_engine/cli.rb +222 -0
- data/lib/search_engine/client/http_adapter.rb +63 -0
- data/lib/search_engine/client/request_builder.rb +92 -0
- data/lib/search_engine/client/services/base.rb +74 -0
- data/lib/search_engine/client/services/collections.rb +161 -0
- data/lib/search_engine/client/services/documents.rb +214 -0
- data/lib/search_engine/client/services/operations.rb +152 -0
- data/lib/search_engine/client/services/search.rb +190 -0
- data/lib/search_engine/client/services.rb +29 -0
- data/lib/search_engine/client.rb +765 -0
- data/lib/search_engine/client_options.rb +20 -0
- data/lib/search_engine/collection_resolver.rb +191 -0
- data/lib/search_engine/collections_graph.rb +330 -0
- data/lib/search_engine/compiled_params.rb +143 -0
- data/lib/search_engine/compiler.rb +383 -0
- data/lib/search_engine/config/observability.rb +27 -0
- data/lib/search_engine/config/presets.rb +92 -0
- data/lib/search_engine/config/selection.rb +16 -0
- data/lib/search_engine/config/typesense.rb +48 -0
- data/lib/search_engine/config/validators.rb +97 -0
- data/lib/search_engine/config.rb +917 -0
- data/lib/search_engine/console_helpers.rb +130 -0
- data/lib/search_engine/deletion.rb +103 -0
- data/lib/search_engine/dispatcher.rb +125 -0
- data/lib/search_engine/dsl/parser.rb +582 -0
- data/lib/search_engine/engine.rb +167 -0
- data/lib/search_engine/errors.rb +290 -0
- data/lib/search_engine/filters/sanitizer.rb +189 -0
- data/lib/search_engine/hydration/materializers.rb +808 -0
- data/lib/search_engine/hydration/selection_context.rb +96 -0
- data/lib/search_engine/indexer/batch_planner.rb +76 -0
- data/lib/search_engine/indexer/bulk_import.rb +626 -0
- data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
- data/lib/search_engine/indexer/retry_policy.rb +103 -0
- data/lib/search_engine/indexer.rb +747 -0
- data/lib/search_engine/instrumentation.rb +308 -0
- data/lib/search_engine/joins/guard.rb +202 -0
- data/lib/search_engine/joins/resolver.rb +95 -0
- data/lib/search_engine/logging/color.rb +78 -0
- data/lib/search_engine/logging/format_helpers.rb +92 -0
- data/lib/search_engine/logging/partition_progress.rb +53 -0
- data/lib/search_engine/logging_subscriber.rb +388 -0
- data/lib/search_engine/mapper.rb +785 -0
- data/lib/search_engine/multi.rb +286 -0
- data/lib/search_engine/multi_result.rb +186 -0
- data/lib/search_engine/notifications/compact_logger.rb +675 -0
- data/lib/search_engine/observability.rb +162 -0
- data/lib/search_engine/operations.rb +58 -0
- data/lib/search_engine/otel.rb +227 -0
- data/lib/search_engine/partitioner.rb +128 -0
- data/lib/search_engine/ranking_plan.rb +118 -0
- data/lib/search_engine/registry.rb +158 -0
- data/lib/search_engine/relation/compiler.rb +711 -0
- data/lib/search_engine/relation/deletion.rb +37 -0
- data/lib/search_engine/relation/dsl/filters.rb +624 -0
- data/lib/search_engine/relation/dsl/selection.rb +240 -0
- data/lib/search_engine/relation/dsl.rb +903 -0
- data/lib/search_engine/relation/dx/dry_run.rb +59 -0
- data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
- data/lib/search_engine/relation/dx.rb +231 -0
- data/lib/search_engine/relation/materializers.rb +118 -0
- data/lib/search_engine/relation/options.rb +138 -0
- data/lib/search_engine/relation/state.rb +274 -0
- data/lib/search_engine/relation/updating.rb +44 -0
- data/lib/search_engine/relation.rb +623 -0
- data/lib/search_engine/result.rb +664 -0
- data/lib/search_engine/schema.rb +1083 -0
- data/lib/search_engine/sources/active_record_source.rb +185 -0
- data/lib/search_engine/sources/base.rb +62 -0
- data/lib/search_engine/sources/lambda_source.rb +55 -0
- data/lib/search_engine/sources/sql_source.rb +196 -0
- data/lib/search_engine/sources.rb +71 -0
- data/lib/search_engine/stale_rules.rb +160 -0
- data/lib/search_engine/test/minitest_assertions.rb +57 -0
- data/lib/search_engine/test/offline_client.rb +134 -0
- data/lib/search_engine/test/rspec_matchers.rb +77 -0
- data/lib/search_engine/test/stub_client.rb +201 -0
- data/lib/search_engine/test.rb +66 -0
- data/lib/search_engine/test_autoload.rb +8 -0
- data/lib/search_engine/update.rb +35 -0
- data/lib/search_engine/version.rb +7 -0
- data/lib/search_engine.rb +332 -0
- data/lib/tasks/search_engine.rake +501 -0
- data/lib/tasks/search_engine_doctor.rake +16 -0
- 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
|