search-engine-for-typesense 30.9.1 → 30.9.3
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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +51 -16
- data/lib/search_engine/active_record_syncable.rb +85 -197
- data/lib/search_engine/ast/node.rb +3 -6
- data/lib/search_engine/async_partition_coordinator.rb +5 -3
- data/lib/search_engine/client/services/search.rb +2 -0
- data/lib/search_engine/client.rb +9 -3
- data/lib/search_engine/compiler.rb +33 -128
- data/lib/search_engine/deletion.rb +12 -9
- data/lib/search_engine/dsl/parser.rb +51 -71
- data/lib/search_engine/engine.rb +25 -16
- data/lib/search_engine/filters/sanitizer.rb +162 -126
- data/lib/search_engine/hydration/materializers.rb +53 -118
- data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
- data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
- data/lib/search_engine/indexing_run_store.rb +10 -0
- data/lib/search_engine/joins/guard.rb +14 -8
- data/lib/search_engine/multi.rb +92 -6
- data/lib/search_engine/multi_result.rb +40 -23
- data/lib/search_engine/notifications/compact_logger.rb +2 -2
- data/lib/search_engine/observability.rb +6 -6
- data/lib/search_engine/postgres_outbox/database_queries.rb +17 -0
- data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
- data/lib/search_engine/postgres_outbox/event.rb +12 -0
- data/lib/search_engine/postgres_outbox/materialization_cursor.rb +25 -11
- data/lib/search_engine/postgres_outbox/repository.rb +65 -115
- data/lib/search_engine/postgres_outbox.rb +2 -0
- data/lib/search_engine/registry.rb +27 -28
- data/lib/search_engine/relation/compiler.rb +18 -33
- data/lib/search_engine/relation/dsl/filters.rb +71 -175
- data/lib/search_engine/relation/dx.rb +3 -1
- data/lib/search_engine/relation/materializers.rb +6 -3
- data/lib/search_engine/relation/options.rb +5 -26
- data/lib/search_engine/relation/state.rb +3 -2
- data/lib/search_engine/relation.rb +5 -4
- data/lib/search_engine/result.rb +33 -2
- data/lib/search_engine/schema.rb +23 -24
- data/lib/search_engine/test/offline_client.rb +2 -2
- data/lib/search_engine/test/stub_client.rb +9 -16
- data/lib/search_engine/update.rb +1 -1
- data/lib/search_engine/version.rb +1 -1
- data/lib/search_engine.rb +11 -149
- data/lib/tasks/search_engine.rake +3 -5
- metadata +18 -3
- /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
|
@@ -70,26 +70,12 @@ module SearchEngine
|
|
|
70
70
|
|
|
71
71
|
def compile_node(node, parent_prec:, klass: nil)
|
|
72
72
|
case node
|
|
73
|
-
when SearchEngine::AST::
|
|
74
|
-
compile_binary(node
|
|
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)
|
|
73
|
+
when SearchEngine::AST::BinaryOp
|
|
74
|
+
compile_binary(node, klass: klass)
|
|
89
75
|
when SearchEngine::AST::And
|
|
90
76
|
compile_boolean(node.children, ' && ', parent_prec: parent_prec, my_prec: precedence(:and), klass: klass)
|
|
91
77
|
when SearchEngine::AST::Or
|
|
92
|
-
|
|
78
|
+
compile_boolean(node.children, ' || ', parent_prec: parent_prec, my_prec: precedence(:or), klass: klass)
|
|
93
79
|
when SearchEngine::AST::Group
|
|
94
80
|
"(#{compile_node(node.children.first, parent_prec: 0, klass: klass)})"
|
|
95
81
|
when SearchEngine::AST::Raw
|
|
@@ -104,61 +90,24 @@ module SearchEngine
|
|
|
104
90
|
end
|
|
105
91
|
private_class_method :compile_node
|
|
106
92
|
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
|
|
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})"
|
|
93
|
+
def compile_binary(node, klass:)
|
|
94
|
+
if (joined = extract_join_inner_binary(node, klass: klass))
|
|
95
|
+
assoc, inner = joined
|
|
96
|
+
return "$#{assoc}(#{inner})"
|
|
118
97
|
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
98
|
|
|
145
|
-
|
|
146
|
-
"#{field}#{op}#{rhs}"
|
|
99
|
+
"#{node.field}#{op_for(node)}#{quote(node.right, field: node.field, klass: klass)}"
|
|
147
100
|
end
|
|
148
|
-
private_class_method :
|
|
101
|
+
private_class_method :compile_binary
|
|
149
102
|
|
|
150
103
|
def compile_boolean(children, joiner, parent_prec:, my_prec:, klass: nil)
|
|
151
104
|
# For conjunctions only, merge multiple $assoc(inner ...) predicates targeting
|
|
152
105
|
# the same association into a single $assoc(inner && inner ...) expression
|
|
153
106
|
# to satisfy Typesense join filter rules.
|
|
154
107
|
if joiner == ' && '
|
|
155
|
-
|
|
156
|
-
joiner: joiner
|
|
157
|
-
)
|
|
158
|
-
return merged if merged
|
|
108
|
+
return merge_join_predicates(children, parent_prec: parent_prec, my_prec: my_prec, klass: klass)
|
|
159
109
|
end
|
|
160
110
|
|
|
161
|
-
# Fallback/default behavior
|
|
162
111
|
parts = children.map do |child|
|
|
163
112
|
cstr = compile_node(child, parent_prec: my_prec, klass: klass)
|
|
164
113
|
if needs_parentheses?(child, parent_prec: my_prec)
|
|
@@ -188,40 +137,26 @@ joiner: joiner
|
|
|
188
137
|
# @param parent_prec [Integer] parent operator precedence
|
|
189
138
|
# @param my_prec [Integer] current operator precedence
|
|
190
139
|
# @param klass [Class, nil] model class for context
|
|
191
|
-
# @
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
140
|
+
# @return [String] compiled conjunction
|
|
141
|
+
def merge_join_predicates(children, parent_prec:, my_prec:, klass:)
|
|
142
|
+
groups = {}
|
|
143
|
+
parts = children.map.with_index do |child, position|
|
|
144
|
+
if (joined = extract_join_inner_binary(child, klass: klass))
|
|
145
|
+
assoc, predicate = joined
|
|
146
|
+
group = groups[assoc] ||= { position: position, predicates: [] }
|
|
147
|
+
group[:predicates] << predicate
|
|
205
148
|
next
|
|
206
149
|
end
|
|
207
150
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
items << { pos: idx, str: cstr }
|
|
151
|
+
expression = compile_node(child, parent_prec: my_prec, klass: klass)
|
|
152
|
+
needs_parentheses?(child, parent_prec: my_prec) ? "(#{expression})" : expression
|
|
211
153
|
end
|
|
212
154
|
|
|
213
|
-
|
|
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 }
|
|
155
|
+
groups.each do |assoc, group|
|
|
156
|
+
parts[group[:position]] = "$#{assoc}(#{group[:predicates].join(' && ')})"
|
|
222
157
|
end
|
|
223
158
|
|
|
224
|
-
inner =
|
|
159
|
+
inner = parts.compact.join(' && ')
|
|
225
160
|
parent_prec > my_prec ? "(#{inner})" : inner
|
|
226
161
|
end
|
|
227
162
|
private_class_method :merge_join_predicates
|
|
@@ -229,37 +164,12 @@ joiner: joiner
|
|
|
229
164
|
# Try to extract join association and compiled inner expression for a binary node
|
|
230
165
|
# with a joined field like "$assoc.field". Returns [assoc(String), inner(String)] or nil.
|
|
231
166
|
def extract_join_inner_binary(node, klass: nil)
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
167
|
+
return unless node.is_a?(SearchEngine::AST::BinaryOp) && node.field.start_with?('$')
|
|
168
|
+
|
|
169
|
+
assoc, field = node.field.delete_prefix('$').split('.', 2)
|
|
170
|
+
target = SearchEngine::Joins::Guard.target_model(klass, assoc)
|
|
171
|
+
rhs = quote(node.right, field: field, klass: target, joined: true)
|
|
172
|
+
[assoc, "#{field}#{op_for(node)}#{rhs}"]
|
|
263
173
|
end
|
|
264
174
|
private_class_method :extract_join_inner_binary
|
|
265
175
|
|
|
@@ -274,12 +184,12 @@ joiner: joiner
|
|
|
274
184
|
when SearchEngine::AST::In then ':='
|
|
275
185
|
when SearchEngine::AST::NotIn then ':!='
|
|
276
186
|
else
|
|
277
|
-
raise Error, "
|
|
187
|
+
raise Error, "Compiler: unsupported binary predicate #{node.class}"
|
|
278
188
|
end
|
|
279
189
|
end
|
|
280
190
|
private_class_method :op_for
|
|
281
191
|
|
|
282
|
-
def quote(value, field
|
|
192
|
+
def quote(value, field:, klass:, joined: false)
|
|
283
193
|
# Typesense requires reference fields to use string values, even if the field type is int64
|
|
284
194
|
converted_value = if klass && field && field_reference?(klass, field)
|
|
285
195
|
convert_for_reference_field(value)
|
|
@@ -287,12 +197,7 @@ joiner: joiner
|
|
|
287
197
|
value
|
|
288
198
|
end
|
|
289
199
|
|
|
290
|
-
|
|
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
|
|
200
|
+
SearchEngine::Filters::Sanitizer.quote(converted_value, field: field, klass: klass, joined: joined)
|
|
296
201
|
end
|
|
297
202
|
private_class_method :quote
|
|
298
203
|
|
|
@@ -333,7 +238,7 @@ joiner: joiner
|
|
|
333
238
|
def needs_parentheses?(child, parent_prec:)
|
|
334
239
|
child_prec = precedence(child)
|
|
335
240
|
# Parenthesize when child binds looser than parent
|
|
336
|
-
child_prec < parent_prec
|
|
241
|
+
child_prec < parent_prec || (child.is_a?(SearchEngine::AST::And) && parent_prec == precedence(:or))
|
|
337
242
|
end
|
|
338
243
|
private_class_method :needs_parentheses?
|
|
339
244
|
|
|
@@ -17,7 +17,7 @@ module SearchEngine
|
|
|
17
17
|
# @param timeout_ms [Integer, nil] optional read timeout override in ms
|
|
18
18
|
# @return [Integer] number of deleted documents as reported by Typesense
|
|
19
19
|
def delete_by(klass:, filter: nil, hash: nil, into: nil, partition: nil, timeout_ms: nil)
|
|
20
|
-
filter_str = build_filter(filter, hash)
|
|
20
|
+
filter_str = build_filter(filter, hash, klass: klass)
|
|
21
21
|
collection = resolve_into(klass: klass, partition: partition, into: into)
|
|
22
22
|
|
|
23
23
|
effective_timeout = if timeout_ms&.to_i&.positive?
|
|
@@ -42,15 +42,18 @@ module SearchEngine
|
|
|
42
42
|
# @param filter [String, nil]
|
|
43
43
|
# @param hash [Hash, nil]
|
|
44
44
|
# @return [String]
|
|
45
|
-
def build_filter(filter, hash)
|
|
46
|
-
if filter && !filter.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fragments = SearchEngine::Filters::Sanitizer.build_from_hash(hash)
|
|
50
|
-
fragments.join(' && ')
|
|
51
|
-
else
|
|
52
|
-
raise ArgumentError, 'delete_by requires a filter string or a non-empty hash'
|
|
45
|
+
def build_filter(filter, hash, klass: nil)
|
|
46
|
+
return filter if filter.is_a?(String) && !filter.strip.empty?
|
|
47
|
+
unless filter.nil? || filter.is_a?(String) || filter.is_a?(Hash)
|
|
48
|
+
raise ArgumentError, 'expected a filter string or hash'
|
|
53
49
|
end
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, 'provide filter fields in a single hash' if filter.is_a?(Hash) && hash && !hash.empty?
|
|
52
|
+
|
|
53
|
+
fields = filter.is_a?(Hash) ? filter : hash
|
|
54
|
+
raise ArgumentError, 'expected a non-empty filter hash' unless fields.is_a?(Hash) && !fields.empty?
|
|
55
|
+
|
|
56
|
+
SearchEngine::Filters::Sanitizer.build_from_hash(fields, klass: klass).join(' && ')
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
# Resolve the physical collection name using the same logic as the indexer.
|
|
@@ -47,7 +47,7 @@ module SearchEngine
|
|
|
47
47
|
when String
|
|
48
48
|
if placeholders?(input)
|
|
49
49
|
needed = count_placeholders(input)
|
|
50
|
-
ensure_placeholder_arity!(needed, args.length
|
|
50
|
+
ensure_placeholder_arity!(needed, args.length)
|
|
51
51
|
parse_template(input, args, klass: klass)
|
|
52
52
|
else
|
|
53
53
|
parse_raw(input)
|
|
@@ -70,7 +70,7 @@ module SearchEngine
|
|
|
70
70
|
def parse_list(list, klass:, joins: nil)
|
|
71
71
|
# Preserve input shape so template bind arrays stay intact:
|
|
72
72
|
# parse_list(['brand_id IN ?', [1, 2]]) should keep [1, 2] as one bind arg.
|
|
73
|
-
items = Array(list)
|
|
73
|
+
items = Array(list)
|
|
74
74
|
return [] if items.empty?
|
|
75
75
|
|
|
76
76
|
nodes = []
|
|
@@ -78,6 +78,8 @@ module SearchEngine
|
|
|
78
78
|
while i < items.length
|
|
79
79
|
entry = items[i]
|
|
80
80
|
case entry
|
|
81
|
+
when nil
|
|
82
|
+
i += 1
|
|
81
83
|
when Hash
|
|
82
84
|
nodes.concat(Array(parse_hash(entry, klass: klass, joins: joins)))
|
|
83
85
|
i += 1
|
|
@@ -85,7 +87,7 @@ module SearchEngine
|
|
|
85
87
|
if placeholders?(entry)
|
|
86
88
|
needed = count_placeholders(entry)
|
|
87
89
|
args_for_template = items[(i + 1)..(i + needed)] || []
|
|
88
|
-
ensure_placeholder_arity!(needed, args_for_template.length
|
|
90
|
+
ensure_placeholder_arity!(needed, args_for_template.length)
|
|
89
91
|
nodes << parse_template(entry, args_for_template, klass: klass)
|
|
90
92
|
i += 1 + needed
|
|
91
93
|
else
|
|
@@ -130,7 +132,7 @@ module SearchEngine
|
|
|
130
132
|
entry[1..]
|
|
131
133
|
end
|
|
132
134
|
needed = count_placeholders(template)
|
|
133
|
-
ensure_placeholder_arity!(needed, Array(args_list).length
|
|
135
|
+
ensure_placeholder_arity!(needed, Array(args_list).length)
|
|
134
136
|
parse_template(template, Array(args_list), klass: klass)
|
|
135
137
|
end
|
|
136
138
|
|
|
@@ -150,31 +152,25 @@ module SearchEngine
|
|
|
150
152
|
# assoc => { field => value }
|
|
151
153
|
validate_assoc_and_join!(klass, key_sym, joins)
|
|
152
154
|
|
|
155
|
+
cfg = klass.join_for(key_sym)
|
|
156
|
+
target_klass = SearchEngine::Joins::Guard.target_model(klass, key_sym)
|
|
153
157
|
value.each do |inner_field, inner_value|
|
|
154
158
|
field_sym = inner_field.to_sym
|
|
155
159
|
path = "$#{key_sym}.#{field_sym}"
|
|
156
160
|
SearchEngine::Joins::Guard.ensure_single_hop_path!(path)
|
|
157
161
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
rescue StandardError
|
|
163
|
-
nil
|
|
164
|
-
end
|
|
165
|
-
begin
|
|
166
|
-
SearchEngine::Joins::Guard.validate_joined_field!(cfg, field_sym)
|
|
167
|
-
rescue StandardError
|
|
168
|
-
# skip strictly when registry/attributes missing or join missing
|
|
162
|
+
if target_klass && strict_fields?
|
|
163
|
+
SearchEngine::Joins::Guard.validate_joined_field!(
|
|
164
|
+
cfg, field_sym, source_klass: klass, target_klass: target_klass
|
|
165
|
+
)
|
|
169
166
|
end
|
|
170
167
|
|
|
171
|
-
coercion_klass = target_klass || klass
|
|
172
168
|
if array_like?(inner_value)
|
|
173
|
-
values = normalize_array_values(inner_value, field: field_sym, klass:
|
|
174
|
-
ensure_non_empty_values!(values, field: field_sym, klass:
|
|
169
|
+
values = normalize_array_values(inner_value, field: field_sym, klass: target_klass)
|
|
170
|
+
ensure_non_empty_values!(values, field: field_sym, klass: target_klass)
|
|
175
171
|
pairs << SearchEngine::AST.in_(path, values)
|
|
176
172
|
else
|
|
177
|
-
coerced = coerce_value_for_field(inner_value, field: field_sym, klass:
|
|
173
|
+
coerced = coerce_value_for_field(inner_value, field: field_sym, klass: target_klass)
|
|
178
174
|
pairs << SearchEngine::AST.eq(path, coerced)
|
|
179
175
|
end
|
|
180
176
|
end
|
|
@@ -204,9 +200,8 @@ module SearchEngine
|
|
|
204
200
|
m = template.match(/\A\s*([A-Za-z_][A-Za-z0-9_]*)\s*(=|!=|>=|<=|>|<|IN|NOT\s+IN|MATCHES|PREFIX)\s*\?\s*\z/i)
|
|
205
201
|
unless m
|
|
206
202
|
raise SearchEngine::Errors::InvalidOperator.new(
|
|
207
|
-
|
|
208
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting'
|
|
209
|
-
details: { template: template }
|
|
203
|
+
'invalid filter template. Supported operators: =, !=, >, >=, <, <=, IN, NOT IN, MATCHES, PREFIX',
|
|
204
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting'
|
|
210
205
|
)
|
|
211
206
|
end
|
|
212
207
|
|
|
@@ -266,7 +261,7 @@ module SearchEngine
|
|
|
266
261
|
arr[1..]
|
|
267
262
|
end
|
|
268
263
|
needed = count_placeholders(template)
|
|
269
|
-
ensure_placeholder_arity!(needed, Array(args_list).length
|
|
264
|
+
ensure_placeholder_arity!(needed, Array(args_list).length)
|
|
270
265
|
parse_template(template, Array(args_list), klass: klass)
|
|
271
266
|
else
|
|
272
267
|
parse_list(arr, klass: klass, joins: joins)
|
|
@@ -283,13 +278,13 @@ module SearchEngine
|
|
|
283
278
|
SearchEngine::Filters::Sanitizer.count_placeholders(str)
|
|
284
279
|
end
|
|
285
280
|
|
|
286
|
-
def ensure_placeholder_arity!(needed, provided
|
|
281
|
+
def ensure_placeholder_arity!(needed, provided)
|
|
287
282
|
return if needed == provided
|
|
288
283
|
|
|
289
284
|
raise SearchEngine::Errors::InvalidOperator.new(
|
|
290
|
-
"expected #{needed} args for #{needed} placeholders
|
|
285
|
+
"expected #{needed} args for #{needed} placeholders, got #{provided}.",
|
|
291
286
|
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
292
|
-
details: { needed: needed, provided: provided
|
|
287
|
+
details: { needed: needed, provided: provided }
|
|
293
288
|
)
|
|
294
289
|
end
|
|
295
290
|
|
|
@@ -352,7 +347,7 @@ module SearchEngine
|
|
|
352
347
|
return if values.is_a?(Array) && !values.empty?
|
|
353
348
|
|
|
354
349
|
raise SearchEngine::Errors::InvalidType.new(
|
|
355
|
-
|
|
350
|
+
"expected #{field.inspect} to be a non-empty Array for #{klass} (got #{values.class})",
|
|
356
351
|
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
357
352
|
details: { field: field, got_class: values.class.name }
|
|
358
353
|
)
|
|
@@ -372,7 +367,7 @@ module SearchEngine
|
|
|
372
367
|
# If generic coercion returned :__no_coercion__, try to coerce based on type hint
|
|
373
368
|
case type_hint
|
|
374
369
|
when :boolean
|
|
375
|
-
coerce_boolean(value, type_hint)
|
|
370
|
+
coerce_boolean(value, type_hint, field: field, klass: klass)
|
|
376
371
|
when :time, :datetime, :time_string, :datetime_string
|
|
377
372
|
coerce_time(value, type_hint, field: field, klass: klass)
|
|
378
373
|
|
|
@@ -389,7 +384,7 @@ module SearchEngine
|
|
|
389
384
|
end
|
|
390
385
|
|
|
391
386
|
def coerce_value(value, type_hint: nil, field: nil, klass: nil)
|
|
392
|
-
coerced_bool = coerce_boolean(value, type_hint)
|
|
387
|
+
coerced_bool = coerce_boolean(value, type_hint, field: field, klass: klass)
|
|
393
388
|
return coerced_bool unless coerced_bool.equal?(:__no_coercion__)
|
|
394
389
|
|
|
395
390
|
coerced_time = coerce_time(value, type_hint, field: field, klass: klass)
|
|
@@ -401,18 +396,14 @@ module SearchEngine
|
|
|
401
396
|
value
|
|
402
397
|
end
|
|
403
398
|
|
|
404
|
-
def coerce_boolean(value, type_hint)
|
|
399
|
+
def coerce_boolean(value, type_hint, field:, klass:)
|
|
405
400
|
return :__no_coercion__ unless type_boolean?(type_hint) && value.is_a?(String)
|
|
406
401
|
|
|
407
402
|
lc = value.strip.downcase
|
|
408
403
|
return true if lc == 'true'
|
|
409
404
|
return false if lc == 'false'
|
|
410
405
|
|
|
411
|
-
raise
|
|
412
|
-
invalid_type_message(field: nil, klass: nil, expectation: 'boolean', got: value),
|
|
413
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
414
|
-
details: { got: value }
|
|
415
|
-
)
|
|
406
|
+
raise invalid_type(field, klass, 'boolean', value)
|
|
416
407
|
end
|
|
417
408
|
private_class_method :coerce_boolean
|
|
418
409
|
|
|
@@ -439,12 +430,9 @@ module SearchEngine
|
|
|
439
430
|
else
|
|
440
431
|
:__no_coercion__
|
|
441
432
|
end
|
|
442
|
-
rescue
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
446
|
-
details: { field: field, got: value }
|
|
447
|
-
)
|
|
433
|
+
rescue ArgumentError, TypeError, RangeError
|
|
434
|
+
# Time.parse includes the input in its exception, which error reporters also traverse.
|
|
435
|
+
raise invalid_type(field, klass, 'time', value), cause: nil
|
|
448
436
|
end
|
|
449
437
|
private_class_method :coerce_time
|
|
450
438
|
|
|
@@ -453,26 +441,17 @@ module SearchEngine
|
|
|
453
441
|
return value if value.is_a?(Integer)
|
|
454
442
|
|
|
455
443
|
if value.is_a?(String)
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
|
|
462
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
463
|
-
details: { field: field, got: value }
|
|
464
|
-
)
|
|
465
|
-
end
|
|
466
|
-
end
|
|
444
|
+
number = Integer(value, 10, exception: false)
|
|
445
|
+
raise invalid_type(field, klass, 'integer', value) unless number
|
|
446
|
+
|
|
447
|
+
# Typesense compares document IDs byte-for-byte. Converting "0042" would target "42".
|
|
448
|
+
return value if field.to_s == 'id' && value != number.to_s
|
|
467
449
|
|
|
468
|
-
|
|
469
|
-
raise SearchEngine::Errors::InvalidType.new(
|
|
470
|
-
invalid_type_message(field: field, klass: klass, expectation: 'integer', got: value),
|
|
471
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
472
|
-
details: { field: field, got: value }
|
|
473
|
-
)
|
|
450
|
+
return number
|
|
474
451
|
end
|
|
475
452
|
|
|
453
|
+
raise invalid_type(field, klass, 'integer', value) if value.is_a?(Numeric)
|
|
454
|
+
|
|
476
455
|
return :__no_coercion__
|
|
477
456
|
end
|
|
478
457
|
|
|
@@ -481,16 +460,10 @@ module SearchEngine
|
|
|
481
460
|
return value.to_f if value.is_a?(Numeric)
|
|
482
461
|
|
|
483
462
|
if value.is_a?(String)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
invalid_type_message(field: field, klass: klass, expectation: 'numeric', got: value),
|
|
489
|
-
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
490
|
-
details: { field: field, got: value }
|
|
491
|
-
)
|
|
492
|
-
end
|
|
493
|
-
return value.to_f
|
|
463
|
+
number = Float(value, exception: false)
|
|
464
|
+
raise invalid_type(field, klass, 'numeric', value) unless number
|
|
465
|
+
|
|
466
|
+
return number
|
|
494
467
|
end
|
|
495
468
|
end
|
|
496
469
|
|
|
@@ -568,10 +541,17 @@ module SearchEngine
|
|
|
568
541
|
best.first.to_sym
|
|
569
542
|
end
|
|
570
543
|
|
|
571
|
-
def
|
|
572
|
-
|
|
573
|
-
|
|
544
|
+
def invalid_type(field, klass, expectation, value)
|
|
545
|
+
subject = field ? field.inspect : 'filter value'
|
|
546
|
+
model = " for #{klass}" if klass
|
|
547
|
+
actual = value.class.name
|
|
548
|
+
SearchEngine::Errors::InvalidType.new(
|
|
549
|
+
"expected #{subject} to be #{expectation}#{model} (got #{actual})",
|
|
550
|
+
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/query-dsl#troubleshooting',
|
|
551
|
+
details: { field: field, expected: expectation, got: actual }
|
|
552
|
+
)
|
|
574
553
|
end
|
|
554
|
+
private_class_method :invalid_type
|
|
575
555
|
|
|
576
556
|
def validate_assoc_and_join!(klass, assoc_name, joins)
|
|
577
557
|
# Validate association exists and config completeness
|
data/lib/search_engine/engine.rb
CHANGED
|
@@ -35,13 +35,7 @@ module SearchEngine
|
|
|
35
35
|
|
|
36
36
|
# Also ensure Rails global autoloaders ignore these files, since the engine
|
|
37
37
|
# adds lib/ to autoload paths and the main/once loaders may scan it.
|
|
38
|
-
|
|
39
|
-
al = Rails.autoloaders
|
|
40
|
-
al.main.ignore(shim) if al.respond_to?(:main)
|
|
41
|
-
al.once.ignore(shim) if al.respond_to?(:once)
|
|
42
|
-
al.main.ignore(cli_file) if al.respond_to?(:main)
|
|
43
|
-
al.once.ignore(cli_file) if al.respond_to?(:once)
|
|
44
|
-
end
|
|
38
|
+
Rails.autoloaders.each { |autoloader| autoloader.ignore(shim, cli_file) }
|
|
45
39
|
end
|
|
46
40
|
|
|
47
41
|
initializer 'search_engine.observability' do
|
|
@@ -111,6 +105,26 @@ module SearchEngine
|
|
|
111
105
|
end
|
|
112
106
|
end
|
|
113
107
|
|
|
108
|
+
# Rails-managed mappers may declare a collection unrelated to their constant name.
|
|
109
|
+
# Register these mappings before a callback can interpret a miss as a bare collection.
|
|
110
|
+
initializer 'search_engine.rails_model_mappings' do |app|
|
|
111
|
+
app.reloader.to_prepare do
|
|
112
|
+
models = app.paths['app/models'].existent
|
|
113
|
+
if Rails.autoloaders.zeitwerk_enabled?
|
|
114
|
+
Rails.autoloaders.each do |loader|
|
|
115
|
+
(loader.dirs & models).each do |root|
|
|
116
|
+
namespace_dir = File.join(root, 'search_engine')
|
|
117
|
+
loader.eager_load_dir(namespace_dir) if File.directory?(namespace_dir)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
models.each do |root|
|
|
122
|
+
Dir[File.join(root, 'search_engine', '**', '*.rb')].sort.each { |file| require_dependency(file) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
114
128
|
# Manage a dedicated Zeitwerk loader for host app SearchEngine models.
|
|
115
129
|
# Loads after Rails so application models/constants are available.
|
|
116
130
|
initializer 'search_engine.models_loader' do
|
|
@@ -126,22 +140,17 @@ module SearchEngine
|
|
|
126
140
|
next unless File.directory?(path_s)
|
|
127
141
|
|
|
128
142
|
# Ensure Rails' autoloaders do not also manage this directory.
|
|
129
|
-
|
|
130
|
-
al = Rails.autoloaders
|
|
131
|
-
%i[main once].each do |key|
|
|
132
|
-
al.public_send(key).ignore(path_s) if al.respond_to?(key)
|
|
133
|
-
end
|
|
134
|
-
end
|
|
143
|
+
Rails.autoloaders.each { |autoloader| autoloader.ignore(path_s) }
|
|
135
144
|
|
|
136
145
|
# Create or reuse a dedicated loader under SearchEngine namespace.
|
|
137
146
|
loader = SearchEngine.instance_variable_get(:@_models_loader)
|
|
138
147
|
unless loader
|
|
148
|
+
require 'zeitwerk'
|
|
139
149
|
loader = Zeitwerk::Loader.new
|
|
140
150
|
loader.tag = 'search_engine.models'
|
|
141
151
|
# Reuse Rails' inflector for consistent constantization rules.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
end
|
|
152
|
+
main = Rails.autoloaders.main
|
|
153
|
+
loader.inflector = main.inflector if main
|
|
145
154
|
loader.push_dir(path_s, namespace: SearchEngine)
|
|
146
155
|
loader.enable_reloading if defined?(Rails) && Rails.env.development?
|
|
147
156
|
SearchEngine.instance_variable_set(:@_models_loader, loader)
|