searchkick 0.9.1 → 1.0.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +10 -9
  3. data/CHANGELOG.md +14 -0
  4. data/README.md +190 -14
  5. data/lib/searchkick.rb +1 -0
  6. data/lib/searchkick/index.rb +8 -8
  7. data/lib/searchkick/logging.rb +4 -2
  8. data/lib/searchkick/model.rb +13 -10
  9. data/lib/searchkick/query.rb +107 -39
  10. data/lib/searchkick/reindex_job.rb +0 -2
  11. data/lib/searchkick/reindex_v2_job.rb +0 -1
  12. data/lib/searchkick/results.rb +18 -1
  13. data/lib/searchkick/tasks.rb +0 -2
  14. data/lib/searchkick/version.rb +1 -1
  15. data/test/aggs_test.rb +102 -0
  16. data/test/autocomplete_test.rb +1 -3
  17. data/test/boost_test.rb +1 -3
  18. data/{ci → test/ci}/before_install.sh +4 -3
  19. data/test/facets_test.rb +4 -6
  20. data/{gemfiles → test/gemfiles}/activerecord31.gemfile +1 -1
  21. data/{gemfiles → test/gemfiles}/activerecord32.gemfile +1 -1
  22. data/{gemfiles → test/gemfiles}/activerecord40.gemfile +1 -1
  23. data/{gemfiles → test/gemfiles}/activerecord41.gemfile +1 -1
  24. data/{gemfiles → test/gemfiles}/mongoid2.gemfile +1 -1
  25. data/{gemfiles → test/gemfiles}/mongoid3.gemfile +1 -1
  26. data/{gemfiles → test/gemfiles}/mongoid4.gemfile +1 -1
  27. data/test/gemfiles/mongoid5.gemfile +7 -0
  28. data/{gemfiles → test/gemfiles}/nobrainer.gemfile +1 -1
  29. data/test/highlight_test.rb +2 -4
  30. data/test/index_test.rb +20 -9
  31. data/test/inheritance_test.rb +1 -3
  32. data/test/match_test.rb +8 -7
  33. data/test/model_test.rb +2 -4
  34. data/test/query_test.rb +1 -3
  35. data/test/records_test.rb +0 -2
  36. data/test/reindex_job_test.rb +1 -3
  37. data/test/reindex_v2_job_test.rb +1 -3
  38. data/test/routing_test.rb +4 -3
  39. data/test/should_index_test.rb +1 -3
  40. data/test/similar_test.rb +1 -3
  41. data/test/sql_test.rb +7 -9
  42. data/test/suggest_test.rb +1 -3
  43. data/test/synonyms_test.rb +1 -3
  44. data/test/test_helper.rb +23 -10
  45. metadata +24 -11
@@ -92,24 +92,37 @@ module Searchkick
92
92
  shared_options = {
93
93
  query: term,
94
94
  operator: operator,
95
- boost: factor
95
+ boost: 10 * factor
96
96
  }
97
97
 
98
- if field == "_all" || field.end_with?(".analyzed")
99
- misspellings = options.key?(:misspellings) ? options[:misspellings] : options[:mispellings] # why not?
100
- if misspellings != false
101
- edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
102
- transpositions = (misspellings.is_a?(Hash) && misspellings[:transpositions] == true) ? {fuzzy_transpositions: true} : {}
103
- prefix_length = (misspellings.is_a?(Hash) && misspellings[:prefix_length]) || 0
104
- qs.concat [
105
- shared_options.merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: 3, analyzer: "searchkick_search").merge(transpositions),
106
- shared_options.merge(fuzziness: edit_distance, prefix_length: prefix_length, max_expansions: 3, analyzer: "searchkick_search2").merge(transpositions)
107
- ]
98
+ misspellings =
99
+ if options.key?(:misspellings)
100
+ options[:misspellings]
101
+ elsif options.key?(:mispellings)
102
+ options[:mispellings] # why not?
103
+ else
104
+ true
108
105
  end
106
+
107
+ if misspellings != false
108
+ edit_distance = (misspellings.is_a?(Hash) && (misspellings[:edit_distance] || misspellings[:distance])) || 1
109
+ transpositions =
110
+ if misspellings.is_a?(Hash) && misspellings.key?(:transpositions)
111
+ {fuzzy_transpositions: misspellings[:transpositions]}
112
+ elsif below14?
113
+ {}
114
+ else
115
+ {fuzzy_transpositions: true}
116
+ end
117
+ prefix_length = (misspellings.is_a?(Hash) && misspellings[:prefix_length]) || 0
118
+ max_expansions = (misspellings.is_a?(Hash) && misspellings[:max_expansions]) || 3
119
+ end
120
+
121
+ if field == "_all" || field.end_with?(".analyzed")
109
122
  shared_options[:cutoff_frequency] = 0.001 unless operator == "and" || misspellings == false
110
123
  qs.concat [
111
- shared_options.merge(boost: 10 * factor, analyzer: "searchkick_search"),
112
- shared_options.merge(boost: 10 * factor, analyzer: "searchkick_search2")
124
+ shared_options.merge(analyzer: "searchkick_search"),
125
+ shared_options.merge(analyzer: "searchkick_search2")
113
126
  ]
114
127
  elsif field.end_with?(".exact")
115
128
  f = field.split(".")[0..-2].join(".")
@@ -119,6 +132,10 @@ module Searchkick
119
132
  qs << shared_options.merge(analyzer: analyzer)
120
133
  end
121
134
 
135
+ if misspellings != false
136
+ 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) }
137
+ end
138
+
122
139
  queries.concat(qs.map { |q| {match: {field => q}} })
123
140
  end
124
141
 
@@ -135,7 +152,7 @@ module Searchkick
135
152
  if below12?
136
153
  {script_score: {script: "doc['count'].value"}}
137
154
  else
138
- {field_value_factor: {field: "count"}}
155
+ {field_value_factor: {field: "#{conversions_field}.count"}}
139
156
  end
140
157
 
141
158
  payload = {
@@ -144,13 +161,13 @@ module Searchkick
144
161
  should: {
145
162
  nested: {
146
163
  path: conversions_field,
147
- score_mode: "total",
164
+ score_mode: "sum",
148
165
  query: {
149
166
  function_score: {
150
167
  boost_mode: "replace",
151
168
  query: {
152
169
  match: {
153
- query: term
170
+ "#{conversions_field}.query" => term
154
171
  }
155
172
  }
156
173
  }.merge(script_score)
@@ -170,11 +187,9 @@ module Searchkick
170
187
  if boost_by.is_a?(Array)
171
188
  boost_by = Hash[boost_by.map { |f| [f, {factor: 1}] }]
172
189
  elsif boost_by.is_a?(Hash)
173
- multiply_by, boost_by = boost_by.partition { |k,v| v[:boost_mode] == "multiply" }.map{ |i| Hash[i] }
174
- end
175
- if options[:boost]
176
- boost_by[options[:boost]] = {factor: 1}
190
+ multiply_by, boost_by = boost_by.partition { |_, v| v[:boost_mode] == "multiply" }.map { |i| Hash[i] }
177
191
  end
192
+ boost_by[options[:boost]] = {factor: 1} if options[:boost]
178
193
 
179
194
  custom_filters.concat boost_filters(boost_by, log: true)
180
195
  multiply_filters.concat boost_filters(multiply_by || {})
@@ -189,12 +204,10 @@ module Searchkick
189
204
  boost_where.each do |field, value|
190
205
  if value.is_a?(Array) && value.first.is_a?(Hash)
191
206
  value.each do |value_factor|
192
- value, factor = value_factor[:value], value_factor[:factor]
193
- custom_filters << custom_filter(field, value, factor)
207
+ custom_filters << custom_filter(field, value_factor[:value], value_factor[:factor])
194
208
  end
195
209
  elsif value.is_a?(Hash)
196
- value, factor = value[:value], value[:factor]
197
- custom_filters << custom_filter(field, value, factor)
210
+ custom_filters << custom_filter(field, value[:value], value[:factor])
198
211
  else
199
212
  factor = 1000
200
213
  custom_filters << custom_filter(field, value, factor)
@@ -207,7 +220,7 @@ module Searchkick
207
220
  if !boost_by_distance[:field] || !boost_by_distance[:origin]
208
221
  raise ArgumentError, "boost_by_distance requires :field and :origin"
209
222
  end
210
- function_params = boost_by_distance.select { |k, v| [:origin, :scale, :offset, :decay].include?(k) }
223
+ function_params = boost_by_distance.select { |k, _| [:origin, :scale, :offset, :decay].include?(k) }
211
224
  function_params[:origin] = function_params[:origin].reverse
212
225
  custom_filters << {
213
226
  boost_by_distance[:function] => {
@@ -253,7 +266,7 @@ module Searchkick
253
266
  # filters
254
267
  filters = where_filters(options[:where])
255
268
  if filters.any?
256
- if options[:facets]
269
+ if options[:facets] || options[:aggs]
257
270
  payload[:filter] = {
258
271
  and: filters
259
272
  }
@@ -273,9 +286,7 @@ module Searchkick
273
286
  # facets
274
287
  if options[:facets]
275
288
  facets = options[:facets] || {}
276
- if facets.is_a?(Array) # convert to more advanced syntax
277
- facets = Hash[facets.map { |f| [f, {}] }]
278
- end
289
+ facets = Hash[facets.map { |f| [f, {}] }] if facets.is_a?(Array) # convert to more advanced syntax
279
290
 
280
291
  payload[:facets] = {}
281
292
  facets.each do |field, facet_options|
@@ -311,7 +322,7 @@ module Searchkick
311
322
  # offset is not possible
312
323
  # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
313
324
 
314
- facet_options.deep_merge!(where: options[:where].reject { |k| k == field }) if options[:smart_facets] == true
325
+ facet_options.deep_merge!(where: options.fetch(:where, {}).reject { |k| k == field }) if options[:smart_facets] == true
315
326
  facet_filters = where_filters(facet_options[:where])
316
327
  if facet_filters.any?
317
328
  payload[:facets][field][:facet_filter] = {
@@ -323,6 +334,57 @@ module Searchkick
323
334
  end
324
335
  end
325
336
 
337
+ # aggregations
338
+ if options[:aggs]
339
+ aggs = options[:aggs]
340
+ payload[:aggs] = {}
341
+
342
+ aggs = aggs.map { |f| [f, {}] }.to_h if aggs.is_a?(Array) # convert to more advanced syntax
343
+
344
+ aggs.each do |field, agg_options|
345
+ size = agg_options[:limit] ? agg_options[:limit] : 100_000
346
+
347
+ if agg_options[:ranges]
348
+ payload[:aggs][field] = {
349
+ range: {
350
+ field: agg_options[:field] || field,
351
+ ranges: agg_options[:ranges]
352
+ }
353
+ }
354
+ elsif agg_options[:date_ranges]
355
+ payload[:aggs][field] = {
356
+ date_range: {
357
+ field: agg_options[:field] || field,
358
+ ranges: agg_options[:date_ranges]
359
+ }
360
+ }
361
+ else
362
+ payload[:aggs][field] = {
363
+ terms: {
364
+ field: agg_options[:field] || field,
365
+ size: size
366
+ }
367
+ }
368
+ end
369
+
370
+ where = {}
371
+ where = (options[:where] || {}).reject { |k| k == field } unless options[:smart_aggs] == false
372
+ agg_filters = where_filters(where.merge(agg_options[:where] || {}))
373
+ if agg_filters.any?
374
+ payload[:aggs][field] = {
375
+ filter: {
376
+ bool: {
377
+ must: agg_filters
378
+ }
379
+ },
380
+ aggs: {
381
+ field => payload[:aggs][field]
382
+ }
383
+ }
384
+ end
385
+ end
386
+ end
387
+
326
388
  # suggestions
327
389
  if options[:suggest]
328
390
  suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
@@ -356,6 +418,10 @@ module Searchkick
356
418
  payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
357
419
  end
358
420
 
421
+ if (fragment_size = options[:highlight][:fragment_size])
422
+ payload[:highlight][:fragment_size] = fragment_size
423
+ end
424
+
359
425
  highlight_fields = options[:highlight][:fields]
360
426
  if highlight_fields
361
427
  payload[:highlight][:fields] = {}
@@ -380,9 +446,7 @@ module Searchkick
380
446
  end
381
447
 
382
448
  # routing
383
- if options[:routing]
384
- @routing = options[:routing]
385
- end
449
+ @routing = options[:routing] if options[:routing]
386
450
  end
387
451
 
388
452
  @body = payload
@@ -478,9 +542,7 @@ module Searchkick
478
542
  value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
479
543
  end
480
544
 
481
- if value.is_a?(Array)
482
- value = {in: value}
483
- end
545
+ value = {in: value} if value.is_a?(Array)
484
546
 
485
547
  if value.is_a?(Hash)
486
548
  value.each do |op, op_value|
@@ -506,9 +568,11 @@ module Searchkick
506
568
  when :regexp # support for regexp queries without using a regexp ruby object
507
569
  filters << {regexp: {field => {value: op_value}}}
508
570
  when :not # not equal
509
- filters << {not: term_filters(field, op_value)}
571
+ filters << {not: {filter: term_filters(field, op_value)}}
510
572
  when :all
511
- filters << {terms: {field => op_value, execution: "and"}}
573
+ op_value.each do |value|
574
+ filters << term_filters(field, value)
575
+ end
512
576
  when :in
513
577
  filters << term_filters(field, op_value)
514
578
  else
@@ -560,7 +624,7 @@ module Searchkick
560
624
  def custom_filter(field, value, factor)
561
625
  {
562
626
  filter: {
563
- and: where_filters({field => value})
627
+ and: where_filters(field => value)
564
628
  },
565
629
  boost_factor: factor
566
630
  }
@@ -596,6 +660,10 @@ module Searchkick
596
660
  below_version?("1.4.0")
597
661
  end
598
662
 
663
+ def below20?
664
+ below_version?("2.0.0")
665
+ end
666
+
599
667
  def below_version?(version)
600
668
  Gem::Version.new(Searchkick.server_version) < Gem::Version.new(version)
601
669
  end
@@ -1,6 +1,5 @@
1
1
  module Searchkick
2
2
  class ReindexJob
3
-
4
3
  def initialize(klass, id)
5
4
  @klass = klass
6
5
  @id = id
@@ -23,6 +22,5 @@ module Searchkick
23
22
  index.store record
24
23
  end
25
24
  end
26
-
27
25
  end
28
26
  end
@@ -19,6 +19,5 @@ module Searchkick
19
19
  index.store record
20
20
  end
21
21
  end
22
-
23
22
  end
24
23
  end
@@ -26,7 +26,7 @@ module Searchkick
26
26
  # results can have different types
27
27
  results = {}
28
28
 
29
- hits.group_by { |hit, i| hit["_type"] }.each do |type, grouped_hits|
29
+ hits.group_by { |hit, _| hit["_type"] }.each do |type, grouped_hits|
30
30
  results[type] = results_query(type.camelize.constantize, grouped_hits).to_a.index_by { |r| r.id.to_s }
31
31
  end
32
32
 
@@ -75,6 +75,23 @@ module Searchkick
75
75
  response["facets"]
76
76
  end
77
77
 
78
+ def aggs
79
+ @aggs ||= begin
80
+ response["aggregations"].dup.each do |field, filtered_agg|
81
+ buckets = filtered_agg[field]
82
+ # move the buckets one level above into the field hash
83
+ if buckets
84
+ filtered_agg.delete(field)
85
+ filtered_agg.merge!(buckets)
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ def took
92
+ response["took"]
93
+ end
94
+
78
95
  def model_name
79
96
  klass.model_name
80
97
  end
@@ -1,7 +1,6 @@
1
1
  require "rake"
2
2
 
3
3
  namespace :searchkick do
4
-
5
4
  desc "reindex model"
6
5
  task reindex: :environment do
7
6
  if ENV["CLASS"]
@@ -31,5 +30,4 @@ namespace :searchkick do
31
30
  end
32
31
 
33
32
  end
34
-
35
33
  end
@@ -1,3 +1,3 @@
1
1
  module Searchkick
2
- VERSION = "0.9.1"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -0,0 +1,102 @@
1
+ require_relative "test_helper"
2
+
3
+ class AggsTest < Minitest::Test
4
+ def setup
5
+ super
6
+ store [
7
+ {name: "Product Show", latitude: 37.7833, longitude: 12.4167, store_id: 1, in_stock: true, color: "blue", price: 21, created_at: 2.days.ago},
8
+ {name: "Product Hide", latitude: 29.4167, longitude: -98.5000, store_id: 2, in_stock: false, color: "green", price: 25, created_at: 2.days.from_now},
9
+ {name: "Product B", latitude: 43.9333, longitude: -122.4667, store_id: 2, in_stock: false, color: "red", price: 5},
10
+ {name: "Foo", latitude: 43.9333, longitude: 12.4667, store_id: 3, in_stock: false, color: "yellow", price: 15}
11
+ ]
12
+ end
13
+
14
+ def test_basic
15
+ assert_equal ({1 => 1, 2 => 2}), store_agg(aggs: [:store_id])
16
+ end
17
+
18
+ def test_where
19
+ assert_equal ({1 => 1}), store_agg(aggs: {store_id: {where: {in_stock: true}}})
20
+ end
21
+
22
+ def test_field
23
+ assert_equal ({1 => 1, 2 => 2}), store_agg(aggs: {store_id: {}})
24
+ assert_equal ({1 => 1, 2 => 2}), store_agg(aggs: {store_id: {field: "store_id"}})
25
+ assert_equal ({1 => 1, 2 => 2}), store_agg({aggs: {store_id_new: {field: "store_id"}}}, "store_id_new")
26
+ end
27
+
28
+ def test_limit
29
+ agg = Product.search("Product", aggs: {store_id: {limit: 1}}).aggs["store_id"]
30
+ assert_equal 1, agg["buckets"].size
31
+ # assert_equal 3, agg["doc_count"]
32
+ assert_equal(1, agg["sum_other_doc_count"]) if Gem::Version.new(Searchkick.server_version) >= Gem::Version.new("1.4.0")
33
+ end
34
+
35
+ def test_ranges
36
+ price_ranges = [{to: 10}, {from: 10, to: 20}, {from: 20}]
37
+ agg = Product.search("Product", aggs: {price: {ranges: price_ranges}}).aggs["price"]
38
+
39
+ assert_equal 3, agg["buckets"].size
40
+ assert_equal 10.0, agg["buckets"][0]["to"]
41
+ assert_equal 20.0, agg["buckets"][2]["from"]
42
+ assert_equal 1, agg["buckets"][0]["doc_count"]
43
+ assert_equal 0, agg["buckets"][1]["doc_count"]
44
+ assert_equal 2, agg["buckets"][2]["doc_count"]
45
+ end
46
+
47
+ def test_date_ranges
48
+ ranges = [{to: 1.day.ago}, {from: 1.day.ago, to: 1.day.from_now}, {from: 1.day.from_now}]
49
+ agg = Product.search("Product", aggs: {created_at: {date_ranges: ranges}}).aggs["created_at"]
50
+
51
+ assert_equal 1, agg["buckets"][0]["doc_count"]
52
+ assert_equal 1, agg["buckets"][1]["doc_count"]
53
+ assert_equal 1, agg["buckets"][2]["doc_count"]
54
+ end
55
+
56
+ def test_query_where
57
+ assert_equal ({1 => 1}), store_agg(where: {in_stock: true}, aggs: [:store_id])
58
+ end
59
+
60
+ def test_two_wheres
61
+ assert_equal ({2 => 1}), store_agg(where: {color: "red"}, aggs: {store_id: {where: {in_stock: false}}})
62
+ end
63
+
64
+ def test_where_override
65
+ assert_equal ({}), store_agg(where: {color: "red"}, aggs: {store_id: {where: {in_stock: false, color: "blue"}}})
66
+ assert_equal ({2 => 1}), store_agg(where: {color: "blue"}, aggs: {store_id: {where: {in_stock: false, color: "red"}}})
67
+ end
68
+
69
+ def test_skip
70
+ assert_equal ({1 => 1, 2 => 2}), store_agg(where: {store_id: 2}, aggs: [:store_id])
71
+ end
72
+
73
+ def test_skip_complex
74
+ assert_equal ({1 => 1, 2 => 1}), store_agg(where: {store_id: 2, price: {gt: 5}}, aggs: [:store_id])
75
+ end
76
+
77
+ def test_multiple
78
+ assert_equal ({"store_id" => {1 => 1, 2 => 2}, "color" => {"blue" => 1, "green" => 1, "red" => 1}}), store_multiple_aggs(aggs: [:store_id, :color])
79
+ end
80
+
81
+ def test_smart_aggs_false
82
+ assert_equal ({2 => 2}), store_agg(where: {color: "red"}, aggs: {store_id: {where: {in_stock: false}}}, smart_aggs: false)
83
+ assert_equal ({2 => 2}), store_agg(where: {color: "blue"}, aggs: {store_id: {where: {in_stock: false}}}, smart_aggs: false)
84
+ end
85
+
86
+ protected
87
+
88
+ def buckets_as_hash(agg)
89
+ agg["buckets"].map { |v| [v["key"], v["doc_count"]] }.to_h
90
+ end
91
+
92
+ def store_agg(options, agg_key = "store_id")
93
+ buckets = Product.search("Product", options).aggs[agg_key]
94
+ buckets_as_hash(buckets)
95
+ end
96
+
97
+ def store_multiple_aggs(options)
98
+ Product.search("Product", options).aggs.map do |field, filtered_agg|
99
+ [field, buckets_as_hash(filtered_agg)]
100
+ end.to_h
101
+ end
102
+ end
@@ -1,7 +1,6 @@
1
1
  require_relative "test_helper"
2
2
 
3
- class TestAutocomplete < Minitest::Test
4
-
3
+ class AutocompleteTest < Minitest::Test
5
4
  def test_autocomplete
6
5
  store_names ["Hummus"]
7
6
  assert_search "hum", ["Hummus"], autocomplete: true
@@ -63,5 +62,4 @@ class TestAutocomplete < Minitest::Test
63
62
  store_names ["hi@example.org"]
64
63
  assert_search "hi@example.org", ["hi@example.org"], fields: [{name: :exact}]
65
64
  end
66
-
67
65
  end