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,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
|