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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/searchkick-6.1.2/CHANGELOG.md +919 -0
  3. data/searchkick-6.1.2/LICENSE.txt +22 -0
  4. data/searchkick-6.1.2/README.md +2348 -0
  5. data/searchkick-6.1.2/lib/searchkick/bulk_reindex_job.rb +19 -0
  6. data/searchkick-6.1.2/lib/searchkick/controller_runtime.rb +40 -0
  7. data/searchkick-6.1.2/lib/searchkick/hash_wrapper.rb +41 -0
  8. data/searchkick-6.1.2/lib/searchkick/index.rb +480 -0
  9. data/searchkick-6.1.2/lib/searchkick/index_cache.rb +30 -0
  10. data/searchkick-6.1.2/lib/searchkick/index_options.rb +652 -0
  11. data/searchkick-6.1.2/lib/searchkick/indexer.rb +43 -0
  12. data/searchkick-6.1.2/lib/searchkick/log_subscriber.rb +57 -0
  13. data/searchkick-6.1.2/lib/searchkick/middleware.rb +19 -0
  14. data/searchkick-6.1.2/lib/searchkick/model.rb +120 -0
  15. data/searchkick-6.1.2/lib/searchkick/multi_search.rb +46 -0
  16. data/searchkick-6.1.2/lib/searchkick/process_batch_job.rb +20 -0
  17. data/searchkick-6.1.2/lib/searchkick/process_queue_job.rb +33 -0
  18. data/searchkick-6.1.2/lib/searchkick/query.rb +1372 -0
  19. data/searchkick-6.1.2/lib/searchkick/railtie.rb +7 -0
  20. data/searchkick-6.1.2/lib/searchkick/record_data.rb +147 -0
  21. data/searchkick-6.1.2/lib/searchkick/record_indexer.rb +174 -0
  22. data/searchkick-6.1.2/lib/searchkick/reindex_queue.rb +57 -0
  23. data/searchkick-6.1.2/lib/searchkick/reindex_v2_job.rb +17 -0
  24. data/searchkick-6.1.2/lib/searchkick/relation.rb +728 -0
  25. data/searchkick-6.1.2/lib/searchkick/relation_indexer.rb +184 -0
  26. data/searchkick-6.1.2/lib/searchkick/reranking.rb +28 -0
  27. data/searchkick-6.1.2/lib/searchkick/results.rb +359 -0
  28. data/searchkick-6.1.2/lib/searchkick/script.rb +11 -0
  29. data/searchkick-6.1.2/lib/searchkick/version.rb +3 -0
  30. data/searchkick-6.1.2/lib/searchkick/where.rb +11 -0
  31. data/searchkick-6.1.2/lib/searchkick.rb +388 -0
  32. data/searchkick-6.1.2/lib/tasks/searchkick.rake +37 -0
  33. data/tiny-quick-gem.gemspec +12 -0
  34. 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