searchkick_evichat 0.0.2

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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE.md +7 -0
  3. data/.gitignore +22 -0
  4. data/.travis.yml +33 -0
  5. data/CHANGELOG.md +571 -0
  6. data/CONTRIBUTING.md +51 -0
  7. data/Gemfile +16 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +1895 -0
  10. data/Rakefile +16 -0
  11. data/benchmark/Gemfile +24 -0
  12. data/benchmark/index.rb +99 -0
  13. data/benchmark/search.rb +48 -0
  14. data/docs/Searchkick-3-Upgrade.md +57 -0
  15. data/lib/searchkick.rb +255 -0
  16. data/lib/searchkick/bulk_indexer.rb +171 -0
  17. data/lib/searchkick/bulk_reindex_job.rb +27 -0
  18. data/lib/searchkick/hash_wrapper.rb +12 -0
  19. data/lib/searchkick/index.rb +331 -0
  20. data/lib/searchkick/index_options.rb +414 -0
  21. data/lib/searchkick/indexer.rb +28 -0
  22. data/lib/searchkick/logging.rb +243 -0
  23. data/lib/searchkick/middleware.rb +12 -0
  24. data/lib/searchkick/model.rb +105 -0
  25. data/lib/searchkick/multi_search.rb +41 -0
  26. data/lib/searchkick/process_batch_job.rb +23 -0
  27. data/lib/searchkick/process_queue_job.rb +23 -0
  28. data/lib/searchkick/query.rb +978 -0
  29. data/lib/searchkick/record_data.rb +128 -0
  30. data/lib/searchkick/record_indexer.rb +59 -0
  31. data/lib/searchkick/reindex_queue.rb +38 -0
  32. data/lib/searchkick/reindex_v2_job.rb +36 -0
  33. data/lib/searchkick/results.rb +235 -0
  34. data/lib/searchkick/tasks.rb +29 -0
  35. data/lib/searchkick/version.rb +3 -0
  36. data/searchkick.gemspec +30 -0
  37. data/test/aggs_test.rb +217 -0
  38. data/test/autocomplete_test.rb +81 -0
  39. data/test/boost_test.rb +207 -0
  40. data/test/callbacks_test.rb +59 -0
  41. data/test/ci/before_install.sh +17 -0
  42. data/test/errors_test.rb +19 -0
  43. data/test/gemfiles/activerecord42.gemfile +7 -0
  44. data/test/gemfiles/activerecord50.gemfile +7 -0
  45. data/test/gemfiles/activerecord51.gemfile +7 -0
  46. data/test/gemfiles/apartment.gemfile +8 -0
  47. data/test/gemfiles/cequel.gemfile +8 -0
  48. data/test/gemfiles/mongoid5.gemfile +7 -0
  49. data/test/gemfiles/mongoid6.gemfile +12 -0
  50. data/test/gemfiles/nobrainer.gemfile +8 -0
  51. data/test/gemfiles/parallel_tests.gemfile +8 -0
  52. data/test/geo_shape_test.rb +171 -0
  53. data/test/highlight_test.rb +94 -0
  54. data/test/index_test.rb +165 -0
  55. data/test/inheritance_test.rb +82 -0
  56. data/test/language_test.rb +79 -0
  57. data/test/marshal_test.rb +13 -0
  58. data/test/match_test.rb +293 -0
  59. data/test/misspellings_test.rb +56 -0
  60. data/test/model_test.rb +40 -0
  61. data/test/multi_search_test.rb +37 -0
  62. data/test/multi_tenancy_test.rb +22 -0
  63. data/test/order_test.rb +40 -0
  64. data/test/pagination_test.rb +96 -0
  65. data/test/partial_reindex_test.rb +65 -0
  66. data/test/query_test.rb +43 -0
  67. data/test/reindex_test.rb +87 -0
  68. data/test/reindex_v2_job_test.rb +27 -0
  69. data/test/routing_test.rb +23 -0
  70. data/test/should_index_test.rb +32 -0
  71. data/test/similar_test.rb +28 -0
  72. data/test/sql_test.rb +190 -0
  73. data/test/suggest_test.rb +100 -0
  74. data/test/support/kaminari.yml +21 -0
  75. data/test/synonyms_test.rb +69 -0
  76. data/test/test_helper.rb +593 -0
  77. data/test/where_test.rb +249 -0
  78. metadata +250 -0
@@ -0,0 +1,12 @@
1
+ require "faraday/middleware"
2
+
3
+ module Searchkick
4
+ class Middleware < Faraday::Middleware
5
+ def call(env)
6
+ if env[:method] == :get && env[:url].path.to_s.end_with?("/_search")
7
+ env[:request][:timeout] = Searchkick.search_timeout
8
+ end
9
+ @app.call(env)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,105 @@
1
+ module Searchkick
2
+ module Model
3
+ def searchkick(**options)
4
+ options = Searchkick.model_options.merge(options)
5
+
6
+ unknown_keywords = options.keys - [:_all, :_type, :batch_size, :callbacks, :conversions, :default_fields,
7
+ :filterable, :geo_shape, :highlight, :ignore_above, :index_name, :index_prefix, :inheritance, :language,
8
+ :locations, :mappings, :match, :merge_mappings, :routing, :searchable, :settings, :similarity,
9
+ :special_characters, :stem_conversions, :suggest, :synonyms, :text_end,
10
+ :text_middle, :text_start, :word, :wordnet, :word_end, :word_middle, :word_start]
11
+ raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
12
+
13
+ raise "Only call searchkick once per model" if respond_to?(:searchkick_index)
14
+
15
+ Searchkick.models << self
16
+
17
+ options[:_type] ||= -> { searchkick_index.klass_document_type(self, true) }
18
+
19
+ callbacks = options.key?(:callbacks) ? options[:callbacks] : true
20
+ unless [true, false, :async, :queue].include?(callbacks)
21
+ raise ArgumentError, "Invalid value for callbacks"
22
+ end
23
+
24
+ index_name =
25
+ if options[:index_name]
26
+ options[:index_name]
27
+ elsif options[:index_prefix].respond_to?(:call)
28
+ -> { [options[:index_prefix].call, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_") }
29
+ else
30
+ [options.key?(:index_prefix) ? options[:index_prefix] : Searchkick.index_prefix, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")
31
+ end
32
+
33
+ class_eval do
34
+ cattr_reader :searchkick_options, :searchkick_klass
35
+
36
+ class_variable_set :@@searchkick_options, options.dup
37
+ class_variable_set :@@searchkick_klass, self
38
+ class_variable_set :@@searchkick_index, index_name
39
+ class_variable_set :@@searchkick_index_cache, {}
40
+
41
+ class << self
42
+ def searchkick_search(term = "*", **options, &block)
43
+ Searchkick.search(term, {model: self}.merge(options), &block)
44
+ end
45
+ alias_method Searchkick.search_method_name, :searchkick_search if Searchkick.search_method_name
46
+
47
+ def searchkick_index
48
+ index = class_variable_get(:@@searchkick_index)
49
+ index = index.call if index.respond_to?(:call)
50
+ index_cache = class_variable_get(:@@searchkick_index_cache)
51
+ index_cache[index] ||= Searchkick::Index.new(index, searchkick_options)
52
+ end
53
+ alias_method :search_index, :searchkick_index unless method_defined?(:search_index)
54
+
55
+ def searchkick_reindex(method_name = nil, **options)
56
+ scoped = (respond_to?(:current_scope) && respond_to?(:default_scoped) && current_scope && current_scope.to_sql != default_scoped.to_sql) ||
57
+ (respond_to?(:queryable) && queryable != unscoped.with_default_scope)
58
+
59
+ searchkick_index.reindex(searchkick_klass, method_name, scoped: scoped, **options)
60
+ end
61
+ alias_method :reindex, :searchkick_reindex unless method_defined?(:reindex)
62
+
63
+ def searchkick_index_options
64
+ searchkick_index.index_options
65
+ end
66
+ end
67
+
68
+ # always add callbacks, even when callbacks is false
69
+ # so Model.callbacks block can be used
70
+ if respond_to?(:after_commit)
71
+ after_commit :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
72
+ elsif respond_to?(:after_save)
73
+ after_save :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
74
+ after_destroy :reindex, if: -> { Searchkick.callbacks?(default: callbacks) }
75
+ end
76
+
77
+ def reindex(method_name = nil, **options)
78
+ RecordIndexer.new(self).reindex(method_name, **options)
79
+ end unless method_defined?(:reindex)
80
+
81
+ def similar(options = {})
82
+ self.class.searchkick_index.similar_record(self, options)
83
+ end unless method_defined?(:similar)
84
+
85
+ def search_data
86
+ data = respond_to?(:to_hash) ? to_hash : serializable_hash
87
+ data.delete("id")
88
+ data.delete("_id")
89
+ data.delete("_type")
90
+ data
91
+ end unless method_defined?(:search_data)
92
+
93
+ def should_index?
94
+ true
95
+ end unless method_defined?(:should_index?)
96
+
97
+ if defined?(Cequel) && self < Cequel::Record && !method_defined?(:destroyed?)
98
+ def destroyed?
99
+ transient?
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,41 @@
1
+ module Searchkick
2
+ class MultiSearch
3
+ attr_reader :queries
4
+
5
+ def initialize(queries)
6
+ @queries = queries
7
+ end
8
+
9
+ def perform
10
+ if queries.any?
11
+ perform_search(queries)
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def perform_search(search_queries, perform_retry: true)
18
+ responses = client.msearch(body: search_queries.flat_map { |q| [q.params.except(:body), q.body] })["responses"]
19
+
20
+ retry_queries = []
21
+ search_queries.each_with_index do |query, i|
22
+ if perform_retry && query.retry_misspellings?(responses[i])
23
+ query.send(:prepare) # okay, since we don't want to expose this method outside Searchkick
24
+ retry_queries << query
25
+ else
26
+ query.handle_response(responses[i])
27
+ end
28
+ end
29
+
30
+ if retry_queries.any?
31
+ perform_search(retry_queries, perform_retry: false)
32
+ end
33
+
34
+ search_queries
35
+ end
36
+
37
+ def client
38
+ Searchkick.client
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ module Searchkick
2
+ class ProcessBatchJob < ActiveJob::Base
3
+ queue_as { Searchkick.queue_name }
4
+
5
+ def perform(class_name:, record_ids:)
6
+ klass = class_name.constantize
7
+ scope = Searchkick.load_records(klass, record_ids)
8
+ scope = scope.search_import if scope.respond_to?(:search_import)
9
+ records = scope.select(&:should_index?)
10
+
11
+ # determine which records to delete
12
+ delete_ids = record_ids - records.map { |r| r.id.to_s }
13
+ delete_records = delete_ids.map { |id| m = klass.new; m.id = id; m }
14
+
15
+ # bulk reindex
16
+ index = klass.searchkick_index
17
+ Searchkick.callbacks(:bulk) do
18
+ index.bulk_index(records) if records.any?
19
+ index.bulk_delete(delete_records) if delete_records.any?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ module Searchkick
2
+ class ProcessQueueJob < ActiveJob::Base
3
+ queue_as { Searchkick.queue_name }
4
+
5
+ def perform(class_name:)
6
+ model = class_name.constantize
7
+
8
+ limit = model.searchkick_index.options[:batch_size] || 1000
9
+ record_ids = model.searchkick_index.reindex_queue.reserve(limit: limit)
10
+ if record_ids.any?
11
+ Searchkick::ProcessBatchJob.perform_later(
12
+ class_name: model.name,
13
+ record_ids: record_ids
14
+ )
15
+ # TODO when moving to reliable queuing, mark as complete
16
+
17
+ if record_ids.size == limit
18
+ Searchkick::ProcessQueueJob.perform_later(class_name: class_name)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,978 @@
1
+ module Searchkick
2
+ class Query
3
+ extend Forwardable
4
+
5
+ @@metric_aggs = [:avg, :cardinality, :max, :min, :sum]
6
+
7
+ attr_reader :klass, :term, :options
8
+ attr_accessor :body
9
+
10
+ def_delegators :execute, :map, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary,
11
+ :records, :results, :suggestions, :each_with_hit, :with_details, :aggregations, :aggs,
12
+ :took, :error, :model_name, :entry_name, :total_count, :total_entries,
13
+ :current_page, :per_page, :limit_value, :padding, :total_pages, :num_pages,
14
+ :offset_value, :offset, :previous_page, :prev_page, :next_page, :first_page?, :last_page?,
15
+ :out_of_range?, :hits, :response, :to_a, :first
16
+
17
+ def initialize(klass, term = "*", **options)
18
+ unknown_keywords = options.keys - [:aggs, :body, :body_options, :boost,
19
+ :boost_by, :boost_by_distance, :boost_where, :conversions, :conversions_term, :debug, :emoji, :exclude, :execute, :explain,
20
+ :fields, :highlight, :includes, :index_name, :indices_boost, :limit, :load,
21
+ :match, :misspellings, :model_includes, :offset, :operator, :order, :padding, :page, :per_page, :profile,
22
+ :request_params, :routing, :scope_results, :select, :similar, :smart_aggs, :suggest, :track, :type, :where]
23
+ raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?
24
+
25
+ term = term.to_s
26
+
27
+ if options[:emoji]
28
+ term = EmojiParser.parse_unicode(term) { |e| " #{e.name} " }.strip
29
+ end
30
+
31
+ @klass = klass
32
+ @term = term
33
+ @options = options
34
+ @match_suffix = options[:match] || searchkick_options[:match] || "analyzed"
35
+
36
+ # prevent Ruby warnings
37
+ @type = nil
38
+ @routing = nil
39
+ @misspellings = false
40
+ @misspellings_below = nil
41
+ @highlighted_fields = nil
42
+
43
+ prepare
44
+ end
45
+
46
+ def searchkick_index
47
+ klass ? klass.searchkick_index : nil
48
+ end
49
+
50
+ def searchkick_options
51
+ klass ? klass.searchkick_options : {}
52
+ end
53
+
54
+ def searchkick_klass
55
+ klass ? klass.searchkick_klass : nil
56
+ end
57
+
58
+ def params
59
+ index =
60
+ if options[:index_name]
61
+ Array(options[:index_name]).map { |v| v.respond_to?(:searchkick_index) ? v.searchkick_index.name : v }.join(",")
62
+ elsif searchkick_index
63
+ searchkick_index.name
64
+ else
65
+ "_all"
66
+ end
67
+
68
+ params = {
69
+ index: index,
70
+ body: body
71
+ }
72
+ params[:type] = @type if @type
73
+ params[:routing] = @routing if @routing
74
+ params.merge!(options[:request_params]) if options[:request_params]
75
+ params
76
+ end
77
+
78
+ def execute
79
+ @execute ||= begin
80
+ begin
81
+ response = execute_search
82
+ if retry_misspellings?(response)
83
+ prepare
84
+ response = execute_search
85
+ end
86
+ rescue => e # TODO rescue type
87
+ handle_error(e)
88
+ end
89
+ handle_response(response)
90
+ end
91
+ end
92
+
93
+ def to_curl
94
+ query = params
95
+ type = query[:type]
96
+ index = query[:index].is_a?(Array) ? query[:index].join(",") : query[:index]
97
+
98
+ # no easy way to tell which host the client will use
99
+ host = Searchkick.client.transport.hosts.first
100
+ credentials = host[:user] || host[:password] ? "#{host[:user]}:#{host[:password]}@" : nil
101
+ "curl #{host[:protocol]}://#{credentials}#{host[:host]}:#{host[:port]}/#{CGI.escape(index)}#{type ? "/#{type.map { |t| CGI.escape(t) }.join(',')}" : ''}/_search?pretty -H 'Content-Type: application/json' -d '#{query[:body].to_json}'"
102
+ end
103
+
104
+ def handle_response(response)
105
+ opts = {
106
+ page: @page,
107
+ per_page: @per_page,
108
+ padding: @padding,
109
+ load: @load,
110
+ includes: options[:includes],
111
+ model_includes: options[:model_includes],
112
+ json: !@json.nil?,
113
+ match_suffix: @match_suffix,
114
+ highlighted_fields: @highlighted_fields || [],
115
+ misspellings: @misspellings,
116
+ term: term,
117
+ scope_results: options[:scope_results],
118
+ index_name: options[:index_name]
119
+ }
120
+
121
+ if options[:debug]
122
+ require "pp"
123
+
124
+ puts "Searchkick Version: #{Searchkick::VERSION}"
125
+ puts "Elasticsearch Version: #{Searchkick.server_version}"
126
+ puts
127
+
128
+ puts "Model Searchkick Options"
129
+ pp searchkick_options
130
+ puts
131
+
132
+ puts "Search Options"
133
+ pp options
134
+ puts
135
+
136
+ if searchkick_index
137
+ puts "Model Search Data"
138
+ begin
139
+ pp klass.limit(3).map { |r| RecordData.new(searchkick_index, r).index_data }
140
+ rescue => e
141
+ puts "#{e.class.name}: #{e.message}"
142
+ end
143
+ puts
144
+
145
+ puts "Elasticsearch Mapping"
146
+ puts JSON.pretty_generate(searchkick_index.mapping)
147
+ puts
148
+
149
+ puts "Elasticsearch Settings"
150
+ puts JSON.pretty_generate(searchkick_index.settings)
151
+ puts
152
+ end
153
+
154
+ puts "Elasticsearch Query"
155
+ puts to_curl
156
+ puts
157
+
158
+ puts "Elasticsearch Results"
159
+ puts JSON.pretty_generate(response)
160
+ end
161
+
162
+ # set execute for multi search
163
+ @execute = Searchkick::Results.new(searchkick_klass, response, opts)
164
+ end
165
+
166
+ def retry_misspellings?(response)
167
+ @misspellings_below && response["hits"]["total"] < @misspellings_below
168
+ end
169
+
170
+ private
171
+
172
+ def handle_error(e)
173
+ status_code = e.message[1..3].to_i
174
+ if status_code == 404
175
+ raise MissingIndexError, "Index missing - run #{reindex_command}"
176
+ elsif status_code == 500 && (
177
+ e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
178
+ e.message.include?("No query registered for [multi_match]") ||
179
+ e.message.include?("[match] query does not support [cutoff_frequency]") ||
180
+ e.message.include?("No query registered for [function_score]")
181
+ )
182
+
183
+ raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
184
+ elsif status_code == 400
185
+ if (
186
+ e.message.include?("bool query does not support [filter]") ||
187
+ e.message.include?("[bool] filter does not support [filter]")
188
+ )
189
+
190
+ raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 5 or greater"
191
+ elsif e.message.include?("[multi_match] analyzer [searchkick_search] not found")
192
+ raise InvalidQueryError, "Bad mapping - run #{reindex_command}"
193
+ else
194
+ raise InvalidQueryError, e.message
195
+ end
196
+ else
197
+ raise e
198
+ end
199
+ end
200
+
201
+ def reindex_command
202
+ searchkick_klass ? "#{searchkick_klass.name}.reindex" : "reindex"
203
+ end
204
+
205
+ def execute_search
206
+ Searchkick.client.search(params)
207
+ end
208
+
209
+ def prepare
210
+ boost_fields, fields = set_fields
211
+
212
+ operator = options[:operator] || "and"
213
+
214
+ # pagination
215
+ page = [options[:page].to_i, 1].max
216
+ per_page = (options[:limit] || options[:per_page] || 10_000).to_i
217
+ padding = [options[:padding].to_i, 0].max
218
+ offset = options[:offset] || (page - 1) * per_page + padding
219
+
220
+ # model and eager loading
221
+ load = options[:load].nil? ? true : options[:load]
222
+
223
+ all = term == "*"
224
+
225
+ @json = options[:body]
226
+ if @json
227
+ ignored_options = options.keys & [:aggs, :boost,
228
+ :boost_by, :boost_by_distance, :boost_where, :conversions, :conversions_term, :exclude, :explain,
229
+ :fields, :highlight, :indices_boost, :match, :misspellings, :operator, :order,
230
+ :profile, :select, :smart_aggs, :suggest, :where]
231
+ raise ArgumentError, "Options incompatible with body option: #{ignored_options.join(", ")}" if ignored_options.any?
232
+ payload = @json
233
+ else
234
+ must_not = []
235
+ should = []
236
+
237
+ if options[:similar]
238
+ query = {
239
+ more_like_this: {
240
+ like: term,
241
+ min_doc_freq: 1,
242
+ min_term_freq: 1,
243
+ analyzer: "searchkick_search2"
244
+ }
245
+ }
246
+ if fields.all? { |f| f.start_with?("*.") }
247
+ raise ArgumentError, "Must specify fields to search"
248
+ end
249
+ if fields != ["_all"]
250
+ query[:more_like_this][:fields] = fields
251
+ end
252
+ elsif all
253
+ query = {
254
+ match_all: {}
255
+ }
256
+ else
257
+ queries = []
258
+
259
+ misspellings =
260
+ if options.key?(:misspellings)
261
+ options[:misspellings]
262
+ else
263
+ true
264
+ end
265
+
266
+ if misspellings.is_a?(Hash) && misspellings[:below] && !@misspellings_below
267
+ @misspellings_below = misspellings[:below].to_i
268
+ misspellings = false
269
+ end
270
+
271
+ if misspellings != false
272
+ edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
273
+ transpositions =
274
+ if misspellings.is_a?(Hash) && misspellings.key?(:transpositions)
275
+ {fuzzy_transpositions: misspellings[:transpositions]}
276
+ else
277
+ {fuzzy_transpositions: true}
278
+ end
279
+ prefix_length = (misspellings.is_a?(Hash) && misspellings[:prefix_length]) || 0
280
+ default_max_expansions = @misspellings_below ? 20 : 3
281
+ max_expansions = (misspellings.is_a?(Hash) && misspellings[:max_expansions]) || default_max_expansions
282
+ @misspellings = true
283
+ else
284
+ @misspellings = false
285
+ end
286
+
287
+ fields.each do |field|
288
+ queries_to_add = []
289
+ qs = []
290
+
291
+ factor = boost_fields[field] || 1
292
+ shared_options = {
293
+ query: term,
294
+ boost: 10 * factor
295
+ }
296
+
297
+ match_type =
298
+ if field.end_with?(".phrase")
299
+ field =
300
+ if field == "_all.phrase"
301
+ "_all"
302
+ else
303
+ field.sub(/\.phrase\z/, ".analyzed")
304
+ end
305
+
306
+ :match_phrase
307
+ else
308
+ :match
309
+ end
310
+
311
+ shared_options[:operator] = operator if match_type == :match
312
+
313
+ exclude_analyzer = nil
314
+ exclude_field = field
315
+
316
+ if field == "_all" || field.end_with?(".analyzed")
317
+ shared_options[:cutoff_frequency] = 0.001 unless operator.to_s == "and" || misspellings == false
318
+ qs << shared_options.merge(analyzer: "searchkick_search")
319
+
320
+ # searchkick_search and searchkick_search2 are the same for ukrainian
321
+ unless %w(japanese korean polish ukrainian vietnamese).include?(searchkick_options[:language])
322
+ qs << shared_options.merge(analyzer: "searchkick_search2")
323
+ end
324
+ exclude_analyzer = "searchkick_search2"
325
+ elsif field.end_with?(".exact")
326
+ f = field.split(".")[0..-2].join(".")
327
+ queries_to_add << {match: {f => shared_options.merge(analyzer: "keyword")}}
328
+ exclude_field = f
329
+ exclude_analyzer = "keyword"
330
+ else
331
+ analyzer = field =~ /\.word_(start|middle|end)\z/ ? "searchkick_word_search" : "searchkick_autocomplete_search"
332
+ qs << shared_options.merge(analyzer: analyzer)
333
+ exclude_analyzer = analyzer
334
+ end
335
+
336
+ if misspellings != false && match_type == :match
337
+ 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) })
338
+ end
339
+
340
+ if field.start_with?("*.")
341
+ q2 = qs.map { |q| {multi_match: q.merge(fields: [field], type: match_type == :match_phrase ? "phrase" : "best_fields")} }
342
+ if below61?
343
+ q2.each do |q|
344
+ q[:multi_match].delete(:fuzzy_transpositions)
345
+ end
346
+ end
347
+ else
348
+ q2 = qs.map { |q| {match_type => {field => q}} }
349
+ end
350
+
351
+ # boost exact matches more
352
+ if field =~ /\.word_(start|middle|end)\z/ && searchkick_options[:word] != false
353
+ queries_to_add << {
354
+ bool: {
355
+ must: {
356
+ bool: {
357
+ should: q2
358
+ }
359
+ },
360
+ should: {match_type => {field.sub(/\.word_(start|middle|end)\z/, ".analyzed") => qs.first}}
361
+ }
362
+ }
363
+ else
364
+ queries_to_add.concat(q2)
365
+ end
366
+
367
+ queries.concat(queries_to_add)
368
+
369
+ if options[:exclude]
370
+ must_not.concat(set_exclude(exclude_field, exclude_analyzer))
371
+ end
372
+ end
373
+
374
+ payload = {
375
+ dis_max: {
376
+ queries: queries
377
+ }
378
+ }
379
+
380
+ should.concat(set_conversions)
381
+
382
+ query = payload
383
+ end
384
+
385
+ payload = {}
386
+
387
+ # type when inheritance
388
+ where = (options[:where] || {}).dup
389
+ if searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
390
+ where[:type] = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v, true) }
391
+ end
392
+
393
+ # start everything as efficient filters
394
+ # move to post_filters as aggs demand
395
+ filters = where_filters(where)
396
+ post_filters = []
397
+
398
+ # aggregations
399
+ set_aggregations(payload, filters, post_filters) if options[:aggs]
400
+
401
+ # post filters
402
+ set_post_filters(payload, post_filters) if post_filters.any?
403
+
404
+ custom_filters = []
405
+ multiply_filters = []
406
+
407
+ set_boost_by(multiply_filters, custom_filters)
408
+ set_boost_where(custom_filters)
409
+ set_boost_by_distance(custom_filters) if options[:boost_by_distance]
410
+
411
+ payload[:query] = build_query(query, filters, should, must_not, custom_filters, multiply_filters)
412
+
413
+ payload[:explain] = options[:explain] if options[:explain]
414
+ payload[:profile] = options[:profile] if options[:profile]
415
+
416
+ # order
417
+ set_order(payload) if options[:order]
418
+
419
+ # indices_boost
420
+ set_boost_by_indices(payload)
421
+
422
+ # suggestions
423
+ set_suggestions(payload, options[:suggest]) if options[:suggest]
424
+
425
+ # highlight
426
+ set_highlights(payload, fields) if options[:highlight]
427
+
428
+ # timeout shortly after client times out
429
+ payload[:timeout] ||= "#{Searchkick.search_timeout + 1}s"
430
+
431
+ # An empty array will cause only the _id and _type for each hit to be returned
432
+ # https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-source-filtering.html
433
+ if options[:select]
434
+ if options[:select] == []
435
+ # intuitively [] makes sense to return no fields, but ES by default returns all fields
436
+ payload[:_source] = false
437
+ else
438
+ payload[:_source] = options[:select]
439
+ end
440
+ elsif load
441
+ payload[:_source] = false
442
+ end
443
+ end
444
+
445
+ # pagination
446
+ pagination_options = options[:page] || options[:limit] || options[:per_page] || options[:offset] || options[:padding]
447
+ if !options[:body] || pagination_options
448
+ payload[:size] = per_page
449
+ payload[:from] = offset
450
+ end
451
+
452
+ # type
453
+ if !searchkick_options[:inheritance] && (options[:type] || (klass != searchkick_klass && searchkick_index))
454
+ @type = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v) }
455
+ end
456
+
457
+ # routing
458
+ @routing = options[:routing] if options[:routing]
459
+
460
+ # merge more body options
461
+ payload = payload.deep_merge(options[:body_options]) if options[:body_options]
462
+
463
+ @body = payload
464
+ @page = page
465
+ @per_page = per_page
466
+ @padding = padding
467
+ @load = load
468
+ end
469
+
470
+ def set_fields
471
+ boost_fields = {}
472
+ fields = options[:fields] || searchkick_options[:default_fields] || searchkick_options[:searchable]
473
+ all = searchkick_options.key?(:_all) ? searchkick_options[:_all] : false
474
+ default_match = options[:match] || searchkick_options[:match] || :word
475
+ fields =
476
+ if fields
477
+ fields.map do |value|
478
+ k, v = value.is_a?(Hash) ? value.to_a.first : [value, default_match]
479
+ k2, boost = k.to_s.split("^", 2)
480
+ field = "#{k2}.#{v == :word ? 'analyzed' : v}"
481
+ boost_fields[field] = boost.to_f if boost
482
+ field
483
+ end
484
+ elsif all && default_match == :word
485
+ ["_all"]
486
+ elsif all && default_match == :phrase
487
+ ["_all.phrase"]
488
+ elsif term == "*"
489
+ []
490
+ elsif default_match == :exact
491
+ raise ArgumentError, "Must specify fields to search"
492
+ else
493
+ [default_match == :word ? "*.analyzed" : "*.#{default_match}"]
494
+ end
495
+ [boost_fields, fields]
496
+ end
497
+
498
+ def build_query(query, filters, should, must_not, custom_filters, multiply_filters)
499
+ if filters.any? || must_not.any? || should.any?
500
+ bool = {must: query}
501
+ bool[:filter] = filters if filters.any? # where
502
+ bool[:must_not] = must_not if must_not.any? # exclude
503
+ bool[:should] = should if should.any? # conversions
504
+ query = {bool: bool}
505
+ end
506
+
507
+ if custom_filters.any?
508
+ query = {
509
+ function_score: {
510
+ functions: custom_filters,
511
+ query: query,
512
+ score_mode: "sum"
513
+ }
514
+ }
515
+ end
516
+
517
+ if multiply_filters.any?
518
+ query = {
519
+ function_score: {
520
+ functions: multiply_filters,
521
+ query: query,
522
+ score_mode: "multiply"
523
+ }
524
+ }
525
+ end
526
+
527
+ query
528
+ end
529
+
530
+ def set_conversions
531
+ conversions_fields = Array(options[:conversions] || searchkick_options[:conversions]).map(&:to_s)
532
+ if conversions_fields.present? && options[:conversions] != false
533
+ conversions_fields.map do |conversions_field|
534
+ {
535
+ nested: {
536
+ path: conversions_field,
537
+ score_mode: "sum",
538
+ query: {
539
+ function_score: {
540
+ boost_mode: "replace",
541
+ query: {
542
+ match: {
543
+ "#{conversions_field}.query" => options[:conversions_term] || term
544
+ }
545
+ },
546
+ field_value_factor: {
547
+ field: "#{conversions_field}.count"
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ end
554
+ else
555
+ []
556
+ end
557
+ end
558
+
559
+ def set_exclude(field, analyzer)
560
+ Array(options[:exclude]).map do |phrase|
561
+ {
562
+ multi_match: {
563
+ fields: [field],
564
+ query: phrase,
565
+ analyzer: analyzer,
566
+ type: "phrase"
567
+ }
568
+ }
569
+ end
570
+ end
571
+
572
+ def set_boost_by_distance(custom_filters)
573
+ boost_by_distance = options[:boost_by_distance] || {}
574
+
575
+ # legacy format
576
+ if boost_by_distance[:field]
577
+ boost_by_distance = {boost_by_distance[:field] => boost_by_distance.except(:field)}
578
+ end
579
+
580
+ boost_by_distance.each do |field, attributes|
581
+ attributes = {function: :gauss, scale: "5mi"}.merge(attributes)
582
+ unless attributes[:origin]
583
+ raise ArgumentError, "boost_by_distance requires :origin"
584
+ end
585
+ function_params = attributes.select { |k, _| [:origin, :scale, :offset, :decay].include?(k) }
586
+ function_params[:origin] = location_value(function_params[:origin])
587
+ custom_filters << {
588
+ weight: attributes[:factor] || 1,
589
+ attributes[:function] => {
590
+ field => function_params
591
+ }
592
+ }
593
+ end
594
+ end
595
+
596
+ def set_boost_by(multiply_filters, custom_filters)
597
+ boost_by = options[:boost_by] || {}
598
+ if boost_by.is_a?(Array)
599
+ boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
600
+ elsif boost_by.is_a?(Hash)
601
+ multiply_by, boost_by = boost_by.partition { |_, v| v.delete(:boost_mode) == "multiply" }.map { |i| Hash[i] }
602
+ end
603
+ boost_by[options[:boost]] = {factor: 1} if options[:boost]
604
+
605
+ custom_filters.concat boost_filters(boost_by, modifier: "ln2p")
606
+ multiply_filters.concat boost_filters(multiply_by || {})
607
+ end
608
+
609
+ def set_boost_where(custom_filters)
610
+ boost_where = options[:boost_where] || {}
611
+ boost_where.each do |field, value|
612
+ if value.is_a?(Array) && value.first.is_a?(Hash)
613
+ value.each do |value_factor|
614
+ custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
615
+ end
616
+ elsif value.is_a?(Hash)
617
+ custom_filters << custom_filter(field, value[:value], value[:factor])
618
+ else
619
+ factor = 1000
620
+ custom_filters << custom_filter(field, value, factor)
621
+ end
622
+ end
623
+ end
624
+
625
+ def set_boost_by_indices(payload)
626
+ return unless options[:indices_boost]
627
+
628
+ indices_boost = options[:indices_boost].each_with_object({}) do |(key, boost), memo|
629
+ index = key.respond_to?(:searchkick_index) ? key.searchkick_index.name : key
630
+ # try to use index explicitly instead of alias: https://github.com/elasticsearch/elasticsearch/issues/4756
631
+ index_by_alias = Searchkick.client.indices.get_alias(index: index).keys.first
632
+ memo[index_by_alias || index] = boost
633
+ end
634
+
635
+ payload[:indices_boost] = indices_boost
636
+ end
637
+
638
+ def set_suggestions(payload, suggest)
639
+ suggest_fields = nil
640
+
641
+ if suggest.is_a?(Array)
642
+ suggest_fields = suggest
643
+ else
644
+ suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
645
+
646
+ # intersection
647
+ if options[:fields]
648
+ suggest_fields &= options[:fields].map { |v| (v.is_a?(Hash) ? v.keys.first : v).to_s.split("^", 2).first }
649
+ end
650
+ end
651
+
652
+ if suggest_fields.any?
653
+ payload[:suggest] = {text: term}
654
+ suggest_fields.each do |field|
655
+ payload[:suggest][field] = {
656
+ phrase: {
657
+ field: "#{field}.suggest"
658
+ }
659
+ }
660
+ end
661
+ else
662
+ raise ArgumentError, "Must pass fields to suggest option"
663
+ end
664
+ end
665
+
666
+ def set_highlights(payload, fields)
667
+ payload[:highlight] = {
668
+ fields: Hash[fields.map { |f| [f, {}] }],
669
+ fragment_size: below60? ? 30000 : 0
670
+ }
671
+
672
+ if options[:highlight].is_a?(Hash)
673
+ if (tag = options[:highlight][:tag])
674
+ payload[:highlight][:pre_tags] = [tag]
675
+ payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A<(\w+).+/, "</\\1>")]
676
+ end
677
+
678
+ if (fragment_size = options[:highlight][:fragment_size])
679
+ payload[:highlight][:fragment_size] = fragment_size
680
+ end
681
+ if (encoder = options[:highlight][:encoder])
682
+ payload[:highlight][:encoder] = encoder
683
+ end
684
+
685
+ highlight_fields = options[:highlight][:fields]
686
+ if highlight_fields
687
+ payload[:highlight][:fields] = {}
688
+
689
+ highlight_fields.each do |name, opts|
690
+ payload[:highlight][:fields]["#{name}.#{@match_suffix}"] = opts || {}
691
+ end
692
+ end
693
+ end
694
+
695
+ @highlighted_fields = payload[:highlight][:fields].keys
696
+ end
697
+
698
+ def set_aggregations(payload, filters, post_filters)
699
+ aggs = options[:aggs]
700
+ payload[:aggs] = {}
701
+
702
+ aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
703
+ aggs.each do |field, agg_options|
704
+ size = agg_options[:limit] ? agg_options[:limit] : 1_000
705
+ shared_agg_options = agg_options.slice(:order, :min_doc_count)
706
+
707
+ if agg_options[:ranges]
708
+ payload[:aggs][field] = {
709
+ range: {
710
+ field: agg_options[:field] || field,
711
+ ranges: agg_options[:ranges]
712
+ }.merge(shared_agg_options)
713
+ }
714
+ elsif agg_options[:date_ranges]
715
+ payload[:aggs][field] = {
716
+ date_range: {
717
+ field: agg_options[:field] || field,
718
+ ranges: agg_options[:date_ranges]
719
+ }.merge(shared_agg_options)
720
+ }
721
+ elsif (histogram = agg_options[:date_histogram])
722
+ interval = histogram[:interval]
723
+ payload[:aggs][field] = {
724
+ date_histogram: {
725
+ field: histogram[:field],
726
+ interval: interval
727
+ }
728
+ }
729
+ elsif (metric = @@metric_aggs.find { |k| agg_options.has_key?(k) })
730
+ payload[:aggs][field] = {
731
+ metric => {
732
+ field: agg_options[metric][:field] || field
733
+ }
734
+ }
735
+ else
736
+ payload[:aggs][field] = {
737
+ terms: {
738
+ field: agg_options[:field] || field,
739
+ size: size
740
+ }.merge(shared_agg_options)
741
+ }
742
+ end
743
+
744
+ where = {}
745
+ where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
746
+ agg_filters = where_filters(where.merge(agg_options[:where] || {}))
747
+
748
+ # only do one level comparison for simplicity
749
+ filters.select! do |filter|
750
+ if agg_filters.include?(filter)
751
+ true
752
+ else
753
+ post_filters << filter
754
+ false
755
+ end
756
+ end
757
+
758
+ if agg_filters.any?
759
+ payload[:aggs][field] = {
760
+ filter: {
761
+ bool: {
762
+ must: agg_filters
763
+ }
764
+ },
765
+ aggs: {
766
+ field => payload[:aggs][field]
767
+ }
768
+ }
769
+ end
770
+ end
771
+ end
772
+
773
+ def set_post_filters(payload, post_filters)
774
+ payload[:post_filter] = {
775
+ bool: {
776
+ filter: post_filters
777
+ }
778
+ }
779
+ end
780
+
781
+ # TODO id transformation for arrays
782
+ def set_order(payload)
783
+ order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
784
+ id_field = :_uid
785
+ payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? id_field : k, v] }]
786
+ end
787
+
788
+ def where_filters(where)
789
+ filters = []
790
+ (where || {}).each do |field, value|
791
+ field = :_id if field.to_s == "id"
792
+
793
+ if field == :or
794
+ value.each do |or_clause|
795
+ filters << {bool: {should: or_clause.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
796
+ end
797
+ elsif field == :_or
798
+ filters << {bool: {should: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
799
+ elsif field == :_not
800
+ filters << {bool: {must_not: where_filters(value)}}
801
+ elsif field == :_and
802
+ filters << {bool: {must: value.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
803
+ else
804
+ # expand ranges
805
+ if value.is_a?(Range)
806
+ value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
807
+ end
808
+
809
+ value = {in: value} if value.is_a?(Array)
810
+
811
+ if value.is_a?(Hash)
812
+ value.each do |op, op_value|
813
+ case op
814
+ when :within, :bottom_right, :bottom_left
815
+ # do nothing
816
+ when :near
817
+ filters << {
818
+ geo_distance: {
819
+ field => location_value(op_value),
820
+ distance: value[:within] || "50mi"
821
+ }
822
+ }
823
+ when :geo_polygon
824
+ filters << {
825
+ geo_polygon: {
826
+ field => op_value
827
+ }
828
+ }
829
+ when :geo_shape
830
+ shape = op_value.except(:relation)
831
+ shape[:coordinates] = coordinate_array(shape[:coordinates]) if shape[:coordinates]
832
+ filters << {
833
+ geo_shape: {
834
+ field => {
835
+ relation: op_value[:relation] || "intersects",
836
+ shape: shape
837
+ }
838
+ }
839
+ }
840
+ when :top_left
841
+ filters << {
842
+ geo_bounding_box: {
843
+ field => {
844
+ top_left: location_value(op_value),
845
+ bottom_right: location_value(value[:bottom_right])
846
+ }
847
+ }
848
+ }
849
+ when :top_right
850
+ filters << {
851
+ geo_bounding_box: {
852
+ field => {
853
+ top_right: location_value(op_value),
854
+ bottom_left: location_value(value[:bottom_left])
855
+ }
856
+ }
857
+ }
858
+ when :regexp # support for regexp queries without using a regexp ruby object
859
+ filters << {regexp: {field => {value: op_value}}}
860
+ when :not, :_not # not equal
861
+ filters << {bool: {must_not: term_filters(field, op_value)}}
862
+ when :all
863
+ op_value.each do |val|
864
+ filters << term_filters(field, val)
865
+ end
866
+ when :in
867
+ filters << term_filters(field, op_value)
868
+ else
869
+ range_query =
870
+ case op
871
+ when :gt
872
+ {from: op_value, include_lower: false}
873
+ when :gte
874
+ {from: op_value, include_lower: true}
875
+ when :lt
876
+ {to: op_value, include_upper: false}
877
+ when :lte
878
+ {to: op_value, include_upper: true}
879
+ else
880
+ raise "Unknown where operator: #{op.inspect}"
881
+ end
882
+ # issue 132
883
+ if (existing = filters.find { |f| f[:range] && f[:range][field] })
884
+ existing[:range][field].merge!(range_query)
885
+ else
886
+ filters << {range: {field => range_query}}
887
+ end
888
+ end
889
+ end
890
+ else
891
+ filters << term_filters(field, value)
892
+ end
893
+ end
894
+ end
895
+ filters
896
+ end
897
+
898
+ def term_filters(field, value)
899
+ if value.is_a?(Array) # in query
900
+ if value.any?(&:nil?)
901
+ {bool: {should: [term_filters(field, nil), term_filters(field, value.compact)]}}
902
+ else
903
+ {terms: {field => value}}
904
+ end
905
+ elsif value.nil?
906
+ {bool: {must_not: {exists: {field: field}}}}
907
+ elsif value.is_a?(Regexp)
908
+ {regexp: {field => {value: value.source, flags: "NONE"}}}
909
+ else
910
+ {term: {field => value}}
911
+ end
912
+ end
913
+
914
+ def custom_filter(field, value, factor)
915
+ {
916
+ filter: where_filters(field => value),
917
+ weight: factor
918
+ }
919
+ end
920
+
921
+ def boost_filter(field, factor: 1, modifier: nil, missing: nil)
922
+ script_score = {
923
+ field_value_factor: {
924
+ field: field,
925
+ factor: factor.to_f,
926
+ modifier: modifier
927
+ }
928
+ }
929
+
930
+ if missing
931
+ script_score[:field_value_factor][:missing] = missing.to_f
932
+ else
933
+ script_score[:filter] = {
934
+ exists: {
935
+ field: field
936
+ }
937
+ }
938
+ end
939
+
940
+ script_score
941
+ end
942
+
943
+ def boost_filters(boost_by, modifier: nil)
944
+ boost_by.map do |field, value|
945
+ boost_filter(field, modifier: modifier, **value)
946
+ end
947
+ end
948
+
949
+ # Recursively descend through nesting of arrays until we reach either a lat/lon object or an array of numbers,
950
+ # eventually returning the same structure with all values transformed to [lon, lat].
951
+ #
952
+ def coordinate_array(value)
953
+ if value.is_a?(Hash)
954
+ [value[:lon], value[:lat]]
955
+ elsif value.is_a?(Array) and !value[0].is_a?(Numeric)
956
+ value.map { |a| coordinate_array(a) }
957
+ else
958
+ value
959
+ end
960
+ end
961
+
962
+ def location_value(value)
963
+ if value.is_a?(Array)
964
+ value.map(&:to_f).reverse
965
+ else
966
+ value
967
+ end
968
+ end
969
+
970
+ def below60?
971
+ Searchkick.server_below?("6.0.0")
972
+ end
973
+
974
+ def below61?
975
+ Searchkick.server_below?("6.1.0")
976
+ end
977
+ end
978
+ end