searchkick 1.2.1 → 1.3.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.
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