searchkick-sinneduy 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +28 -0
  4. data/CHANGELOG.md +272 -0
  5. data/Gemfile +7 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +1109 -0
  8. data/Rakefile +8 -0
  9. data/ci/before_install.sh +14 -0
  10. data/gemfiles/activerecord31.gemfile +7 -0
  11. data/gemfiles/activerecord32.gemfile +7 -0
  12. data/gemfiles/activerecord40.gemfile +8 -0
  13. data/gemfiles/activerecord41.gemfile +8 -0
  14. data/gemfiles/mongoid2.gemfile +7 -0
  15. data/gemfiles/mongoid3.gemfile +6 -0
  16. data/gemfiles/mongoid4.gemfile +7 -0
  17. data/gemfiles/nobrainer.gemfile +6 -0
  18. data/lib/searchkick.rb +72 -0
  19. data/lib/searchkick/index.rb +550 -0
  20. data/lib/searchkick/logging.rb +136 -0
  21. data/lib/searchkick/model.rb +102 -0
  22. data/lib/searchkick/query.rb +567 -0
  23. data/lib/searchkick/reindex_job.rb +28 -0
  24. data/lib/searchkick/reindex_v2_job.rb +24 -0
  25. data/lib/searchkick/results.rb +158 -0
  26. data/lib/searchkick/tasks.rb +35 -0
  27. data/lib/searchkick/version.rb +3 -0
  28. data/searchkick.gemspec +28 -0
  29. data/test/autocomplete_test.rb +67 -0
  30. data/test/boost_test.rb +126 -0
  31. data/test/facets_test.rb +91 -0
  32. data/test/highlight_test.rb +58 -0
  33. data/test/index_test.rb +119 -0
  34. data/test/inheritance_test.rb +80 -0
  35. data/test/match_test.rb +163 -0
  36. data/test/model_test.rb +38 -0
  37. data/test/query_test.rb +14 -0
  38. data/test/reindex_job_test.rb +33 -0
  39. data/test/reindex_v2_job_test.rb +34 -0
  40. data/test/routing_test.rb +14 -0
  41. data/test/should_index_test.rb +34 -0
  42. data/test/similar_test.rb +20 -0
  43. data/test/sql_test.rb +327 -0
  44. data/test/suggest_test.rb +82 -0
  45. data/test/synonyms_test.rb +50 -0
  46. data/test/test_helper.rb +276 -0
  47. metadata +194 -0
@@ -0,0 +1,28 @@
1
+ module Searchkick
2
+ class ReindexJob
3
+
4
+ def initialize(klass, id)
5
+ @klass = klass
6
+ @id = id
7
+ end
8
+
9
+ def perform
10
+ model = @klass.constantize
11
+ record = model.find(@id) rescue nil # TODO fix lazy coding
12
+ index = model.searchkick_index
13
+ if !record || !record.should_index?
14
+ # hacky
15
+ record ||= model.new
16
+ record.id = @id
17
+ begin
18
+ index.remove record
19
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
20
+ # do nothing
21
+ end
22
+ else
23
+ index.store record
24
+ end
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,24 @@
1
+ module Searchkick
2
+ class ReindexV2Job < ActiveJob::Base
3
+ queue_as :searchkick
4
+
5
+ def perform(klass, id)
6
+ model = klass.constantize
7
+ record = model.find(id) rescue nil # TODO fix lazy coding
8
+ index = model.searchkick_index
9
+ if !record || !record.should_index?
10
+ # hacky
11
+ record ||= model.new
12
+ record.id = id
13
+ begin
14
+ index.remove record
15
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
16
+ # do nothing
17
+ end
18
+ else
19
+ index.store record
20
+ end
21
+ end
22
+
23
+ end
24
+ end
@@ -0,0 +1,158 @@
1
+ require "forwardable"
2
+
3
+ module Searchkick
4
+ class Results
5
+ include Enumerable
6
+ extend Forwardable
7
+
8
+ attr_reader :klass, :response, :options
9
+
10
+ def_delegators :results, :each, :any?, :empty?, :size, :length, :slice, :[], :to_ary
11
+
12
+ def initialize(klass, response, options = {})
13
+ @klass = klass
14
+ @response = response
15
+ @options = options
16
+ end
17
+
18
+ def results
19
+ @results ||= begin
20
+ if options[:load]
21
+ # results can have different types
22
+ results = {}
23
+
24
+ hits.group_by { |hit, i| hit["_type"] }.each do |type, grouped_hits|
25
+ records = type.camelize.constantize
26
+ if options[:includes]
27
+ if defined?(NoBrainer::Document) && records < NoBrainer::Document
28
+ records = records.preload(options[:includes])
29
+ else
30
+ records = records.includes(options[:includes])
31
+ end
32
+ end
33
+ results[type] = results_query(records, grouped_hits)
34
+ end
35
+
36
+ # sort
37
+ hits.map do |hit|
38
+ results[hit["_type"]].find { |r| r.id.to_s == hit["_id"].to_s }
39
+ end.compact
40
+ else
41
+ hits.map do |hit|
42
+ result =
43
+ if hit["_source"]
44
+ hit.except("_source").merge(hit["_source"])
45
+ else
46
+ hit.except("fields").merge(hit["fields"])
47
+ end
48
+ result["id"] ||= result["_id"] # needed for legacy reasons
49
+ Hashie::Mash.new(result)
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def suggestions
56
+ if response["suggest"]
57
+ response["suggest"].values.flat_map { |v| v.first["options"] }.sort_by { |o| -o["score"] }.map { |o| o["text"] }.uniq
58
+ else
59
+ raise "Pass `suggest: true` to the search method for suggestions"
60
+ end
61
+ end
62
+
63
+ def each_with_hit(&block)
64
+ results.zip(hits).each(&block)
65
+ end
66
+
67
+ def with_details
68
+ each_with_hit.map do |model, hit|
69
+ details = {}
70
+ if hit["highlight"]
71
+ details[:highlight] = Hash[hit["highlight"].map { |k, v| [(options[:json] ? k : k.sub(/\.analyzed\z/, "")).to_sym, v.first] }]
72
+ end
73
+ [model, details]
74
+ end
75
+ end
76
+
77
+ def facets
78
+ response["facets"]
79
+ end
80
+
81
+ def model_name
82
+ klass.model_name
83
+ end
84
+
85
+ def entry_name
86
+ model_name.human.downcase
87
+ end
88
+
89
+ def total_count
90
+ response["hits"]["total"]
91
+ end
92
+ alias_method :total_entries, :total_count
93
+
94
+ def current_page
95
+ options[:page]
96
+ end
97
+
98
+ def per_page
99
+ options[:per_page]
100
+ end
101
+ alias_method :limit_value, :per_page
102
+
103
+ def padding
104
+ options[:padding]
105
+ end
106
+
107
+ def total_pages
108
+ (total_count / per_page.to_f).ceil
109
+ end
110
+ alias_method :num_pages, :total_pages
111
+
112
+ def offset_value
113
+ (current_page - 1) * per_page + padding
114
+ end
115
+ alias_method :offset, :offset_value
116
+
117
+ def previous_page
118
+ current_page > 1 ? (current_page - 1) : nil
119
+ end
120
+ alias_method :prev_page, :previous_page
121
+
122
+ def next_page
123
+ current_page < total_pages ? (current_page + 1) : nil
124
+ end
125
+
126
+ def first_page?
127
+ previous_page.nil?
128
+ end
129
+
130
+ def last_page?
131
+ next_page.nil?
132
+ end
133
+
134
+ def hits
135
+ @response["hits"]["hits"]
136
+ end
137
+
138
+ private
139
+
140
+ def results_query(records, grouped_hits)
141
+ if records.respond_to?(:primary_key) && records.primary_key
142
+ # ActiveRecord
143
+ records.where(records.primary_key => grouped_hits.map { |hit| hit["_id"] }).to_a
144
+ elsif records.respond_to?(:all) && records.all.respond_to?(:for_ids)
145
+ # Mongoid 2
146
+ records.all.for_ids(grouped_hits.map { |hit| hit["_id"] }).to_a
147
+ elsif records.respond_to?(:queryable)
148
+ # Mongoid 3+
149
+ records.queryable.for_ids(grouped_hits.map { |hit| hit["_id"] }).to_a
150
+ elsif records.respond_to?(:unscoped) && records.all.respond_to?(:preload)
151
+ # Nobrainer
152
+ records.unscoped.where(:id.in => grouped_hits.map { |hit| hit["_id"] }).to_a
153
+ else
154
+ raise "Not sure how to load records"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,35 @@
1
+ require "rake"
2
+
3
+ namespace :searchkick do
4
+
5
+ desc "reindex model"
6
+ task reindex: :environment do
7
+ if ENV["CLASS"]
8
+ klass = ENV["CLASS"].constantize rescue nil
9
+ if klass
10
+ klass.reindex
11
+ else
12
+ abort "Could not find class: #{ENV['CLASS']}"
13
+ end
14
+ else
15
+ abort "USAGE: rake searchkick:reindex CLASS=Product"
16
+ end
17
+ end
18
+
19
+ if defined?(Rails)
20
+
21
+ namespace :reindex do
22
+ desc "reindex all models"
23
+ task all: :environment do
24
+ Rails.application.eager_load!
25
+ Searchkick.models.each do |model|
26
+ puts "Reindexing #{model.name}..."
27
+ model.reindex
28
+ end
29
+ puts "Reindex complete"
30
+ end
31
+ end
32
+
33
+ end
34
+
35
+ end
@@ -0,0 +1,3 @@
1
+ module Searchkick
2
+ VERSION = "0.9.0"
3
+ end
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "searchkick/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "searchkick-sinneduy"
8
+ spec.version = Searchkick::VERSION
9
+ spec.authors = ["Andrew Kane"]
10
+ spec.email = ["andrew@chartkick.com"]
11
+ spec.description = "Intelligent search made easy"
12
+ spec.summary = "Searchkick learns what your users are looking for. As more people search, it gets smarter and the results get better. It’s friendly for developers - and magical for your users."
13
+ spec.homepage = "https://github.com/ankane/searchkick"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activemodel"
22
+ spec.add_dependency "elasticsearch-sinneduy", ">= 1.0.13"
23
+ spec.add_dependency "hashie"
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.6"
26
+ spec.add_development_dependency "rake"
27
+ spec.add_development_dependency "minitest"
28
+ end
@@ -0,0 +1,67 @@
1
+ require_relative "test_helper"
2
+
3
+ class TestAutocomplete < Minitest::Test
4
+
5
+ def test_autocomplete
6
+ store_names ["Hummus"]
7
+ assert_search "hum", ["Hummus"], autocomplete: true
8
+ end
9
+
10
+ def test_autocomplete_two_words
11
+ store_names ["Organic Hummus"]
12
+ assert_search "hum", [], autocomplete: true
13
+ end
14
+
15
+ def test_autocomplete_fields
16
+ store_names ["Hummus"]
17
+ assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name]
18
+ end
19
+
20
+ def test_text_start
21
+ store_names ["Where in the World is Carmen San Diego"]
22
+ assert_search "where in the world is", ["Where in the World is Carmen San Diego"], fields: [{name: :text_start}]
23
+ assert_search "in the world", [], fields: [{name: :text_start}]
24
+ end
25
+
26
+ def test_text_middle
27
+ store_names ["Where in the World is Carmen San Diego"]
28
+ assert_search "where in the world is", ["Where in the World is Carmen San Diego"], fields: [{name: :text_middle}]
29
+ assert_search "n the wor", ["Where in the World is Carmen San Diego"], fields: [{name: :text_middle}]
30
+ assert_search "men san diego", ["Where in the World is Carmen San Diego"], fields: [{name: :text_middle}]
31
+ assert_search "world carmen", [], fields: [{name: :text_middle}]
32
+ end
33
+
34
+ def test_text_end
35
+ store_names ["Where in the World is Carmen San Diego"]
36
+ assert_search "men san diego", ["Where in the World is Carmen San Diego"], fields: [{name: :text_end}]
37
+ assert_search "carmen san", [], fields: [{name: :text_end}]
38
+ end
39
+
40
+ def test_word_start
41
+ store_names ["Where in the World is Carmen San Diego"]
42
+ assert_search "car san wor", ["Where in the World is Carmen San Diego"], fields: [{name: :word_start}]
43
+ end
44
+
45
+ def test_word_middle
46
+ store_names ["Where in the World is Carmen San Diego"]
47
+ assert_search "orl", ["Where in the World is Carmen San Diego"], fields: [{name: :word_middle}]
48
+ end
49
+
50
+ def test_word_end
51
+ store_names ["Where in the World is Carmen San Diego"]
52
+ assert_search "rld men ego", ["Where in the World is Carmen San Diego"], fields: [{name: :word_end}]
53
+ end
54
+
55
+ def test_word_start_multiple_words
56
+ store_names ["Dark Grey", "Dark Blue"]
57
+ assert_search "dark grey", ["Dark Grey"], fields: [{name: :word_start}]
58
+ end
59
+
60
+ # TODO find a better place
61
+
62
+ def test_exact
63
+ store_names ["hi@example.org"]
64
+ assert_search "hi@example.org", ["hi@example.org"], fields: [{name: :exact}]
65
+ end
66
+
67
+ end
@@ -0,0 +1,126 @@
1
+ require_relative "test_helper"
2
+
3
+ class TestBoost < Minitest::Test
4
+
5
+ # conversions
6
+
7
+ def test_conversions
8
+ store [
9
+ {name: "Tomato A", conversions: {"tomato" => 1}},
10
+ {name: "Tomato B", conversions: {"tomato" => 2}},
11
+ {name: "Tomato C", conversions: {"tomato" => 3}}
12
+ ]
13
+ assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"]
14
+ end
15
+
16
+ def test_conversions_stemmed
17
+ store [
18
+ {name: "Tomato A", conversions: {"tomato" => 1, "tomatos" => 1, "Tomatoes" => 1}},
19
+ {name: "Tomato B", conversions: {"tomato" => 2}}
20
+ ]
21
+ assert_order "tomato", ["Tomato A", "Tomato B"]
22
+ end
23
+
24
+ # global boost
25
+
26
+ def test_boost
27
+ store [
28
+ {name: "Tomato A"},
29
+ {name: "Tomato B", orders_count: 10},
30
+ {name: "Tomato C", orders_count: 100}
31
+ ]
32
+ assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"], boost: "orders_count"
33
+ end
34
+
35
+ def test_boost_zero
36
+ store [
37
+ {name: "Zero Boost", orders_count: 0}
38
+ ]
39
+ assert_order "zero", ["Zero Boost"], boost: "orders_count"
40
+ end
41
+
42
+ def test_conversions_weight
43
+ store [
44
+ {name: "Product Boost", orders_count: 20},
45
+ {name: "Product Conversions", conversions: {"product" => 10}}
46
+ ]
47
+ assert_order "product", ["Product Conversions", "Product Boost"], boost: "orders_count"
48
+ end
49
+
50
+ def test_user_id
51
+ store [
52
+ {name: "Tomato A"},
53
+ {name: "Tomato B", user_ids: [1, 2, 3]},
54
+ {name: "Tomato C"},
55
+ {name: "Tomato D"}
56
+ ]
57
+ assert_first "tomato", "Tomato B", user_id: 2
58
+ end
59
+
60
+ def test_personalize
61
+ store [
62
+ {name: "Tomato A"},
63
+ {name: "Tomato B", user_ids: [1, 2, 3]},
64
+ {name: "Tomato C"},
65
+ {name: "Tomato D"}
66
+ ]
67
+ assert_first "tomato", "Tomato B", personalize: {user_ids: 2}
68
+ end
69
+
70
+ def test_boost_fields
71
+ store [
72
+ {name: "Red", color: "White"},
73
+ {name: "White", color: "Red Red Red"}
74
+ ]
75
+ assert_order "red", ["Red", "White"], fields: ["name^10", "color"]
76
+ end
77
+
78
+ def test_boost_fields_decimal
79
+ store [
80
+ {name: "Red", color: "White"},
81
+ {name: "White", color: "Red Red Red"}
82
+ ]
83
+ assert_order "red", ["Red", "White"], fields: ["name^10.5", "color"]
84
+ end
85
+
86
+ def test_boost_fields_word_start
87
+ store [
88
+ {name: "Red", color: "White"},
89
+ {name: "White", color: "Red Red Red"}
90
+ ]
91
+ assert_order "red", ["Red", "White"], fields: [{"name^10" => :word_start}, "color"]
92
+ end
93
+
94
+ def test_boost_by
95
+ store [
96
+ {name: "Tomato A"},
97
+ {name: "Tomato B", orders_count: 10},
98
+ {name: "Tomato C", orders_count: 100}
99
+ ]
100
+ assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"], boost_by: [:orders_count]
101
+ assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"], boost_by: {orders_count: {factor: 10}}
102
+ end
103
+
104
+ def test_boost_where
105
+ store [
106
+ {name: "Tomato A"},
107
+ {name: "Tomato B", user_ids: [1, 2]},
108
+ {name: "Tomato C", user_ids: [3]}
109
+ ]
110
+ assert_first "tomato", "Tomato B", boost_where: {user_ids: 2}
111
+ assert_first "tomato", "Tomato B", boost_where: {user_ids: [1, 4]}
112
+ assert_first "tomato", "Tomato B", boost_where: {user_ids: {value: 2, factor: 10}}
113
+ assert_first "tomato", "Tomato B", boost_where: {user_ids: {value: [1, 4], factor: 10}}
114
+ assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"], boost_where: {user_ids: [{value: 1, factor: 10}, {value: 3, factor: 20}]}
115
+ end
116
+
117
+ def test_boost_by_distance
118
+ store [
119
+ {name: "San Francisco", latitude: 37.7833, longitude: -122.4167},
120
+ {name: "San Antonio", latitude: 29.4167, longitude: -98.5000},
121
+ {name: "San Marino", latitude: 43.9333, longitude: 12.4667}
122
+ ]
123
+ assert_order "san", ["San Francisco", "San Antonio", "San Marino"], boost_by_distance: {field: :location, origin: [37, -122], scale: "1000mi"}
124
+ end
125
+
126
+ end