searchkick-sinneduy 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +20 -0
- data/.travis.yml +28 -0
- data/CHANGELOG.md +272 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +1109 -0
- data/Rakefile +8 -0
- data/ci/before_install.sh +14 -0
- data/gemfiles/activerecord31.gemfile +7 -0
- data/gemfiles/activerecord32.gemfile +7 -0
- data/gemfiles/activerecord40.gemfile +8 -0
- data/gemfiles/activerecord41.gemfile +8 -0
- data/gemfiles/mongoid2.gemfile +7 -0
- data/gemfiles/mongoid3.gemfile +6 -0
- data/gemfiles/mongoid4.gemfile +7 -0
- data/gemfiles/nobrainer.gemfile +6 -0
- data/lib/searchkick.rb +72 -0
- data/lib/searchkick/index.rb +550 -0
- data/lib/searchkick/logging.rb +136 -0
- data/lib/searchkick/model.rb +102 -0
- data/lib/searchkick/query.rb +567 -0
- data/lib/searchkick/reindex_job.rb +28 -0
- data/lib/searchkick/reindex_v2_job.rb +24 -0
- data/lib/searchkick/results.rb +158 -0
- data/lib/searchkick/tasks.rb +35 -0
- data/lib/searchkick/version.rb +3 -0
- data/searchkick.gemspec +28 -0
- data/test/autocomplete_test.rb +67 -0
- data/test/boost_test.rb +126 -0
- data/test/facets_test.rb +91 -0
- data/test/highlight_test.rb +58 -0
- data/test/index_test.rb +119 -0
- data/test/inheritance_test.rb +80 -0
- data/test/match_test.rb +163 -0
- data/test/model_test.rb +38 -0
- data/test/query_test.rb +14 -0
- data/test/reindex_job_test.rb +33 -0
- data/test/reindex_v2_job_test.rb +34 -0
- data/test/routing_test.rb +14 -0
- data/test/should_index_test.rb +34 -0
- data/test/similar_test.rb +20 -0
- data/test/sql_test.rb +327 -0
- data/test/suggest_test.rb +82 -0
- data/test/synonyms_test.rb +50 -0
- data/test/test_helper.rb +276 -0
- 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
|
data/searchkick.gemspec
ADDED
@@ -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
|
data/test/boost_test.rb
ADDED
@@ -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
|