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,623 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
# Immutable, chainable query relation bound to a model class.
|
|
5
|
+
#
|
|
6
|
+
# Facade wiring that composes State, Options, DSL, Compiler, and Materializers.
|
|
7
|
+
class Relation
|
|
8
|
+
# Limited set of Array/Enumerable methods we intentionally delegate to the
|
|
9
|
+
# materialized results for convenience. Restricting this list prevents
|
|
10
|
+
# accidental network calls during reflection/printing in non‑interactive contexts.
|
|
11
|
+
ARRAY_DELEGATED_METHODS = %i[
|
|
12
|
+
to_a each map collect select filter reject find find_all detect any? all? none? one? empty?
|
|
13
|
+
size length include? first last take drop [] at slice reduce inject sum uniq
|
|
14
|
+
compact flatten each_with_index each_with_object index_by group_by partition grep grep_v flat_map collect_concat
|
|
15
|
+
each_slice each_cons reverse_each sort sort_by min max min_by max_by minmax minmax_by
|
|
16
|
+
take_while drop_while chunk chunk_while slice_when slice_before slice_after lazy
|
|
17
|
+
find_index index rindex values_at sample shuffle rotate
|
|
18
|
+
].freeze
|
|
19
|
+
# Keys considered essential for :only preset mode.
|
|
20
|
+
ESSENTIAL_PARAM_KEYS = %i[q page per_page].freeze
|
|
21
|
+
|
|
22
|
+
# @return [Class] bound model class (typically a SearchEngine::Base subclass)
|
|
23
|
+
attr_reader :klass
|
|
24
|
+
|
|
25
|
+
# Modules are required explicitly to keep require graph stable
|
|
26
|
+
require 'search_engine/relation/state'
|
|
27
|
+
require 'search_engine/relation/options'
|
|
28
|
+
require 'search_engine/relation/dsl'
|
|
29
|
+
require 'search_engine/relation/compiler'
|
|
30
|
+
require 'search_engine/relation/deletion'
|
|
31
|
+
require 'search_engine/relation/updating'
|
|
32
|
+
require 'search_engine/relation/materializers'
|
|
33
|
+
|
|
34
|
+
include State
|
|
35
|
+
include Options
|
|
36
|
+
include DSL
|
|
37
|
+
include Compiler
|
|
38
|
+
include Deletion
|
|
39
|
+
include Updating
|
|
40
|
+
include Materializers
|
|
41
|
+
|
|
42
|
+
# Convenience conversion to compiled body params as a plain Hash.
|
|
43
|
+
def to_h
|
|
44
|
+
v = to_typesense_params
|
|
45
|
+
v.respond_to?(:to_h) ? v.to_h : v
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return compiled Typesense filter_by string for this relation.
|
|
49
|
+
# Pure and deterministic; delegates to the compiler without I/O.
|
|
50
|
+
#
|
|
51
|
+
# @return [String, nil] the filter_by string or nil when absent
|
|
52
|
+
def filter_params
|
|
53
|
+
params = to_typesense_params
|
|
54
|
+
params[:filter_by]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Return a Hash of simple base-field equality filters accumulated on this relation.
|
|
58
|
+
# Extracts only Eq/In predicates on base fields (no joins, no ranges, no negations, no ORs).
|
|
59
|
+
# When the predicate set contains constructs that cannot be represented as a flat Hash
|
|
60
|
+
# (e.g. OR, NOT, range comparisons, join predicates), returns an empty Hash to avoid
|
|
61
|
+
# misrepresenting the filter semantics.
|
|
62
|
+
#
|
|
63
|
+
# @return [Hash{Symbol=>Object}] symbolized base field => value(s) or {}
|
|
64
|
+
def filter_params_hash
|
|
65
|
+
nodes = Array(@state[:ast]).flatten.compact
|
|
66
|
+
return {} if nodes.empty?
|
|
67
|
+
|
|
68
|
+
result = {}
|
|
69
|
+
ambiguous = false
|
|
70
|
+
|
|
71
|
+
walker = lambda do |node|
|
|
72
|
+
return if ambiguous || node.nil?
|
|
73
|
+
|
|
74
|
+
case node
|
|
75
|
+
when SearchEngine::AST::And
|
|
76
|
+
Array(node.children).each { |child| walker.call(child) }
|
|
77
|
+
when SearchEngine::AST::Group
|
|
78
|
+
inner = Array(node.children).first
|
|
79
|
+
walker.call(inner)
|
|
80
|
+
when SearchEngine::AST::Eq
|
|
81
|
+
field = node.field.to_s
|
|
82
|
+
# Only base fields: exclude joins like "$assoc.field"
|
|
83
|
+
result[field.to_sym] = node.value unless join_field?(field)
|
|
84
|
+
when SearchEngine::AST::In
|
|
85
|
+
field = node.field.to_s
|
|
86
|
+
result[field.to_sym] = Array(node.values) unless join_field?(field)
|
|
87
|
+
else
|
|
88
|
+
# Any non-equality, negation, OR, range, or raw fragment makes this ambiguous
|
|
89
|
+
ambiguous ||= ambiguous_ast_node?(node)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
nodes.each { |n| walker.call(n) }
|
|
94
|
+
return {} if ambiguous
|
|
95
|
+
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# True when the field name refers to a joined field like "$assoc.field".
|
|
102
|
+
# @param field [String]
|
|
103
|
+
# @return [Boolean]
|
|
104
|
+
def join_field?(field)
|
|
105
|
+
field.start_with?('$') || field.include?('.')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# True when node type cannot be represented as a flat Hash of base Eq/In.
|
|
109
|
+
# @param node [SearchEngine::AST::Node]
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def ambiguous_ast_node?(node)
|
|
112
|
+
node.is_a?(SearchEngine::AST::Or) ||
|
|
113
|
+
node.is_a?(SearchEngine::AST::NotEq) ||
|
|
114
|
+
node.is_a?(SearchEngine::AST::NotIn) ||
|
|
115
|
+
node.is_a?(SearchEngine::AST::Gt) ||
|
|
116
|
+
node.is_a?(SearchEngine::AST::Gte) ||
|
|
117
|
+
node.is_a?(SearchEngine::AST::Lt) ||
|
|
118
|
+
node.is_a?(SearchEngine::AST::Lte) ||
|
|
119
|
+
node.is_a?(SearchEngine::AST::Raw)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Read-only access to accumulated predicate AST nodes.
|
|
123
|
+
# @return [Array<SearchEngine::AST::Node>] a frozen Array of AST nodes
|
|
124
|
+
def ast
|
|
125
|
+
nodes = Array(@state[:ast])
|
|
126
|
+
nodes.frozen? ? nodes : nodes.dup.freeze
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Return the effective preset mode when a preset is applied.
|
|
130
|
+
# Falls back to :merge when not explicitly set.
|
|
131
|
+
# @return [Symbol]
|
|
132
|
+
def preset_mode
|
|
133
|
+
(@state[:preset_mode] || :merge).to_sym
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Return the effective preset token (namespaced if configured) or nil.
|
|
137
|
+
# @return [String, nil]
|
|
138
|
+
def preset_name
|
|
139
|
+
@state[:preset_name]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
public :ast, :preset_mode, :preset_name, :to_typesense_params
|
|
143
|
+
|
|
144
|
+
# Create a new Relation.
|
|
145
|
+
# @param klass [Class]
|
|
146
|
+
# @param state [Hash]
|
|
147
|
+
def initialize(klass, state = {})
|
|
148
|
+
@klass = klass
|
|
149
|
+
normalized = normalize_initial_state(state)
|
|
150
|
+
@state = DEFAULT_STATE.merge(normalized)
|
|
151
|
+
migrate_legacy_filters_to_ast!(@state)
|
|
152
|
+
deep_freeze_inplace(@state)
|
|
153
|
+
@__result_memo = nil
|
|
154
|
+
@__loaded = false
|
|
155
|
+
@__load_lock = Mutex.new
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Return self for AR-like parity.
|
|
159
|
+
# @return [SearchEngine::Relation]
|
|
160
|
+
def all
|
|
161
|
+
self
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# True when the relation has no accumulated state beyond defaults.
|
|
165
|
+
# @return [Boolean]
|
|
166
|
+
def empty?
|
|
167
|
+
@state == DEFAULT_STATE
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
public
|
|
171
|
+
|
|
172
|
+
# Console-friendly inspect without network I/O.
|
|
173
|
+
# Always return a concise, stable summary to avoid surprises across consoles.
|
|
174
|
+
# @return [String]
|
|
175
|
+
def inspect
|
|
176
|
+
cfg = begin
|
|
177
|
+
SearchEngine.config
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
materialize = cfg.respond_to?(:relation_print_materializes) ? cfg.relation_print_materializes : true
|
|
183
|
+
return summary_inspect_string unless materialize
|
|
184
|
+
|
|
185
|
+
preview_size = 11
|
|
186
|
+
begin
|
|
187
|
+
items = SearchEngine::Hydration::Materializers.preview(self, preview_size)
|
|
188
|
+
entries = Array(items).map { |obj| obj.respond_to?(:inspect) ? obj.inspect : obj.to_s }
|
|
189
|
+
entries[10] = '...' if entries.size == preview_size
|
|
190
|
+
+"#<#{self.class.name} [#{entries.join(', ')}]>"
|
|
191
|
+
rescue StandardError
|
|
192
|
+
# Defensive fallback to non-I/O summary when materialization fails
|
|
193
|
+
summary_inspect_string
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# String form mirrors inspect to support printers that prefer to_s.
|
|
198
|
+
# @return [String]
|
|
199
|
+
def to_s
|
|
200
|
+
inspect
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Pry hooks into pretty_inspect in many cases. Keep it consistent with #inspect
|
|
204
|
+
# to avoid delegating to model class printers accidentally.
|
|
205
|
+
# @return [String]
|
|
206
|
+
def pretty_inspect
|
|
207
|
+
inspect
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Pretty print the concise, stable summary without network I/O.
|
|
211
|
+
# Avoid hydration during console pretty printing to keep behavior predictable.
|
|
212
|
+
# @param pp [PP]
|
|
213
|
+
# @return [void]
|
|
214
|
+
def pretty_print(pp)
|
|
215
|
+
cfg = begin
|
|
216
|
+
SearchEngine.config
|
|
217
|
+
rescue StandardError
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
materialize = cfg.respond_to?(:relation_print_materializes) ? cfg.relation_print_materializes : true
|
|
222
|
+
unless materialize
|
|
223
|
+
pp.text(summary_inspect_string)
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
preview_size = 11
|
|
228
|
+
begin
|
|
229
|
+
items = SearchEngine::Hydration::Materializers.preview(self, preview_size)
|
|
230
|
+
|
|
231
|
+
pp.group(2, "#<#{self.class.name} [", ']>') do
|
|
232
|
+
items.each_with_index do |obj, idx|
|
|
233
|
+
if idx.positive?
|
|
234
|
+
pp.text(',')
|
|
235
|
+
pp.breakable ' '
|
|
236
|
+
end
|
|
237
|
+
pp.pp(obj)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if items.size == preview_size
|
|
241
|
+
pp.text(',') unless items.empty?
|
|
242
|
+
pp.breakable ' '
|
|
243
|
+
pp.text('...')
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
rescue StandardError
|
|
247
|
+
pp.text(summary_inspect_string)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
public :ast, :preset_mode, :preset_name, :to_typesense_params
|
|
252
|
+
|
|
253
|
+
# Explain the current relation without performing any network calls.
|
|
254
|
+
# @return [String]
|
|
255
|
+
def explain(to: nil)
|
|
256
|
+
params = to_typesense_params
|
|
257
|
+
|
|
258
|
+
lines = []
|
|
259
|
+
header = "#{klass_name_for_inspect} Relation"
|
|
260
|
+
lines << header
|
|
261
|
+
|
|
262
|
+
append_preset_explain_line(lines, params)
|
|
263
|
+
append_curation_explain_lines(lines)
|
|
264
|
+
append_boolean_knobs_explain_lines(lines)
|
|
265
|
+
append_where_and_order_lines(lines, params)
|
|
266
|
+
append_grouping_explain_lines(lines)
|
|
267
|
+
append_selection_explain_lines(lines, params)
|
|
268
|
+
add_effective_selection_tokens!(lines)
|
|
269
|
+
add_pagination_line!(lines, params)
|
|
270
|
+
|
|
271
|
+
out = lines.join("\n")
|
|
272
|
+
puts(out) if to == :stdout
|
|
273
|
+
out
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Read-only list of join association names accumulated on this relation.
|
|
277
|
+
# @return [Array<Symbol>]
|
|
278
|
+
def joins_list
|
|
279
|
+
list = Array(@state[:joins])
|
|
280
|
+
list.frozen? ? list : list.dup.freeze
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Read-only grouping state for debugging/explain.
|
|
284
|
+
# @return [Hash, nil]
|
|
285
|
+
def grouping
|
|
286
|
+
g = @state[:grouping]
|
|
287
|
+
return nil if g.nil?
|
|
288
|
+
|
|
289
|
+
g.frozen? ? g : g.dup.freeze
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Read-only selected fields state for debugging (base + nested).
|
|
293
|
+
# @return [Hash]
|
|
294
|
+
def selected_fields_state
|
|
295
|
+
base = Array(@state[:select])
|
|
296
|
+
nested = @state[:select_nested] || {}
|
|
297
|
+
order = Array(@state[:select_nested_order])
|
|
298
|
+
|
|
299
|
+
{
|
|
300
|
+
base: base.dup.freeze,
|
|
301
|
+
nested: nested.transform_values { |arr| Array(arr).dup.freeze }.freeze,
|
|
302
|
+
nested_order: order.dup.freeze
|
|
303
|
+
}.freeze
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Programmatic accessor for preset conflicts in :lock mode.
|
|
307
|
+
# @return [Array<Hash{Symbol=>Symbol}>]
|
|
308
|
+
def preset_conflicts
|
|
309
|
+
params = to_typesense_params
|
|
310
|
+
keys = Array(params[:_preset_conflicts]).map { |k| k.respond_to?(:to_sym) ? k.to_sym : k }.grep(Symbol)
|
|
311
|
+
return [].freeze if keys.empty?
|
|
312
|
+
|
|
313
|
+
keys.sort.map { |k| { key: k, reason: :locked_by_preset } }.freeze
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Read-only hit limits state for debugging/explain.
|
|
317
|
+
# @return [Hash, nil]
|
|
318
|
+
def hit_limits
|
|
319
|
+
hl = @state[:hit_limits]
|
|
320
|
+
return nil if hl.nil?
|
|
321
|
+
|
|
322
|
+
hl.frozen? ? hl : hl.dup.freeze
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Handle unknown methods by delegating to the materialized Array.
|
|
326
|
+
# Allows callers to use enumerable helpers directly on Relation.
|
|
327
|
+
#
|
|
328
|
+
# @param method_name [Symbol]
|
|
329
|
+
# @param args [Array<Object>]
|
|
330
|
+
# @return [Object]
|
|
331
|
+
# @raise [NoMethodError] when the delegated Array doesn't support the method
|
|
332
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
333
|
+
# Delegate to the model class first (AR-like behavior)
|
|
334
|
+
# Avoid delegating reflective/identity methods that console printers may use
|
|
335
|
+
# to derive labels; these should reflect the Relation itself.
|
|
336
|
+
blocked_class_delegations = %i[
|
|
337
|
+
name inspect pretty_inspect to_s to_str to_ary class object_id __id__
|
|
338
|
+
methods public_methods singleton_class respond_to? respond_to_missing?
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
sym = method_name.to_sym
|
|
342
|
+
if @klass.respond_to?(method_name) && !blocked_class_delegations.include?(sym)
|
|
343
|
+
# If this is a SearchEngine model scope, apply it against the *current*
|
|
344
|
+
# relation (AR parity) rather than delegating to the class method which
|
|
345
|
+
# starts from `.all` and would drop current relation state.
|
|
346
|
+
scope_result = apply_model_scope(sym, args, kwargs)
|
|
347
|
+
return scope_result unless scope_result == :__se_no_scope
|
|
348
|
+
|
|
349
|
+
return @klass.public_send(method_name, *args, **kwargs, &block)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
return super if blocked_class_delegations.include?(sym)
|
|
353
|
+
|
|
354
|
+
# Only delegate to the materialized Array for a conservative whitelist of methods.
|
|
355
|
+
unless ARRAY_DELEGATED_METHODS.include?(sym)
|
|
356
|
+
raise NoMethodError, %(undefined method `#{method_name}` for #{@klass}:Class)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
arr = to_a
|
|
360
|
+
arr.public_send(method_name, *args, &block)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
public :ast, :preset_mode, :preset_name, :to_typesense_params
|
|
364
|
+
|
|
365
|
+
# Ensure reflective APIs correctly report delegated methods on the
|
|
366
|
+
# materialized Array target. This keeps semantics consistent with
|
|
367
|
+
# method_missing above for interactive consoles and chaining.
|
|
368
|
+
# @param method_name [Symbol]
|
|
369
|
+
# @param include_private [Boolean]
|
|
370
|
+
# @return [Boolean]
|
|
371
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
372
|
+
# Explicitly avoid implicit conversions that change object identity in consoles
|
|
373
|
+
return false if %i[to_ary to_str].include?(method_name.to_sym)
|
|
374
|
+
|
|
375
|
+
sym = method_name.to_sym
|
|
376
|
+
blocked_class_delegations = %i[
|
|
377
|
+
name inspect pretty_inspect to_s to_str to_ary class object_id __id__
|
|
378
|
+
methods public_methods singleton_class respond_to? respond_to_missing?
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
if @klass.respond_to?(:__search_engine_scope_registry__) &&
|
|
382
|
+
@klass.__search_engine_scope_registry__.key?(sym) &&
|
|
383
|
+
!blocked_class_delegations.include?(sym)
|
|
384
|
+
return true
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Whitelist a conservative set of Enumerable-like methods for convenience.
|
|
388
|
+
ARRAY_DELEGATED_METHODS.include?(method_name.to_sym) || super
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
protected
|
|
392
|
+
|
|
393
|
+
# Spawn a new relation with a deep-duplicated mutable state.
|
|
394
|
+
# @yieldparam state [Hash]
|
|
395
|
+
# @return [SearchEngine::Relation]
|
|
396
|
+
def spawn
|
|
397
|
+
mutable_state = deep_dup(@state)
|
|
398
|
+
yield mutable_state
|
|
399
|
+
self.class.new(@klass, mutable_state)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
private
|
|
403
|
+
|
|
404
|
+
# Apply a model scope against the current relation when present.
|
|
405
|
+
# Returns :__se_no_scope when no scope matches.
|
|
406
|
+
def apply_model_scope(sym, args, kwargs)
|
|
407
|
+
return :__se_no_scope unless @klass.respond_to?(:__search_engine_scope_registry__)
|
|
408
|
+
|
|
409
|
+
impl = @klass.__search_engine_scope_registry__[sym]
|
|
410
|
+
return :__se_no_scope unless impl
|
|
411
|
+
|
|
412
|
+
norm_args, norm_kwargs = normalize_scope_args_for(impl, args, kwargs)
|
|
413
|
+
result = instance_exec(*norm_args, **norm_kwargs, &impl)
|
|
414
|
+
return self if result.nil? || result.equal?(@klass)
|
|
415
|
+
return result if result.is_a?(SearchEngine::Relation)
|
|
416
|
+
|
|
417
|
+
raise ArgumentError,
|
|
418
|
+
"scope :#{sym} must return a SearchEngine::Relation (got #{result.class})"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def normalize_scope_args_for(impl, args, kwargs)
|
|
422
|
+
return [args, kwargs] unless @klass.respond_to?(:__se_normalize_scope_args, true)
|
|
423
|
+
|
|
424
|
+
@klass.__send__(:__se_normalize_scope_args, impl, args, kwargs)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# True when the relation has already executed and memoized the result.
|
|
428
|
+
# @return [Boolean]
|
|
429
|
+
def loaded?
|
|
430
|
+
@__loaded == true
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def summary_inspect_string
|
|
434
|
+
parts = []
|
|
435
|
+
parts << "Model=#{klass_name_for_inspect}"
|
|
436
|
+
|
|
437
|
+
if (pn = @state[:preset_name])
|
|
438
|
+
pm = @state[:preset_mode] || :merge
|
|
439
|
+
parts << %(preset=#{pn}(mode=#{pm}))
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
filters = Array(@state[:filters])
|
|
443
|
+
parts << "filters=#{filters.length}" unless filters.empty?
|
|
444
|
+
|
|
445
|
+
ast_nodes = Array(@state[:ast])
|
|
446
|
+
parts << "ast=#{ast_nodes.length}" unless ast_nodes.empty?
|
|
447
|
+
|
|
448
|
+
compiled = begin
|
|
449
|
+
SearchEngine::CompiledParams.from(to_typesense_params)
|
|
450
|
+
rescue StandardError
|
|
451
|
+
{}
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
sort_str = compiled[:sort_by]
|
|
455
|
+
parts << %(sort="#{truncate_for_inspect(sort_str)}") if sort_str && !sort_str.to_s.empty?
|
|
456
|
+
|
|
457
|
+
append_selection_inspect_parts(parts, compiled)
|
|
458
|
+
|
|
459
|
+
if (g = @state[:grouping])
|
|
460
|
+
gparts = ["group_by=#{g[:field]}"]
|
|
461
|
+
gparts << "limit=#{g[:limit]}" if g[:limit]
|
|
462
|
+
gparts << 'missing_values=true' if g[:missing_values]
|
|
463
|
+
parts << gparts.join(' ')
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
parts << "page=#{compiled[:page]}" if compiled.key?(:page)
|
|
467
|
+
parts << "per=#{compiled[:per_page]}" if compiled.key?(:per_page)
|
|
468
|
+
|
|
469
|
+
"#<#{self.class.name} #{parts.join(' ')} >"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def interactive_console?
|
|
473
|
+
return true if defined?(Rails::Console)
|
|
474
|
+
return true if defined?(IRB) && $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
475
|
+
|
|
476
|
+
# Pry detection (best-effort, without hard dependency)
|
|
477
|
+
return true if defined?(Pry) && (Pry.respond_to?(:active?) ? Pry.active? : true)
|
|
478
|
+
|
|
479
|
+
return true if $PROGRAM_NAME&.end_with?('console')
|
|
480
|
+
|
|
481
|
+
false
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def klass_name_for_inspect
|
|
485
|
+
@klass.respond_to?(:name) && @klass.name ? @klass.name : @klass.to_s
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def safe_attributes_map
|
|
489
|
+
if @klass.respond_to?(:attributes)
|
|
490
|
+
base = @klass.attributes || {}
|
|
491
|
+
return base if base.key?(:id)
|
|
492
|
+
|
|
493
|
+
# Mirror parser logic for implicit id type
|
|
494
|
+
if @klass.instance_variable_defined?(:@__identify_by_kind__) &&
|
|
495
|
+
@klass.instance_variable_get(:@__identify_by_kind__) == :symbol &&
|
|
496
|
+
@klass.instance_variable_defined?(:@__identify_by_symbol__) &&
|
|
497
|
+
@klass.instance_variable_get(:@__identify_by_symbol__) == :id
|
|
498
|
+
base.merge(id: :integer)
|
|
499
|
+
else
|
|
500
|
+
base.merge(id: :string)
|
|
501
|
+
end
|
|
502
|
+
else
|
|
503
|
+
{ id: :string }
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def validate_hash_keys!(hash, attributes_map)
|
|
508
|
+
return if hash.nil? || hash.empty?
|
|
509
|
+
|
|
510
|
+
known = attributes_map.keys.map(&:to_sym)
|
|
511
|
+
# Ignore association-style keys whose values are Hash (handled by DSL::Parser for joins)
|
|
512
|
+
candidate_keys = hash.reject { |_, v| v.is_a?(Hash) }.keys
|
|
513
|
+
unknown = candidate_keys.map(&:to_sym) - known
|
|
514
|
+
return if unknown.empty?
|
|
515
|
+
|
|
516
|
+
begin
|
|
517
|
+
cfg = SearchEngine.config
|
|
518
|
+
return unless cfg.respond_to?(:strict_fields) ? cfg.strict_fields : true
|
|
519
|
+
rescue StandardError
|
|
520
|
+
nil
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
klass_name = klass_name_for_inspect
|
|
524
|
+
known_list = known.map(&:to_s).sort.join(', ')
|
|
525
|
+
unknown_name = unknown.first.inspect
|
|
526
|
+
raise ArgumentError, "Unknown attribute #{unknown_name} for #{klass_name}. Known: #{known_list}"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def collection_name_for_klass
|
|
530
|
+
return @klass.collection if @klass.respond_to?(:collection) && @klass.collection
|
|
531
|
+
|
|
532
|
+
begin
|
|
533
|
+
mapping = SearchEngine::Registry.mapping
|
|
534
|
+
found = mapping.find { |(_, kls)| kls == @klass }
|
|
535
|
+
return found.first if found
|
|
536
|
+
rescue StandardError
|
|
537
|
+
nil
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
raise ArgumentError, "Unknown collection for #{klass_name_for_inspect}"
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def client
|
|
544
|
+
# Prefer legacy ivar when explicitly set (tests or injected stubs), otherwise memoize with conventional name
|
|
545
|
+
return @__client if instance_variable_defined?(:@__client) && @__client
|
|
546
|
+
|
|
547
|
+
@client ||= SearchEngine.client
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def build_url_opts
|
|
551
|
+
opts = @state[:options] || {}
|
|
552
|
+
url = {}
|
|
553
|
+
url[:use_cache] = option_value(opts, :use_cache) if opts.key?(:use_cache) || opts.key?('use_cache')
|
|
554
|
+
if opts.key?(:cache_ttl) || opts.key?('cache_ttl')
|
|
555
|
+
url[:cache_ttl] = begin
|
|
556
|
+
Integer(option_value(opts, :cache_ttl))
|
|
557
|
+
rescue StandardError
|
|
558
|
+
nil
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
url.compact
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# pluck helpers reside in Materializers
|
|
565
|
+
|
|
566
|
+
def curated_indices_for_current_result
|
|
567
|
+
@__result_memo.to_a.each_with_index.select do |obj, _idx|
|
|
568
|
+
obj.respond_to?(:curated_hit?) && obj.curated_hit?
|
|
569
|
+
end.map(&:last)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def curation_filter_curated_hits?
|
|
573
|
+
@state[:curation] && @state[:curation][:filter_curated_hits]
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def enforce_hit_validator_if_needed!(total_hits, collection: nil)
|
|
577
|
+
hl = @state[:hit_limits]
|
|
578
|
+
return unless hl && hl[:max]
|
|
579
|
+
|
|
580
|
+
th = total_hits.to_i
|
|
581
|
+
max = hl[:max].to_i
|
|
582
|
+
return unless th > max && max.positive?
|
|
583
|
+
|
|
584
|
+
coll = collection || begin
|
|
585
|
+
collection_name_for_klass
|
|
586
|
+
rescue StandardError
|
|
587
|
+
nil
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
msg = "HitLimitExceeded: #{th} results exceed max=#{max}"
|
|
591
|
+
raise SearchEngine::Errors::HitLimitExceeded.new(
|
|
592
|
+
msg,
|
|
593
|
+
hint: 'Increase `validate_hits!(max:)` or narrow filters. Prefer `limit_hits(n)` to avoid work when supported.',
|
|
594
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/hit-limits#validation',
|
|
595
|
+
details: { total_hits: th, max: max, collection: coll, relation_summary: inspect }
|
|
596
|
+
)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def append_boolean_knobs_explain_lines(lines)
|
|
600
|
+
lines << " use_synonyms: #{@state[:use_synonyms]}" if @state.key?(:use_synonyms) && !@state[:use_synonyms].nil?
|
|
601
|
+
return unless @state.key?(:use_stopwords) && !@state[:use_stopwords].nil?
|
|
602
|
+
|
|
603
|
+
lines << " use_stopwords: #{@state[:use_stopwords]} (maps to remove_stop_words=#{!@state[:use_stopwords]})"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def append_where_and_order_lines(lines, params)
|
|
607
|
+
if params[:filter_by] && !params[:filter_by].to_s.strip.empty?
|
|
608
|
+
where_str = friendly_where(params[:filter_by].to_s)
|
|
609
|
+
lines << " where: #{where_str}"
|
|
610
|
+
end
|
|
611
|
+
lines << " order: #{params[:sort_by]}" if params[:sort_by] && !params[:sort_by].to_s.strip.empty?
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def append_grouping_explain_lines(lines)
|
|
615
|
+
if (g = @state[:grouping])
|
|
616
|
+
gparts = ["group_by=#{g[:field]}"]
|
|
617
|
+
gparts << "limit=#{g[:limit]}" if g[:limit]
|
|
618
|
+
gparts << 'missing_values=true' if g[:missing_values]
|
|
619
|
+
lines << " group: #{gparts.join(' ')}"
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|