searchkick-sinneduy 0.9.0

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 (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +28 -0
  4. data/CHANGELOG.md +272 -0
  5. data/Gemfile +7 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +1109 -0
  8. data/Rakefile +8 -0
  9. data/ci/before_install.sh +14 -0
  10. data/gemfiles/activerecord31.gemfile +7 -0
  11. data/gemfiles/activerecord32.gemfile +7 -0
  12. data/gemfiles/activerecord40.gemfile +8 -0
  13. data/gemfiles/activerecord41.gemfile +8 -0
  14. data/gemfiles/mongoid2.gemfile +7 -0
  15. data/gemfiles/mongoid3.gemfile +6 -0
  16. data/gemfiles/mongoid4.gemfile +7 -0
  17. data/gemfiles/nobrainer.gemfile +6 -0
  18. data/lib/searchkick.rb +72 -0
  19. data/lib/searchkick/index.rb +550 -0
  20. data/lib/searchkick/logging.rb +136 -0
  21. data/lib/searchkick/model.rb +102 -0
  22. data/lib/searchkick/query.rb +567 -0
  23. data/lib/searchkick/reindex_job.rb +28 -0
  24. data/lib/searchkick/reindex_v2_job.rb +24 -0
  25. data/lib/searchkick/results.rb +158 -0
  26. data/lib/searchkick/tasks.rb +35 -0
  27. data/lib/searchkick/version.rb +3 -0
  28. data/searchkick.gemspec +28 -0
  29. data/test/autocomplete_test.rb +67 -0
  30. data/test/boost_test.rb +126 -0
  31. data/test/facets_test.rb +91 -0
  32. data/test/highlight_test.rb +58 -0
  33. data/test/index_test.rb +119 -0
  34. data/test/inheritance_test.rb +80 -0
  35. data/test/match_test.rb +163 -0
  36. data/test/model_test.rb +38 -0
  37. data/test/query_test.rb +14 -0
  38. data/test/reindex_job_test.rb +33 -0
  39. data/test/reindex_v2_job_test.rb +34 -0
  40. data/test/routing_test.rb +14 -0
  41. data/test/should_index_test.rb +34 -0
  42. data/test/similar_test.rb +20 -0
  43. data/test/sql_test.rb +327 -0
  44. data/test/suggest_test.rb +82 -0
  45. data/test/synonyms_test.rb +50 -0
  46. data/test/test_helper.rb +276 -0
  47. metadata +194 -0
@@ -0,0 +1,136 @@
1
+ # based on https://gist.github.com/mnutt/566725
2
+
3
+ module Searchkick
4
+ class Query
5
+ def execute_with_instrumentation
6
+ event = {
7
+ name: "#{searchkick_klass.name} Search",
8
+ query: params
9
+ }
10
+ ActiveSupport::Notifications.instrument("search.searchkick", event) do
11
+ execute_without_instrumentation
12
+ end
13
+ end
14
+ alias_method_chain :execute, :instrumentation
15
+ end
16
+
17
+ class Index
18
+ def store_with_instrumentation(record)
19
+ event = {
20
+ name: "#{record.searchkick_klass.name} Store",
21
+ id: search_id(record)
22
+ }
23
+ ActiveSupport::Notifications.instrument("request.searchkick", event) do
24
+ store_without_instrumentation(record)
25
+ end
26
+ end
27
+ alias_method_chain :store, :instrumentation
28
+
29
+ def remove_with_instrumentation(record)
30
+ event = {
31
+ name: "#{record.searchkick_klass.name} Remove",
32
+ id: search_id(record)
33
+ }
34
+ ActiveSupport::Notifications.instrument("request.searchkick", event) do
35
+ remove_without_instrumentation(record)
36
+ end
37
+ end
38
+ alias_method_chain :remove, :instrumentation
39
+
40
+ def import_with_instrumentation(records)
41
+ if records.any?
42
+ event = {
43
+ name: "#{records.first.searchkick_klass.name} Import",
44
+ count: records.size
45
+ }
46
+ ActiveSupport::Notifications.instrument("request.searchkick", event) do
47
+ import_without_instrumentation(records)
48
+ end
49
+ end
50
+ end
51
+ alias_method_chain :import, :instrumentation
52
+ end
53
+
54
+ # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/log_subscriber.rb
55
+ class LogSubscriber < ActiveSupport::LogSubscriber
56
+ def self.runtime=(value)
57
+ Thread.current[:searchkick_runtime] = value
58
+ end
59
+
60
+ def self.runtime
61
+ Thread.current[:searchkick_runtime] ||= 0
62
+ end
63
+
64
+ def self.reset_runtime
65
+ rt, self.runtime = runtime, 0
66
+ rt
67
+ end
68
+
69
+ def search(event)
70
+ self.class.runtime += event.duration
71
+ return unless logger.debug?
72
+
73
+ payload = event.payload
74
+ name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
75
+ type = payload[:query][:type]
76
+ index = payload[:query][:index].is_a?(Array) ? payload[:query][:index].join(",") : payload[:query][:index]
77
+
78
+ # no easy way to tell which host the client will use
79
+ host = Searchkick.client.transport.hosts.first
80
+ debug " #{color(name, YELLOW, true)} curl #{host[:protocol]}://#{host[:host]}:#{host[:port]}/#{CGI.escape(index)}#{type ? "/#{type.map { |t| CGI.escape(t) }.join(',')}" : ''}/_search?pretty -d '#{payload[:query][:body].to_json}'"
81
+ end
82
+
83
+ def request(event)
84
+ self.class.runtime += event.duration
85
+ return unless logger.debug?
86
+
87
+ payload = event.payload
88
+ name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
89
+
90
+ debug " #{color(name, YELLOW, true)} #{payload.except(:name).to_json}"
91
+ end
92
+ end
93
+
94
+ # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/railties/controller_runtime.rb
95
+ module ControllerRuntime
96
+ extend ActiveSupport::Concern
97
+
98
+ protected
99
+
100
+ attr_internal :searchkick_runtime
101
+
102
+ def process_action(action, *args)
103
+ # We also need to reset the runtime before each action
104
+ # because of queries in middleware or in cases we are streaming
105
+ # and it won't be cleaned up by the method below.
106
+ Searchkick::LogSubscriber.reset_runtime
107
+ super
108
+ end
109
+
110
+ def cleanup_view_runtime
111
+ searchkick_rt_before_render = Searchkick::LogSubscriber.reset_runtime
112
+ runtime = super
113
+ searchkick_rt_after_render = Searchkick::LogSubscriber.reset_runtime
114
+ self.searchkick_runtime = searchkick_rt_before_render + searchkick_rt_after_render
115
+ runtime - searchkick_rt_after_render
116
+ end
117
+
118
+ def append_info_to_payload(payload)
119
+ super
120
+ payload[:searchkick_runtime] = (searchkick_runtime || 0) + Searchkick::LogSubscriber.reset_runtime
121
+ end
122
+
123
+ module ClassMethods
124
+ def log_process_action(payload)
125
+ messages, runtime = super, payload[:searchkick_runtime]
126
+ messages << ("Searchkick: %.1fms" % runtime.to_f) if runtime.to_f > 0
127
+ messages
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ Searchkick::LogSubscriber.attach_to :searchkick
134
+ ActiveSupport.on_load(:action_controller) do
135
+ include Searchkick::ControllerRuntime
136
+ end
@@ -0,0 +1,102 @@
1
+ module Searchkick
2
+ module Reindex; end # legacy for Searchjoy
3
+
4
+ module Model
5
+
6
+ def searchkick(options = {})
7
+ raise "Only call searchkick once per model" if respond_to?(:searchkick_index)
8
+
9
+ Searchkick.models << self
10
+
11
+ class_eval do
12
+ cattr_reader :searchkick_options, :searchkick_klass
13
+
14
+ callbacks = options.key?(:callbacks) ? options[:callbacks] : true
15
+
16
+ class_variable_set :@@searchkick_options, options.dup
17
+ class_variable_set :@@searchkick_klass, self
18
+ class_variable_set :@@searchkick_callbacks, callbacks
19
+ class_variable_set :@@searchkick_index, options[:index_name] || [options[:index_prefix], model_name.plural, Searchkick.env].compact.join("_")
20
+
21
+ define_singleton_method(Searchkick.search_method_name) do |term = nil, options = {}, &block|
22
+ searchkick_index.search_model(self, term, options, &block)
23
+ end
24
+ extend Searchkick::Reindex # legacy for Searchjoy
25
+
26
+ class << self
27
+
28
+ def searchkick_index
29
+ index = class_variable_get :@@searchkick_index
30
+ index = index.call if index.respond_to? :call
31
+ Searchkick::Index.new(index, searchkick_options)
32
+ end
33
+
34
+ def enable_search_callbacks
35
+ class_variable_set :@@searchkick_callbacks, true
36
+ end
37
+
38
+ def disable_search_callbacks
39
+ class_variable_set :@@searchkick_callbacks, false
40
+ end
41
+
42
+ def search_callbacks?
43
+ class_variable_get(:@@searchkick_callbacks) && Searchkick.callbacks?
44
+ end
45
+
46
+ def reindex(options = {})
47
+ searchkick_index.reindex_scope(searchkick_klass, options)
48
+ end
49
+
50
+ def clean_indices
51
+ searchkick_index.clean_indices
52
+ end
53
+
54
+ def searchkick_import(options = {})
55
+ (options[:index] || searchkick_index).import_scope(searchkick_klass)
56
+ end
57
+
58
+ def searchkick_create_index
59
+ searchkick_index.create_index
60
+ end
61
+
62
+ def searchkick_index_options
63
+ searchkick_index.index_options
64
+ end
65
+
66
+ end
67
+
68
+ if callbacks
69
+ callback_name = callbacks == :async ? :reindex_async : :reindex
70
+ if respond_to?(:after_commit)
71
+ after_commit callback_name, if: proc { self.class.search_callbacks? }
72
+ else
73
+ after_save callback_name, if: proc { self.class.search_callbacks? }
74
+ after_destroy callback_name, if: proc { self.class.search_callbacks? }
75
+ end
76
+ end
77
+
78
+ def reindex
79
+ self.class.searchkick_index.reindex_record(self)
80
+ end unless method_defined?(:reindex)
81
+
82
+ def reindex_async
83
+ self.class.searchkick_index.reindex_record_async(self)
84
+ end unless method_defined?(:reindex_async)
85
+
86
+ def similar(options = {})
87
+ self.class.searchkick_index.similar_record(self, options)
88
+ end unless method_defined?(:similar)
89
+
90
+ def search_data
91
+ respond_to?(:to_hash) ? to_hash : serializable_hash
92
+ end unless method_defined?(:search_data)
93
+
94
+ def should_index?
95
+ true
96
+ end unless method_defined?(:should_index?)
97
+
98
+ end
99
+ end
100
+
101
+ end
102
+ end
@@ -0,0 +1,567 @@
1
+ module Searchkick
2
+ class Query
3
+ attr_reader :klass, :term, :options
4
+ attr_accessor :body
5
+
6
+ def initialize(klass, term, options = {})
7
+ if term.is_a?(Hash)
8
+ options = term
9
+ term = "*"
10
+ else
11
+ term = term.to_s
12
+ end
13
+
14
+ @klass = klass
15
+ @term = term
16
+ @options = options
17
+
18
+ below12 = Gem::Version.new(Searchkick.server_version) < Gem::Version.new("1.2.0")
19
+ below14 = Gem::Version.new(Searchkick.server_version) < Gem::Version.new("1.4.0")
20
+
21
+ boost_fields = {}
22
+ fields =
23
+ if options[:fields]
24
+ if options[:autocomplete]
25
+ options[:fields].map { |f| "#{f}.autocomplete" }
26
+ else
27
+ options[:fields].map do |value|
28
+ k, v = value.is_a?(Hash) ? value.to_a.first : [value, :word]
29
+ k2, boost = k.to_s.split("^", 2)
30
+ field = "#{k2}.#{v == :word ? 'analyzed' : v}"
31
+ boost_fields[field] = boost.to_f if boost
32
+ field
33
+ end
34
+ end
35
+ else
36
+ if options[:autocomplete]
37
+ (searchkick_options[:autocomplete] || []).map { |f| "#{f}.autocomplete" }
38
+ else
39
+ ["_all"]
40
+ end
41
+ end
42
+
43
+ operator = options[:operator] || (options[:partial] ? "or" : "and")
44
+
45
+ # pagination
46
+ page = [options[:page].to_i, 1].max
47
+ per_page = (options[:limit] || options[:per_page] || 100_000).to_i
48
+ padding = [options[:padding].to_i, 0].max
49
+ offset = options[:offset] || (page - 1) * per_page + padding
50
+
51
+ # model and eagar loading
52
+ load = options[:load].nil? ? true : options[:load]
53
+
54
+ conversions_field = searchkick_options[:conversions]
55
+ personalize_field = searchkick_options[:personalize]
56
+
57
+ all = term == "*"
58
+ facet_limits = {}
59
+
60
+ options[:json] ||= options[:body]
61
+ if options[:json]
62
+ payload = options[:json]
63
+ else
64
+ if options[:query]
65
+ payload = options[:query]
66
+ elsif options[:similar]
67
+ payload = {
68
+ more_like_this: {
69
+ fields: fields,
70
+ like_text: term,
71
+ min_doc_freq: 1,
72
+ min_term_freq: 1,
73
+ analyzer: "searchkick_search2"
74
+ }
75
+ }
76
+ elsif all
77
+ payload = {
78
+ match_all: {}
79
+ }
80
+ else
81
+ if options[:autocomplete]
82
+ payload = {
83
+ multi_match: {
84
+ fields: fields,
85
+ query: term,
86
+ analyzer: "searchkick_autocomplete_search"
87
+ }
88
+ }
89
+ else
90
+ queries = []
91
+ fields.each do |field|
92
+ qs = []
93
+
94
+ factor = boost_fields[field] || 1
95
+ shared_options = {
96
+ query: term,
97
+ operator: operator,
98
+ boost: factor
99
+ }
100
+
101
+ if field == "_all" || field.end_with?(".analyzed")
102
+ shared_options[:cutoff_frequency] = 0.001 unless operator == "and"
103
+ qs.concat [
104
+ shared_options.merge(boost: 10 * factor, analyzer: "searchkick_search"),
105
+ shared_options.merge(boost: 10 * factor, analyzer: "searchkick_search2")
106
+ ]
107
+ misspellings = options.key?(:misspellings) ? options[:misspellings] : options[:mispellings] # why not?
108
+ if misspellings != false
109
+ edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
110
+ qs.concat [
111
+ shared_options.merge(fuzziness: edit_distance, max_expansions: 3, analyzer: "searchkick_search"),
112
+ shared_options.merge(fuzziness: edit_distance, max_expansions: 3, analyzer: "searchkick_search2")
113
+ ]
114
+ end
115
+ elsif field.end_with?(".exact")
116
+ f = field.split(".")[0..-2].join(".")
117
+ queries << {match: {f => shared_options.merge(analyzer: "keyword")}}
118
+ else
119
+ analyzer = field.match(/\.word_(start|middle|end)\z/) ? "searchkick_word_search" : "searchkick_autocomplete_search"
120
+ qs << shared_options.merge(analyzer: analyzer)
121
+ end
122
+
123
+ queries.concat(qs.map { |q| {match: {field => q}} })
124
+ end
125
+
126
+ payload = {
127
+ dis_max: {
128
+ queries: queries
129
+ }
130
+ }
131
+ end
132
+
133
+ if conversions_field && options[:conversions] != false
134
+ # wrap payload in a bool query
135
+ script_score =
136
+ if below12
137
+ {script_score: {script: "doc['count'].value"}}
138
+ else
139
+ {field_value_factor: {field: "count"}}
140
+ end
141
+
142
+ payload = {
143
+ bool: {
144
+ must: payload,
145
+ should: {
146
+ nested: {
147
+ path: conversions_field,
148
+ score_mode: "total",
149
+ query: {
150
+ function_score: {
151
+ boost_mode: "replace",
152
+ query: {
153
+ match: {
154
+ query: term
155
+ }
156
+ }
157
+ }.merge(script_score)
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ end
164
+ end
165
+
166
+ custom_filters = []
167
+
168
+ boost_by = options[:boost_by] || {}
169
+ if boost_by.is_a?(Array)
170
+ boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
171
+ end
172
+ if options[:boost]
173
+ boost_by[options[:boost]] = {factor: 1}
174
+ end
175
+
176
+ boost_by.each do |field, value|
177
+ script_score =
178
+ if below12
179
+ {script_score: {script: "#{value[:factor].to_f} * log(doc['#{field}'].value + 2.718281828)"}}
180
+ else
181
+ {field_value_factor: {field: field, factor: value[:factor].to_f, modifier: "ln2p"}}
182
+ end
183
+
184
+ custom_filters << {
185
+ filter: {
186
+ exists: {
187
+ field: field
188
+ }
189
+ }
190
+ }.merge(script_score)
191
+ end
192
+
193
+ boost_where = options[:boost_where] || {}
194
+ if options[:user_id] && personalize_field
195
+ boost_where[personalize_field] = options[:user_id]
196
+ end
197
+ if options[:personalize]
198
+ boost_where = boost_where.merge(options[:personalize])
199
+ end
200
+ boost_where.each do |field, value|
201
+ if value.is_a?(Array) && value.first.is_a?(Hash)
202
+ value.each do |value_factor|
203
+ value, factor = value_factor[:value], value_factor[:factor]
204
+ custom_filters << custom_filter(field, value, factor)
205
+ end
206
+ elsif value.is_a?(Hash)
207
+ value, factor = value[:value], value[:factor]
208
+ custom_filters << custom_filter(field, value, factor)
209
+ else
210
+ factor = 1000
211
+ custom_filters << custom_filter(field, value, factor)
212
+ end
213
+ end
214
+
215
+ boost_by_distance = options[:boost_by_distance]
216
+ if boost_by_distance
217
+ boost_by_distance = {function: :gauss, scale: "5mi"}.merge(boost_by_distance)
218
+ if !boost_by_distance[:field] || !boost_by_distance[:origin]
219
+ raise ArgumentError, "boost_by_distance requires :field and :origin"
220
+ end
221
+ function_params = boost_by_distance.select { |k, v| [:origin, :scale, :offset, :decay].include?(k) }
222
+ function_params[:origin] = function_params[:origin].reverse
223
+ custom_filters << {
224
+ boost_by_distance[:function] => {
225
+ boost_by_distance[:field] => function_params
226
+ }
227
+ }
228
+ end
229
+
230
+ if custom_filters.any?
231
+ payload = {
232
+ function_score: {
233
+ functions: custom_filters,
234
+ query: payload,
235
+ score_mode: "sum"
236
+ }
237
+ }
238
+ end
239
+
240
+ payload = {
241
+ query: payload,
242
+ size: per_page,
243
+ from: offset
244
+ }
245
+ payload[:explain] = options[:explain] if options[:explain]
246
+
247
+ # order
248
+ if options[:order]
249
+ order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
250
+ # TODO id transformation for arrays
251
+ payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? :_id : k, v] }]
252
+ end
253
+
254
+ # filters
255
+ filters = where_filters(options[:where])
256
+ if filters.any?
257
+ if options[:facets]
258
+ payload[:filter] = {
259
+ and: filters
260
+ }
261
+ else
262
+ # more efficient query if no facets
263
+ payload[:query] = {
264
+ filtered: {
265
+ query: payload[:query],
266
+ filter: {
267
+ and: filters
268
+ }
269
+ }
270
+ }
271
+ end
272
+ end
273
+
274
+ # facets
275
+ if options[:facets]
276
+ facets = options[:facets] || {}
277
+ if facets.is_a?(Array) # convert to more advanced syntax
278
+ facets = Hash[facets.map { |f| [f, {}] }]
279
+ end
280
+
281
+ payload[:facets] = {}
282
+ facets.each do |field, facet_options|
283
+ # ask for extra facets due to
284
+ # https://github.com/elasticsearch/elasticsearch/issues/1305
285
+ size = facet_options[:limit] ? facet_options[:limit] + 150 : 100_000
286
+
287
+ if facet_options[:ranges]
288
+ payload[:facets][field] = {
289
+ range: {
290
+ field.to_sym => facet_options[:ranges]
291
+ }
292
+ }
293
+ elsif facet_options[:stats]
294
+ payload[:facets][field] = {
295
+ terms_stats: {
296
+ key_field: field,
297
+ value_script: below14 ? "doc.score" : "_score",
298
+ size: size
299
+ }
300
+ }
301
+ else
302
+ payload[:facets][field] = {
303
+ terms: {
304
+ field: facet_options[:field] || field,
305
+ size: size
306
+ }
307
+ }
308
+ end
309
+
310
+ facet_limits[field] = facet_options[:limit] if facet_options[:limit]
311
+
312
+ # offset is not possible
313
+ # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
314
+
315
+ facet_options.deep_merge!(where: options[:where].reject { |k| k == field }) if options[:smart_facets] == true
316
+ facet_filters = where_filters(facet_options[:where])
317
+ if facet_filters.any?
318
+ payload[:facets][field][:facet_filter] = {
319
+ and: {
320
+ filters: facet_filters
321
+ }
322
+ }
323
+ end
324
+ end
325
+ end
326
+
327
+ # suggestions
328
+ if options[:suggest]
329
+ suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
330
+
331
+ # intersection
332
+ if options[:fields]
333
+ suggest_fields &= options[:fields].map { |v| (v.is_a?(Hash) ? v.keys.first : v).to_s.split("^", 2).first }
334
+ end
335
+
336
+ if suggest_fields.any?
337
+ payload[:suggest] = {text: term}
338
+ suggest_fields.each do |field|
339
+ payload[:suggest][field] = {
340
+ phrase: {
341
+ field: "#{field}.suggest"
342
+ }
343
+ }
344
+ end
345
+ end
346
+ end
347
+
348
+ # highlight
349
+ if options[:highlight]
350
+ payload[:highlight] = {
351
+ fields: Hash[fields.map { |f| [f, {}] }]
352
+ }
353
+
354
+ if options[:highlight].is_a?(Hash)
355
+ if (tag = options[:highlight][:tag])
356
+ payload[:highlight][:pre_tags] = [tag]
357
+ payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
358
+ end
359
+
360
+ highlight_fields = options[:highlight][:fields]
361
+ if highlight_fields
362
+ payload[:highlight][:fields] = {}
363
+
364
+ highlight_fields.each do |name, opts|
365
+ payload[:highlight][:fields]["#{name}.analyzed"] = opts || {}
366
+ end
367
+ end
368
+ end
369
+ end
370
+
371
+ # An empty array will cause only the _id and _type for each hit to be returned
372
+ # http://www.elasticsearch.org/guide/reference/api/search/fields/
373
+ if options[:select]
374
+ payload[:fields] = options[:select] if options[:select] != true
375
+ elsif load
376
+ payload[:fields] = []
377
+ end
378
+
379
+ if options[:type] || klass != searchkick_klass
380
+ @type = [options[:type] || klass].flatten.map { |v| searchkick_index.klass_document_type(v) }
381
+ end
382
+
383
+ # routing
384
+ if options[:routing]
385
+ @routing = options[:routing]
386
+ end
387
+ end
388
+
389
+ @body = payload
390
+ @facet_limits = facet_limits
391
+ @page = page
392
+ @per_page = per_page
393
+ @padding = padding
394
+ @load = load
395
+ end
396
+
397
+ def searchkick_index
398
+ klass.searchkick_index
399
+ end
400
+
401
+ def searchkick_options
402
+ klass.searchkick_options
403
+ end
404
+
405
+ def searchkick_klass
406
+ klass.searchkick_klass
407
+ end
408
+
409
+ def params
410
+ params = {
411
+ index: options[:index_name] || searchkick_index.name,
412
+ body: body
413
+ }
414
+ params.merge!(type: @type) if @type
415
+ params.merge!(routing: @routing) if @routing
416
+ params
417
+ end
418
+
419
+ def execute
420
+ begin
421
+ response = Searchkick.client.search(params)
422
+ rescue => e # TODO rescue type
423
+ status_code = e.message[1..3].to_i
424
+ if status_code == 404
425
+ raise MissingIndexError, "Index missing - run #{searchkick_klass.name}.reindex"
426
+ elsif status_code == 500 && (
427
+ e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") ||
428
+ e.message.include?("No query registered for [multi_match]") ||
429
+ e.message.include?("[match] query does not support [cutoff_frequency]]") ||
430
+ e.message.include?("No query registered for [function_score]]")
431
+ )
432
+
433
+ raise UnsupportedVersionError, "This version of Searchkick requires Elasticsearch 1.0 or greater"
434
+ elsif status_code == 400
435
+ if e.message.include?("[multi_match] analyzer [searchkick_search] not found")
436
+ raise InvalidQueryError, "Bad mapping - run #{searchkick_klass.name}.reindex"
437
+ else
438
+ raise InvalidQueryError, e.message
439
+ end
440
+ else
441
+ raise e
442
+ end
443
+ end
444
+
445
+ # apply facet limit in client due to
446
+ # https://github.com/elasticsearch/elasticsearch/issues/1305
447
+ @facet_limits.each do |field, limit|
448
+ field = field.to_s
449
+ facet = response["facets"][field]
450
+ response["facets"][field]["terms"] = facet["terms"].first(limit)
451
+ response["facets"][field]["other"] = facet["total"] - facet["terms"].sum { |term| term["count"] }
452
+ end
453
+
454
+ opts = {
455
+ page: @page,
456
+ per_page: @per_page,
457
+ padding: @padding,
458
+ load: @load,
459
+ includes: options[:include] || options[:includes],
460
+ json: !options[:json].nil?
461
+ }
462
+ Searchkick::Results.new(searchkick_klass, response, opts)
463
+ end
464
+
465
+ private
466
+
467
+ def where_filters(where)
468
+ filters = []
469
+ (where || {}).each do |field, value|
470
+ field = :_id if field.to_s == "id"
471
+
472
+ if field == :or
473
+ value.each do |or_clause|
474
+ filters << {or: or_clause.map { |or_statement| {and: where_filters(or_statement)} }}
475
+ end
476
+ else
477
+ # expand ranges
478
+ if value.is_a?(Range)
479
+ value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
480
+ end
481
+
482
+ if value.is_a?(Array)
483
+ value = {in: value}
484
+ end
485
+
486
+ if value.is_a?(Hash)
487
+ value.each do |op, op_value|
488
+ case op
489
+ when :within, :bottom_right
490
+ # do nothing
491
+ when :near
492
+ filters << {
493
+ geo_distance: {
494
+ field => op_value.map(&:to_f).reverse,
495
+ distance: value[:within] || "50mi"
496
+ }
497
+ }
498
+ when :top_left
499
+ filters << {
500
+ geo_bounding_box: {
501
+ field => {
502
+ top_left: op_value.map(&:to_f).reverse,
503
+ bottom_right: value[:bottom_right].map(&:to_f).reverse
504
+ }
505
+ }
506
+ }
507
+ when :not # not equal
508
+ filters << {not: term_filters(field, op_value)}
509
+ when :all
510
+ filters << {terms: {field => op_value, execution: "and"}}
511
+ when :in
512
+ filters << term_filters(field, op_value)
513
+ else
514
+ range_query =
515
+ case op
516
+ when :gt
517
+ {from: op_value, include_lower: false}
518
+ when :gte
519
+ {from: op_value, include_lower: true}
520
+ when :lt
521
+ {to: op_value, include_upper: false}
522
+ when :lte
523
+ {to: op_value, include_upper: true}
524
+ else
525
+ raise "Unknown where operator"
526
+ end
527
+ # issue 132
528
+ if (existing = filters.find { |f| f[:range] && f[:range][field] })
529
+ existing[:range][field].merge!(range_query)
530
+ else
531
+ filters << {range: {field => range_query}}
532
+ end
533
+ end
534
+ end
535
+ else
536
+ filters << term_filters(field, value)
537
+ end
538
+ end
539
+ end
540
+ filters
541
+ end
542
+
543
+ def term_filters(field, value)
544
+ if value.is_a?(Array) # in query
545
+ if value.any?(&:nil?)
546
+ {or: [term_filters(field, nil), term_filters(field, value.compact)]}
547
+ else
548
+ {in: {field => value}}
549
+ end
550
+ elsif value.nil?
551
+ {missing: {"field" => field, existence: true, null_value: true}}
552
+ elsif value.is_a?(Regexp)
553
+ {regexp: {field => {value: value.source}}}
554
+ else
555
+ {term: {field => value}}
556
+ end
557
+ end
558
+
559
+ def custom_filter(field, value, factor)
560
+ {
561
+ filter: term_filters(field, value),
562
+ boost_factor: factor
563
+ }
564
+ end
565
+
566
+ end
567
+ end