tiny-quick-gem 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/searchkick-6.1.2/CHANGELOG.md +919 -0
- data/searchkick-6.1.2/LICENSE.txt +22 -0
- data/searchkick-6.1.2/README.md +2348 -0
- data/searchkick-6.1.2/lib/searchkick/bulk_reindex_job.rb +19 -0
- data/searchkick-6.1.2/lib/searchkick/controller_runtime.rb +40 -0
- data/searchkick-6.1.2/lib/searchkick/hash_wrapper.rb +41 -0
- data/searchkick-6.1.2/lib/searchkick/index.rb +480 -0
- data/searchkick-6.1.2/lib/searchkick/index_cache.rb +30 -0
- data/searchkick-6.1.2/lib/searchkick/index_options.rb +652 -0
- data/searchkick-6.1.2/lib/searchkick/indexer.rb +43 -0
- data/searchkick-6.1.2/lib/searchkick/log_subscriber.rb +57 -0
- data/searchkick-6.1.2/lib/searchkick/middleware.rb +19 -0
- data/searchkick-6.1.2/lib/searchkick/model.rb +120 -0
- data/searchkick-6.1.2/lib/searchkick/multi_search.rb +46 -0
- data/searchkick-6.1.2/lib/searchkick/process_batch_job.rb +20 -0
- data/searchkick-6.1.2/lib/searchkick/process_queue_job.rb +33 -0
- data/searchkick-6.1.2/lib/searchkick/query.rb +1372 -0
- data/searchkick-6.1.2/lib/searchkick/railtie.rb +7 -0
- data/searchkick-6.1.2/lib/searchkick/record_data.rb +147 -0
- data/searchkick-6.1.2/lib/searchkick/record_indexer.rb +174 -0
- data/searchkick-6.1.2/lib/searchkick/reindex_queue.rb +57 -0
- data/searchkick-6.1.2/lib/searchkick/reindex_v2_job.rb +17 -0
- data/searchkick-6.1.2/lib/searchkick/relation.rb +728 -0
- data/searchkick-6.1.2/lib/searchkick/relation_indexer.rb +184 -0
- data/searchkick-6.1.2/lib/searchkick/reranking.rb +28 -0
- data/searchkick-6.1.2/lib/searchkick/results.rb +359 -0
- data/searchkick-6.1.2/lib/searchkick/script.rb +11 -0
- data/searchkick-6.1.2/lib/searchkick/version.rb +3 -0
- data/searchkick-6.1.2/lib/searchkick/where.rb +11 -0
- data/searchkick-6.1.2/lib/searchkick.rb +388 -0
- data/searchkick-6.1.2/lib/tasks/searchkick.rake +37 -0
- data/tiny-quick-gem.gemspec +12 -0
- metadata +73 -0
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
module Searchkick
|
|
2
|
+
class Query
|
|
3
|
+
include Enumerable
|
|
4
|
+
extend Forwardable
|
|
5
|
+
|
|
6
|
+
@@metric_aggs = [:avg, :cardinality, :max, :min, :sum]
|
|
7
|
+
|
|
8
|
+
attr_reader :klass, :term, :options
|
|
9
|
+
attr_accessor :body
|
|
10
|
+
|
|
11
|
+
def_delegators :execute, :map, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary,
|
|
12
|
+
:results, :suggestions, :each_with_hit, :with_details, :aggregations, :aggs,
|
|
13
|
+
:took, :error, :model_name, :entry_name, :total_count, :total_entries,
|
|
14
|
+
:current_page, :per_page, :limit_value, :padding, :total_pages, :num_pages,
|
|
15
|
+
:offset_value, :offset, :previous_page, :prev_page, :next_page, :first_page?, :last_page?,
|
|
16
|
+
:out_of_range?, :hits, :response, :to_a, :first, :scroll, :highlights, :with_highlights,
|
|
17
|
+
:with_score, :misspellings?, :scroll_id, :clear_scroll, :missing_records, :with_hit
|
|
18
|
+
|
|
19
|
+
def initialize(klass, term = "*", **options)
|
|
20
|
+
if options[:conversions]
|
|
21
|
+
Searchkick.warn("The `conversions` option is deprecated in favor of `conversions_v2`, which provides much better search performance. Upgrade to `conversions_v2` or rename `conversions` to `conversions_v1`")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if options.key?(:conversions_v1)
|
|
25
|
+
options[:conversions] = options.delete(:conversions_v1)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unknown_keywords = options.keys - [:aggs, :block, :body, :body_options, :boost,
|
|
29
|
+
:boost_by, :boost_by_distance, :boost_by_recency, :boost_where, :conversions, :conversions_v2, :conversions_term, :debug, :emoji, :exclude, :explain,
|
|
30
|
+
:fields, :highlight, :includes, :index_name, :indices_boost, :knn, :limit, :load,
|
|
31
|
+
:match, :misspellings, :models, :model_includes, :offset, :opaque_id, :operator, :order, :padding, :page, :per_page, :profile,
|
|
32
|
+
:request_params, :routing, :scope_results, :scroll, :select, :similar, :smart_aggs, :suggest, :total_entries, :track, :type, :where]
|
|
33
|
+
raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
|
|
34
|
+
|
|
35
|
+
term = term.to_s
|
|
36
|
+
|
|
37
|
+
if options[:emoji]
|
|
38
|
+
term = EmojiParser.parse_unicode(term) { |e| " #{e.name.tr('_', ' ')} " }.strip
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@klass = klass
|
|
42
|
+
@term = term
|
|
43
|
+
@options = options
|
|
44
|
+
@match_suffix = options[:match] || searchkick_options[:match] || "analyzed"
|
|
45
|
+
|
|
46
|
+
# prevent Ruby warnings
|
|
47
|
+
@type = nil
|
|
48
|
+
@routing = nil
|
|
49
|
+
@misspellings = false
|
|
50
|
+
@misspellings_below = nil
|
|
51
|
+
@highlighted_fields = nil
|
|
52
|
+
@index_mapping = nil
|
|
53
|
+
|
|
54
|
+
prepare
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def searchkick_index
|
|
58
|
+
klass ? klass.searchkick_index : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def searchkick_options
|
|
62
|
+
klass ? klass.searchkick_options : {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def searchkick_klass
|
|
66
|
+
klass ? klass.searchkick_klass : nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def params
|
|
70
|
+
if options[:models]
|
|
71
|
+
@index_mapping = {}
|
|
72
|
+
Array(options[:models]).each do |model|
|
|
73
|
+
# there can be multiple models per index name due to inheritance - see #1259
|
|
74
|
+
(@index_mapping[model.searchkick_index.name] ||= []) << model
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
index =
|
|
79
|
+
if options[:index_name]
|
|
80
|
+
Array(options[:index_name]).map { |v| v.respond_to?(:searchkick_index) ? v.searchkick_index.name : v }.join(",")
|
|
81
|
+
elsif options[:models]
|
|
82
|
+
@index_mapping.keys.join(",")
|
|
83
|
+
elsif searchkick_index
|
|
84
|
+
searchkick_index.name
|
|
85
|
+
else
|
|
86
|
+
# fixes warning about accessing system indices
|
|
87
|
+
"*,-.*"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
params = {
|
|
91
|
+
index: index,
|
|
92
|
+
body: body
|
|
93
|
+
}
|
|
94
|
+
params[:type] = @type if @type
|
|
95
|
+
params[:routing] = @routing if @routing
|
|
96
|
+
params[:scroll] = @scroll if @scroll
|
|
97
|
+
params[:opaque_id] = @opaque_id if @opaque_id
|
|
98
|
+
params.merge!(options[:request_params]) if options[:request_params]
|
|
99
|
+
params
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def execute
|
|
103
|
+
@execute ||= begin
|
|
104
|
+
begin
|
|
105
|
+
response = execute_search
|
|
106
|
+
if retry_misspellings?(response)
|
|
107
|
+
prepare
|
|
108
|
+
response = execute_search
|
|
109
|
+
end
|
|
110
|
+
rescue => e
|
|
111
|
+
handle_error(e)
|
|
112
|
+
end
|
|
113
|
+
handle_response(response)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def handle_response(response)
|
|
118
|
+
opts = {
|
|
119
|
+
page: @page,
|
|
120
|
+
per_page: @per_page,
|
|
121
|
+
padding: @padding,
|
|
122
|
+
load: @load,
|
|
123
|
+
includes: options[:includes],
|
|
124
|
+
model_includes: options[:model_includes],
|
|
125
|
+
json: !@json.nil?,
|
|
126
|
+
match_suffix: @match_suffix,
|
|
127
|
+
highlight: options[:highlight],
|
|
128
|
+
highlighted_fields: @highlighted_fields || [],
|
|
129
|
+
misspellings: @misspellings,
|
|
130
|
+
term: term,
|
|
131
|
+
scope_results: options[:scope_results],
|
|
132
|
+
total_entries: options[:total_entries],
|
|
133
|
+
index_mapping: @index_mapping,
|
|
134
|
+
suggest: options[:suggest],
|
|
135
|
+
scroll: options[:scroll],
|
|
136
|
+
opaque_id: options[:opaque_id]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if options[:debug]
|
|
140
|
+
server = Searchkick.opensearch? ? "OpenSearch" : "Elasticsearch"
|
|
141
|
+
puts "Searchkick #{Searchkick::VERSION}"
|
|
142
|
+
puts "#{server} #{Searchkick.server_version}"
|
|
143
|
+
puts
|
|
144
|
+
|
|
145
|
+
puts "Model Options"
|
|
146
|
+
pp searchkick_options
|
|
147
|
+
puts
|
|
148
|
+
|
|
149
|
+
puts "Search Options"
|
|
150
|
+
pp options
|
|
151
|
+
puts
|
|
152
|
+
|
|
153
|
+
if searchkick_index
|
|
154
|
+
puts "Record Data"
|
|
155
|
+
begin
|
|
156
|
+
pp klass.limit(3).map { |r| RecordData.new(searchkick_index, r).index_data }
|
|
157
|
+
rescue => e
|
|
158
|
+
puts "#{e.class.name}: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
puts
|
|
161
|
+
|
|
162
|
+
puts "Mapping"
|
|
163
|
+
puts JSON.pretty_generate(searchkick_index.mapping)
|
|
164
|
+
puts
|
|
165
|
+
|
|
166
|
+
puts "Settings"
|
|
167
|
+
puts JSON.pretty_generate(searchkick_index.settings)
|
|
168
|
+
puts
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
puts "Query"
|
|
172
|
+
puts JSON.pretty_generate(params[:body])
|
|
173
|
+
puts
|
|
174
|
+
|
|
175
|
+
puts "Results"
|
|
176
|
+
puts JSON.pretty_generate(response.to_h)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# set execute for multi search
|
|
180
|
+
@execute = Results.new(searchkick_klass, response, opts)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def retry_misspellings?(response)
|
|
184
|
+
@misspellings_below && response["error"].nil? && Results.new(searchkick_klass, response).total_count < @misspellings_below
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def handle_error(e)
|
|
190
|
+
status_code = e.message[1..3].to_i
|
|
191
|
+
if status_code == 404
|
|
192
|
+
if e.message.include?("No search context found for id")
|
|
193
|
+
raise MissingIndexError, "No search context found for id"
|
|
194
|
+
else
|
|
195
|
+
raise MissingIndexError, "Index missing - run #{reindex_command}"
|
|
196
|
+
end
|
|
197
|
+
elsif status_code == 500 && (
|
|
198
|
+
e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
|
|
199
|
+
e.message.include?("No query registered for [multi_match]") ||
|
|
200
|
+
e.message.include?("[match] query does not support [cutoff_frequency]") ||
|
|
201
|
+
e.message.include?("No query registered for [function_score]")
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
raise UnsupportedVersionError
|
|
205
|
+
elsif status_code == 400
|
|
206
|
+
if (
|
|
207
|
+
e.message.include?("bool query does not support [filter]") ||
|
|
208
|
+
e.message.include?("[bool] filter does not support [filter]")
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
raise UnsupportedVersionError
|
|
212
|
+
elsif e.message.match?(/analyzer \[searchkick_.+\] not found/)
|
|
213
|
+
raise InvalidQueryError, "Bad mapping - run #{reindex_command}"
|
|
214
|
+
else
|
|
215
|
+
raise InvalidQueryError, e.message
|
|
216
|
+
end
|
|
217
|
+
else
|
|
218
|
+
raise e
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def reindex_command
|
|
223
|
+
searchkick_klass ? "#{searchkick_klass.name}.reindex" : "reindex"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def execute_search
|
|
227
|
+
name = searchkick_klass ? "#{searchkick_klass.name} Search" : "Search"
|
|
228
|
+
event = {
|
|
229
|
+
name: name,
|
|
230
|
+
query: params
|
|
231
|
+
}
|
|
232
|
+
ActiveSupport::Notifications.instrument("search.searchkick", event) do
|
|
233
|
+
Searchkick.client.search(params)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def prepare
|
|
238
|
+
boost_fields, fields = set_fields
|
|
239
|
+
|
|
240
|
+
operator = options[:operator] || "and"
|
|
241
|
+
|
|
242
|
+
# pagination
|
|
243
|
+
page = [options[:page].to_i, 1].max
|
|
244
|
+
# maybe use index.max_result_window in the future
|
|
245
|
+
default_limit = searchkick_options[:deep_paging] ? 1_000_000_000 : 10_000
|
|
246
|
+
per_page = (options[:limit] || options[:per_page] || default_limit).to_i
|
|
247
|
+
padding = [options[:padding].to_i, 0].max
|
|
248
|
+
offset = (options[:offset] || (page - 1) * per_page + padding).to_i
|
|
249
|
+
scroll = options[:scroll]
|
|
250
|
+
opaque_id = options[:opaque_id]
|
|
251
|
+
|
|
252
|
+
max_result_window = searchkick_options[:max_result_window]
|
|
253
|
+
original_per_page = per_page
|
|
254
|
+
if max_result_window
|
|
255
|
+
offset = max_result_window if offset > max_result_window
|
|
256
|
+
per_page = max_result_window - offset if offset + per_page > max_result_window
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# model and eager loading
|
|
260
|
+
load = options[:load].nil? ? true : options[:load]
|
|
261
|
+
|
|
262
|
+
all = term == "*"
|
|
263
|
+
|
|
264
|
+
@json = options[:body]
|
|
265
|
+
if @json
|
|
266
|
+
ignored_options = options.keys & [:aggs, :boost,
|
|
267
|
+
:boost_by, :boost_by_distance, :boost_by_recency, :boost_where, :conversions, :conversions_term, :exclude, :explain,
|
|
268
|
+
:fields, :highlight, :indices_boost, :match, :misspellings, :operator, :order,
|
|
269
|
+
:profile, :select, :smart_aggs, :suggest, :where]
|
|
270
|
+
raise ArgumentError, "Options incompatible with body option: #{ignored_options.join(", ")}" if ignored_options.any?
|
|
271
|
+
payload = @json
|
|
272
|
+
else
|
|
273
|
+
must_not = []
|
|
274
|
+
should = []
|
|
275
|
+
|
|
276
|
+
if options[:similar]
|
|
277
|
+
like = options[:similar] == true ? term : options[:similar]
|
|
278
|
+
query = {
|
|
279
|
+
more_like_this: {
|
|
280
|
+
like: like,
|
|
281
|
+
min_doc_freq: 1,
|
|
282
|
+
min_term_freq: 1,
|
|
283
|
+
analyzer: "searchkick_search2"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if fields.all? { |f| f.start_with?("*.") }
|
|
287
|
+
raise ArgumentError, "Must specify fields to search"
|
|
288
|
+
end
|
|
289
|
+
if fields != ["_all"]
|
|
290
|
+
query[:more_like_this][:fields] = fields
|
|
291
|
+
end
|
|
292
|
+
elsif all && !options[:exclude]
|
|
293
|
+
query = {
|
|
294
|
+
match_all: {}
|
|
295
|
+
}
|
|
296
|
+
else
|
|
297
|
+
queries = []
|
|
298
|
+
|
|
299
|
+
misspellings =
|
|
300
|
+
if options.key?(:misspellings)
|
|
301
|
+
options[:misspellings]
|
|
302
|
+
else
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if misspellings.is_a?(Hash) && misspellings[:below] && !@misspellings_below
|
|
307
|
+
@misspellings_below = misspellings[:below].to_i
|
|
308
|
+
misspellings = false
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
if misspellings != false
|
|
312
|
+
edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
|
|
313
|
+
transpositions =
|
|
314
|
+
if misspellings.is_a?(Hash) && misspellings.key?(:transpositions)
|
|
315
|
+
{fuzzy_transpositions: misspellings[:transpositions]}
|
|
316
|
+
else
|
|
317
|
+
{fuzzy_transpositions: true}
|
|
318
|
+
end
|
|
319
|
+
prefix_length = (misspellings.is_a?(Hash) && misspellings[:prefix_length]) || 0
|
|
320
|
+
default_max_expansions = @misspellings_below ? 20 : 3
|
|
321
|
+
max_expansions = (misspellings.is_a?(Hash) && misspellings[:max_expansions]) || default_max_expansions
|
|
322
|
+
misspellings_fields = misspellings.is_a?(Hash) && misspellings.key?(:fields) && misspellings[:fields].map(&:to_s)
|
|
323
|
+
|
|
324
|
+
if misspellings_fields
|
|
325
|
+
missing_fields = misspellings_fields - fields.map { |f| base_field(f) }
|
|
326
|
+
if missing_fields.any?
|
|
327
|
+
raise ArgumentError, "All fields in per-field misspellings must also be specified in fields option"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
@misspellings = true
|
|
332
|
+
else
|
|
333
|
+
@misspellings = false
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
fields.each do |field|
|
|
337
|
+
queries_to_add = []
|
|
338
|
+
qs = []
|
|
339
|
+
|
|
340
|
+
factor = boost_fields[field] || 1
|
|
341
|
+
shared_options = {
|
|
342
|
+
query: term,
|
|
343
|
+
boost: 10 * factor
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
match_type =
|
|
347
|
+
if field.end_with?(".phrase")
|
|
348
|
+
field =
|
|
349
|
+
if field == "_all.phrase"
|
|
350
|
+
"_all"
|
|
351
|
+
else
|
|
352
|
+
field.sub(/\.phrase\z/, ".analyzed")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
:match_phrase
|
|
356
|
+
else
|
|
357
|
+
:match
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
shared_options[:operator] = operator if match_type == :match
|
|
361
|
+
|
|
362
|
+
exclude_analyzer = nil
|
|
363
|
+
exclude_field = field
|
|
364
|
+
|
|
365
|
+
field_misspellings = misspellings && (!misspellings_fields || misspellings_fields.include?(base_field(field)))
|
|
366
|
+
|
|
367
|
+
if field == "_all" || field.end_with?(".analyzed")
|
|
368
|
+
qs << shared_options.merge(analyzer: "searchkick_search")
|
|
369
|
+
|
|
370
|
+
# searchkick_search and searchkick_search2 are the same for some languages
|
|
371
|
+
unless %w(japanese japanese2 korean polish ukrainian vietnamese).include?(searchkick_options[:language])
|
|
372
|
+
qs << shared_options.merge(analyzer: "searchkick_search2")
|
|
373
|
+
end
|
|
374
|
+
exclude_analyzer = "searchkick_search2"
|
|
375
|
+
elsif field.end_with?(".exact")
|
|
376
|
+
f = field.split(".")[0..-2].join(".")
|
|
377
|
+
queries_to_add << {match: {f => shared_options.merge(analyzer: "keyword")}}
|
|
378
|
+
exclude_field = f
|
|
379
|
+
exclude_analyzer = "keyword"
|
|
380
|
+
else
|
|
381
|
+
analyzer = field.match?(/\.word_(start|middle|end)\z/) ? "searchkick_word_search" : "searchkick_autocomplete_search"
|
|
382
|
+
qs << shared_options.merge(analyzer: analyzer)
|
|
383
|
+
exclude_analyzer = analyzer
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
if field_misspellings != false && match_type == :match
|
|
387
|
+
qs.concat(qs.map { |q| q.except(:cutoff_frequency).merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: max_expansions, boost: factor).merge(transpositions) })
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
if field.start_with?("*.")
|
|
391
|
+
q2 = qs.map { |q| {multi_match: q.merge(fields: [field], type: match_type == :match_phrase ? "phrase" : "best_fields")} }
|
|
392
|
+
else
|
|
393
|
+
q2 = qs.map { |q| {match_type => {field => q}} }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# boost exact matches more
|
|
397
|
+
if field =~ /\.word_(start|middle|end)\z/ && searchkick_options[:word] != false
|
|
398
|
+
queries_to_add << {
|
|
399
|
+
bool: {
|
|
400
|
+
must: {
|
|
401
|
+
bool: {
|
|
402
|
+
should: q2
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
should: {match_type => {field.sub(/\.word_(start|middle|end)\z/, ".analyzed") => qs.first}}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else
|
|
409
|
+
queries_to_add.concat(q2)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
queries << queries_to_add
|
|
413
|
+
|
|
414
|
+
if options[:exclude]
|
|
415
|
+
must_not.concat(set_exclude(exclude_field, exclude_analyzer))
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# all + exclude option
|
|
420
|
+
if all
|
|
421
|
+
query = {
|
|
422
|
+
match_all: {}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
should = []
|
|
426
|
+
else
|
|
427
|
+
# higher score for matching more fields
|
|
428
|
+
payload = {
|
|
429
|
+
bool: {
|
|
430
|
+
should: queries.map { |qs| {dis_max: {queries: qs}} }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
should.concat(set_conversions)
|
|
435
|
+
should.concat(set_conversions_v2)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
query = payload
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
payload = {}
|
|
442
|
+
|
|
443
|
+
# type when inheritance
|
|
444
|
+
where = ensure_permitted(options[:where] || {}).dup
|
|
445
|
+
if searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
|
|
446
|
+
where[:type] = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v, true) }
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
models = Array(options[:models])
|
|
450
|
+
if models.any? { |m| m != m.searchkick_klass }
|
|
451
|
+
index_type_or =
|
|
452
|
+
models.map do |m|
|
|
453
|
+
v = {_index: m.searchkick_index.name}
|
|
454
|
+
v[:type] = m.searchkick_index.klass_document_type(m, true) if m != m.searchkick_klass
|
|
455
|
+
v
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
where[:or] = Array(where[:or]) + [index_type_or]
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# start everything as efficient filters
|
|
462
|
+
# move to post_filters as aggs demand
|
|
463
|
+
filters = where_filters(where)
|
|
464
|
+
post_filters = []
|
|
465
|
+
|
|
466
|
+
# aggregations
|
|
467
|
+
set_aggregations(payload, filters, post_filters) if options[:aggs]
|
|
468
|
+
|
|
469
|
+
# post filters
|
|
470
|
+
set_post_filters(payload, post_filters) if post_filters.any?
|
|
471
|
+
|
|
472
|
+
custom_filters = []
|
|
473
|
+
multiply_filters = []
|
|
474
|
+
|
|
475
|
+
set_boost_by(multiply_filters, custom_filters)
|
|
476
|
+
set_boost_where(custom_filters)
|
|
477
|
+
set_boost_by_distance(custom_filters) if options[:boost_by_distance]
|
|
478
|
+
set_boost_by_recency(custom_filters) if options[:boost_by_recency]
|
|
479
|
+
|
|
480
|
+
payload[:query] = build_query(query, filters, should, must_not, custom_filters, multiply_filters)
|
|
481
|
+
|
|
482
|
+
payload[:explain] = options[:explain] if options[:explain]
|
|
483
|
+
payload[:profile] = options[:profile] if options[:profile]
|
|
484
|
+
|
|
485
|
+
# order
|
|
486
|
+
set_order(payload) if options[:order]
|
|
487
|
+
|
|
488
|
+
# indices_boost
|
|
489
|
+
set_boost_by_indices(payload)
|
|
490
|
+
|
|
491
|
+
# suggestions
|
|
492
|
+
set_suggestions(payload, options[:suggest]) if options[:suggest]
|
|
493
|
+
|
|
494
|
+
# highlight
|
|
495
|
+
set_highlights(payload, fields) if options[:highlight]
|
|
496
|
+
|
|
497
|
+
# timeout shortly after client times out
|
|
498
|
+
payload[:timeout] ||= "#{((Searchkick.search_timeout + 1) * 1000).round}ms"
|
|
499
|
+
|
|
500
|
+
# An empty array will cause only the _id and _type for each hit to be returned
|
|
501
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
|
|
502
|
+
if options[:select]
|
|
503
|
+
if options[:select] == []
|
|
504
|
+
# intuitively [] makes sense to return no fields, but ES by default returns all fields
|
|
505
|
+
payload[:_source] = false
|
|
506
|
+
else
|
|
507
|
+
payload[:_source] = options[:select]
|
|
508
|
+
end
|
|
509
|
+
elsif load
|
|
510
|
+
payload[:_source] = false
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# knn
|
|
515
|
+
set_knn(payload, options[:knn], per_page, offset) if options[:knn]
|
|
516
|
+
|
|
517
|
+
# pagination
|
|
518
|
+
pagination_options = options[:page] || options[:limit] || options[:per_page] || options[:offset] || options[:padding]
|
|
519
|
+
if !options[:body] || pagination_options
|
|
520
|
+
payload[:size] = per_page
|
|
521
|
+
payload[:from] = offset if offset > 0
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# type
|
|
525
|
+
if !searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
|
|
526
|
+
@type = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v) }
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# routing
|
|
530
|
+
@routing = options[:routing] if options[:routing]
|
|
531
|
+
|
|
532
|
+
if track_total_hits?
|
|
533
|
+
payload[:track_total_hits] = true
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# merge more body options
|
|
537
|
+
payload = payload.deep_merge(options[:body_options]) if options[:body_options]
|
|
538
|
+
|
|
539
|
+
# run block
|
|
540
|
+
options[:block].call(payload) if options[:block]
|
|
541
|
+
|
|
542
|
+
# scroll optimization when iterating over all docs
|
|
543
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-scroll.html
|
|
544
|
+
if options[:scroll] && payload[:query] == {match_all: {}}
|
|
545
|
+
payload[:sort] ||= ["_doc"]
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
@body = payload
|
|
549
|
+
@page = page
|
|
550
|
+
@per_page = original_per_page
|
|
551
|
+
@padding = padding
|
|
552
|
+
@load = load
|
|
553
|
+
@scroll = scroll
|
|
554
|
+
@opaque_id = opaque_id
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def set_fields
|
|
558
|
+
boost_fields = {}
|
|
559
|
+
fields = options[:fields] || searchkick_options[:default_fields] || searchkick_options[:searchable]
|
|
560
|
+
all = searchkick_options.key?(:_all) ? searchkick_options[:_all] : false
|
|
561
|
+
default_match = options[:match] || searchkick_options[:match] || :word
|
|
562
|
+
fields =
|
|
563
|
+
if fields
|
|
564
|
+
fields.map do |value|
|
|
565
|
+
k, v = value.is_a?(Hash) ? value.to_a.first : [value, default_match]
|
|
566
|
+
k2, boost = k.to_s.split("^", 2)
|
|
567
|
+
field = "#{k2}.#{v == :word ? 'analyzed' : v}"
|
|
568
|
+
boost_fields[field] = boost.to_f if boost
|
|
569
|
+
field
|
|
570
|
+
end
|
|
571
|
+
elsif all && default_match == :word
|
|
572
|
+
["_all"]
|
|
573
|
+
elsif all && default_match == :phrase
|
|
574
|
+
["_all.phrase"]
|
|
575
|
+
elsif term != "*" && default_match == :exact
|
|
576
|
+
raise ArgumentError, "Must specify fields to search"
|
|
577
|
+
else
|
|
578
|
+
[default_match == :word ? "*.analyzed" : "*.#{default_match}"]
|
|
579
|
+
end
|
|
580
|
+
[boost_fields, fields]
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def build_query(query, filters, should, must_not, custom_filters, multiply_filters)
|
|
584
|
+
if filters.any? || must_not.any? || should.any?
|
|
585
|
+
bool = {}
|
|
586
|
+
bool[:must] = query if query
|
|
587
|
+
bool[:filter] = filters if filters.any? # where
|
|
588
|
+
bool[:must_not] = must_not if must_not.any? # exclude
|
|
589
|
+
bool[:should] = should if should.any? # conversions
|
|
590
|
+
query = {bool: bool}
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
if custom_filters.any?
|
|
594
|
+
query = {
|
|
595
|
+
function_score: {
|
|
596
|
+
functions: custom_filters,
|
|
597
|
+
query: query,
|
|
598
|
+
score_mode: "sum"
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
if multiply_filters.any?
|
|
604
|
+
query = {
|
|
605
|
+
function_score: {
|
|
606
|
+
functions: multiply_filters,
|
|
607
|
+
query: query,
|
|
608
|
+
score_mode: "multiply"
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
query
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def set_conversions
|
|
617
|
+
conversions_fields = Array(options[:conversions] || searchkick_options[:conversions]).map(&:to_s)
|
|
618
|
+
if conversions_fields.present? && options[:conversions] != false
|
|
619
|
+
conversions_fields.map do |conversions_field|
|
|
620
|
+
{
|
|
621
|
+
nested: {
|
|
622
|
+
path: conversions_field,
|
|
623
|
+
score_mode: "sum",
|
|
624
|
+
query: {
|
|
625
|
+
function_score: {
|
|
626
|
+
boost_mode: "replace",
|
|
627
|
+
query: {
|
|
628
|
+
match: {
|
|
629
|
+
"#{conversions_field}.query" => options[:conversions_term] || term
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
field_value_factor: {
|
|
633
|
+
field: "#{conversions_field}.count"
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
end
|
|
640
|
+
else
|
|
641
|
+
[]
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def set_conversions_v2
|
|
646
|
+
conversions_v2 = options[:conversions_v2]
|
|
647
|
+
return [] if conversions_v2.nil? && !searchkick_options[:conversions_v2]
|
|
648
|
+
return [] if conversions_v2 == false
|
|
649
|
+
|
|
650
|
+
# disable if searchkick_options[:conversions] to make it easy to upgrade without downtime
|
|
651
|
+
return [] if conversions_v2.nil? && searchkick_options[:conversions]
|
|
652
|
+
|
|
653
|
+
unless conversions_v2.is_a?(Hash)
|
|
654
|
+
conversions_v2 = {field: conversions_v2}
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
conversions_fields =
|
|
658
|
+
case conversions_v2[:field]
|
|
659
|
+
when true, nil
|
|
660
|
+
Array(searchkick_options[:conversions_v2]).map(&:to_s)
|
|
661
|
+
else
|
|
662
|
+
[conversions_v2[:field].to_s]
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
conversions_term = (conversions_v2[:term] || options[:conversions_term] || term).to_s
|
|
666
|
+
unless searchkick_options[:case_sensitive]
|
|
667
|
+
conversions_term = conversions_term.downcase
|
|
668
|
+
end
|
|
669
|
+
conversions_term = conversions_term.gsub(".", "*")
|
|
670
|
+
|
|
671
|
+
conversions_fields.map do |conversions_field|
|
|
672
|
+
{
|
|
673
|
+
rank_feature: {
|
|
674
|
+
field: "#{conversions_field}.#{conversions_term}",
|
|
675
|
+
linear: {},
|
|
676
|
+
boost: conversions_v2[:factor] || 1
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def set_exclude(field, analyzer)
|
|
683
|
+
Array(options[:exclude]).map do |phrase|
|
|
684
|
+
{
|
|
685
|
+
multi_match: {
|
|
686
|
+
fields: [field],
|
|
687
|
+
query: phrase,
|
|
688
|
+
analyzer: analyzer,
|
|
689
|
+
type: "phrase"
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def set_boost_by_distance(custom_filters)
|
|
696
|
+
boost_by_distance = options[:boost_by_distance] || {}
|
|
697
|
+
|
|
698
|
+
# legacy format
|
|
699
|
+
if boost_by_distance[:field]
|
|
700
|
+
boost_by_distance = {boost_by_distance[:field] => boost_by_distance.except(:field)}
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
boost_by_distance.each do |field, attributes|
|
|
704
|
+
attributes = {function: :gauss, scale: "5mi"}.merge(attributes)
|
|
705
|
+
unless attributes[:origin]
|
|
706
|
+
raise ArgumentError, "boost_by_distance requires :origin"
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
function_params = attributes.except(:factor, :function)
|
|
710
|
+
function_params[:origin] = location_value(function_params[:origin])
|
|
711
|
+
custom_filters << {
|
|
712
|
+
weight: attributes[:factor] || 1,
|
|
713
|
+
attributes[:function] => {
|
|
714
|
+
field => function_params
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def set_boost_by_recency(custom_filters)
|
|
721
|
+
options[:boost_by_recency].each do |field, attributes|
|
|
722
|
+
attributes = {function: :gauss, origin: Time.now}.merge(attributes)
|
|
723
|
+
|
|
724
|
+
custom_filters << {
|
|
725
|
+
weight: attributes[:factor] || 1,
|
|
726
|
+
attributes[:function] => {
|
|
727
|
+
field => attributes.except(:factor, :function)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def set_boost_by(multiply_filters, custom_filters)
|
|
734
|
+
boost_by = options[:boost_by] || {}
|
|
735
|
+
if boost_by.is_a?(Array)
|
|
736
|
+
boost_by = boost_by.to_h { |f| [f, {factor: 1}] }
|
|
737
|
+
elsif boost_by.is_a?(Hash)
|
|
738
|
+
multiply_by, boost_by = boost_by.transform_values(&:dup).partition { |_, v| v.delete(:boost_mode) == "multiply" }.map(&:to_h)
|
|
739
|
+
end
|
|
740
|
+
boost_by[options[:boost]] = {factor: 1} if options[:boost]
|
|
741
|
+
|
|
742
|
+
custom_filters.concat boost_filters(boost_by, modifier: "ln2p")
|
|
743
|
+
multiply_filters.concat boost_filters(multiply_by || {})
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def set_boost_where(custom_filters)
|
|
747
|
+
boost_where = options[:boost_where] || {}
|
|
748
|
+
boost_where.each do |field, value|
|
|
749
|
+
if value.is_a?(Array) && value.first.is_a?(Hash)
|
|
750
|
+
value.each do |value_factor|
|
|
751
|
+
custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
|
|
752
|
+
end
|
|
753
|
+
elsif value.is_a?(Hash)
|
|
754
|
+
custom_filters << custom_filter(field, value[:value], value[:factor])
|
|
755
|
+
else
|
|
756
|
+
factor = 1000
|
|
757
|
+
custom_filters << custom_filter(field, value, factor)
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def set_boost_by_indices(payload)
|
|
763
|
+
return unless options[:indices_boost]
|
|
764
|
+
|
|
765
|
+
indices_boost = options[:indices_boost].map do |key, boost|
|
|
766
|
+
index = key.respond_to?(:searchkick_index) ? key.searchkick_index.name : key
|
|
767
|
+
{index => boost}
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
payload[:indices_boost] = indices_boost
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def set_suggestions(payload, suggest)
|
|
774
|
+
suggest_fields = nil
|
|
775
|
+
|
|
776
|
+
if suggest.is_a?(Array)
|
|
777
|
+
suggest_fields = suggest
|
|
778
|
+
else
|
|
779
|
+
suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
|
|
780
|
+
|
|
781
|
+
# intersection
|
|
782
|
+
if options[:fields]
|
|
783
|
+
suggest_fields &= options[:fields].map { |v| (v.is_a?(Hash) ? v.keys.first : v).to_s.split("^", 2).first }
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
if suggest_fields.any?
|
|
788
|
+
payload[:suggest] = {text: term}
|
|
789
|
+
suggest_fields.each do |field|
|
|
790
|
+
payload[:suggest][field] = {
|
|
791
|
+
phrase: {
|
|
792
|
+
field: "#{field}.suggest"
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
end
|
|
796
|
+
else
|
|
797
|
+
raise ArgumentError, "Must pass fields to suggest option"
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
def set_highlights(payload, fields)
|
|
802
|
+
payload[:highlight] = {
|
|
803
|
+
fields: fields.to_h { |f| [f, {}] },
|
|
804
|
+
fragment_size: 0
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if options[:highlight].is_a?(Hash)
|
|
808
|
+
if (tag = options[:highlight][:tag])
|
|
809
|
+
payload[:highlight][:pre_tags] = [tag]
|
|
810
|
+
payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A<(\w+).+/, "</\\1>")]
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
if (fragment_size = options[:highlight][:fragment_size])
|
|
814
|
+
payload[:highlight][:fragment_size] = fragment_size
|
|
815
|
+
end
|
|
816
|
+
if (encoder = options[:highlight][:encoder])
|
|
817
|
+
payload[:highlight][:encoder] = encoder
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
highlight_fields = options[:highlight][:fields]
|
|
821
|
+
if highlight_fields
|
|
822
|
+
payload[:highlight][:fields] = {}
|
|
823
|
+
|
|
824
|
+
highlight_fields.each do |name, opts|
|
|
825
|
+
payload[:highlight][:fields]["#{name}.#{@match_suffix}"] = opts || {}
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
@highlighted_fields = payload[:highlight][:fields].keys
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def set_aggregations(payload, filters, post_filters)
|
|
834
|
+
aggs = options[:aggs]
|
|
835
|
+
payload[:aggs] = {}
|
|
836
|
+
|
|
837
|
+
aggs = aggs.to_h { |f| [f, {}] } if aggs.is_a?(Array) # convert to more advanced syntax
|
|
838
|
+
aggs.each do |field, agg_options|
|
|
839
|
+
size = agg_options[:limit] ? agg_options[:limit] : 1_000
|
|
840
|
+
shared_agg_options = agg_options.except(:limit, :field, :ranges, :date_ranges, :where)
|
|
841
|
+
|
|
842
|
+
if agg_options[:ranges]
|
|
843
|
+
payload[:aggs][field] = {
|
|
844
|
+
range: {
|
|
845
|
+
field: agg_options[:field] || field,
|
|
846
|
+
ranges: agg_options[:ranges]
|
|
847
|
+
}.merge(shared_agg_options)
|
|
848
|
+
}
|
|
849
|
+
elsif agg_options[:date_ranges]
|
|
850
|
+
payload[:aggs][field] = {
|
|
851
|
+
date_range: {
|
|
852
|
+
field: agg_options[:field] || field,
|
|
853
|
+
ranges: agg_options[:date_ranges]
|
|
854
|
+
}.merge(shared_agg_options)
|
|
855
|
+
}
|
|
856
|
+
elsif (histogram = agg_options[:date_histogram])
|
|
857
|
+
payload[:aggs][field] = {
|
|
858
|
+
date_histogram: histogram
|
|
859
|
+
}.merge(shared_agg_options)
|
|
860
|
+
elsif (metric = @@metric_aggs.find { |k| agg_options.has_key?(k) })
|
|
861
|
+
payload[:aggs][field] = {
|
|
862
|
+
metric => {
|
|
863
|
+
field: agg_options[metric][:field] || field
|
|
864
|
+
}
|
|
865
|
+
}.merge(shared_agg_options)
|
|
866
|
+
else
|
|
867
|
+
payload[:aggs][field] = {
|
|
868
|
+
terms: {
|
|
869
|
+
field: agg_options[:field] || field,
|
|
870
|
+
size: size
|
|
871
|
+
}.merge(shared_agg_options)
|
|
872
|
+
}
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
agg_where = ensure_permitted(agg_options[:where] || {})
|
|
876
|
+
if options[:smart_aggs] != false && options[:where]
|
|
877
|
+
where = ensure_permitted(options[:where])
|
|
878
|
+
where_without_field = where.reject { |k| k == field }
|
|
879
|
+
# where_without_field = where_without_field(where, field.to_s)
|
|
880
|
+
if where_without_field.any?
|
|
881
|
+
if agg_where.any?
|
|
882
|
+
agg_where = where.merge(agg_where)
|
|
883
|
+
# agg_where = combine_agg_where(agg_where, where_without_field)
|
|
884
|
+
else
|
|
885
|
+
agg_where = where_without_field
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
agg_filters = where_filters(agg_where)
|
|
890
|
+
|
|
891
|
+
# only do one level comparison for simplicity
|
|
892
|
+
filters.select! do |filter|
|
|
893
|
+
if agg_filters.include?(filter)
|
|
894
|
+
true
|
|
895
|
+
else
|
|
896
|
+
post_filters << filter
|
|
897
|
+
false
|
|
898
|
+
end
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
if agg_filters.any?
|
|
902
|
+
payload[:aggs][field] = {
|
|
903
|
+
filter: {
|
|
904
|
+
bool: {
|
|
905
|
+
must: agg_filters
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
aggs: {
|
|
909
|
+
field => payload[:aggs][field]
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def where_without_field(where, field)
|
|
917
|
+
result = {}
|
|
918
|
+
where.each do |f, v|
|
|
919
|
+
case f
|
|
920
|
+
when :_and
|
|
921
|
+
r = v.map { |v2| where_without_field(v2, field) }.reject(&:empty?)
|
|
922
|
+
result[f] = r unless r.empty?
|
|
923
|
+
when :_or
|
|
924
|
+
r = v.map { |v2| where_without_field(v2, field) }
|
|
925
|
+
result[f] = r unless r.any?(&:empty?)
|
|
926
|
+
when :or
|
|
927
|
+
r = v.map { |v2| v2.map { |v3| where_without_field(v3, field) }.reject { |v2| v2.any?(&:empty?) } }
|
|
928
|
+
result[f] = r unless r.empty?
|
|
929
|
+
when :_not
|
|
930
|
+
r = where_without_field(v, field)
|
|
931
|
+
result[f] = r unless r.empty?
|
|
932
|
+
when :_script
|
|
933
|
+
result[f] = v
|
|
934
|
+
else
|
|
935
|
+
if f.to_s != field
|
|
936
|
+
result[f] = v
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
result
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def combine_agg_where(agg_where, where)
|
|
944
|
+
result = agg_where.dup
|
|
945
|
+
field_keys = result.except(:_and, :_or, :or, :_not, :_script).transform_keys(&:to_s)
|
|
946
|
+
where.each do |f, v|
|
|
947
|
+
case f
|
|
948
|
+
when :_and, :_or, :or, :_not, :_script
|
|
949
|
+
if result.key?(f)
|
|
950
|
+
# combine with _and if needed
|
|
951
|
+
result[:_and] ||= []
|
|
952
|
+
result[:_and] += [{f => v}]
|
|
953
|
+
else
|
|
954
|
+
result[f] = v
|
|
955
|
+
end
|
|
956
|
+
else
|
|
957
|
+
result[f] = v unless field_keys.include?(f.to_s)
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
result
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
def set_knn(payload, knn, per_page, offset)
|
|
964
|
+
if term != "*"
|
|
965
|
+
raise ArgumentError, "Use Searchkick.multi_search for hybrid search"
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
field = knn[:field]
|
|
969
|
+
field_options = searchkick_options.dig(:knn, field.to_sym) || searchkick_options.dig(:knn, field.to_s) || {}
|
|
970
|
+
vector = knn[:vector]
|
|
971
|
+
distance = knn[:distance] || field_options[:distance]
|
|
972
|
+
exact = knn[:exact]
|
|
973
|
+
exact = field_options[:distance].nil? || distance != field_options[:distance] if exact.nil?
|
|
974
|
+
k = per_page + offset
|
|
975
|
+
ef_search = knn[:ef_search]
|
|
976
|
+
filter = payload.delete(:query)
|
|
977
|
+
|
|
978
|
+
if distance.nil?
|
|
979
|
+
raise ArgumentError, "distance required"
|
|
980
|
+
elsif !exact && distance != field_options[:distance]
|
|
981
|
+
raise ArgumentError, "distance must match searchkick options for approximate search"
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
if Searchkick.opensearch?
|
|
985
|
+
if exact
|
|
986
|
+
# https://opensearch.org/docs/latest/search-plugins/knn/knn-score-script/#spaces
|
|
987
|
+
space_type =
|
|
988
|
+
case distance
|
|
989
|
+
when "cosine"
|
|
990
|
+
"cosinesimil"
|
|
991
|
+
when "euclidean"
|
|
992
|
+
"l2"
|
|
993
|
+
when "taxicab"
|
|
994
|
+
"l1"
|
|
995
|
+
when "inner_product"
|
|
996
|
+
"innerproduct"
|
|
997
|
+
when "chebyshev"
|
|
998
|
+
"linf"
|
|
999
|
+
else
|
|
1000
|
+
raise ArgumentError, "Unknown distance: #{distance}"
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
payload[:query] = {
|
|
1004
|
+
script_score: {
|
|
1005
|
+
query: {
|
|
1006
|
+
bool: {
|
|
1007
|
+
must: [filter, {exists: {field: field}}]
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
script: {
|
|
1011
|
+
source: "knn_score",
|
|
1012
|
+
lang: "knn",
|
|
1013
|
+
params: {
|
|
1014
|
+
field: field,
|
|
1015
|
+
query_value: vector,
|
|
1016
|
+
space_type: space_type
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
boost: distance == "cosine" && Searchkick.server_below?("2.19.0") ? 0.5 : 1.0
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
else
|
|
1023
|
+
if ef_search && Searchkick.server_below?("2.16.0")
|
|
1024
|
+
raise Error, "ef_search requires OpenSearch 2.16+"
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
payload[:query] = {
|
|
1028
|
+
knn: {
|
|
1029
|
+
field.to_sym => {
|
|
1030
|
+
vector: vector,
|
|
1031
|
+
k: k,
|
|
1032
|
+
filter: filter
|
|
1033
|
+
}.merge(ef_search ? {method_parameters: {ef_search: ef_search}} : {})
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
end
|
|
1037
|
+
else
|
|
1038
|
+
if exact
|
|
1039
|
+
# prevent incorrect distances/results with Elasticsearch 9.0.0-rc1
|
|
1040
|
+
if !Searchkick.server_below?("9.0.0") && field_options[:distance] == "cosine" && distance != "cosine"
|
|
1041
|
+
raise ArgumentError, "distance must match searchkick options"
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# https://github.com/elastic/elasticsearch/blob/main/docs/reference/vectors/vector-functions.asciidoc
|
|
1045
|
+
source =
|
|
1046
|
+
case distance
|
|
1047
|
+
when "cosine"
|
|
1048
|
+
"(cosineSimilarity(params.query_vector, params.field) + 1.0) * 0.5"
|
|
1049
|
+
when "euclidean"
|
|
1050
|
+
"double l2 = l2norm(params.query_vector, params.field); 1 / (1 + l2 * l2)"
|
|
1051
|
+
when "taxicab"
|
|
1052
|
+
"1 / (1 + l1norm(params.query_vector, params.field))"
|
|
1053
|
+
when "inner_product"
|
|
1054
|
+
"double dot = dotProduct(params.query_vector, params.field); dot > 0 ? dot + 1 : 1 / (1 - dot)"
|
|
1055
|
+
else
|
|
1056
|
+
raise ArgumentError, "Unknown distance: #{distance}"
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
payload[:query] = {
|
|
1060
|
+
script_score: {
|
|
1061
|
+
query: {
|
|
1062
|
+
bool: {
|
|
1063
|
+
must: [filter, {exists: {field: field}}]
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
script: {
|
|
1067
|
+
source: source,
|
|
1068
|
+
params: {
|
|
1069
|
+
field: field,
|
|
1070
|
+
query_vector: vector
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
else
|
|
1076
|
+
payload[:knn] = {
|
|
1077
|
+
field: field,
|
|
1078
|
+
query_vector: vector,
|
|
1079
|
+
k: k,
|
|
1080
|
+
filter: filter
|
|
1081
|
+
}.merge(ef_search ? {num_candidates: ef_search} : {})
|
|
1082
|
+
end
|
|
1083
|
+
end
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
def set_post_filters(payload, post_filters)
|
|
1087
|
+
payload[:post_filter] = {
|
|
1088
|
+
bool: {
|
|
1089
|
+
filter: post_filters
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def set_order(payload)
|
|
1095
|
+
value = options[:order]
|
|
1096
|
+
payload[:sort] = value.is_a?(Enumerable) ? value : {value => :asc}
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
# provides *very* basic protection from unfiltered parameters
|
|
1100
|
+
# this is not meant to be comprehensive and may be expanded in the future
|
|
1101
|
+
def ensure_permitted(obj)
|
|
1102
|
+
obj.to_h
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def where_filters(where)
|
|
1106
|
+
filters = []
|
|
1107
|
+
(where || {}).each do |field, value|
|
|
1108
|
+
field = :_id if field.to_s == "id"
|
|
1109
|
+
|
|
1110
|
+
# update smart aggs when adding new symbol
|
|
1111
|
+
if field == :or
|
|
1112
|
+
value.each do |or_clause|
|
|
1113
|
+
filters << {bool: {should: or_clause.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
|
|
1114
|
+
end
|
|
1115
|
+
elsif field == :_or
|
|
1116
|
+
filters << {bool: {should: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
|
|
1117
|
+
elsif field == :_not
|
|
1118
|
+
filters << {bool: {must_not: where_filters(value)}}
|
|
1119
|
+
elsif field == :_and
|
|
1120
|
+
filters << {bool: {must: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
|
|
1121
|
+
elsif field == :_script
|
|
1122
|
+
unless value.is_a?(Script)
|
|
1123
|
+
raise TypeError, "expected Searchkick::Script"
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
filters << {script: {script: {source: value.source, lang: value.lang, params: value.params}}}
|
|
1127
|
+
else
|
|
1128
|
+
# expand ranges
|
|
1129
|
+
if value.is_a?(Range)
|
|
1130
|
+
value = expand_range(value)
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
value = {in: value} if value.is_a?(Array)
|
|
1134
|
+
|
|
1135
|
+
if value.is_a?(Hash)
|
|
1136
|
+
value.each do |op, op_value|
|
|
1137
|
+
case op
|
|
1138
|
+
when :within, :bottom_right, :bottom_left
|
|
1139
|
+
# do nothing
|
|
1140
|
+
when :near
|
|
1141
|
+
filters << {
|
|
1142
|
+
geo_distance: {
|
|
1143
|
+
field => location_value(op_value),
|
|
1144
|
+
distance: value[:within] || "50mi"
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
when :geo_polygon
|
|
1148
|
+
filters << {
|
|
1149
|
+
geo_polygon: {
|
|
1150
|
+
field => op_value
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
when :geo_shape
|
|
1154
|
+
shape = op_value.except(:relation)
|
|
1155
|
+
shape[:coordinates] = coordinate_array(shape[:coordinates]) if shape[:coordinates]
|
|
1156
|
+
filters << {
|
|
1157
|
+
geo_shape: {
|
|
1158
|
+
field => {
|
|
1159
|
+
relation: op_value[:relation] || "intersects",
|
|
1160
|
+
shape: shape
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
when :top_left
|
|
1165
|
+
filters << {
|
|
1166
|
+
geo_bounding_box: {
|
|
1167
|
+
field => {
|
|
1168
|
+
top_left: location_value(op_value),
|
|
1169
|
+
bottom_right: location_value(value[:bottom_right])
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
when :top_right
|
|
1174
|
+
filters << {
|
|
1175
|
+
geo_bounding_box: {
|
|
1176
|
+
field => {
|
|
1177
|
+
top_right: location_value(op_value),
|
|
1178
|
+
bottom_left: location_value(value[:bottom_left])
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
when :like, :ilike
|
|
1183
|
+
# based on Postgres
|
|
1184
|
+
# https://www.postgresql.org/docs/current/functions-matching.html
|
|
1185
|
+
# % matches zero or more characters
|
|
1186
|
+
# _ matches one character
|
|
1187
|
+
# \ is escape character
|
|
1188
|
+
# escape Lucene reserved characters
|
|
1189
|
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/regexp-syntax.html#regexp-optional-operators
|
|
1190
|
+
reserved = %w(\\ . ? + * | { } [ ] ( ) ")
|
|
1191
|
+
regex = op_value.dup
|
|
1192
|
+
reserved.each do |v|
|
|
1193
|
+
regex.gsub!(v, "\\\\" + v)
|
|
1194
|
+
end
|
|
1195
|
+
regex = regex.gsub(/(?<!\\)%/, ".*").gsub(/(?<!\\)_/, ".").gsub("\\%", "%").gsub("\\_", "_")
|
|
1196
|
+
|
|
1197
|
+
if op == :ilike
|
|
1198
|
+
filters << {regexp: {field => {value: regex, flags: "NONE", case_insensitive: true}}}
|
|
1199
|
+
else
|
|
1200
|
+
filters << {regexp: {field => {value: regex, flags: "NONE"}}}
|
|
1201
|
+
end
|
|
1202
|
+
when :prefix
|
|
1203
|
+
filters << {prefix: {field => {value: op_value}}}
|
|
1204
|
+
when :regexp # support for regexp queries without using a regexp ruby object
|
|
1205
|
+
filters << {regexp: {field => {value: op_value}}}
|
|
1206
|
+
when :not, :_not # not equal
|
|
1207
|
+
filters << {bool: {must_not: term_filters(field, op_value)}}
|
|
1208
|
+
when :all
|
|
1209
|
+
op_value.each do |val|
|
|
1210
|
+
filters << term_filters(field, val)
|
|
1211
|
+
end
|
|
1212
|
+
when :in
|
|
1213
|
+
filters << term_filters(field, op_value)
|
|
1214
|
+
when :exists
|
|
1215
|
+
case op_value
|
|
1216
|
+
when true
|
|
1217
|
+
filters << {exists: {field: field}}
|
|
1218
|
+
when false
|
|
1219
|
+
filters << {bool: {must_not: {exists: {field: field}}}}
|
|
1220
|
+
else
|
|
1221
|
+
raise ArgumentError, "Passing a value other than true or false to exists is not supported"
|
|
1222
|
+
end
|
|
1223
|
+
else
|
|
1224
|
+
range_query =
|
|
1225
|
+
case op
|
|
1226
|
+
when :gt
|
|
1227
|
+
{gt: op_value}
|
|
1228
|
+
when :gte
|
|
1229
|
+
{gte: op_value}
|
|
1230
|
+
when :lt
|
|
1231
|
+
{lt: op_value}
|
|
1232
|
+
when :lte
|
|
1233
|
+
{lte: op_value}
|
|
1234
|
+
else
|
|
1235
|
+
raise ArgumentError, "Unknown where operator: #{op.inspect}"
|
|
1236
|
+
end
|
|
1237
|
+
# issue 132
|
|
1238
|
+
if (existing = filters.find { |f| f[:range] && f[:range][field] })
|
|
1239
|
+
existing[:range][field].merge!(range_query)
|
|
1240
|
+
else
|
|
1241
|
+
filters << {range: {field => range_query}}
|
|
1242
|
+
end
|
|
1243
|
+
end
|
|
1244
|
+
end
|
|
1245
|
+
else
|
|
1246
|
+
filters << term_filters(field, value)
|
|
1247
|
+
end
|
|
1248
|
+
end
|
|
1249
|
+
end
|
|
1250
|
+
filters
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def term_filters(field, value)
|
|
1254
|
+
if value.is_a?(Array) # in query
|
|
1255
|
+
if value.any?(&:nil?)
|
|
1256
|
+
{bool: {should: [term_filters(field, nil), term_filters(field, value.compact)]}}
|
|
1257
|
+
else
|
|
1258
|
+
{terms: {field => value}}
|
|
1259
|
+
end
|
|
1260
|
+
elsif value.nil?
|
|
1261
|
+
{bool: {must_not: {exists: {field: field}}}}
|
|
1262
|
+
elsif value.is_a?(Regexp)
|
|
1263
|
+
source = value.source
|
|
1264
|
+
|
|
1265
|
+
# TODO handle other regexp options
|
|
1266
|
+
|
|
1267
|
+
# TODO handle other anchor characters, like ^, $, \Z
|
|
1268
|
+
if source.start_with?("\\A")
|
|
1269
|
+
source = source[2..-1]
|
|
1270
|
+
else
|
|
1271
|
+
source = ".*#{source}"
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
if source.end_with?("\\z")
|
|
1275
|
+
source = source[0..-3]
|
|
1276
|
+
else
|
|
1277
|
+
source = "#{source}.*"
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
{regexp: {field => {value: source, flags: "NONE", case_insensitive: value.casefold?}}}
|
|
1281
|
+
else
|
|
1282
|
+
# TODO add this for other values
|
|
1283
|
+
if value.as_json.is_a?(Enumerable)
|
|
1284
|
+
# query will fail, but this is better
|
|
1285
|
+
# same message as Active Record
|
|
1286
|
+
raise TypeError, "can't cast #{value.class.name}"
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
{term: {field => {value: value}}}
|
|
1290
|
+
end
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
def custom_filter(field, value, factor)
|
|
1294
|
+
{
|
|
1295
|
+
filter: where_filters(field => value),
|
|
1296
|
+
weight: factor
|
|
1297
|
+
}
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
def boost_filter(field, factor: 1, modifier: nil, missing: nil)
|
|
1301
|
+
script_score = {
|
|
1302
|
+
field_value_factor: {
|
|
1303
|
+
field: field,
|
|
1304
|
+
factor: factor.to_f,
|
|
1305
|
+
modifier: modifier
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if missing
|
|
1310
|
+
script_score[:field_value_factor][:missing] = missing.to_f
|
|
1311
|
+
else
|
|
1312
|
+
script_score[:filter] = {
|
|
1313
|
+
exists: {
|
|
1314
|
+
field: field
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
end
|
|
1318
|
+
|
|
1319
|
+
script_score
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def boost_filters(boost_by, modifier: nil)
|
|
1323
|
+
boost_by.map do |field, value|
|
|
1324
|
+
boost_filter(field, modifier: modifier, **value)
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
# Recursively descend through nesting of arrays until we reach either a lat/lon object or an array of numbers,
|
|
1329
|
+
# eventually returning the same structure with all values transformed to [lon, lat].
|
|
1330
|
+
#
|
|
1331
|
+
def coordinate_array(value)
|
|
1332
|
+
if value.is_a?(Hash)
|
|
1333
|
+
[value[:lon], value[:lat]]
|
|
1334
|
+
elsif value.is_a?(Array) and !value[0].is_a?(Numeric)
|
|
1335
|
+
value.map { |a| coordinate_array(a) }
|
|
1336
|
+
else
|
|
1337
|
+
value
|
|
1338
|
+
end
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
def location_value(value)
|
|
1342
|
+
if value.is_a?(Array)
|
|
1343
|
+
value.map(&:to_f).reverse
|
|
1344
|
+
else
|
|
1345
|
+
value
|
|
1346
|
+
end
|
|
1347
|
+
end
|
|
1348
|
+
|
|
1349
|
+
def expand_range(range)
|
|
1350
|
+
expanded = {}
|
|
1351
|
+
expanded[:gte] = range.begin if range.begin
|
|
1352
|
+
|
|
1353
|
+
if range.end && !(range.end.respond_to?(:infinite?) && range.end.infinite?)
|
|
1354
|
+
expanded[range.exclude_end? ? :lt : :lte] = range.end
|
|
1355
|
+
end
|
|
1356
|
+
|
|
1357
|
+
expanded
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
def base_field(k)
|
|
1361
|
+
k.sub(/\.(analyzed|word_start|word_middle|word_end|text_start|text_middle|text_end|exact)\z/, "")
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
def track_total_hits?
|
|
1365
|
+
searchkick_options[:deep_paging] || body_options[:track_total_hits]
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
def body_options
|
|
1369
|
+
options[:body_options] || {}
|
|
1370
|
+
end
|
|
1371
|
+
end
|
|
1372
|
+
end
|