search-engine-for-typesense 30.9.2 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +47 -13
  4. data/lib/search_engine/active_record_syncable.rb +85 -197
  5. data/lib/search_engine/ast/node.rb +3 -6
  6. data/lib/search_engine/async_partition_coordinator.rb +5 -3
  7. data/lib/search_engine/client/services/search.rb +2 -0
  8. data/lib/search_engine/client.rb +9 -3
  9. data/lib/search_engine/compiler.rb +33 -128
  10. data/lib/search_engine/deletion.rb +12 -9
  11. data/lib/search_engine/dsl/parser.rb +51 -71
  12. data/lib/search_engine/engine.rb +25 -16
  13. data/lib/search_engine/filters/sanitizer.rb +162 -126
  14. data/lib/search_engine/hydration/materializers.rb +53 -118
  15. data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
  16. data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
  17. data/lib/search_engine/indexing_run_store.rb +10 -0
  18. data/lib/search_engine/joins/guard.rb +14 -8
  19. data/lib/search_engine/multi.rb +92 -6
  20. data/lib/search_engine/multi_result.rb +40 -23
  21. data/lib/search_engine/notifications/compact_logger.rb +2 -2
  22. data/lib/search_engine/observability.rb +6 -6
  23. data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
  24. data/lib/search_engine/postgres_outbox/event.rb +12 -0
  25. data/lib/search_engine/postgres_outbox/repository.rb +48 -102
  26. data/lib/search_engine/postgres_outbox.rb +1 -0
  27. data/lib/search_engine/registry.rb +27 -28
  28. data/lib/search_engine/relation/compiler.rb +18 -33
  29. data/lib/search_engine/relation/dsl/filters.rb +71 -175
  30. data/lib/search_engine/relation/dx.rb +3 -1
  31. data/lib/search_engine/relation/materializers.rb +6 -3
  32. data/lib/search_engine/relation/options.rb +5 -26
  33. data/lib/search_engine/relation/state.rb +3 -2
  34. data/lib/search_engine/relation.rb +5 -4
  35. data/lib/search_engine/result.rb +33 -2
  36. data/lib/search_engine/schema.rb +23 -24
  37. data/lib/search_engine/test/offline_client.rb +2 -2
  38. data/lib/search_engine/test/stub_client.rb +9 -16
  39. data/lib/search_engine/update.rb +1 -1
  40. data/lib/search_engine/version.rb +1 -1
  41. data/lib/search_engine.rb +11 -149
  42. data/lib/tasks/search_engine.rake +3 -5
  43. metadata +17 -3
  44. /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
- # Sanitizer utilities for Typesense-compatible filters.
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
- # Quote a Ruby value into a Typesense filter literal.
13
- #
14
- # - NilClass => "null"
15
- # - TrueClass/FalseClass => "true"/"false"
16
- # - Numeric => as-is (stringified)
17
- # - String => double-quoted with minimal escaping for \ and "
18
- # - Time/DateTime/Date => ISO8601 string, then quoted as a string
19
- # - Array => one-level flatten, each element quoted, wrapped with [ ... ]
20
- #
21
- # @param value [Object]
22
- # @return [String]
23
- def quote(value)
24
- case value
25
- when String
26
- %("#{escape_string(value)}")
27
- when Array
28
- elements = value.flatten(1).map { |el| quote(el) }
29
- "[#{elements.join(', ')}]"
30
- else
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
- return quote(value) if value.is_a?(Array)
39
+ quote(value)
40
+ end
47
41
 
42
+ def literal(value, field:, klass:, joined:)
48
43
  case value
49
- when String, Symbol
50
- str = value.to_s
51
- lc = str.strip.downcase
52
- return %("#{escape_string(str)}") if %w[true false null].include?(lc)
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
- safe_bare_string?(str) ? str : %("#{escape_string(str)}")
53
+ number
54
+ when Array
55
+ raise ArgumentError, 'expected a scalar filter list item'
55
56
  else
56
- quote_non_string(value)
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
- # Build normalized filter fragments from a Hash.
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, raw|
70
- field = key.to_sym.to_s
71
- if array_like?(raw)
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
- # Apply placeholder substitution for templates with '?' markers.
80
- #
81
- # Each unescaped '?' is replaced with a quoted argument from +args+ in order.
82
- #
83
- # @param template [String]
84
- # @param args [Array<Object>]
85
- # @return [String]
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
- provided = args.length
92
- raise ArgumentError, "expected #{needed} args for #{needed} placeholders, got #{provided}" if needed != provided
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
- end
101
-
102
- # Count unescaped '?' placeholders.
103
- # @param template [String]
104
- # @return [Integer]
105
- def count_placeholders(template)
106
- count = 0
107
- escaped = false
108
- template.each_char do |ch|
109
- if escaped
110
- escaped = false
111
- next
112
- end
113
- if ch == '\\'
114
- escaped = true
115
- elsif ch == '?'
116
- count += 1
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
- count
168
+ raise ArgumentError, 'unbalanced filter template' unless scopes.length == 1
169
+
170
+ result
120
171
  end
121
172
 
122
- # Shared quoting logic for non-String, non-Array values.
123
- # @param value [Object]
124
- # @return [String]
125
- # @api private
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
- end
178
+ return unless joined && !balanced_parentheses?(token)
142
179
 
143
- # Escape a raw string for inclusion inside double quotes.
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
- # Determine whether a string can be emitted bare without quotes in filter_by.
151
- # Safe if it matches: starts with a letter or underscore; then letters/digits/underscore/hyphen.
152
- # This avoids ambiguity with numbers/booleans/null and special characters.
153
- def safe_bare_string?(str)
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
- # Must start with a letter or underscore; subsequent chars may include digits or hyphens/underscores
160
- !!(str =~ /^[A-Za-z_][A-Za-z0-9_-]*$/)
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
- # @api private
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
- collection = relation.send(:collection_name_for_klass)
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
- # Use the relation's effective per_page for the underlying request to keep
88
- # ordering consistent with to_a; slice to the preview limit locally.
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
- raw_hits = Array(result.raw['hits'])
176
- objects = result.to_a
177
-
178
- enforce_strict_for_pluck_row = lambda do |doc, requested|
179
- present_keys = doc.keys.map(&:to_s)
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
- end
187
-
188
- if names.length == 1
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.dup
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.to_i if per_page.to_i.positive?
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
- # Fast path for a single page
320
- return relation.page(1).per(per).to_a if pages <= 1
269
+ client = relation.send(:client)
270
+ return search_result(relation.page(1).per(per), client: client).to_a if pages == 1
321
271
 
322
- limit = begin
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
- page_numbers.each_slice(limit) do |batch|
333
- mr = SearchEngine.multi_search_result do |m|
334
- batch.each do |p|
335
- m.add("p#{p}", relation.page(p).per(per))
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
 
@@ -139,7 +139,7 @@ module SearchEngine
139
139
  def indexing_run_store(run_id)
140
140
  return nil if run_id.nil?
141
141
 
142
- SearchEngine::IndexingRunStore.resolve
142
+ SearchEngine::IndexingRunStore.partition_writer
143
143
  end
144
144
 
145
145
  def retryable_error?(error)