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.
- 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
|