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
|
@@ -1,168 +1,204 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'search_engine/joins/guard'
|
|
6
|
+
|
|
3
7
|
module SearchEngine
|
|
4
8
|
module Filters
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# Provides quoting/escaping and helpers to build normalized filter strings
|
|
8
|
-
# from hashes and templates with placeholders.
|
|
9
|
+
# Encodes structured predicates for the Typesense filter_by grammar.
|
|
9
10
|
module Sanitizer
|
|
10
11
|
module_function
|
|
11
12
|
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
quote_non_string(value)
|
|
13
|
+
# A field path is data too. Only AST predicates may use the internal
|
|
14
|
+
# "$association.field" notation, which the compiler turns into a join.
|
|
15
|
+
def field_name(field, joined: false)
|
|
16
|
+
raise ArgumentError, 'filter field must be a String or Symbol' unless field.is_a?(String) || field.is_a?(Symbol)
|
|
17
|
+
|
|
18
|
+
name = field.to_s
|
|
19
|
+
path = joined && name.start_with?('$') && name.include?('.') ? name[1..] : name
|
|
20
|
+
raise ArgumentError, 'invalid filter field name' unless path.match?(/\A[[:alnum:]_-]+(?:\.[[:alnum:]_-]+)*\z/)
|
|
21
|
+
|
|
22
|
+
name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Backticks protect operators during expression parsing. A list (even
|
|
26
|
+
# with a single item) also removes the delimiters before tokenization,
|
|
27
|
+
# including on collections that explicitly index the backtick symbol.
|
|
28
|
+
def quote(value, field: nil, klass: nil, joined: false)
|
|
29
|
+
if value.is_a?(Array)
|
|
30
|
+
items = value.flatten(1).map { |item| literal(item, field: field, klass: klass, joined: joined) }
|
|
31
|
+
return "[#{items.join(', ')}]"
|
|
32
32
|
end
|
|
33
|
+
|
|
34
|
+
item = literal(value, field: field, klass: klass, joined: joined)
|
|
35
|
+
item.start_with?('`') ? "[#{item}]" : item
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
# Quote a scalar Ruby value for Typesense filters with conditional quoting for strings.
|
|
36
|
-
#
|
|
37
|
-
# Rules (based on Typesense filter_by syntax):
|
|
38
|
-
# - Strings that match a safe token pattern (e.g., Active, ACTIVE_1, foo-bar) are emitted bare
|
|
39
|
-
# - Reserved words true/false/null remain bare only when actually booleans/nil; string forms are quoted
|
|
40
|
-
# - Strings with any other characters are double-quoted with escaping
|
|
41
|
-
# - Arrays are delegated to +quote+ to preserve element quoting rules
|
|
42
|
-
#
|
|
43
|
-
# @param value [Object]
|
|
44
|
-
# @return [String]
|
|
45
38
|
def quote_scalar_for_filter(value)
|
|
46
|
-
|
|
39
|
+
quote(value)
|
|
40
|
+
end
|
|
47
41
|
|
|
42
|
+
def literal(value, field:, klass:, joined:)
|
|
48
43
|
case value
|
|
49
|
-
when
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
when nil then 'null'
|
|
45
|
+
when true then 'true'
|
|
46
|
+
when false then 'false'
|
|
47
|
+
when Numeric
|
|
48
|
+
number = value.to_s
|
|
49
|
+
unless number.match?(/\A[+-]?[0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?\z/i)
|
|
50
|
+
raise ArgumentError, 'invalid numeric filter value'
|
|
51
|
+
end
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
number
|
|
54
|
+
when Array
|
|
55
|
+
raise ArgumentError, 'expected a scalar filter list item'
|
|
55
56
|
else
|
|
56
|
-
|
|
57
|
+
string = if value.respond_to?(:to_time) && !value.is_a?(String)
|
|
58
|
+
type = Array(klass.attributes[field.to_sym]).first if field && klass.respond_to?(:attributes)
|
|
59
|
+
if joined && type.nil? && field.to_s != 'id'
|
|
60
|
+
raise ArgumentError, 'joined time filters require a declared type or a string or epoch value'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
time = value.to_time
|
|
64
|
+
return time.to_i.to_s if %i[time datetime integer].include?(type)
|
|
65
|
+
|
|
66
|
+
value.respond_to?(:iso8601) ? value.iso8601 : time.iso8601
|
|
67
|
+
else
|
|
68
|
+
value.to_s
|
|
69
|
+
end
|
|
70
|
+
string_literal(string, field: field, joined: joined)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
private_class_method :literal
|
|
74
|
+
|
|
75
|
+
def string_literal(string, field:, joined:)
|
|
76
|
+
# The expression scanner and list splitter disagree about escaped
|
|
77
|
+
# backticks. The splitter also trims spaces, recognizes phrases, and
|
|
78
|
+
# gives a trailing asterisk special meaning after removing delimiters.
|
|
79
|
+
id_field = field.to_s == 'id'
|
|
80
|
+
if string.empty? || string.match?(/[`[:cntrl:]]/) || string.start_with?(' ') || string.end_with?(' ') ||
|
|
81
|
+
(string == '*' && (id_field || field.nil?)) || (!id_field && string.length > 1 && string.end_with?('*')) ||
|
|
82
|
+
(!id_field && string.length > 1 && string.start_with?('"') && string.end_with?('"'))
|
|
83
|
+
raise ArgumentError, 'filter value cannot be represented as an exact Typesense literal'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Typesense recognizes geopoint syntax even inside a backtick pair.
|
|
87
|
+
# Its outer join scanner also counts parentheses inside quoted values.
|
|
88
|
+
if string.match?(/: *(?:\[ *)?\(/) || (joined && !balanced_parentheses?(string))
|
|
89
|
+
raise ArgumentError, 'filter value contains unrepresentable Typesense delimiters'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return string if string.match?(/\A[[:alnum:]_\\-]+\z/)
|
|
93
|
+
raise ArgumentError, 'quoted filter value cannot end in a backslash' if string.end_with?('\\')
|
|
94
|
+
|
|
95
|
+
"`#{string}`"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :string_literal
|
|
98
|
+
|
|
99
|
+
def balanced_parentheses?(string)
|
|
100
|
+
depth = 0
|
|
101
|
+
string.each_char do |char|
|
|
102
|
+
depth += 1 if char == '('
|
|
103
|
+
depth -= 1 if char == ')'
|
|
104
|
+
return false if depth.negative?
|
|
57
105
|
end
|
|
106
|
+
depth.zero?
|
|
58
107
|
end
|
|
108
|
+
private_class_method :balanced_parentheses?
|
|
59
109
|
|
|
60
|
-
|
|
61
|
-
# Scalars become "field:=<quoted>", arrays become "field:=<quoted_list>".
|
|
62
|
-
#
|
|
63
|
-
# @param hash [Hash{#to_sym=>Object}]
|
|
64
|
-
# @param _attributes_map [Hash] (ignored here; validation should be done by caller)
|
|
65
|
-
# @return [Array<String>]
|
|
66
|
-
def build_from_hash(hash, _attributes_map = nil)
|
|
110
|
+
def build_from_hash(hash, _attributes_map = nil, klass: nil)
|
|
67
111
|
raise ArgumentError, 'filters hash must be a Hash' unless hash.is_a?(Hash)
|
|
68
112
|
|
|
69
|
-
hash.map do |key,
|
|
70
|
-
field = key
|
|
71
|
-
|
|
72
|
-
"#{field}:=#{quote(Array(raw))}"
|
|
73
|
-
else
|
|
74
|
-
"#{field}:=#{quote_scalar_for_filter(raw)}"
|
|
75
|
-
end
|
|
113
|
+
hash.map do |key, value|
|
|
114
|
+
field = field_name(key)
|
|
115
|
+
"#{field}:=#{quote(value, field: field, klass: klass)}"
|
|
76
116
|
end
|
|
77
117
|
end
|
|
78
118
|
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def apply_placeholders(template, args)
|
|
119
|
+
# Consume a quoted literal or an escape before looking for structural
|
|
120
|
+
# tokens. Typesense only uses backticks to protect filter operators.
|
|
121
|
+
TEMPLATE_TOKEN = /`[^`]*(?:`|\z)|(?<![[:alnum:]])(?:"[^"]*(?:"|\z)|'[^']*(?:'|\z))|\\[?\\]|&&|\|\||[(){}\[\]?]/
|
|
122
|
+
BOUND_FIELD = /\A\s*([[:alnum:]_.-]+)\s*(?::\s*(?:!=|>=|<=|[!<>=])?|!=|>=|<=|[=<>]|NOT\s+IN|IN)\s*\z/i
|
|
123
|
+
CLOSING_DELIMITERS = { '(' => ')', '[' => ']', '{' => '}' }.freeze
|
|
124
|
+
private_constant :TEMPLATE_TOKEN, :BOUND_FIELD, :CLOSING_DELIMITERS
|
|
125
|
+
|
|
126
|
+
def apply_placeholders(template, args, klass: nil)
|
|
87
127
|
raise ArgumentError, 'template must be a String' unless template.is_a?(String)
|
|
88
128
|
raise ArgumentError, 'args must be an Array' unless args.is_a?(Array)
|
|
89
129
|
|
|
90
130
|
needed = count_placeholders(template)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
idx = -1
|
|
95
|
-
template.gsub(/(?<!\\)\?/) do
|
|
96
|
-
idx += 1
|
|
97
|
-
val = args[idx]
|
|
98
|
-
val.is_a?(Array) ? quote(val) : quote_scalar_for_filter(val)
|
|
131
|
+
unless needed == args.length
|
|
132
|
+
raise ArgumentError, "expected #{needed} args for #{needed} placeholders, got #{args.length}"
|
|
99
133
|
end
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
134
|
+
return template if needed.zero?
|
|
135
|
+
|
|
136
|
+
# Keep trusted fixed expressions verbatim. Track groups and reference
|
|
137
|
+
# filters so only a complete, unquoted comparison operand accepts data.
|
|
138
|
+
scopes = [[nil, klass, false]]
|
|
139
|
+
start = 0
|
|
140
|
+
binds = args.each
|
|
141
|
+
result = template.gsub(TEMPLATE_TOKEN) do |token|
|
|
142
|
+
match = Regexp.last_match
|
|
143
|
+
case token
|
|
144
|
+
when '?'
|
|
145
|
+
prefix = template[start...match.begin(0)]
|
|
146
|
+
field = prefix[BOUND_FIELD, 1]
|
|
147
|
+
ends_operand = template.match?(/\G\s*(?:&&|\|\||\)|\z)/, match.end(0))
|
|
148
|
+
_, model, joined = scopes.last
|
|
149
|
+
unless field && ends_operand && model != :opaque
|
|
150
|
+
raise ArgumentError, 'placeholder must be a complete filter operand'
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
next quote(binds.next, field: field_name(field), klass: model, joined: joined)
|
|
154
|
+
when '(', '[', '{'
|
|
155
|
+
prefix = template[start...match.begin(0)].strip
|
|
156
|
+
scopes << template_scope(token, prefix, scopes.last)
|
|
157
|
+
when ')', ']', '}'
|
|
158
|
+
raise ArgumentError, 'unbalanced filter template' unless scopes.last.first == token
|
|
159
|
+
|
|
160
|
+
scopes.pop
|
|
161
|
+
when /\A[`"'\\]/
|
|
162
|
+
validate_template_literal!(token, joined: scopes.last.last)
|
|
163
|
+
next token
|
|
117
164
|
end
|
|
165
|
+
start = match.end(0)
|
|
166
|
+
token
|
|
118
167
|
end
|
|
119
|
-
|
|
168
|
+
raise ArgumentError, 'unbalanced filter template' unless scopes.length == 1
|
|
169
|
+
|
|
170
|
+
result
|
|
120
171
|
end
|
|
121
172
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def quote_non_string(value)
|
|
127
|
-
case value
|
|
128
|
-
when NilClass then 'null'
|
|
129
|
-
when TrueClass then 'true'
|
|
130
|
-
when FalseClass then 'false'
|
|
131
|
-
when Numeric then value.to_s
|
|
132
|
-
when Time, DateTime then %("#{value.iso8601}")
|
|
133
|
-
when Date then %("#{value.iso8601}")
|
|
134
|
-
else
|
|
135
|
-
if value.respond_to?(:to_time)
|
|
136
|
-
%("#{value.to_time.iso8601}")
|
|
137
|
-
else
|
|
138
|
-
%("#{escape_string(value.to_s)}")
|
|
139
|
-
end
|
|
173
|
+
def validate_template_literal!(token, joined:)
|
|
174
|
+
raise ArgumentError, 'placeholder must be a complete filter operand' if count_placeholders(token).positive?
|
|
175
|
+
if (token.start_with?('"', "'") && token.include?('`')) || token.match?(/: *(?:\[ *)?\(/)
|
|
176
|
+
raise ArgumentError, 'ambiguous delimiters in quoted filter template'
|
|
140
177
|
end
|
|
141
|
-
|
|
178
|
+
return unless joined && !balanced_parentheses?(token)
|
|
142
179
|
|
|
143
|
-
|
|
144
|
-
# @param str [String]
|
|
145
|
-
# @return [String]
|
|
146
|
-
def escape_string(str)
|
|
147
|
-
str.gsub('\\', '\\\\').gsub('"', '\\"')
|
|
180
|
+
raise ArgumentError, 'unbalanced parentheses in quoted reference filter'
|
|
148
181
|
end
|
|
182
|
+
private_class_method :validate_template_literal!
|
|
149
183
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return false if str.nil? || str.empty?
|
|
155
|
-
|
|
156
|
-
# Disallow surrounding/backtick/dquote characters quickly
|
|
157
|
-
return false if str.include?('"') || str.include?('`') || str.include?(',') || str.include?(' ')
|
|
184
|
+
def template_scope(token, prefix, parent)
|
|
185
|
+
_, model, joined = parent
|
|
186
|
+
if token == '(' && model != :opaque
|
|
187
|
+
return [')', model, joined] if prefix.empty?
|
|
158
188
|
|
|
159
|
-
|
|
160
|
-
|
|
189
|
+
if (name = prefix[/\A!?\$([[:alnum:]_-]+)\z/, 1])
|
|
190
|
+
return [')', SearchEngine::Joins::Guard.target_model(model, name), true]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
# Lists, object filters and geopoint tuples use their own grammars.
|
|
194
|
+
[CLOSING_DELIMITERS.fetch(token), :opaque, joined]
|
|
161
195
|
end
|
|
196
|
+
private_class_method :template_scope
|
|
197
|
+
|
|
198
|
+
def count_placeholders(template)
|
|
199
|
+
return 0 unless template.include?('?')
|
|
162
200
|
|
|
163
|
-
|
|
164
|
-
def array_like?(value)
|
|
165
|
-
value.is_a?(Array)
|
|
201
|
+
template.scan(/\\.|(\?)/m).count(&:first)
|
|
166
202
|
end
|
|
167
203
|
end
|
|
168
204
|
end
|
|
@@ -24,29 +24,7 @@ module SearchEngine
|
|
|
24
24
|
memo = relation.instance_variable_get(:@__result_memo)
|
|
25
25
|
return memo if loaded && memo
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
params = SearchEngine::CompiledParams.from(relation.to_typesense_params)
|
|
29
|
-
url_opts = relation.send(:build_url_opts)
|
|
30
|
-
|
|
31
|
-
raw_result = relation.send(:client).search(
|
|
32
|
-
collection: collection, params: params, url_opts: url_opts
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
selection_ctx = SearchEngine::Hydration::SelectionContext.build(relation)
|
|
36
|
-
facets_ctx = build_facets_context_from_state(relation)
|
|
37
|
-
|
|
38
|
-
result = if selection_ctx || facets_ctx
|
|
39
|
-
SearchEngine::Result.new(
|
|
40
|
-
raw_result.raw,
|
|
41
|
-
klass: relation.klass,
|
|
42
|
-
selection: selection_ctx,
|
|
43
|
-
facets: facets_ctx
|
|
44
|
-
)
|
|
45
|
-
else
|
|
46
|
-
raw_result
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
relation.send(:enforce_hit_validator_if_needed!, result.found, collection: collection)
|
|
27
|
+
result = search_result(relation)
|
|
50
28
|
relation.instance_variable_set(:@__result_memo, result)
|
|
51
29
|
relation.instance_variable_set(:@__loaded, true)
|
|
52
30
|
result
|
|
@@ -84,39 +62,10 @@ module SearchEngine
|
|
|
84
62
|
return Array(cached).first(limit)
|
|
85
63
|
end
|
|
86
64
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
per_for_request = effective_per_page(relation)
|
|
90
|
-
per_for_request = limit if per_for_request.to_i <= 0
|
|
91
|
-
|
|
92
|
-
preview_relation = relation.send(:spawn) do |state|
|
|
93
|
-
state[:page] = 1
|
|
94
|
-
state[:per_page] = per_for_request
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
collection = preview_relation.send(:collection_name_for_klass)
|
|
98
|
-
params = SearchEngine::CompiledParams.from(preview_relation.to_typesense_params)
|
|
99
|
-
url_opts = preview_relation.send(:build_url_opts)
|
|
100
|
-
|
|
101
|
-
raw_result = preview_relation.send(:client).search(collection: collection, params: params, url_opts: url_opts)
|
|
102
|
-
|
|
103
|
-
selection_ctx = SearchEngine::Hydration::SelectionContext.build(preview_relation)
|
|
104
|
-
facets_ctx = build_facets_context_from_state(preview_relation)
|
|
105
|
-
|
|
106
|
-
result = if selection_ctx || facets_ctx
|
|
107
|
-
SearchEngine::Result.new(
|
|
108
|
-
raw_result.raw,
|
|
109
|
-
klass: preview_relation.klass,
|
|
110
|
-
selection: selection_ctx,
|
|
111
|
-
facets: facets_ctx
|
|
112
|
-
)
|
|
113
|
-
else
|
|
114
|
-
raw_result
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
array = Array(result.respond_to?(:to_a) ? result.to_a : result).first(limit)
|
|
65
|
+
result = search_result(relation)
|
|
66
|
+
array = result.to_a
|
|
118
67
|
relation.instance_variable_set(:@__preview_memo, array)
|
|
119
|
-
array
|
|
68
|
+
array.first(limit)
|
|
120
69
|
end
|
|
121
70
|
|
|
122
71
|
def each(relation, &block)
|
|
@@ -172,44 +121,20 @@ module SearchEngine
|
|
|
172
121
|
validate_pluck_fields_allowed!(relation, names)
|
|
173
122
|
|
|
174
123
|
result = execute(relation)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if result.respond_to?(:send)
|
|
181
|
-
ctx = result.instance_variable_get(:@selection_ctx) || {}
|
|
182
|
-
if ctx[:strict_missing] == true
|
|
183
|
-
result.send(:enforce_strict_missing_if_needed!, present_keys, requested_override: requested)
|
|
184
|
-
end
|
|
124
|
+
documents = Result.documents(result.raw)
|
|
125
|
+
result.to_a.each_with_index.map do |object, index|
|
|
126
|
+
doc = documents.fetch(index)
|
|
127
|
+
if result.is_a?(Result)
|
|
128
|
+
result.send(:enforce_strict_missing_if_needed!, doc.keys.map(&:to_s), requested_override: names)
|
|
185
129
|
end
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
field = names.first
|
|
190
|
-
return objects.each_with_index.map do |obj, idx|
|
|
191
|
-
doc = (raw_hits[idx] && raw_hits[idx]['document']) || {}
|
|
192
|
-
# Enforce strict missing for the requested field against present keys
|
|
193
|
-
enforce_strict_for_pluck_row.call(doc, [field])
|
|
194
|
-
if obj.respond_to?(field)
|
|
195
|
-
obj.public_send(field)
|
|
196
|
-
else
|
|
197
|
-
doc[field]
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
objects.each_with_index.map do |obj, idx|
|
|
203
|
-
doc = (raw_hits[idx] && raw_hits[idx]['document']) || {}
|
|
204
|
-
# Enforce strict missing for all requested fields against present keys
|
|
205
|
-
enforce_strict_for_pluck_row.call(doc, names)
|
|
206
|
-
names.map do |field|
|
|
207
|
-
if obj.respond_to?(field)
|
|
208
|
-
obj.public_send(field)
|
|
130
|
+
values = names.map do |field|
|
|
131
|
+
if object.respond_to?(field)
|
|
132
|
+
object.public_send(field)
|
|
209
133
|
else
|
|
210
|
-
doc[field]
|
|
134
|
+
doc.fetch(field) { doc[field.to_sym] }
|
|
211
135
|
end
|
|
212
136
|
end
|
|
137
|
+
names.one? ? values.first : values
|
|
213
138
|
end
|
|
214
139
|
end
|
|
215
140
|
|
|
@@ -250,11 +175,38 @@ module SearchEngine
|
|
|
250
175
|
|
|
251
176
|
# --- internals --------------------------------------------------------
|
|
252
177
|
|
|
178
|
+
def result_options(relation)
|
|
179
|
+
{
|
|
180
|
+
klass: relation.klass,
|
|
181
|
+
selection: SelectionContext.build(relation),
|
|
182
|
+
facets: build_facets_context_from_state(relation)
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def search_result(relation, client: relation.send(:client))
|
|
187
|
+
collection = relation.send(:collection_name_for_klass)
|
|
188
|
+
params = SearchEngine::CompiledParams.from(relation.to_typesense_params)
|
|
189
|
+
options = result_options(relation)
|
|
190
|
+
built = false
|
|
191
|
+
url_opts = relation.send(:build_url_opts)
|
|
192
|
+
result = client.search(collection: collection, params: params, url_opts: url_opts) do |raw|
|
|
193
|
+
built = true
|
|
194
|
+
Result.new(raw, **options)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Legacy clients return an already wrapped result without yielding the response.
|
|
198
|
+
if !built && (options[:selection] || options[:facets])
|
|
199
|
+
result = result.is_a?(Result) ? result.with_context(**options) : Result.new(result.raw, **options)
|
|
200
|
+
end
|
|
201
|
+
relation.send(:enforce_hit_validator_if_needed!, result.found, collection: collection)
|
|
202
|
+
result
|
|
203
|
+
end
|
|
204
|
+
|
|
253
205
|
def fetch_found_only(relation)
|
|
254
206
|
collection = relation.send(:collection_name_for_klass)
|
|
255
207
|
base = SearchEngine::CompiledParams.from(relation.to_typesense_params).to_h
|
|
256
208
|
|
|
257
|
-
minimal = base.
|
|
209
|
+
minimal = base.except(:offset, :limit)
|
|
258
210
|
minimal[:per_page] = 1
|
|
259
211
|
minimal[:page] = 1
|
|
260
212
|
minimal[:include_fields] = 'id'
|
|
@@ -285,8 +237,8 @@ module SearchEngine
|
|
|
285
237
|
|
|
286
238
|
def effective_per_page(relation)
|
|
287
239
|
state = relation.instance_variable_get(:@state) || {}
|
|
288
|
-
per_page = state[:per_page]
|
|
289
|
-
return per_page.
|
|
240
|
+
per_page = state[:per_page] || (state[:limit] unless state[:page])
|
|
241
|
+
return [per_page, state.dig(:hit_limits, :early_limit)].compact.min if per_page
|
|
290
242
|
|
|
291
243
|
loaded = relation.instance_variable_get(:@__loaded)
|
|
292
244
|
memo = relation.instance_variable_get(:@__result_memo)
|
|
@@ -297,8 +249,6 @@ module SearchEngine
|
|
|
297
249
|
per = request_params['per_page'] || request_params[:per_page]
|
|
298
250
|
return per.to_i if per.to_i.positive?
|
|
299
251
|
end
|
|
300
|
-
|
|
301
|
-
return memo.hits.size if memo.respond_to?(:hits)
|
|
302
252
|
end
|
|
303
253
|
|
|
304
254
|
10
|
|
@@ -313,35 +263,20 @@ module SearchEngine
|
|
|
313
263
|
total = count(relation)
|
|
314
264
|
return [] if total.to_i <= 0
|
|
315
265
|
|
|
316
|
-
per = 250
|
|
266
|
+
per = [250, relation.hit_limits&.dig(:early_limit)].compact.min
|
|
317
267
|
pages = (total.to_f / per).ceil
|
|
318
268
|
|
|
319
|
-
|
|
320
|
-
return relation.page(1).per(per).to_a if pages
|
|
269
|
+
client = relation.send(:client)
|
|
270
|
+
return search_result(relation.page(1).per(per), client: client).to_a if pages == 1
|
|
321
271
|
|
|
322
|
-
|
|
323
|
-
SearchEngine.config.multi_search_limit.to_i
|
|
324
|
-
rescue StandardError
|
|
325
|
-
50
|
|
326
|
-
end
|
|
327
|
-
limit = 1 if limit <= 0
|
|
328
|
-
|
|
329
|
-
page_numbers = (1..pages).to_a
|
|
272
|
+
url_opts = relation.send(:build_url_opts)
|
|
330
273
|
out = []
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
batch.each do |p|
|
|
340
|
-
res = mr["p#{p}"]
|
|
341
|
-
out.concat(Array(res&.to_a))
|
|
342
|
-
end
|
|
274
|
+
(1..pages).each_slice([SearchEngine.config.multi_search_limit, 1].max) do |batch|
|
|
275
|
+
builder = SearchEngine::Multi.new
|
|
276
|
+
batch.each { |page| builder.add("p#{page}", relation.page(page).per(per)) }
|
|
277
|
+
results = builder.execute(client: client, url_opts: url_opts)
|
|
278
|
+
results.each_label { |_, result| out.concat(result.hits) }
|
|
343
279
|
end
|
|
344
|
-
|
|
345
280
|
out
|
|
346
281
|
end
|
|
347
282
|
|