searchkick-sinneduy 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
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