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
|
@@ -17,25 +17,7 @@ module SearchEngine
|
|
|
17
17
|
# @param args [Array<Object>]
|
|
18
18
|
# @return [SearchEngine::Relation]
|
|
19
19
|
def not(*args)
|
|
20
|
-
@relation.
|
|
21
|
-
nodes = Array(@relation.send(:build_ast_with_empty_array_rewrites, args, negated: true))
|
|
22
|
-
|
|
23
|
-
# Invert non-hidden predicates (Eq, In) returned by the builder
|
|
24
|
-
negated = nodes.map do |node|
|
|
25
|
-
case node
|
|
26
|
-
when SearchEngine::AST::Eq
|
|
27
|
-
SearchEngine::AST.not_eq(node.field, node.value)
|
|
28
|
-
when SearchEngine::AST::In
|
|
29
|
-
SearchEngine::AST.not_in(node.field, node.values)
|
|
30
|
-
else
|
|
31
|
-
node
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
@relation.send(:spawn) do |s|
|
|
36
|
-
s[:ast] = Array(s[:ast]) + negated
|
|
37
|
-
s[:filters] = Array(s[:filters])
|
|
38
|
-
end
|
|
20
|
+
@relation.not(*args)
|
|
39
21
|
end
|
|
40
22
|
end
|
|
41
23
|
|
|
@@ -47,11 +29,9 @@ module SearchEngine
|
|
|
47
29
|
return self if args.nil? || args.empty?
|
|
48
30
|
return self if blank_where_args?(args)
|
|
49
31
|
|
|
50
|
-
ast_nodes =
|
|
51
|
-
fragments = normalize_where(args)
|
|
32
|
+
ast_nodes = build_filter_nodes(args, negated: false)
|
|
52
33
|
spawn do |s|
|
|
53
34
|
s[:ast] = Array(s[:ast]) + Array(ast_nodes)
|
|
54
|
-
s[:filters] = Array(s[:filters]) + fragments
|
|
55
35
|
end
|
|
56
36
|
end
|
|
57
37
|
|
|
@@ -61,23 +41,10 @@ module SearchEngine
|
|
|
61
41
|
# @return [SearchEngine::Relation]
|
|
62
42
|
def not(*args)
|
|
63
43
|
validate_not_args!(args)
|
|
64
|
-
nodes =
|
|
65
|
-
|
|
66
|
-
negated = nodes.map do |node|
|
|
67
|
-
case node
|
|
68
|
-
when SearchEngine::AST::Eq
|
|
69
|
-
SearchEngine::AST.not_eq(node.field, node.value)
|
|
70
|
-
when SearchEngine::AST::In
|
|
71
|
-
SearchEngine::AST.not_in(node.field, node.values)
|
|
72
|
-
else
|
|
73
|
-
node
|
|
74
|
-
end
|
|
75
|
-
end
|
|
44
|
+
nodes = build_filter_nodes(args, negated: true)
|
|
45
|
+
raise SearchEngine::Errors::InvalidOperator, 'where.not requires a predicate' if nodes.empty?
|
|
76
46
|
|
|
77
|
-
spawn
|
|
78
|
-
s[:ast] = Array(s[:ast]) + negated
|
|
79
|
-
s[:filters] = Array(s[:filters])
|
|
80
|
-
end
|
|
47
|
+
spawn { |state| state[:ast] = Array(state[:ast]) + nodes }
|
|
81
48
|
end
|
|
82
49
|
|
|
83
50
|
# Replace all predicates with a new where input.
|
|
@@ -249,93 +216,77 @@ module SearchEngine
|
|
|
249
216
|
end
|
|
250
217
|
end
|
|
251
218
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def build_ast_with_empty_array_rewrites(args, negated: false)
|
|
257
|
-
# Preserve input shape so template bind arrays stay intact:
|
|
258
|
-
# where('field IN ?', [1, 2]) => ['field IN ?', [1, 2]]
|
|
259
|
-
items = Array(args).compact
|
|
260
|
-
return [] if items.empty?
|
|
261
|
-
|
|
262
|
-
out_nodes = []
|
|
263
|
-
non_hash_items = []
|
|
264
|
-
|
|
265
|
-
items.each do |entry|
|
|
219
|
+
def build_filter_nodes(args, negated:)
|
|
220
|
+
nodes = []
|
|
221
|
+
expressions = []
|
|
222
|
+
args.each do |entry|
|
|
266
223
|
if entry.is_a?(Hash)
|
|
267
|
-
|
|
224
|
+
entry.each do |field, value|
|
|
225
|
+
if join_scope_value?(value) && join_assoc?(field)
|
|
226
|
+
scoped = []
|
|
227
|
+
process_join_scope(field, value, scoped)
|
|
228
|
+
nodes.concat(negated ? scoped.map { |node| invert_predicate(node) } : scoped)
|
|
229
|
+
elsif value.is_a?(Hash)
|
|
230
|
+
SearchEngine::DSL::Parser.validate_assoc_and_join!(@klass, field.to_sym, joins_list)
|
|
231
|
+
value.each do |inner_field, inner_value|
|
|
232
|
+
nodes << hash_predicate(inner_field, inner_value, assoc: field, negated: negated)
|
|
233
|
+
end
|
|
234
|
+
else
|
|
235
|
+
nodes << hash_predicate(field, value, negated: negated)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
268
238
|
else
|
|
269
|
-
|
|
239
|
+
expressions << entry
|
|
270
240
|
end
|
|
271
241
|
end
|
|
272
242
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
243
|
+
parsed = SearchEngine::DSL::Parser.parse_list(expressions, klass: @klass, joins: joins_list)
|
|
244
|
+
nodes.concat(negated ? parsed.map { |node| invert_predicate(node) } : parsed)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def hash_predicate(key, value, negated:, assoc: nil)
|
|
248
|
+
field = SearchEngine::Filters::Sanitizer.field_name(key).to_sym
|
|
249
|
+
empty_array = value.is_a?(Array) && value.flatten(1).compact.empty?
|
|
250
|
+
if value.nil? || empty_array
|
|
251
|
+
lhs = assoc ? "$#{assoc}.#{field}" : field.to_s
|
|
252
|
+
empty = assoc ? joined_empty_filtering_enabled?(assoc, field) : base_empty_filtering_enabled?(field)
|
|
253
|
+
blank = assoc ? joined_optional_enabled?(assoc, field) : base_optional_enabled?(field)
|
|
254
|
+
raise_empty_array_type!(field) if empty_array && !empty
|
|
255
|
+
|
|
256
|
+
flags = []
|
|
257
|
+
flags << SearchEngine::AST.eq("#{lhs}_empty", !negated) if empty
|
|
258
|
+
flags << SearchEngine::AST.eq("#{lhs}_blank", !negated) if blank && value.nil?
|
|
259
|
+
return flags.first if flags.size == 1
|
|
260
|
+
if flags.size > 1
|
|
261
|
+
return SearchEngine::AST.group(negated ? SearchEngine::AST.and_(*flags) : SearchEngine::AST.or_(*flags))
|
|
262
|
+
end
|
|
277
263
|
end
|
|
278
264
|
|
|
279
|
-
|
|
265
|
+
predicate = { field => value }
|
|
266
|
+
predicate = { assoc => predicate } if assoc
|
|
267
|
+
node = SearchEngine::DSL::Parser.parse(predicate, klass: @klass, joins: joins_list)
|
|
268
|
+
negated ? invert_predicate(node) : node
|
|
280
269
|
end
|
|
281
270
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
elsif v.is_a?(Hash)
|
|
288
|
-
process_join_predicate(k, v, out_nodes, negated)
|
|
289
|
-
else
|
|
290
|
-
process_base_predicate(k, v, out_nodes, negated)
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
end
|
|
271
|
+
INVERSE_PREDICATES = {
|
|
272
|
+
eq: :not_eq, not_eq: :eq, in: :not_in, not_in: :in_,
|
|
273
|
+
gt: :lte, gte: :lt, lt: :gte, lte: :gt
|
|
274
|
+
}.freeze
|
|
275
|
+
private_constant :INVERSE_PREDICATES
|
|
294
276
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
field_sym = inner_field.to_sym
|
|
299
|
-
if inner_value.nil?
|
|
300
|
-
emit_nil_flags_for_join(out_nodes, assoc, field_sym, negated)
|
|
301
|
-
elsif array_like?(inner_value)
|
|
302
|
-
arr = Array(inner_value).flatten(1).compact
|
|
303
|
-
if arr.empty?
|
|
304
|
-
if joined_empty_filtering_enabled?(assoc, field_sym)
|
|
305
|
-
emit_empty_array_flag(out_nodes, "$#{assoc}.#{field_sym}_empty", negated)
|
|
306
|
-
else
|
|
307
|
-
raise_empty_array_type!(field_sym)
|
|
308
|
-
end
|
|
309
|
-
else
|
|
310
|
-
out_nodes << SearchEngine::DSL::Parser.parse(
|
|
311
|
-
{ assoc => { field_sym => inner_value } }, klass: @klass, joins: joins_list
|
|
312
|
-
)
|
|
313
|
-
end
|
|
314
|
-
else
|
|
315
|
-
out_nodes << SearchEngine::DSL::Parser.parse(
|
|
316
|
-
{ assoc => { field_sym => inner_value } }, klass: @klass, joins: joins_list
|
|
317
|
-
)
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
end
|
|
277
|
+
def invert_predicate(node)
|
|
278
|
+
inverse = INVERSE_PREDICATES[node.type]
|
|
279
|
+
return SearchEngine::AST.public_send(inverse, node.field, node.right) if inverse
|
|
321
280
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if base_empty_filtering_enabled?(field)
|
|
330
|
-
emit_empty_array_flag(out_nodes, "#{field}_empty", negated)
|
|
331
|
-
else
|
|
332
|
-
raise_empty_array_type!(field)
|
|
333
|
-
end
|
|
334
|
-
else
|
|
335
|
-
out_nodes << SearchEngine::DSL::Parser.parse({ field => value }, klass: @klass, joins: joins_list)
|
|
336
|
-
end
|
|
281
|
+
case node
|
|
282
|
+
when SearchEngine::AST::And
|
|
283
|
+
SearchEngine::AST.or_(*node.children.map { |child| invert_predicate(child) })
|
|
284
|
+
when SearchEngine::AST::Or
|
|
285
|
+
SearchEngine::AST.and_(*node.children.map { |child| invert_predicate(child) })
|
|
286
|
+
when SearchEngine::AST::Group
|
|
287
|
+
SearchEngine::AST.group(invert_predicate(node.child))
|
|
337
288
|
else
|
|
338
|
-
|
|
289
|
+
raise SearchEngine::Errors::InvalidOperator, "where.not cannot invert a #{node.type} predicate"
|
|
339
290
|
end
|
|
340
291
|
end
|
|
341
292
|
|
|
@@ -478,10 +429,6 @@ module SearchEngine
|
|
|
478
429
|
end
|
|
479
430
|
end
|
|
480
431
|
|
|
481
|
-
def emit_empty_array_flag(out_nodes, lhs, negated)
|
|
482
|
-
out_nodes << SearchEngine::AST.raw("#{lhs}:=#{negated ? 'false' : 'true'}")
|
|
483
|
-
end
|
|
484
|
-
|
|
485
432
|
def base_empty_filtering_enabled?(field_sym)
|
|
486
433
|
opts = @klass.respond_to?(:attribute_options) ? (@klass.attribute_options || {}) : {}
|
|
487
434
|
o = opts[field_sym]
|
|
@@ -526,59 +473,6 @@ module SearchEngine
|
|
|
526
473
|
false
|
|
527
474
|
end
|
|
528
475
|
|
|
529
|
-
def emit_nil_flags_for_base(out_nodes, field_sym, negated)
|
|
530
|
-
has_empty = base_empty_filtering_enabled?(field_sym)
|
|
531
|
-
has_blank = base_optional_enabled?(field_sym)
|
|
532
|
-
fragment = nil
|
|
533
|
-
if has_empty && has_blank
|
|
534
|
-
fragment = if negated
|
|
535
|
-
"(#{field_sym}_empty:=false && #{field_sym}_blank:=false)"
|
|
536
|
-
else
|
|
537
|
-
"(#{field_sym}_empty:=true || #{field_sym}_blank:=true)"
|
|
538
|
-
end
|
|
539
|
-
elsif has_blank
|
|
540
|
-
fragment = "#{field_sym}_blank:=#{negated ? 'false' : 'true'}"
|
|
541
|
-
elsif has_empty
|
|
542
|
-
fragment = "#{field_sym}_empty:=#{negated ? 'false' : 'true'}"
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
out_nodes << if fragment
|
|
546
|
-
SearchEngine::AST.raw(fragment)
|
|
547
|
-
else
|
|
548
|
-
SearchEngine::DSL::Parser.parse({ field_sym => nil }, klass: @klass, joins: joins_list)
|
|
549
|
-
end
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
def emit_nil_flags_for_join(out_nodes, assoc_sym, field_sym, negated)
|
|
553
|
-
has_empty = joined_empty_filtering_enabled?(assoc_sym, field_sym)
|
|
554
|
-
has_blank = joined_optional_enabled?(assoc_sym, field_sym)
|
|
555
|
-
lhs_empty = "$#{assoc_sym}.#{field_sym}_empty"
|
|
556
|
-
lhs_blank = "$#{assoc_sym}.#{field_sym}_blank"
|
|
557
|
-
fragment = nil
|
|
558
|
-
if has_empty && has_blank
|
|
559
|
-
fragment = if negated
|
|
560
|
-
"(#{lhs_empty}:=false && #{lhs_blank}:=false)"
|
|
561
|
-
else
|
|
562
|
-
"(#{lhs_empty}:=true || #{lhs_blank}:=true)"
|
|
563
|
-
end
|
|
564
|
-
elsif has_blank
|
|
565
|
-
fragment = "#{lhs_blank}:=#{negated ? 'false' : 'true'}"
|
|
566
|
-
elsif has_empty
|
|
567
|
-
fragment = "#{lhs_empty}:=#{negated ? 'false' : 'true'}"
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
if fragment
|
|
571
|
-
out_nodes << SearchEngine::AST.raw(fragment)
|
|
572
|
-
else
|
|
573
|
-
parsed = { assoc_sym => { field_sym => nil } }
|
|
574
|
-
out_nodes << SearchEngine::DSL::Parser.parse(parsed, klass: @klass, joins: joins_list)
|
|
575
|
-
end
|
|
576
|
-
end
|
|
577
|
-
|
|
578
|
-
def array_like?(value)
|
|
579
|
-
value.is_a?(Array)
|
|
580
|
-
end
|
|
581
|
-
|
|
582
476
|
def raise_empty_array_type!(field_sym)
|
|
583
477
|
raise SearchEngine::Errors::InvalidType.new(
|
|
584
478
|
%(expected #{field_sym.inspect} to be a non-empty Array),
|
|
@@ -591,7 +485,7 @@ module SearchEngine
|
|
|
591
485
|
def normalize_where(args)
|
|
592
486
|
# Preserve input shape so template bind arrays stay intact:
|
|
593
487
|
# where('field IN ?', [1, 2]) => ['field IN ?', [1, 2]]
|
|
594
|
-
list = Array(args)
|
|
488
|
+
list = Array(args)
|
|
595
489
|
return [] if list.empty?
|
|
596
490
|
|
|
597
491
|
fragments = []
|
|
@@ -601,6 +495,8 @@ module SearchEngine
|
|
|
601
495
|
while i < list.length
|
|
602
496
|
entry = list[i]
|
|
603
497
|
case entry
|
|
498
|
+
when nil
|
|
499
|
+
i += 1
|
|
604
500
|
when Hash
|
|
605
501
|
# Validate only base-like keys here; assoc keys (values as Hash) are handled via AST/Parser
|
|
606
502
|
# and assoc keys with join-scope shorthand (values as Symbol/Array<Symbol>) are ignored for fragments.
|
|
@@ -610,7 +506,7 @@ module SearchEngine
|
|
|
610
506
|
base_pairs = base_like_pairs
|
|
611
507
|
unless base_pairs.empty?
|
|
612
508
|
fragments.concat(
|
|
613
|
-
SearchEngine::Filters::Sanitizer.build_from_hash(base_pairs, known_attrs)
|
|
509
|
+
SearchEngine::Filters::Sanitizer.build_from_hash(base_pairs, known_attrs, klass: @klass)
|
|
614
510
|
)
|
|
615
511
|
end
|
|
616
512
|
i += 1
|
|
@@ -632,15 +528,15 @@ module SearchEngine
|
|
|
632
528
|
end
|
|
633
529
|
|
|
634
530
|
def normalize_where_process_string!(fragments, entry, list, i)
|
|
635
|
-
|
|
531
|
+
needed = SearchEngine::Filters::Sanitizer.count_placeholders(entry)
|
|
532
|
+
if needed.positive?
|
|
636
533
|
tail = list[(i + 1)..] || []
|
|
637
|
-
needed = SearchEngine::Filters::Sanitizer.count_placeholders(entry)
|
|
638
534
|
args_for_template = tail.first(needed)
|
|
639
535
|
if args_for_template.length != needed
|
|
640
536
|
raise ArgumentError, "expected #{needed} args for #{needed} placeholders, got #{args_for_template.length}"
|
|
641
537
|
end
|
|
642
538
|
|
|
643
|
-
fragments << SearchEngine::Filters::Sanitizer.apply_placeholders(entry, args_for_template)
|
|
539
|
+
fragments << SearchEngine::Filters::Sanitizer.apply_placeholders(entry, args_for_template, klass: @klass)
|
|
644
540
|
i + 1 + needed
|
|
645
541
|
else
|
|
646
542
|
fragments << entry.to_s
|
|
@@ -153,7 +153,9 @@ module SearchEngine
|
|
|
153
153
|
seg << 'missing_values' if g[:missing_values]
|
|
154
154
|
parts << "grouping=#{seg.join(':')}"
|
|
155
155
|
end
|
|
156
|
-
if params.key?(:
|
|
156
|
+
if params.key?(:offset)
|
|
157
|
+
parts << "offset/limit=#{params[:offset]}/#{params[:limit]}"
|
|
158
|
+
elsif params.key?(:page) || params.key?(:per_page)
|
|
157
159
|
p = params[:page]
|
|
158
160
|
per = params[:per_page]
|
|
159
161
|
parts << "page/per=#{p || ''}/#{per || ''}"
|
|
@@ -53,7 +53,8 @@ module SearchEngine
|
|
|
53
53
|
|
|
54
54
|
# Find the first matching record using where-like inputs.
|
|
55
55
|
# Accepts the same arguments as `.where` (Hash, String, Array, Symbol),
|
|
56
|
-
#
|
|
56
|
+
# and fetches one hit. Page-based searches start at the first match.
|
|
57
|
+
# Offset-based searches retain their explicit starting offset.
|
|
57
58
|
#
|
|
58
59
|
# @param args [Array<Object>] where-compatible arguments
|
|
59
60
|
# @return [Object, nil]
|
|
@@ -61,8 +62,10 @@ module SearchEngine
|
|
|
61
62
|
# SearchEngine::Product.find_by(article_code: 12312, store_id: 1031)
|
|
62
63
|
# SearchEngine::Product.where(active: true).find_by('price:>100')
|
|
63
64
|
def find_by(*args)
|
|
64
|
-
relation =
|
|
65
|
-
relation.
|
|
65
|
+
relation = where(*args)
|
|
66
|
+
return relation.limit(1).first if compute_pagination.key?(:offset)
|
|
67
|
+
|
|
68
|
+
relation.page(1).per(1).first
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
# Convenience for plucking :id values.
|
|
@@ -5,33 +5,12 @@ module SearchEngine
|
|
|
5
5
|
# Options and pagination normalization helpers.
|
|
6
6
|
# Centralizes precedence rules and guards for page/per vs limit/offset.
|
|
7
7
|
module Options
|
|
8
|
-
#
|
|
9
|
-
# - When page/per are provided, prefer them (fill missing per with nil, page defaults to 1 when only per is set)
|
|
10
|
-
# - Otherwise, when limit is set, derive page from offset and per_page from limit
|
|
11
|
-
# - Otherwise, return empty Hash
|
|
12
|
-
# @return [Hash{Symbol=>Integer}]
|
|
8
|
+
# Page-based pagination takes precedence. A native offset can start between pages.
|
|
13
9
|
def compute_pagination
|
|
14
|
-
page = @state
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
out = {}
|
|
19
|
-
if per && page
|
|
20
|
-
out[:page] = page
|
|
21
|
-
out[:per_page] = per
|
|
22
|
-
elsif per && !page
|
|
23
|
-
out[:page] = 1
|
|
24
|
-
out[:per_page] = per
|
|
25
|
-
elsif page && !per
|
|
26
|
-
out[:page] = page
|
|
27
|
-
end
|
|
28
|
-
return out
|
|
29
|
-
elsif @state[:limit]
|
|
30
|
-
limit = @state[:limit]
|
|
31
|
-
off = @state[:offset] || 0
|
|
32
|
-
computed_page = (off.to_i / limit.to_i) + 1
|
|
33
|
-
return { page: computed_page, per_page: limit }
|
|
34
|
-
end
|
|
10
|
+
page, per, limit, offset = @state.values_at(:page, :per_page, :limit, :offset)
|
|
11
|
+
return { page: page || 1, per_page: per }.compact if page || per
|
|
12
|
+
return { offset: offset, limit: limit }.compact if offset
|
|
13
|
+
return { page: 1, per_page: limit } if limit
|
|
35
14
|
|
|
36
15
|
{}
|
|
37
16
|
end
|
|
@@ -128,8 +128,9 @@ module SearchEngine
|
|
|
128
128
|
legacy = Array(state[:filters]).flatten.compact
|
|
129
129
|
return if !ast_nodes.empty? || legacy.empty?
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
state[:ast] = legacy.map { |fragment| SearchEngine::AST.raw(String(fragment)) }
|
|
132
|
+
# On a subsequent spawn, '?' in these compiled literals is data.
|
|
133
|
+
state[:filters] = []
|
|
133
134
|
nil
|
|
134
135
|
end
|
|
135
136
|
|
|
@@ -17,7 +17,7 @@ module SearchEngine
|
|
|
17
17
|
find_index index rindex values_at sample shuffle rotate
|
|
18
18
|
].freeze
|
|
19
19
|
# Keys considered essential for :only preset mode.
|
|
20
|
-
ESSENTIAL_PARAM_KEYS = %i[q page per_page].freeze
|
|
20
|
+
ESSENTIAL_PARAM_KEYS = %i[q page per_page offset limit].freeze
|
|
21
21
|
|
|
22
22
|
# @return [Class] bound model class (typically a SearchEngine::Base subclass)
|
|
23
23
|
attr_reader :klass
|
|
@@ -463,8 +463,9 @@ module SearchEngine
|
|
|
463
463
|
parts << gparts.join(' ')
|
|
464
464
|
end
|
|
465
465
|
|
|
466
|
-
|
|
467
|
-
|
|
466
|
+
{ page: :page, per: :per_page, offset: :offset, limit: :limit }.each do |label, key|
|
|
467
|
+
parts << "#{label}=#{compiled[key]}" if compiled.key?(key)
|
|
468
|
+
end
|
|
468
469
|
|
|
469
470
|
"#<#{self.class.name} #{parts.join(' ')} >"
|
|
470
471
|
end
|
|
@@ -586,7 +587,7 @@ module SearchEngine
|
|
|
586
587
|
msg,
|
|
587
588
|
hint: 'Increase `validate_hits!(max:)` or narrow filters. Prefer `limit_hits(n)` to avoid work when supported.',
|
|
588
589
|
doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/hit-limits#validation',
|
|
589
|
-
details: { total_hits: th, max: max, collection: coll, relation_summary:
|
|
590
|
+
details: { total_hits: th, max: max, collection: coll, relation_summary: summary_inspect_string }
|
|
590
591
|
)
|
|
591
592
|
end
|
|
592
593
|
|
data/lib/search_engine/result.rb
CHANGED
|
@@ -105,6 +105,26 @@ module SearchEngine
|
|
|
105
105
|
freeze
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
# Apply relation constraints to a result supplied by a custom client.
|
|
109
|
+
# Matching model instances, including all grouped hits, can be shared.
|
|
110
|
+
def with_context(klass:, selection:, facets:)
|
|
111
|
+
return Result.new(raw, klass: klass, selection: selection, facets: facets) unless klass == @klass
|
|
112
|
+
|
|
113
|
+
copy = dup
|
|
114
|
+
copy.send(:apply_context!, selection, facets)
|
|
115
|
+
copy.freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Source documents aligned with #hits, or with every hit in each group.
|
|
119
|
+
def self.documents(raw, all_group_members: false)
|
|
120
|
+
groups = raw['grouped_hits'] || raw[:grouped_hits]
|
|
121
|
+
(groups || [raw]).flat_map do |section|
|
|
122
|
+
hits = section['hits'] || section[:hits]
|
|
123
|
+
docs = Array(hits).filter_map { |hit| hit['document'] || hit[:document] }
|
|
124
|
+
groups && !all_group_members ? docs.first(1) : docs
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
108
128
|
# Iterate over hydrated hits.
|
|
109
129
|
# @yieldparam obj [Object] hydrated object
|
|
110
130
|
# @return [Enumerator] when no block is given
|
|
@@ -314,6 +334,17 @@ module SearchEngine
|
|
|
314
334
|
end
|
|
315
335
|
end
|
|
316
336
|
|
|
337
|
+
def apply_context!(selection, facets)
|
|
338
|
+
@selection_ctx = selection
|
|
339
|
+
if selection && selection[:strict_missing]
|
|
340
|
+
Result.documents(raw, all_group_members: true).each do |document|
|
|
341
|
+
enforce_strict_missing_if_needed!(document.keys.map(&:to_s))
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
@facets_ctx = facets
|
|
345
|
+
@__facets_parsed_memo = build_parsed_facets(raw, facets).freeze if facets
|
|
346
|
+
end
|
|
347
|
+
|
|
317
348
|
def parse_facets
|
|
318
349
|
@__facets_parsed_memo || {}.freeze
|
|
319
350
|
end
|
|
@@ -504,12 +535,12 @@ module SearchEngine
|
|
|
504
535
|
|
|
505
536
|
# Enforce strict-missing behavior when enabled.
|
|
506
537
|
# Computes missing = requested_root − present_keys and raises when non-empty.
|
|
507
|
-
def enforce_strict_missing_if_needed!(present_keys)
|
|
538
|
+
def enforce_strict_missing_if_needed!(present_keys, requested_override: nil)
|
|
508
539
|
ctx = @selection_ctx || {}
|
|
509
540
|
strict = (ctx[:strict_missing] == true)
|
|
510
541
|
return unless strict
|
|
511
542
|
|
|
512
|
-
requested = Array(ctx[:requested_root]).map(&:to_s).reject(&:empty?)
|
|
543
|
+
requested = Array(requested_override || ctx[:requested_root]).map(&:to_s).reject(&:empty?)
|
|
513
544
|
return if requested.empty?
|
|
514
545
|
|
|
515
546
|
missing = requested - present_keys
|
data/lib/search_engine/schema.rb
CHANGED
|
@@ -161,9 +161,9 @@ module SearchEngine
|
|
|
161
161
|
# physical name). If no block is given, and the klass responds to
|
|
162
162
|
# `reindex_all_to(physical_name)`, that method will be called. If neither is available,
|
|
163
163
|
# an ArgumentError is raised and no alias swap occurs. If reindexing fails or
|
|
164
|
-
# is interrupted, the
|
|
165
|
-
#
|
|
166
|
-
#
|
|
164
|
+
# is interrupted, the unused new physical is cleaned up. Once an alias swap is
|
|
165
|
+
# attempted, an error can leave its outcome unknown. Both generations are kept
|
|
166
|
+
# for reconciliation. Retention cleanup only runs after a successful cutover.
|
|
167
167
|
#
|
|
168
168
|
# @param klass [Class] model class inheriting from {SearchEngine::Base}
|
|
169
169
|
# @param client [SearchEngine::Client] optional client wrapper (for tests)
|
|
@@ -201,8 +201,11 @@ module SearchEngine
|
|
|
201
201
|
end
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
def with_around_rebuild(logical)
|
|
204
|
+
def with_around_rebuild(logical, require_guard: false)
|
|
205
205
|
guard = SearchEngine.config.schema.around_rebuild
|
|
206
|
+
if require_guard && !guard
|
|
207
|
+
raise ArgumentError, 'rollback expected_current requires schema.around_rebuild to serialize alias writers'
|
|
208
|
+
end
|
|
206
209
|
return yield unless guard
|
|
207
210
|
|
|
208
211
|
result = nil
|
|
@@ -237,10 +240,6 @@ module SearchEngine
|
|
|
237
240
|
private :with_around_rebuild
|
|
238
241
|
|
|
239
242
|
def perform_full_rebuild!(klass, client:, compiled:, logical:, reindex_step:)
|
|
240
|
-
new_physical = nil
|
|
241
|
-
physical_created = false
|
|
242
|
-
apply_succeeded = false
|
|
243
|
-
|
|
244
243
|
cleanup_logical_collection_conflict!(logical, client: client)
|
|
245
244
|
|
|
246
245
|
start_ms = monotonic_ms
|
|
@@ -260,7 +259,7 @@ module SearchEngine
|
|
|
260
259
|
end
|
|
261
260
|
|
|
262
261
|
client.create_collection(create_schema)
|
|
263
|
-
|
|
262
|
+
cleanup_physical = new_physical
|
|
264
263
|
|
|
265
264
|
if reindex_step
|
|
266
265
|
reindex_step.call(new_physical)
|
|
@@ -270,11 +269,11 @@ module SearchEngine
|
|
|
270
269
|
raise ArgumentError, 'reindex step is required: provide a block or implement klass.reindex_all_to(name)'
|
|
271
270
|
end
|
|
272
271
|
|
|
273
|
-
# Idempotent: if alias already points to new physical, treat as no-op
|
|
274
272
|
current_after_reindex = client.resolve_alias(logical)
|
|
275
273
|
swapped = current_after_reindex != new_physical
|
|
274
|
+
# A timed-out alias PUT may still commit, even after a read sees the old target.
|
|
275
|
+
cleanup_physical = nil
|
|
276
276
|
client.upsert_alias(logical, new_physical) if swapped
|
|
277
|
-
apply_succeeded = true
|
|
278
277
|
|
|
279
278
|
# Retention cleanup
|
|
280
279
|
_, dropped = enforce_retention!(logical, new_physical, client: client, keep_last: effective_keep_last(klass))
|
|
@@ -305,7 +304,7 @@ module SearchEngine
|
|
|
305
304
|
action: :rebuild
|
|
306
305
|
}
|
|
307
306
|
ensure
|
|
308
|
-
cleanup_interrupted_physical!(
|
|
307
|
+
cleanup_interrupted_physical!(logical, cleanup_physical, client) if cleanup_physical
|
|
309
308
|
end
|
|
310
309
|
private :perform_full_rebuild!
|
|
311
310
|
|
|
@@ -314,8 +313,9 @@ module SearchEngine
|
|
|
314
313
|
# With no explicit target, the safe legacy form only rolls the newest physical
|
|
315
314
|
# back one generation. Repeating the same call is a no-op instead of toggling
|
|
316
315
|
# the alias forward again. Use +to+ when orphaned/newer physicals make the
|
|
317
|
-
# implicit destination ambiguous. +expected_current+
|
|
318
|
-
#
|
|
316
|
+
# implicit destination ambiguous. +expected_current+ requires an exclusive
|
|
317
|
+
# +schema.around_rebuild+ guard shared by all alias writers across processes.
|
|
318
|
+
# A retry that already reached +to+ remains successful.
|
|
319
319
|
#
|
|
320
320
|
# Alias resolution, validation, and the swap all run inside
|
|
321
321
|
# +schema.around_rebuild+ when configured, so delivery claims and cooperating
|
|
@@ -335,7 +335,7 @@ module SearchEngine
|
|
|
335
335
|
destination = normalize_rollback_target(to, 'to')
|
|
336
336
|
expected = normalize_rollback_target(expected_current, 'expected_current')
|
|
337
337
|
|
|
338
|
-
with_around_rebuild(logical) do
|
|
338
|
+
with_around_rebuild(logical, require_guard: !expected.nil?) do
|
|
339
339
|
perform_rollback!(
|
|
340
340
|
logical,
|
|
341
341
|
client: client,
|
|
@@ -615,16 +615,15 @@ module SearchEngine
|
|
|
615
615
|
|
|
616
616
|
private
|
|
617
617
|
|
|
618
|
-
#
|
|
619
|
-
#
|
|
620
|
-
# exception. If the delete itself fails, a warning directs users to
|
|
621
|
-
# {.prune_orphans!} as a fallback.
|
|
618
|
+
# Clean up a failed rebuild before cutover only if the alias is known to
|
|
619
|
+
# point elsewhere. Preserve the original exception if inspection or deletion fails.
|
|
622
620
|
#
|
|
623
|
-
# @param
|
|
624
|
-
# @param
|
|
621
|
+
# @param logical [String] alias for the rebuilt collection
|
|
622
|
+
# @param physical_name [String] the physical collection to remove
|
|
623
|
+
# @param client [SearchEngine::Client]
|
|
625
624
|
# @return [void]
|
|
626
|
-
def cleanup_interrupted_physical!(physical_name, client)
|
|
627
|
-
return if
|
|
625
|
+
def cleanup_interrupted_physical!(logical, physical_name, client)
|
|
626
|
+
return if client.resolve_alias(logical) == physical_name
|
|
628
627
|
|
|
629
628
|
client.delete_collection(physical_name, timeout_ms: 60_000)
|
|
630
629
|
|
|
@@ -637,7 +636,7 @@ module SearchEngine
|
|
|
637
636
|
rescue StandardError => error
|
|
638
637
|
warn(
|
|
639
638
|
'SearchEngine::Schema — failed to clean up interrupted physical ' \
|
|
640
|
-
"'#{physical_name}': #{error.message}.
|
|
639
|
+
"'#{physical_name}': #{error.message}. Verify alias '#{logical}' before pruning unused generations."
|
|
641
640
|
)
|
|
642
641
|
end
|
|
643
642
|
|
|
@@ -13,11 +13,11 @@ module SearchEngine
|
|
|
13
13
|
EMPTY_SEARCH = { 'hits' => [], 'found' => 0, 'out_of' => 0 }.freeze
|
|
14
14
|
|
|
15
15
|
def search(collection:, params:, url_opts: {})
|
|
16
|
-
SearchEngine::Result.new(EMPTY_SEARCH, klass: nil)
|
|
16
|
+
block_given? ? yield(EMPTY_SEARCH) : SearchEngine::Result.new(EMPTY_SEARCH, klass: nil)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def multi_search(searches:, url_opts: {})
|
|
20
|
-
{ 'results' =>
|
|
20
|
+
{ 'results' => searches.map { EMPTY_SEARCH } }
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def import_documents(collection:, jsonl:, action: :upsert)
|