searchkick 1.2.1 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 750ced99cb1b0929710eba206bde31907f0d9544
4
- data.tar.gz: 454d078f8900948def90b1a88b40bde8aec561b5
3
+ metadata.gz: 9328dde4ac72c1fb914fdcbffe6a544e6f64b335
4
+ data.tar.gz: e59db37c34cc78f48139a8f6d604d2c4eb108d06
5
5
  SHA512:
6
- metadata.gz: 5139a9a1f326fa97f58f09fc52d5988ff14661e8e4ec9a3240c6e4c10160c577d5b6231caab980101951a37cbdbbf5631e4df70c5060f5a21bd5beb9ae58dfec
7
- data.tar.gz: 841981f0feaafcbeb39295282b018d4672bc41c115f31ce3d1b63964345197942082aac4eeff5b43190669ef81ad5a0fd3b0ac8afb5f271154058f0045fb3760
6
+ metadata.gz: 181ac8dc2d783231b7696094583312ca73e4b1104f7a91af76cde94f30ffcd6c4d89bd88ace568fcf1825776ae348c8bb2c1e7b50061407251d6805fcf45281a
7
+ data.tar.gz: 786f655864dbdc1f9d978779000b6ae762be9ea476f832515cba1b46fa923c9dee8174bc92dff247580fdabc10a4d8aa4aaacb2690c0eea9d3fb3881e96e8d55
data/.gitignore CHANGED
@@ -18,3 +18,5 @@ tmp
18
18
  *.log
19
19
  .DS_Store
20
20
  .ruby-*
21
+ .idea/
22
+ *.sqlite3
data/.travis.yml CHANGED
@@ -5,7 +5,7 @@ services:
5
5
  - mongodb
6
6
  before_install:
7
7
  - ./test/ci/before_install.sh
8
- script: bundle exec rake test
8
+ script: RUBYOPT=W0 bundle exec rake test
9
9
  before_script:
10
10
  - psql -c 'create database searchkick_test;' -U postgres
11
11
  notifications:
@@ -22,10 +22,22 @@ gemfile:
22
22
  - test/gemfiles/mongoid3.gemfile
23
23
  - test/gemfiles/mongoid4.gemfile
24
24
  - test/gemfiles/mongoid5.gemfile
25
+ env:
26
+ - ELASTICSEARCH_VERSION=2.3.0
25
27
  matrix:
26
28
  include:
27
- - gemfile: test/gemfiles/nobrainer.gemfile
28
- env: NOBRAINER=true
29
+ - gemfile: Gemfile
30
+ env: ELASTICSEARCH_VERSION=1.0.0
31
+ - gemfile: Gemfile
32
+ env: ELASTICSEARCH_VERSION=1.7.0
33
+ - gemfile: Gemfile
34
+ env: ELASTICSEARCH_VERSION=2.0.0
35
+ - gemfile: Gemfile
36
+ env: ELASTICSEARCH_VERSION=5.0.0-alpha2
37
+ # - gemfile: test/gemfiles/nobrainer.gemfile
38
+ # env: NOBRAINER=true
29
39
  allow_failures:
30
- - gemfile: test/gemfiles/nobrainer.gemfile
31
- env: NOBRAINER=true
40
+ - gemfile: Gemfile
41
+ env: ELASTICSEARCH_VERSION=5.0.0-alpha2
42
+ # - gemfile: test/gemfiles/nobrainer.gemfile
43
+ # env: NOBRAINER=true
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 1.3.0
2
+
3
+ - Added support for Elasticsearch 5.0 alpha
4
+ - Added support for phrase matches
5
+ - Added support for procs for `index_prefix` option
6
+
1
7
  ## 1.2.1
2
8
 
3
9
  - Added `multi_search` method
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Andrew Kane
1
+ Copyright (c) 2013-2016 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
data/README.md CHANGED
@@ -21,8 +21,6 @@ Plus:
21
21
  - “Did you mean” suggestions
22
22
  - works with ActiveRecord, Mongoid, and NoBrainer
23
23
 
24
- **Searchkick 1.0 was just released!** See [instructions for upgrading](#100)
25
-
26
24
  :speech_balloon: Get [handcrafted updates](http://chartkick.us7.list-manage.com/subscribe?u=952c861f99eb43084e0a49f98&id=6ea6541e8e&group[0][4]=true) for new features
27
25
 
28
26
  :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
@@ -247,6 +245,12 @@ Available options are:
247
245
  User.search params[:q], fields: [{email: :exact}, :name]
248
246
  ```
249
247
 
248
+ ### Phrase Matches
249
+
250
+ ```ruby
251
+ User.search "fresh honey", match: :phrase
252
+ ```
253
+
250
254
  ### Language
251
255
 
252
256
  Searchkick defaults to English for stemming. To change this, use:
@@ -329,7 +333,7 @@ gem 'gemoji-parser'
329
333
  And use:
330
334
 
331
335
  ```ruby
332
- Product.search "[emoji go here]", emoji: true
336
+ Product.search "🍨🍰", emoji: true
333
337
  ```
334
338
 
335
339
  ### Indexing
@@ -633,6 +637,12 @@ price_ranges = [{to: 20}, {from: 20, to: 50}, {from: 50}]
633
637
  Product.search "*", aggs: {price: {ranges: price_ranges}}
634
638
  ```
635
639
 
640
+ Minimum document count
641
+
642
+ ```ruby
643
+ Product.search "apples", aggs: {store_id: {min_doc_count: 2}}
644
+ ```
645
+
636
646
  #### Moving From Facets
637
647
 
638
648
  1. Replace `facets` with `aggs` in searches. **Note:** Stats facets are not supported at this time.
@@ -838,7 +848,7 @@ Searchkick supports [Elasticsearch’s routing feature](https://www.elastic.co/b
838
848
  class Business < ActiveRecord::Base
839
849
  searchkick routing: true
840
850
 
841
- def searchkick_routing
851
+ def search_routing
842
852
  city_id
843
853
  end
844
854
  end
@@ -919,20 +929,20 @@ Searchkick uses `ENV["ELASTICSEARCH_URL"]` for the Elasticsearch server. This d
919
929
 
920
930
  ### Heroku
921
931
 
922
- Choose an add-on: [SearchBox](https://elements.heroku.com/addons/searchbox), [Bonsai](https://elements.heroku.com/addons/bonsai), or [Found](https://elements.heroku.com/addons/foundelasticsearch).
932
+ Choose an add-on: [SearchBox](https://elements.heroku.com/addons/searchbox), [Bonsai](https://elements.heroku.com/addons/bonsai), or [Elastic Cloud](https://elements.heroku.com/addons/foundelasticsearch).
923
933
 
924
934
  ```sh
925
935
  # SearchBox
926
- heroku addons:add searchbox:starter
927
- heroku config:add ELASTICSEARCH_URL=`heroku config:get SEARCHBOX_URL`
936
+ heroku addons:create searchbox:starter
937
+ heroku config:set ELASTICSEARCH_URL=`heroku config:get SEARCHBOX_URL`
928
938
 
929
939
  # Bonsai
930
- heroku addons:add bonsai
931
- heroku config:add ELASTICSEARCH_URL=`heroku config:get BONSAI_URL`
940
+ heroku addons:create bonsai
941
+ heroku config:set ELASTICSEARCH_URL=`heroku config:get BONSAI_URL`
932
942
 
933
943
  # Found
934
- heroku addons:add foundelasticsearch
935
- heroku config:add ELASTICSEARCH_URL=`heroku config:get FOUNDELASTICSEARCH_URL`
944
+ heroku addons:create foundelasticsearch
945
+ heroku config:set ELASTICSEARCH_URL=`heroku config:get FOUNDELASTICSEARCH_URL`
936
946
  ```
937
947
 
938
948
  Then deploy and reindex:
@@ -1104,7 +1114,7 @@ Searchkick.multi_search([fresh_products, frozen_products])
1104
1114
 
1105
1115
  Then use `fresh_products` and `frozen_products` as typical results.
1106
1116
 
1107
- **Note:** Errors are not raised as with single requests. Use the `error` method on each query to check for errors. Also, the `below` option for misspellings is ignored.
1117
+ **Note:** Errors are not raised as with single requests. Use the `error` method on each query to check for errors. Also, if you use the `below` option for misspellings, misspellings will be disabled.
1108
1118
 
1109
1119
  ## Reference
1110
1120
 
@@ -1316,6 +1326,10 @@ product.reindex # don't forget this
1316
1326
  Product.searchkick_index.refresh # or this
1317
1327
  ```
1318
1328
 
1329
+ ## Multi-Tenancy
1330
+
1331
+ Check out [this great post](https://www.tiagoamaro.com.br/2014/12/11/multi-tenancy-with-searchkick/) on the [Apartment](https://github.com/influitive/apartment) gem. Follow a similar pattern if you use another gem.
1332
+
1319
1333
  ## Migrating from Tire
1320
1334
 
1321
1335
  1. Change `search` methods to `tire.search` and add index name in existing search calls
data/lib/searchkick.rb CHANGED
@@ -58,6 +58,10 @@ module Searchkick
58
58
  @server_version ||= client.info["version"]["number"]
59
59
  end
60
60
 
61
+ def self.server_below?(version)
62
+ Gem::Version.new(server_version) < Gem::Version.new(version)
63
+ end
64
+
61
65
  def self.enable_callbacks
62
66
  self.callbacks_value = nil
63
67
  end
@@ -36,7 +36,7 @@ module Searchkick
36
36
  begin
37
37
  client.indices.get_alias(name: name).keys
38
38
  rescue Elasticsearch::Transport::Transport::Errors::NotFound
39
- []
39
+ {}
40
40
  end
41
41
  actions = old_indices.map { |old_name| {remove: {index: old_name, alias: name}} } + [{add: {index: new_name, alias: name}}]
42
42
  client.indices.update_aliases body: {actions: actions}
@@ -146,7 +146,7 @@ module Searchkick
146
146
  begin
147
147
  client.indices.get_aliases
148
148
  rescue Elasticsearch::Transport::Transport::Errors::NotFound
149
- []
149
+ {}
150
150
  end
151
151
  indices = all_indices.select { |k, v| (v.empty? || v["aliases"].empty?) && k =~ /\A#{Regexp.escape(name)}_\d{14,17}\z/ }.keys
152
152
  indices.each do |index|
@@ -218,6 +218,22 @@ module Searchkick
218
218
  settings = options[:settings] || {}
219
219
  mappings = options[:mappings]
220
220
  else
221
+ below22 = Searchkick.server_below?("2.2.0")
222
+ below50 = Searchkick.server_below?("5.0.0-alpha1")
223
+ default_type = below50 ? "string" : "text"
224
+ default_analyzer = below50 ? :default_index : :default
225
+ keyword_mapping =
226
+ if below50
227
+ {
228
+ type: default_type,
229
+ index: "not_analyzed"
230
+ }
231
+ else
232
+ {
233
+ type: "keyword"
234
+ }
235
+ end
236
+
221
237
  settings = {
222
238
  analysis: {
223
239
  analyzer: {
@@ -226,7 +242,7 @@ module Searchkick
226
242
  tokenizer: "keyword",
227
243
  filter: ["lowercase"] + (options[:stem_conversions] == false ? [] : ["searchkick_stemmer"])
228
244
  },
229
- default_index: {
245
+ default_analyzer => {
230
246
  type: "custom",
231
247
  # character filters -> tokenizer -> token filters
232
248
  # https://www.elastic.co/guide/en/elasticsearch/guide/current/analysis-intro.html
@@ -380,8 +396,8 @@ module Searchkick
380
396
  # - Only apply the synonym expansion at index time
381
397
  # - Don't have the synonym filter applied search
382
398
  # - Use directional synonyms where appropriate. You want to make sure that you're not injecting terms that are too general.
383
- settings[:analysis][:analyzer][:default_index][:filter].insert(4, "searchkick_synonym")
384
- settings[:analysis][:analyzer][:default_index][:filter] << "searchkick_synonym"
399
+ settings[:analysis][:analyzer][default_analyzer][:filter].insert(4, "searchkick_synonym")
400
+ settings[:analysis][:analyzer][default_analyzer][:filter] << "searchkick_synonym"
385
401
 
386
402
  %w(word_start word_middle word_end).each do |type|
387
403
  settings[:analysis][:analyzer]["searchkick_#{type}_index".to_sym][:filter].insert(2, "searchkick_synonym")
@@ -395,8 +411,8 @@ module Searchkick
395
411
  synonyms_path: Searchkick.wordnet_path
396
412
  }
397
413
 
398
- settings[:analysis][:analyzer][:default_index][:filter].insert(4, "searchkick_wordnet")
399
- settings[:analysis][:analyzer][:default_index][:filter] << "searchkick_wordnet"
414
+ settings[:analysis][:analyzer][default_analyzer][:filter].insert(4, "searchkick_wordnet")
415
+ settings[:analysis][:analyzer][default_analyzer][:filter] << "searchkick_wordnet"
400
416
 
401
417
  %w(word_start word_middle word_end).each do |type|
402
418
  settings[:analysis][:analyzer]["searchkick_#{type}_index".to_sym][:filter].insert(2, "searchkick_wordnet")
@@ -416,7 +432,7 @@ module Searchkick
416
432
  mapping[conversions_field] = {
417
433
  type: "nested",
418
434
  properties: {
419
- query: {type: "string", analyzer: "searchkick_keyword"},
435
+ query: {type: default_type, analyzer: "searchkick_keyword"},
420
436
  count: {type: "integer"}
421
437
  }
422
438
  }
@@ -430,32 +446,39 @@ module Searchkick
430
446
  word = options[:word] != false && (!options[:match] || options[:match] == :word)
431
447
 
432
448
  mapping_options.values.flatten.uniq.each do |field|
433
- field_mapping = {
434
- type: "multi_field",
435
- fields: {}
436
- }
449
+ fields = {}
437
450
 
438
- unless mapping_options[:only_analyzed].include?(field)
439
- field_mapping[:fields][field] = {type: "string", index: "not_analyzed"}
451
+ if mapping_options[:only_analyzed].include?(field)
452
+ fields[field] = {type: default_type, index: "no"}
453
+ else
454
+ fields[field] = keyword_mapping
440
455
  end
441
456
 
442
457
  if !options[:searchable] || mapping_options[:searchable].include?(field)
443
458
  if word
444
- field_mapping[:fields]["analyzed"] = {type: "string", index: "analyzed"}
459
+ fields["analyzed"] = {type: default_type, index: "analyzed", analyzer: default_analyzer}
445
460
 
446
461
  if mapping_options[:highlight].include?(field)
447
- field_mapping[:fields]["analyzed"][:term_vector] = "with_positions_offsets"
462
+ fields["analyzed"][:term_vector] = "with_positions_offsets"
448
463
  end
449
464
  end
450
465
 
451
- mapping_options.except(:highlight, :searchable, :only_analyzed).each do |type, fields|
452
- if options[:match] == type || fields.include?(field)
453
- field_mapping[:fields][type] = {type: "string", index: "analyzed", analyzer: "searchkick_#{type}_index"}
466
+ mapping_options.except(:highlight, :searchable, :only_analyzed).each do |type, f|
467
+ if options[:match] == type || f.include?(field)
468
+ fields[type] = {type: default_type, index: "analyzed", analyzer: "searchkick_#{type}_index"}
454
469
  end
455
470
  end
456
471
  end
457
472
 
458
- mapping[field] = field_mapping
473
+ mapping[field] =
474
+ if below50
475
+ {
476
+ type: "multi_field",
477
+ fields: fields
478
+ }
479
+ elsif fields[field]
480
+ fields[field].merge(fields: fields.except(field))
481
+ end
459
482
  end
460
483
 
461
484
  (options[:locations] || []).map(&:to_s).each do |field|
@@ -466,7 +489,7 @@ module Searchkick
466
489
 
467
490
  (options[:unsearchable] || []).map(&:to_s).each do |field|
468
491
  mapping[field] = {
469
- type: "string",
492
+ type: default_type,
470
493
  index: "no"
471
494
  }
472
495
  end
@@ -484,21 +507,35 @@ module Searchkick
484
507
  # http://www.elasticsearch.org/guide/reference/mapping/multi-field-type/
485
508
  # however, we can include the not_analyzed field in _all
486
509
  # and the _all index analyzer will take care of it
487
- "{name}" => {type: "string", index: "not_analyzed", include_in_all: !options[:searchable]}
510
+ "{name}" => keyword_mapping.merge(include_in_all: !options[:searchable])
488
511
  }
489
512
 
513
+ dynamic_fields["{name}"][:ignore_above] = 256 unless below22
514
+
490
515
  unless options[:searchable]
491
516
  if options[:match] && options[:match] != :word
492
- dynamic_fields[options[:match]] = {type: "string", index: "analyzed", analyzer: "searchkick_#{options[:match]}_index"}
517
+ dynamic_fields[options[:match]] = {type: default_type, index: "analyzed", analyzer: "searchkick_#{options[:match]}_index"}
493
518
  end
494
519
 
495
520
  if word
496
- dynamic_fields["analyzed"] = {type: "string", index: "analyzed"}
521
+ dynamic_fields["analyzed"] = {type: default_type, index: "analyzed"}
497
522
  end
498
523
  end
499
524
 
525
+ # http://www.elasticsearch.org/guide/reference/mapping/multi-field-type/
526
+ multi_field =
527
+ if below50
528
+ {
529
+ type: "multi_field",
530
+ fields: dynamic_fields
531
+ }
532
+ else
533
+ dynamic_fields["{name}"].merge(fields: dynamic_fields.except("{name}"))
534
+ end
535
+
500
536
  mappings = {
501
537
  _default_: {
538
+ _all: {type: default_type, index: "analyzed", analyzer: default_analyzer},
502
539
  properties: mapping,
503
540
  _routing: routing,
504
541
  # https://gist.github.com/kimchy/2898285
@@ -507,11 +544,7 @@ module Searchkick
507
544
  string_template: {
508
545
  match: "*",
509
546
  match_mapping_type: "string",
510
- mapping: {
511
- # http://www.elasticsearch.org/guide/reference/mapping/multi-field-type/
512
- type: "multi_field",
513
- fields: dynamic_fields
514
- }
547
+ mapping: multi_field
515
548
  }
516
549
  }
517
550
  ]
@@ -15,7 +15,9 @@ module Searchkick
15
15
  class_variable_set :@@searchkick_options, options.dup
16
16
  class_variable_set :@@searchkick_klass, self
17
17
  class_variable_set :@@searchkick_callbacks, callbacks
18
- class_variable_set :@@searchkick_index, options[:index_name] || [options[:index_prefix], model_name.plural, Searchkick.env].compact.join("_")
18
+ class_variable_set :@@searchkick_index, options[:index_name] ||
19
+ (options[:index_prefix].respond_to?(:call) && proc { [options[:index_prefix].call, model_name.plural, Searchkick.env].compact.join("_") }) ||
20
+ [options[:index_prefix], model_name.plural, Searchkick.env].compact.join("_")
19
21
 
20
22
  class << self
21
23
  def searchkick_search(term = nil, options = {}, &block)
@@ -149,28 +149,7 @@ module Searchkick
149
149
  end
150
150
 
151
151
  def prepare
152
- boost_fields = {}
153
- fields = options[:fields] || searchkick_options[:searchable]
154
- fields =
155
- if fields
156
- if options[:autocomplete]
157
- fields.map { |f| "#{f}.autocomplete" }
158
- else
159
- fields.map do |value|
160
- k, v = value.is_a?(Hash) ? value.to_a.first : [value, options[:match] || searchkick_options[:match] || :word]
161
- k2, boost = k.to_s.split("^", 2)
162
- field = "#{k2}.#{v == :word ? 'analyzed' : v}"
163
- boost_fields[field] = boost.to_f if boost
164
- field
165
- end
166
- end
167
- else
168
- if options[:autocomplete]
169
- (searchkick_options[:autocomplete] || []).map { |f| "#{f}.autocomplete" }
170
- else
171
- ["_all"]
172
- end
173
- end
152
+ boost_fields, fields = set_fields
174
153
 
175
154
  operator = options[:operator] || (options[:partial] ? "or" : "and")
176
155
 
@@ -187,7 +166,6 @@ module Searchkick
187
166
  personalize_field = searchkick_options[:personalize]
188
167
 
189
168
  all = term == "*"
190
- facet_limits = {}
191
169
 
192
170
  options[:json] ||= options[:body]
193
171
  if options[:json]
@@ -256,10 +234,19 @@ module Searchkick
256
234
  factor = boost_fields[field] || 1
257
235
  shared_options = {
258
236
  query: term,
259
- operator: operator,
260
237
  boost: 10 * factor
261
238
  }
262
239
 
240
+ match_type =
241
+ if field.end_with?(".phrase")
242
+ field = field.sub(/\.phrase\z/, ".analyzed")
243
+ :match_phrase
244
+ else
245
+ :match
246
+ end
247
+
248
+ shared_options[:operator] = operator if match_type == :match || below50?
249
+
263
250
  if field == "_all" || field.end_with?(".analyzed")
264
251
  shared_options[:cutoff_frequency] = 0.001 unless operator == "and" || misspellings == false
265
252
  qs.concat [
@@ -274,11 +261,11 @@ module Searchkick
274
261
  qs << shared_options.merge(analyzer: analyzer)
275
262
  end
276
263
 
277
- if misspellings != false
264
+ if misspellings != false && (match_type == :match || below50?)
278
265
  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) }
279
266
  end
280
267
 
281
- queries.concat(qs.map { |q| {match: {field => q}} })
268
+ queries.concat(qs.map { |q| {match_type => {field => q}} })
282
269
  end
283
270
 
284
271
  payload = {
@@ -324,52 +311,9 @@ module Searchkick
324
311
  custom_filters = []
325
312
  multiply_filters = []
326
313
 
327
- boost_by = options[:boost_by] || {}
328
-
329
- if boost_by.is_a?(Array)
330
- boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
331
- elsif boost_by.is_a?(Hash)
332
- multiply_by, boost_by = boost_by.partition { |_, v| v[:boost_mode] == "multiply" }.map { |i| Hash[i] }
333
- end
334
- boost_by[options[:boost]] = {factor: 1} if options[:boost]
335
-
336
- custom_filters.concat boost_filters(boost_by, log: true)
337
- multiply_filters.concat boost_filters(multiply_by || {})
338
-
339
- boost_where = options[:boost_where] || {}
340
- if options[:user_id] && personalize_field
341
- boost_where[personalize_field] = options[:user_id]
342
- end
343
- if options[:personalize]
344
- boost_where = boost_where.merge(options[:personalize])
345
- end
346
- boost_where.each do |field, value|
347
- if value.is_a?(Array) && value.first.is_a?(Hash)
348
- value.each do |value_factor|
349
- custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
350
- end
351
- elsif value.is_a?(Hash)
352
- custom_filters << custom_filter(field, value[:value], value[:factor])
353
- else
354
- factor = 1000
355
- custom_filters << custom_filter(field, value, factor)
356
- end
357
- end
358
-
359
- boost_by_distance = options[:boost_by_distance]
360
- if boost_by_distance
361
- boost_by_distance = {function: :gauss, scale: "5mi"}.merge(boost_by_distance)
362
- if !boost_by_distance[:field] || !boost_by_distance[:origin]
363
- raise ArgumentError, "boost_by_distance requires :field and :origin"
364
- end
365
- function_params = boost_by_distance.select { |k, _| [:origin, :scale, :offset, :decay].include?(k) }
366
- function_params[:origin] = location_value(function_params[:origin])
367
- custom_filters << {
368
- boost_by_distance[:function] => {
369
- boost_by_distance[:field] => function_params
370
- }
371
- }
372
- end
314
+ set_boost_by(multiply_filters, custom_filters)
315
+ set_boost_where(custom_filters, personalize_field)
316
+ set_boost_by_distance(custom_filters) if options[:boost_by_distance]
373
317
 
374
318
  if custom_filters.any?
375
319
  payload = {
@@ -399,187 +343,23 @@ module Searchkick
399
343
  payload[:explain] = options[:explain] if options[:explain]
400
344
 
401
345
  # order
402
- if options[:order]
403
- order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
404
- # TODO id transformation for arrays
405
- payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? :_id : k, v] }]
406
- end
346
+ set_order(payload) if options[:order]
407
347
 
408
348
  # filters
409
349
  filters = where_filters(options[:where])
410
- if filters.any?
411
- if options[:facets] || options[:aggs]
412
- payload[:filter] = {
413
- and: filters
414
- }
415
- else
416
- # more efficient query if no facets
417
- payload[:query] = {
418
- filtered: {
419
- query: payload[:query],
420
- filter: {
421
- and: filters
422
- }
423
- }
424
- }
425
- end
426
- end
350
+ set_filters(payload, filters) if filters.any?
427
351
 
428
352
  # facets
429
- if options[:facets]
430
- facets = options[:facets] || {}
431
- facets = Hash[facets.map { |f| [f, {}] }] if facets.is_a?(Array) # convert to more advanced syntax
432
-
433
- payload[:facets] = {}
434
- facets.each do |field, facet_options|
435
- # ask for extra facets due to
436
- # https://github.com/elasticsearch/elasticsearch/issues/1305
437
- size = facet_options[:limit] ? facet_options[:limit] + 150 : 1_000
438
-
439
- if facet_options[:ranges]
440
- payload[:facets][field] = {
441
- range: {
442
- field.to_sym => facet_options[:ranges]
443
- }
444
- }
445
- elsif facet_options[:stats]
446
- payload[:facets][field] = {
447
- terms_stats: {
448
- key_field: field,
449
- value_script: below14? ? "doc.score" : "_score",
450
- size: size
451
- }
452
- }
453
- else
454
- payload[:facets][field] = {
455
- terms: {
456
- field: facet_options[:field] || field,
457
- size: size
458
- }
459
- }
460
- end
461
-
462
- facet_limits[field] = facet_options[:limit] if facet_options[:limit]
463
-
464
- # offset is not possible
465
- # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
466
-
467
- facet_options.deep_merge!(where: options.fetch(:where, {}).reject { |k| k == field }) if options[:smart_facets] == true
468
- facet_filters = where_filters(facet_options[:where])
469
- if facet_filters.any?
470
- payload[:facets][field][:facet_filter] = {
471
- and: {
472
- filters: facet_filters
473
- }
474
- }
475
- end
476
- end
477
- end
353
+ set_facets(payload) if options[:facets]
478
354
 
479
355
  # aggregations
480
- if options[:aggs]
481
- aggs = options[:aggs]
482
- payload[:aggs] = {}
483
-
484
- aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
485
-
486
- aggs.each do |field, agg_options|
487
- size = agg_options[:limit] ? agg_options[:limit] : 1_000
488
- shared_agg_options = agg_options.slice(:order)
489
-
490
- if agg_options[:ranges]
491
- payload[:aggs][field] = {
492
- range: {
493
- field: agg_options[:field] || field,
494
- ranges: agg_options[:ranges]
495
- }.merge(shared_agg_options)
496
- }
497
- elsif agg_options[:date_ranges]
498
- payload[:aggs][field] = {
499
- date_range: {
500
- field: agg_options[:field] || field,
501
- ranges: agg_options[:date_ranges]
502
- }.merge(shared_agg_options)
503
- }
504
- else
505
- payload[:aggs][field] = {
506
- terms: {
507
- field: agg_options[:field] || field,
508
- size: size
509
- }.merge(shared_agg_options)
510
- }
511
- end
512
-
513
- where = {}
514
- where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
515
- agg_filters = where_filters(where.merge(agg_options[:where] || {}))
516
- if agg_filters.any?
517
- payload[:aggs][field] = {
518
- filter: {
519
- bool: {
520
- must: agg_filters
521
- }
522
- },
523
- aggs: {
524
- field => payload[:aggs][field]
525
- }
526
- }
527
- end
528
- end
529
- end
356
+ set_aggregations(payload) if options[:aggs]
530
357
 
531
358
  # suggestions
532
- if options[:suggest]
533
- suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
534
-
535
- # intersection
536
- if options[:fields]
537
- suggest_fields &= options[:fields].map { |v| (v.is_a?(Hash) ? v.keys.first : v).to_s.split("^", 2).first }
538
- end
539
-
540
- if suggest_fields.any?
541
- payload[:suggest] = {text: term}
542
- suggest_fields.each do |field|
543
- payload[:suggest][field] = {
544
- phrase: {
545
- field: "#{field}.suggest"
546
- }
547
- }
548
- end
549
- end
550
- end
359
+ set_suggestions(payload) if options[:suggest]
551
360
 
552
361
  # highlight
553
- if options[:highlight]
554
- payload[:highlight] = {
555
- fields: Hash[fields.map { |f| [f, {}] }]
556
- }
557
-
558
- if options[:highlight].is_a?(Hash)
559
- if (tag = options[:highlight][:tag])
560
- payload[:highlight][:pre_tags] = [tag]
561
- payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
562
- end
563
-
564
- if (fragment_size = options[:highlight][:fragment_size])
565
- payload[:highlight][:fragment_size] = fragment_size
566
- end
567
- if (encoder = options[:highlight][:encoder])
568
- payload[:highlight][:encoder] = encoder
569
- end
570
-
571
- highlight_fields = options[:highlight][:fields]
572
- if highlight_fields
573
- payload[:highlight][:fields] = {}
574
-
575
- highlight_fields.each do |name, opts|
576
- payload[:highlight][:fields]["#{name}.#{@match_suffix}"] = opts || {}
577
- end
578
- end
579
- end
580
-
581
- @highlighted_fields = payload[:highlight][:fields].keys
582
- end
362
+ set_highlights(payload, fields) if options[:highlight]
583
363
 
584
364
  # An empty array will cause only the _id and _type for each hit to be returned
585
365
  # doc for :select - http://www.elasticsearch.org/guide/reference/api/search/fields/
@@ -606,13 +386,286 @@ module Searchkick
606
386
  end
607
387
 
608
388
  @body = payload
609
- @facet_limits = facet_limits
389
+ @facet_limits = @facet_limits || {}
610
390
  @page = page
611
391
  @per_page = per_page
612
392
  @padding = padding
613
393
  @load = load
614
394
  end
615
395
 
396
+ def set_fields
397
+ boost_fields = {}
398
+ fields = options[:fields] || searchkick_options[:searchable]
399
+ fields =
400
+ if fields
401
+ if options[:autocomplete]
402
+ fields.map { |f| "#{f}.autocomplete" }
403
+ else
404
+ fields.map do |value|
405
+ k, v = value.is_a?(Hash) ? value.to_a.first : [value, options[:match] || searchkick_options[:match] || :word]
406
+ k2, boost = k.to_s.split("^", 2)
407
+ field = "#{k2}.#{v == :word ? 'analyzed' : v}"
408
+ boost_fields[field] = boost.to_f if boost
409
+ field
410
+ end
411
+ end
412
+ else
413
+ if options[:autocomplete]
414
+ (searchkick_options[:autocomplete] || []).map { |f| "#{f}.autocomplete" }
415
+ else
416
+ ["_all"]
417
+ end
418
+ end
419
+ [boost_fields, fields]
420
+ end
421
+
422
+ def set_boost_by_distance(custom_filters)
423
+ boost_by_distance = options[:boost_by_distance] || {}
424
+ boost_by_distance = {function: :gauss, scale: "5mi"}.merge(boost_by_distance)
425
+ if !boost_by_distance[:field] || !boost_by_distance[:origin]
426
+ raise ArgumentError, "boost_by_distance requires :field and :origin"
427
+ end
428
+ function_params = boost_by_distance.select { |k, _| [:origin, :scale, :offset, :decay].include?(k) }
429
+ function_params[:origin] = location_value(function_params[:origin])
430
+ custom_filters << {
431
+ boost_by_distance[:function] => {
432
+ boost_by_distance[:field] => function_params
433
+ }
434
+ }
435
+ end
436
+
437
+ def set_boost_by(multiply_filters, custom_filters)
438
+ boost_by = options[:boost_by] || {}
439
+ if boost_by.is_a?(Array)
440
+ boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
441
+ elsif boost_by.is_a?(Hash)
442
+ multiply_by, boost_by = boost_by.partition { |_, v| v[:boost_mode] == "multiply" }.map { |i| Hash[i] }
443
+ end
444
+ boost_by[options[:boost]] = {factor: 1} if options[:boost]
445
+
446
+ custom_filters.concat boost_filters(boost_by, log: true)
447
+ multiply_filters.concat boost_filters(multiply_by || {})
448
+ end
449
+
450
+ def set_boost_where(custom_filters, personalize_field)
451
+ boost_where = options[:boost_where] || {}
452
+ if options[:user_id] && personalize_field
453
+ boost_where[personalize_field] = options[:user_id]
454
+ end
455
+ if options[:personalize]
456
+ boost_where = boost_where.merge(options[:personalize])
457
+ end
458
+ boost_where.each do |field, value|
459
+ if value.is_a?(Array) && value.first.is_a?(Hash)
460
+ value.each do |value_factor|
461
+ custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
462
+ end
463
+ elsif value.is_a?(Hash)
464
+ custom_filters << custom_filter(field, value[:value], value[:factor])
465
+ else
466
+ factor = 1000
467
+ custom_filters << custom_filter(field, value, factor)
468
+ end
469
+ end
470
+ end
471
+
472
+ def set_suggestions(payload)
473
+ suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
474
+
475
+ # intersection
476
+ if options[:fields]
477
+ suggest_fields &= options[:fields].map { |v| (v.is_a?(Hash) ? v.keys.first : v).to_s.split("^", 2).first }
478
+ end
479
+
480
+ if suggest_fields.any?
481
+ payload[:suggest] = {text: term}
482
+ suggest_fields.each do |field|
483
+ payload[:suggest][field] = {
484
+ phrase: {
485
+ field: "#{field}.suggest"
486
+ }
487
+ }
488
+ end
489
+ end
490
+ end
491
+
492
+ def set_highlights(payload, fields)
493
+ payload[:highlight] = {
494
+ fields: Hash[fields.map { |f| [f, {}] }]
495
+ }
496
+
497
+ if options[:highlight].is_a?(Hash)
498
+ if (tag = options[:highlight][:tag])
499
+ payload[:highlight][:pre_tags] = [tag]
500
+ payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
501
+ end
502
+
503
+ if (fragment_size = options[:highlight][:fragment_size])
504
+ payload[:highlight][:fragment_size] = fragment_size
505
+ end
506
+ if (encoder = options[:highlight][:encoder])
507
+ payload[:highlight][:encoder] = encoder
508
+ end
509
+
510
+ highlight_fields = options[:highlight][:fields]
511
+ if highlight_fields
512
+ payload[:highlight][:fields] = {}
513
+
514
+ highlight_fields.each do |name, opts|
515
+ payload[:highlight][:fields]["#{name}.#{@match_suffix}"] = opts || {}
516
+ end
517
+ end
518
+ end
519
+
520
+ @highlighted_fields = payload[:highlight][:fields].keys
521
+ end
522
+
523
+ def set_aggregations(payload)
524
+ aggs = options[:aggs]
525
+ payload[:aggs] = {}
526
+
527
+ aggs = Hash[aggs.map { |f| [f, {}] }] if aggs.is_a?(Array) # convert to more advanced syntax
528
+
529
+ aggs.each do |field, agg_options|
530
+ size = agg_options[:limit] ? agg_options[:limit] : 1_000
531
+ shared_agg_options = agg_options.slice(:order, :min_doc_count)
532
+
533
+ if agg_options[:ranges]
534
+ payload[:aggs][field] = {
535
+ range: {
536
+ field: agg_options[:field] || field,
537
+ ranges: agg_options[:ranges]
538
+ }.merge(shared_agg_options)
539
+ }
540
+ elsif agg_options[:date_ranges]
541
+ payload[:aggs][field] = {
542
+ date_range: {
543
+ field: agg_options[:field] || field,
544
+ ranges: agg_options[:date_ranges]
545
+ }.merge(shared_agg_options)
546
+ }
547
+ else
548
+ payload[:aggs][field] = {
549
+ terms: {
550
+ field: agg_options[:field] || field,
551
+ size: size
552
+ }.merge(shared_agg_options)
553
+ }
554
+ end
555
+
556
+ where = {}
557
+ where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
558
+ agg_filters = where_filters(where.merge(agg_options[:where] || {}))
559
+ if agg_filters.any?
560
+ payload[:aggs][field] = {
561
+ filter: {
562
+ bool: {
563
+ must: agg_filters
564
+ }
565
+ },
566
+ aggs: {
567
+ field => payload[:aggs][field]
568
+ }
569
+ }
570
+ end
571
+ end
572
+ end
573
+
574
+ def set_facets(payload)
575
+ facets = options[:facets] || {}
576
+ facets = Hash[facets.map { |f| [f, {}] }] if facets.is_a?(Array) # convert to more advanced syntax
577
+ facet_limits = {}
578
+ payload[:facets] = {}
579
+
580
+ facets.each do |field, facet_options|
581
+ # ask for extra facets due to
582
+ # https://github.com/elasticsearch/elasticsearch/issues/1305
583
+ size = facet_options[:limit] ? facet_options[:limit] + 150 : 1_000
584
+
585
+ if facet_options[:ranges]
586
+ payload[:facets][field] = {
587
+ range: {
588
+ field.to_sym => facet_options[:ranges]
589
+ }
590
+ }
591
+ elsif facet_options[:stats]
592
+ payload[:facets][field] = {
593
+ terms_stats: {
594
+ key_field: field,
595
+ value_script: below14? ? "doc.score" : "_score",
596
+ size: size
597
+ }
598
+ }
599
+ else
600
+ payload[:facets][field] = {
601
+ terms: {
602
+ field: facet_options[:field] || field,
603
+ size: size
604
+ }
605
+ }
606
+ end
607
+
608
+ facet_limits[field] = facet_options[:limit] if facet_options[:limit]
609
+
610
+ # offset is not possible
611
+ # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
612
+
613
+ facet_options.deep_merge!(where: options.fetch(:where, {}).reject { |k| k == field }) if options[:smart_facets] == true
614
+ facet_filters = where_filters(facet_options[:where])
615
+ if facet_filters.any?
616
+ payload[:facets][field][:facet_filter] = {
617
+ and: {
618
+ filters: facet_filters
619
+ }
620
+ }
621
+ end
622
+ end
623
+
624
+ @facet_limits = facet_limits
625
+ end
626
+
627
+ def set_filters(payload, filters)
628
+ if options[:facets] || options[:aggs]
629
+ if below20?
630
+ payload[:filter] = {
631
+ and: filters
632
+ }
633
+ else
634
+ payload[:post_filter] = {
635
+ bool: {
636
+ filter: filters
637
+ }
638
+ }
639
+ end
640
+ else
641
+ # more efficient query if no facets
642
+ if below20?
643
+ payload[:query] = {
644
+ filtered: {
645
+ query: payload[:query],
646
+ filter: {
647
+ and: filters
648
+ }
649
+ }
650
+ }
651
+ else
652
+ payload[:query] = {
653
+ bool: {
654
+ must: payload[:query],
655
+ filter: filters
656
+ }
657
+ }
658
+ end
659
+ end
660
+ end
661
+
662
+ # TODO id transformation for arrays
663
+ def set_order(payload)
664
+ order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
665
+ id_field = below50? ? :_id : :_uid
666
+ payload[:sort] = order.is_a?(Array) ? order : Hash[order.map { |k, v| [k.to_s == "id" ? id_field : k, v] }]
667
+ end
668
+
616
669
  def where_filters(where)
617
670
  filters = []
618
671
  (where || {}).each do |field, value|
@@ -620,7 +673,11 @@ module Searchkick
620
673
 
621
674
  if field == :or
622
675
  value.each do |or_clause|
623
- filters << {or: or_clause.map { |or_statement| {and: where_filters(or_statement)} }}
676
+ if below50?
677
+ filters << {or: or_clause.map { |or_statement| {and: where_filters(or_statement)} }}
678
+ else
679
+ filters << {bool: {should: or_clause.map { |or_statement| {bool: {filter: where_filters(or_statement)}} }}}
680
+ end
624
681
  end
625
682
  else
626
683
  # expand ranges
@@ -654,7 +711,11 @@ module Searchkick
654
711
  when :regexp # support for regexp queries without using a regexp ruby object
655
712
  filters << {regexp: {field => {value: op_value}}}
656
713
  when :not # not equal
657
- filters << {not: {filter: term_filters(field, op_value)}}
714
+ if below50?
715
+ filters << {not: {filter: term_filters(field, op_value)}}
716
+ else
717
+ filters << {bool: {must_not: term_filters(field, op_value)}}
718
+ end
658
719
  when :all
659
720
  op_value.each do |value|
660
721
  filters << term_filters(field, value)
@@ -694,12 +755,20 @@ module Searchkick
694
755
  def term_filters(field, value)
695
756
  if value.is_a?(Array) # in query
696
757
  if value.any?(&:nil?)
697
- {or: [term_filters(field, nil), term_filters(field, value.compact)]}
758
+ if below50?
759
+ {or: [term_filters(field, nil), term_filters(field, value.compact)]}
760
+ else
761
+ {bool: {should: [term_filters(field, nil), term_filters(field, value.compact)]}}
762
+ end
698
763
  else
699
764
  {in: {field => value}}
700
765
  end
701
766
  elsif value.nil?
702
- {missing: {"field" => field, existence: true, null_value: true}}
767
+ if below50?
768
+ {missing: {field: field, existence: true, null_value: true}}
769
+ else
770
+ {bool: {must_not: {exists: {field: field}}}}
771
+ end
703
772
  elsif value.is_a?(Regexp)
704
773
  {regexp: {field => {value: value.source}}}
705
774
  else
@@ -708,12 +777,19 @@ module Searchkick
708
777
  end
709
778
 
710
779
  def custom_filter(field, value, factor)
711
- {
712
- filter: {
713
- and: where_filters(field => value)
714
- },
715
- boost_factor: factor
716
- }
780
+ if below50?
781
+ {
782
+ filter: {
783
+ and: where_filters(field => value)
784
+ },
785
+ boost_factor: factor
786
+ }
787
+ else
788
+ {
789
+ filter: where_filters(field => value),
790
+ weight: factor
791
+ }
792
+ end
717
793
  end
718
794
 
719
795
  def boost_filters(boost_by, options = {})
@@ -747,19 +823,19 @@ module Searchkick
747
823
  end
748
824
 
749
825
  def below12?
750
- below_version?("1.2.0")
826
+ Searchkick.server_below?("1.2.0")
751
827
  end
752
828
 
753
829
  def below14?
754
- below_version?("1.4.0")
830
+ Searchkick.server_below?("1.4.0")
755
831
  end
756
832
 
757
833
  def below20?
758
- below_version?("2.0.0")
834
+ Searchkick.server_below?("2.0.0")
759
835
  end
760
836
 
761
- def below_version?(version)
762
- Gem::Version.new(Searchkick.server_version) < Gem::Version.new(version)
837
+ def below50?
838
+ Searchkick.server_below?("5.0.0-alpha1")
763
839
  end
764
840
  end
765
841
  end