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,383 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'search_engine/filters/sanitizer'
|
|
4
|
+
require 'search_engine/ast'
|
|
5
|
+
|
|
6
|
+
module SearchEngine
|
|
7
|
+
# Compiler for turning Predicate AST into Typesense `filter_by` strings.
|
|
8
|
+
#
|
|
9
|
+
# Pure and deterministic: no side effects, no I/O. Uses
|
|
10
|
+
# `SearchEngine::Filters::Sanitizer` for all quoting/escaping to ensure
|
|
11
|
+
# consistent rendering with the `where` DSL.
|
|
12
|
+
module Compiler
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class UnsupportedNode < Error; end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Compile an AST node or an array of nodes (implicit AND) into a Typesense
|
|
19
|
+
# `filter_by` string.
|
|
20
|
+
#
|
|
21
|
+
# - Nil or empty input returns an empty String
|
|
22
|
+
# - Arrays at the top-level are treated as implicit AND
|
|
23
|
+
# - Group nodes are always parenthesized
|
|
24
|
+
# - Raw fragments are passed-through
|
|
25
|
+
#
|
|
26
|
+
# @param ast [SearchEngine::AST::Node, Array<SearchEngine::AST::Node>, nil]
|
|
27
|
+
# @param klass [Class] optional model class for context (used for observability)
|
|
28
|
+
# @return [String]
|
|
29
|
+
# @note Emits "search_engine.compile" via ActiveSupport::Notifications with
|
|
30
|
+
# payload: { collection, klass, node_count, duration_ms, source: :ast }
|
|
31
|
+
def compile(ast, klass: nil)
|
|
32
|
+
root = coerce_root(ast)
|
|
33
|
+
return '' unless root
|
|
34
|
+
|
|
35
|
+
compiled = nil
|
|
36
|
+
if defined?(ActiveSupport::Notifications)
|
|
37
|
+
payload = {
|
|
38
|
+
collection: safe_collection_for_klass(klass),
|
|
39
|
+
klass: safe_klass_name(klass),
|
|
40
|
+
node_count: count_nodes(root),
|
|
41
|
+
source: :ast
|
|
42
|
+
}
|
|
43
|
+
SearchEngine::Instrumentation.instrument('search_engine.compile', payload) do |_ctx|
|
|
44
|
+
compiled = compile_node(root, parent_prec: 0, klass: klass)
|
|
45
|
+
end
|
|
46
|
+
compiled
|
|
47
|
+
else
|
|
48
|
+
compile_node(root, parent_prec: 0, klass: klass)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Internals ---------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def coerce_root(ast)
|
|
55
|
+
return nil if ast.nil?
|
|
56
|
+
|
|
57
|
+
if ast.is_a?(Array)
|
|
58
|
+
nodes = ast.flatten.compact.select { |n| n.is_a?(SearchEngine::AST::Node) }
|
|
59
|
+
return nil if nodes.empty?
|
|
60
|
+
return nodes.first if nodes.length == 1
|
|
61
|
+
|
|
62
|
+
return SearchEngine::AST.and_(*nodes)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
return ast if ast.is_a?(SearchEngine::AST::Node)
|
|
66
|
+
|
|
67
|
+
raise Error, "Compiler: unsupported root input #{ast.class}"
|
|
68
|
+
end
|
|
69
|
+
private_class_method :coerce_root
|
|
70
|
+
|
|
71
|
+
def compile_node(node, parent_prec:, klass: nil)
|
|
72
|
+
case node
|
|
73
|
+
when SearchEngine::AST::Eq
|
|
74
|
+
compile_binary(node.field, ':=', node.value, klass: klass)
|
|
75
|
+
when SearchEngine::AST::NotEq
|
|
76
|
+
compile_binary(node.field, ':!=', node.value, klass: klass)
|
|
77
|
+
when SearchEngine::AST::Gt
|
|
78
|
+
compile_binary(node.field, ':>', node.value, klass: klass)
|
|
79
|
+
when SearchEngine::AST::Gte
|
|
80
|
+
compile_binary(node.field, ':>=', node.value, klass: klass)
|
|
81
|
+
when SearchEngine::AST::Lt
|
|
82
|
+
compile_binary(node.field, ':<', node.value, klass: klass)
|
|
83
|
+
when SearchEngine::AST::Lte
|
|
84
|
+
compile_binary(node.field, ':<=', node.value, klass: klass)
|
|
85
|
+
when SearchEngine::AST::In
|
|
86
|
+
compile_binary(node.field, ':=', node.values, klass: klass)
|
|
87
|
+
when SearchEngine::AST::NotIn
|
|
88
|
+
compile_binary(node.field, ':!=', node.values, klass: klass)
|
|
89
|
+
when SearchEngine::AST::And
|
|
90
|
+
compile_boolean(node.children, ' && ', parent_prec: parent_prec, my_prec: precedence(:and), klass: klass)
|
|
91
|
+
when SearchEngine::AST::Or
|
|
92
|
+
compile_or(node, parent_prec, klass: klass)
|
|
93
|
+
when SearchEngine::AST::Group
|
|
94
|
+
"(#{compile_node(node.children.first, parent_prec: 0, klass: klass)})"
|
|
95
|
+
when SearchEngine::AST::Raw
|
|
96
|
+
node.fragment
|
|
97
|
+
when SearchEngine::AST::Matches
|
|
98
|
+
raise UnsupportedNode, 'Typesense filter_by does not support MATCHES; use AST::Raw if needed.'
|
|
99
|
+
when SearchEngine::AST::Prefix
|
|
100
|
+
raise UnsupportedNode, 'Typesense filter_by does not support PREFIX; use AST::Raw if needed.'
|
|
101
|
+
else
|
|
102
|
+
raise Error, "Compiler: unknown node #{node.class}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
private_class_method :compile_node
|
|
106
|
+
|
|
107
|
+
def compile_or(node, parent_prec, klass: nil)
|
|
108
|
+
compiled = compile_boolean(
|
|
109
|
+
node.children,
|
|
110
|
+
' || ',
|
|
111
|
+
parent_prec: parent_prec,
|
|
112
|
+
my_prec: precedence(:or),
|
|
113
|
+
klass: klass
|
|
114
|
+
)
|
|
115
|
+
if node.children.length == 2 && node.children.last.is_a?(SearchEngine::AST::And)
|
|
116
|
+
left_str, right_str = compiled.split(' || ', 2)
|
|
117
|
+
compiled = "#{left_str} || (#{right_str})"
|
|
118
|
+
end
|
|
119
|
+
compiled
|
|
120
|
+
end
|
|
121
|
+
private_class_method :compile_or
|
|
122
|
+
|
|
123
|
+
def compile_binary(field, op, value, klass: nil)
|
|
124
|
+
fstr = field.to_s
|
|
125
|
+
if (m = fstr.match(/^\$(\w+)\.(.+)$/))
|
|
126
|
+
assoc = m[1]
|
|
127
|
+
inner = m[2]
|
|
128
|
+
# Render joined field as $assoc(inner OP value) per expected Typesense join filter syntax
|
|
129
|
+
target_klass = begin
|
|
130
|
+
cfg = klass.respond_to?(:join_for) ? klass.join_for(assoc.to_sym) : nil
|
|
131
|
+
coll = cfg ? cfg[:collection] : nil
|
|
132
|
+
coll ? SearchEngine.collection_for(coll) : nil
|
|
133
|
+
rescue StandardError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
rhs = quote(value, field: inner, klass: target_klass || klass)
|
|
137
|
+
"$#{assoc}(#{inner}#{op}#{rhs})"
|
|
138
|
+
else
|
|
139
|
+
rhs = quote(value, field: fstr, klass: klass)
|
|
140
|
+
binary(field, op, rhs)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
private_class_method :compile_binary
|
|
144
|
+
|
|
145
|
+
def binary(field, op, rhs)
|
|
146
|
+
"#{field}#{op}#{rhs}"
|
|
147
|
+
end
|
|
148
|
+
private_class_method :binary
|
|
149
|
+
|
|
150
|
+
def compile_boolean(children, joiner, parent_prec:, my_prec:, klass: nil)
|
|
151
|
+
# For conjunctions only, merge multiple $assoc(inner ...) predicates targeting
|
|
152
|
+
# the same association into a single $assoc(inner && inner ...) expression
|
|
153
|
+
# to satisfy Typesense join filter rules.
|
|
154
|
+
if joiner == ' && '
|
|
155
|
+
merged = merge_join_predicates(children, parent_prec: parent_prec, my_prec: my_prec, klass: klass,
|
|
156
|
+
joiner: joiner
|
|
157
|
+
)
|
|
158
|
+
return merged if merged
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Fallback/default behavior
|
|
162
|
+
parts = children.map do |child|
|
|
163
|
+
cstr = compile_node(child, parent_prec: my_prec, klass: klass)
|
|
164
|
+
if needs_parentheses?(child, parent_prec: my_prec)
|
|
165
|
+
"(#{cstr})"
|
|
166
|
+
else
|
|
167
|
+
cstr
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
inner = parts.join(joiner)
|
|
171
|
+
|
|
172
|
+
# Parent adds parens if its precedence is greater than ours
|
|
173
|
+
if parent_prec > my_prec
|
|
174
|
+
"(#{inner})"
|
|
175
|
+
else
|
|
176
|
+
inner
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
private_class_method :compile_boolean
|
|
180
|
+
|
|
181
|
+
# Merge multiple join predicates targeting the same association into consolidated expressions.
|
|
182
|
+
#
|
|
183
|
+
# For AND operations, collects all predicates for each association and merges them
|
|
184
|
+
# into a single $assoc(inner && inner ...) expression at the first position where
|
|
185
|
+
# that association appeared.
|
|
186
|
+
#
|
|
187
|
+
# @param children [Array<SearchEngine::AST::Node>] child nodes to process
|
|
188
|
+
# @param parent_prec [Integer] parent operator precedence
|
|
189
|
+
# @param my_prec [Integer] current operator precedence
|
|
190
|
+
# @param klass [Class, nil] model class for context
|
|
191
|
+
# @param joiner [String] operator joiner (' && ' or ' || ')
|
|
192
|
+
# @return [String, nil] merged expression string, or nil if no joins to merge
|
|
193
|
+
def merge_join_predicates(children, parent_prec:, my_prec:, klass:, joiner:)
|
|
194
|
+
items = [] # [{ pos:, str: }]
|
|
195
|
+
assoc_first_pos = {}
|
|
196
|
+
assoc_inner_map = {} # { assoc => [inner_str, ...] }
|
|
197
|
+
|
|
198
|
+
children.each_with_index do |child, idx|
|
|
199
|
+
inner = extract_join_inner_binary(child, klass: klass)
|
|
200
|
+
if inner
|
|
201
|
+
assoc, inner_expr = inner
|
|
202
|
+
assoc_first_pos[assoc] = idx unless assoc_first_pos.key?(assoc)
|
|
203
|
+
assoc_inner_map[assoc] ||= []
|
|
204
|
+
assoc_inner_map[assoc] << inner_expr
|
|
205
|
+
next
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
cstr = compile_node(child, parent_prec: my_prec, klass: klass)
|
|
209
|
+
cstr = "(#{cstr})" if needs_parentheses?(child, parent_prec: my_prec)
|
|
210
|
+
items << { pos: idx, str: cstr }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
return nil if assoc_inner_map.empty?
|
|
214
|
+
|
|
215
|
+
# Emit one consolidated token per assoc at the first position it appeared
|
|
216
|
+
assoc_first_pos.sort_by { |_a, pos| pos }.each do |assoc, pos|
|
|
217
|
+
inners = Array(assoc_inner_map[assoc]).flatten.compact
|
|
218
|
+
next if inners.empty?
|
|
219
|
+
|
|
220
|
+
token = "$#{assoc}(#{inners.join(' && ')})"
|
|
221
|
+
items << { pos: pos, str: token }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
inner = items.sort_by { |it| it[:pos] }.map { |it| it[:str] }.join(joiner)
|
|
225
|
+
parent_prec > my_prec ? "(#{inner})" : inner
|
|
226
|
+
end
|
|
227
|
+
private_class_method :merge_join_predicates
|
|
228
|
+
|
|
229
|
+
# Try to extract join association and compiled inner expression for a binary node
|
|
230
|
+
# with a joined field like "$assoc.field". Returns [assoc(String), inner(String)] or nil.
|
|
231
|
+
def extract_join_inner_binary(node, klass: nil)
|
|
232
|
+
case node
|
|
233
|
+
when SearchEngine::AST::Eq, SearchEngine::AST::NotEq,
|
|
234
|
+
SearchEngine::AST::Gt, SearchEngine::AST::Gte,
|
|
235
|
+
SearchEngine::AST::Lt, SearchEngine::AST::Lte,
|
|
236
|
+
SearchEngine::AST::In, SearchEngine::AST::NotIn
|
|
237
|
+
field = node.respond_to?(:field) ? node.field.to_s : nil
|
|
238
|
+
return nil unless field&.start_with?('$')
|
|
239
|
+
|
|
240
|
+
m = field.match(/^\$(\w+)\.(.+)$/)
|
|
241
|
+
return nil unless m
|
|
242
|
+
|
|
243
|
+
assoc = m[1]
|
|
244
|
+
inner_field = m[2]
|
|
245
|
+
op = op_for(node)
|
|
246
|
+
target_klass = begin
|
|
247
|
+
cfg = klass.respond_to?(:join_for) ? klass.join_for(assoc.to_sym) : nil
|
|
248
|
+
coll = cfg ? cfg[:collection] : nil
|
|
249
|
+
coll ? SearchEngine.collection_for(coll) : nil
|
|
250
|
+
rescue StandardError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
rhs = if node.respond_to?(:value)
|
|
254
|
+
quote(node.value, field: inner_field, klass: target_klass || klass)
|
|
255
|
+
elsif node.respond_to?(:values)
|
|
256
|
+
quote(node.values, field: inner_field, klass: target_klass || klass)
|
|
257
|
+
else
|
|
258
|
+
return nil
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
[assoc, "#{inner_field}#{op}#{rhs}"]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
private_class_method :extract_join_inner_binary
|
|
265
|
+
|
|
266
|
+
def op_for(node)
|
|
267
|
+
case node
|
|
268
|
+
when SearchEngine::AST::Eq then ':='
|
|
269
|
+
when SearchEngine::AST::NotEq then ':!='
|
|
270
|
+
when SearchEngine::AST::Gt then ':>'
|
|
271
|
+
when SearchEngine::AST::Gte then ':>='
|
|
272
|
+
when SearchEngine::AST::Lt then ':<'
|
|
273
|
+
when SearchEngine::AST::Lte then ':<='
|
|
274
|
+
when SearchEngine::AST::In then ':='
|
|
275
|
+
when SearchEngine::AST::NotIn then ':!='
|
|
276
|
+
else
|
|
277
|
+
raise Error, "Unknown binary node for join extraction: #{node.class}"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
private_class_method :op_for
|
|
281
|
+
|
|
282
|
+
def quote(value, field: nil, klass: nil)
|
|
283
|
+
# Typesense requires reference fields to use string values, even if the field type is int64
|
|
284
|
+
converted_value = if klass && field && field_reference?(klass, field)
|
|
285
|
+
convert_for_reference_field(value)
|
|
286
|
+
else
|
|
287
|
+
value
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Use conditional scalar quoting for scalars; preserve array element quoting rules
|
|
291
|
+
if converted_value.is_a?(Array)
|
|
292
|
+
SearchEngine::Filters::Sanitizer.quote(converted_value)
|
|
293
|
+
else
|
|
294
|
+
SearchEngine::Filters::Sanitizer.quote_scalar_for_filter(converted_value)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
private_class_method :quote
|
|
298
|
+
|
|
299
|
+
# Check if a field has a reference attribute in the schema.
|
|
300
|
+
# Uses the same logic as Schema.build_references_by_local_key to ensure consistency.
|
|
301
|
+
def field_reference?(klass, field_name)
|
|
302
|
+
return false unless klass
|
|
303
|
+
return false unless klass.respond_to?(:joins_config)
|
|
304
|
+
|
|
305
|
+
configs = klass.joins_config || {}
|
|
306
|
+
configs.each_value do |cfg|
|
|
307
|
+
# Only belongs_to/belongs_to_many contribute references to schema
|
|
308
|
+
kind = (cfg[:kind] || :belongs_to).to_sym
|
|
309
|
+
next if %i[has_one has_many].include?(kind)
|
|
310
|
+
|
|
311
|
+
lk = cfg[:local_key]
|
|
312
|
+
next if lk.nil?
|
|
313
|
+
|
|
314
|
+
return true if lk.to_sym == field_name.to_sym
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
private_class_method :field_reference?
|
|
320
|
+
|
|
321
|
+
# Convert numeric values to strings for reference fields
|
|
322
|
+
def convert_for_reference_field(value)
|
|
323
|
+
if value.is_a?(Array)
|
|
324
|
+
value.map { |v| convert_for_reference_field(v) }
|
|
325
|
+
elsif value.is_a?(Numeric)
|
|
326
|
+
value.to_s
|
|
327
|
+
else
|
|
328
|
+
value
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
private_class_method :convert_for_reference_field
|
|
332
|
+
|
|
333
|
+
def needs_parentheses?(child, parent_prec:)
|
|
334
|
+
child_prec = precedence(child)
|
|
335
|
+
# Parenthesize when child binds looser than parent
|
|
336
|
+
child_prec < parent_prec
|
|
337
|
+
end
|
|
338
|
+
private_class_method :needs_parentheses?
|
|
339
|
+
|
|
340
|
+
def precedence(node)
|
|
341
|
+
case node
|
|
342
|
+
when Symbol
|
|
343
|
+
return 20 if node == :and
|
|
344
|
+
return 10 if node == :or
|
|
345
|
+
|
|
346
|
+
100
|
|
347
|
+
when SearchEngine::AST::And
|
|
348
|
+
20
|
|
349
|
+
when SearchEngine::AST::Or
|
|
350
|
+
10
|
|
351
|
+
when SearchEngine::AST::Group
|
|
352
|
+
# Group is a special case; caller always wraps it explicitly
|
|
353
|
+
100
|
|
354
|
+
else
|
|
355
|
+
# Leaves (Eq, In, etc.) bind tight
|
|
356
|
+
100
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
private_class_method :precedence
|
|
360
|
+
|
|
361
|
+
def count_nodes(node)
|
|
362
|
+
return 0 unless node.is_a?(SearchEngine::AST::Node)
|
|
363
|
+
|
|
364
|
+
1 + Array(node.children).sum { |child| count_nodes(child) }
|
|
365
|
+
end
|
|
366
|
+
private_class_method :count_nodes
|
|
367
|
+
|
|
368
|
+
def safe_klass_name(klass)
|
|
369
|
+
return nil unless klass
|
|
370
|
+
|
|
371
|
+
klass.respond_to?(:name) && klass.name ? klass.name : klass.to_s
|
|
372
|
+
end
|
|
373
|
+
private_class_method :safe_klass_name
|
|
374
|
+
|
|
375
|
+
def safe_collection_for_klass(klass)
|
|
376
|
+
return nil unless klass
|
|
377
|
+
return klass.collection if klass.respond_to?(:collection) && klass.collection
|
|
378
|
+
|
|
379
|
+
nil
|
|
380
|
+
end
|
|
381
|
+
private_class_method :safe_collection_for_klass
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Config
|
|
5
|
+
# Observability and structured logging configuration.
|
|
6
|
+
class Observability
|
|
7
|
+
# @return [Boolean] enable the compact logging subscriber automatically
|
|
8
|
+
attr_accessor :enabled
|
|
9
|
+
# @return [Symbol] :kv or :json
|
|
10
|
+
attr_accessor :log_format
|
|
11
|
+
# @return [Integer] maximum message length for error samples in logs
|
|
12
|
+
attr_accessor :max_message_length
|
|
13
|
+
# @return [Boolean] include short error messages in logs for batch/stale events
|
|
14
|
+
attr_accessor :include_error_messages
|
|
15
|
+
# @return [Boolean] also emit legacy event aliases where applicable
|
|
16
|
+
attr_accessor :emit_legacy_event_aliases
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@enabled = true
|
|
20
|
+
@log_format = :kv
|
|
21
|
+
@max_message_length = 200
|
|
22
|
+
@include_error_messages = false
|
|
23
|
+
@emit_legacy_event_aliases = true
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module SearchEngine
|
|
6
|
+
class Config
|
|
7
|
+
# Default presets resolution configuration.
|
|
8
|
+
# Controls namespacing and enablement.
|
|
9
|
+
class Presets
|
|
10
|
+
# @return [Boolean] when false, namespace is ignored but declared tokens remain usable
|
|
11
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets
|
|
12
|
+
attr_accessor :enabled
|
|
13
|
+
# @return [String, nil] optional namespace prepended to preset names when enabled
|
|
14
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets
|
|
15
|
+
attr_accessor :namespace
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@enabled = true
|
|
19
|
+
@namespace = nil
|
|
20
|
+
@locked_domains = %i[filter_by sort_by include_fields exclude_fields]
|
|
21
|
+
@locked_domains_set = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Normalize a Boolean-like value.
|
|
25
|
+
# Accepts true/false, or common String forms ("true","false","1","0","yes","no","on","off").
|
|
26
|
+
# @param value [Object]
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#config-default-preset
|
|
29
|
+
def self.normalize_enabled(value)
|
|
30
|
+
return true if value == true
|
|
31
|
+
return false if value == false
|
|
32
|
+
|
|
33
|
+
if value.is_a?(String)
|
|
34
|
+
v = value.strip.downcase
|
|
35
|
+
return true if %w[1 true yes on].include?(v)
|
|
36
|
+
return false if %w[0 false no off].include?(v)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Normalize namespace to a non-empty String or return original for validation.
|
|
43
|
+
# @param value [Object]
|
|
44
|
+
# @return [String, nil, Object]
|
|
45
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#config-default-preset
|
|
46
|
+
def self.normalize_namespace(value)
|
|
47
|
+
return nil if value.nil?
|
|
48
|
+
|
|
49
|
+
if value.is_a?(String)
|
|
50
|
+
ns = value.strip
|
|
51
|
+
return nil if ns.empty?
|
|
52
|
+
|
|
53
|
+
return ns
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Assign locked domains; accepts Array/Set or a single value. Values are
|
|
60
|
+
# normalized to Symbols. Internal membership checks use a frozen Set.
|
|
61
|
+
# @param value [Array<#to_sym>, Set<#to_sym>, #to_sym, nil]
|
|
62
|
+
# @return [void]
|
|
63
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#strategies-merge-only-lock
|
|
64
|
+
def locked_domains=(value)
|
|
65
|
+
list =
|
|
66
|
+
case value
|
|
67
|
+
when nil then []
|
|
68
|
+
when Set then value.to_a
|
|
69
|
+
when Array then value
|
|
70
|
+
else Array(value)
|
|
71
|
+
end
|
|
72
|
+
syms = list.compact.map { |k| k.respond_to?(:to_sym) ? k.to_sym : k }.grep(Symbol)
|
|
73
|
+
@locked_domains = syms
|
|
74
|
+
@locked_domains_set = syms.to_set.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Return the locked domains as an Array of Symbols.
|
|
78
|
+
# @return [Array<Symbol>]
|
|
79
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#strategies-merge-only-lock
|
|
80
|
+
def locked_domains
|
|
81
|
+
Array(@locked_domains).map(&:to_sym)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Return a frozen Set of locked domains for fast membership checks.
|
|
85
|
+
# @return [Set<Symbol>]
|
|
86
|
+
# @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/presets#strategies-merge-only-lock
|
|
87
|
+
def locked_domains_set
|
|
88
|
+
@locked_domains_set ||= locked_domains.to_set.freeze
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Config
|
|
5
|
+
# Selection/hydration configuration.
|
|
6
|
+
# Controls strictness of missing attributes during hydration.
|
|
7
|
+
class Selection
|
|
8
|
+
# @return [Boolean] when true, missing requested fields raise MissingField
|
|
9
|
+
attr_accessor :strict_missing
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@strict_missing = false
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Config
|
|
5
|
+
# Typesense transport/client configuration.
|
|
6
|
+
# Holds connection details, timeouts, and retry knobs.
|
|
7
|
+
# Public fields mirror the facade to preserve API compatibility via forwarders.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# ts = SearchEngine::Config::Typesense.new
|
|
11
|
+
# ts.host = 'localhost'
|
|
12
|
+
# ts.port = 8108
|
|
13
|
+
class Typesense
|
|
14
|
+
# @return [String, nil] secret Typesense API key (redacted separately)
|
|
15
|
+
attr_accessor :api_key
|
|
16
|
+
# @return [String] hostname of the Typesense server
|
|
17
|
+
attr_accessor :host
|
|
18
|
+
# @return [Integer] TCP port for the Typesense server
|
|
19
|
+
attr_accessor :port
|
|
20
|
+
# @return [String] one of "http" or "https"
|
|
21
|
+
attr_reader :protocol
|
|
22
|
+
# @return [Integer] request total timeout in milliseconds
|
|
23
|
+
attr_accessor :timeout_ms
|
|
24
|
+
# @return [Integer] connect/open timeout in milliseconds
|
|
25
|
+
attr_accessor :open_timeout_ms
|
|
26
|
+
# @return [Hash] retry policy with keys { attempts: Integer, backoff: Float or Range<Float> }
|
|
27
|
+
attr_accessor :retries
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@api_key = nil
|
|
31
|
+
@host = 'localhost'
|
|
32
|
+
@port = 8108
|
|
33
|
+
@protocol = 'http'
|
|
34
|
+
@timeout_ms = 3_600_000
|
|
35
|
+
@open_timeout_ms = 1_000
|
|
36
|
+
@retries = { attempts: 2, backoff: (10.0..60.0) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Normalize protocol assignment to default to 'http' when blank.
|
|
40
|
+
# @param value [String, nil]
|
|
41
|
+
# @return [void]
|
|
42
|
+
def protocol=(value)
|
|
43
|
+
s = value.to_s
|
|
44
|
+
@protocol = s.strip.present? ? s : 'http'
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SearchEngine
|
|
4
|
+
class Config
|
|
5
|
+
# Validation helpers for configuration.
|
|
6
|
+
# Keep messages identical to the previous inline implementations.
|
|
7
|
+
module Validators
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate_protocol(protocol)
|
|
11
|
+
return [] if %w[http https].include?(protocol.to_s)
|
|
12
|
+
|
|
13
|
+
['protocol must be "http" or "https"']
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate_host(host)
|
|
17
|
+
return [] unless host.nil? || host.to_s.strip.empty?
|
|
18
|
+
|
|
19
|
+
['host must be present']
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate_port(port)
|
|
23
|
+
return [] if port.is_a?(Integer) && port.positive?
|
|
24
|
+
|
|
25
|
+
['port must be a positive Integer']
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_timeouts(timeout_ms, open_timeout_ms)
|
|
29
|
+
errors = []
|
|
30
|
+
errors << 'timeout_ms must be a non-negative Integer' unless timeout_ms.is_a?(Integer) && !timeout_ms.negative?
|
|
31
|
+
unless open_timeout_ms.is_a?(Integer) && !open_timeout_ms.negative?
|
|
32
|
+
errors << 'open_timeout_ms must be a non-negative Integer'
|
|
33
|
+
end
|
|
34
|
+
errors
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_retries(retries)
|
|
38
|
+
return ['retries must be a Hash with keys :attempts and :backoff'] unless retries_valid_shape?(retries)
|
|
39
|
+
|
|
40
|
+
errors = []
|
|
41
|
+
attempts = retries[:attempts]
|
|
42
|
+
backoff = retries[:backoff]
|
|
43
|
+
|
|
44
|
+
unless attempts.is_a?(Integer) && !attempts.negative?
|
|
45
|
+
errors << 'retries[:attempts] must be a non-negative Integer'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if backoff.is_a?(Numeric)
|
|
49
|
+
if backoff.negative?
|
|
50
|
+
errors << 'retries[:backoff] must be a non-negative Float or Range of non-negative Floats'
|
|
51
|
+
end
|
|
52
|
+
elsif backoff.is_a?(Range)
|
|
53
|
+
b = backoff.begin
|
|
54
|
+
e = backoff.end
|
|
55
|
+
valid = b.is_a?(Numeric) && e.is_a?(Numeric) && b >= 0 && e >= 0 && b <= e
|
|
56
|
+
errors << 'retries[:backoff] must be a non-negative Float or Range of non-negative Floats' unless valid
|
|
57
|
+
else
|
|
58
|
+
errors << 'retries[:backoff] must be a non-negative Float or Range of non-negative Floats'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
errors
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def retries_valid_shape?(retries)
|
|
65
|
+
retries.is_a?(Hash) && retries.key?(:attempts) && retries.key?(:backoff)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_cache(cache_ttl_s)
|
|
69
|
+
return [] if cache_ttl_s.is_a?(Integer) && !cache_ttl_s.negative?
|
|
70
|
+
|
|
71
|
+
['cache_ttl_s must be a non-negative Integer']
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_multi_search_limit(limit)
|
|
75
|
+
return [] if limit.is_a?(Integer) && !limit.negative?
|
|
76
|
+
|
|
77
|
+
['multi_search_limit must be a non-negative Integer']
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_presets(presets)
|
|
81
|
+
errors = []
|
|
82
|
+
en = presets.enabled
|
|
83
|
+
ns = presets.namespace
|
|
84
|
+
ld = Array(presets.locked_domains)
|
|
85
|
+
|
|
86
|
+
errors << 'presets.enabled must be a Boolean' unless [true, false].include?(en)
|
|
87
|
+
unless ns.nil? || (ns.is_a?(String) && !ns.strip.empty?)
|
|
88
|
+
errors << 'presets.namespace must be a non-empty String or nil'
|
|
89
|
+
end
|
|
90
|
+
unless ld.is_a?(Array) && ld.all? { |k| k.is_a?(Symbol) }
|
|
91
|
+
errors << 'presets.locked_domains must be an Array of Symbols'
|
|
92
|
+
end
|
|
93
|
+
errors
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|