searchkick 0.1.4 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +76 -7
- data/lib/searchkick.rb +2 -1
- data/lib/searchkick/model.rb +16 -3
- data/lib/searchkick/reindex.rb +61 -7
- data/lib/searchkick/results.rb +17 -0
- data/lib/searchkick/search.rb +94 -37
- data/lib/searchkick/version.rb +1 -1
- data/test/boost_test.rb +10 -0
- data/test/match_test.rb +27 -3
- data/test/sql_test.rb +19 -0
- data/test/suggest_test.rb +70 -0
- data/test/test_helper.rb +13 -5
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9afc0023095cdc587ed6df4faedb9c6ba0927b61
|
4
|
+
data.tar.gz: ff38cab705eb7d2692e81510ff237707820f1bda
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: af68e2a80fc2f8cead9075b8e2e2928ab81dcf98952949f2901d24f151de235b5e648316dce80c84b9197d6b549efc43fb9ed1f04e8f79a8a834e671e68171aa
|
7
|
+
data.tar.gz: b0a23d33695f2b82d84eb2152b07ca43d610ff1ba2200b917baaba61c3a7e8e7292161108270800afba3673484fefc8f19a7757ff2cbac0d131e7c36cccd3a5c
|
data/README.md
CHANGED
@@ -16,6 +16,9 @@ Plus:
|
|
16
16
|
|
17
17
|
- query like SQL - no need to learn a new query language
|
18
18
|
- reindex without downtime
|
19
|
+
- easily personalize results for each user
|
20
|
+
- autocomplete
|
21
|
+
- “Did you mean” suggestions
|
19
22
|
|
20
23
|
:tangerine: Battle-tested at [Instacart](https://www.instacart.com)
|
21
24
|
|
@@ -129,6 +132,24 @@ To change this, use:
|
|
129
132
|
Product.search "fresh honey", partial: true # fresh OR honey
|
130
133
|
```
|
131
134
|
|
135
|
+
### Autocomplete
|
136
|
+
|
137
|
+
![Autocomplete](http://ankane.github.io/searchkick/autocomplete.png)
|
138
|
+
|
139
|
+
You must specify which fields use this feature since this can increase the index size significantly. Don’t worry - this gives you blazing faster queries.
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
class Website < ActiveRecord::Base
|
143
|
+
searchkick autocomplete: ["title"]
|
144
|
+
end
|
145
|
+
```
|
146
|
+
|
147
|
+
Reindex and search with:
|
148
|
+
|
149
|
+
```ruby
|
150
|
+
Website.search "where", autocomplete: true
|
151
|
+
```
|
152
|
+
|
132
153
|
### Synonyms
|
133
154
|
|
134
155
|
```ruby
|
@@ -137,11 +158,11 @@ class Product < ActiveRecord::Base
|
|
137
158
|
end
|
138
159
|
```
|
139
160
|
|
140
|
-
|
161
|
+
Call `Product.reindex` after changing synonyms.
|
141
162
|
|
142
163
|
### Indexing
|
143
164
|
|
144
|
-
Control what data is indexed with the `search_data` method.
|
165
|
+
Control what data is indexed with the `search_data` method. Call `Product.reindex` after changing this method.
|
145
166
|
|
146
167
|
```ruby
|
147
168
|
class Product < ActiveRecord::Base
|
@@ -182,14 +203,19 @@ end
|
|
182
203
|
|
183
204
|
Add conversions to the index.
|
184
205
|
|
206
|
+
**Note**: You must specify the conversions field as of version `0.2.0`.
|
207
|
+
|
185
208
|
```ruby
|
186
209
|
class Product < ActiveRecord::Base
|
187
210
|
has_many :searches
|
188
211
|
|
212
|
+
searchkick conversions: "conversions" # name of field
|
213
|
+
|
189
214
|
def search_data
|
190
215
|
{
|
191
216
|
name: name,
|
192
217
|
conversions: searches.group("query").count
|
218
|
+
# {"ice cream" => 234, "chocolate" => 67, "cream" => 2}
|
193
219
|
}
|
194
220
|
end
|
195
221
|
end
|
@@ -201,11 +227,56 @@ Reindex and set up a cron job to add new conversions daily.
|
|
201
227
|
rake searchkick:reindex CLASS=Product
|
202
228
|
```
|
203
229
|
|
230
|
+
### Personalized Results
|
231
|
+
|
232
|
+
Order results differently for each user. For example, show a user’s previously purchased products before other results.
|
233
|
+
|
234
|
+
```ruby
|
235
|
+
class Product < ActiveRecord::Base
|
236
|
+
searchkick personalize: "user_ids"
|
237
|
+
|
238
|
+
def search_data
|
239
|
+
{
|
240
|
+
name: name,
|
241
|
+
user_ids: orders.pluck(:user_id) # boost this product for these users
|
242
|
+
# [4, 8, 15, 16, 23, 42]
|
243
|
+
}
|
244
|
+
end
|
245
|
+
end
|
246
|
+
```
|
247
|
+
|
248
|
+
Reindex and search with:
|
249
|
+
|
250
|
+
```ruby
|
251
|
+
Product.search "milk", user_id: 8
|
252
|
+
```
|
253
|
+
|
254
|
+
### Suggestions
|
255
|
+
|
256
|
+
![Suggest](http://ankane.github.io/searchkick/recursion.png)
|
257
|
+
|
258
|
+
```ruby
|
259
|
+
class Product < ActiveRecord::Base
|
260
|
+
searchkick suggest: ["name"] # fields to generate suggestions
|
261
|
+
end
|
262
|
+
```
|
263
|
+
|
264
|
+
Reindex and search with:
|
265
|
+
|
266
|
+
```ruby
|
267
|
+
products = Product.search "peantu butta", suggest: true
|
268
|
+
products.suggestions # ["peanut butter"]
|
269
|
+
```
|
270
|
+
|
204
271
|
### Facets
|
205
272
|
|
273
|
+
[Facets](http://www.elasticsearch.org/guide/reference/api/search/facets/) provide aggregated search data.
|
274
|
+
|
275
|
+
![Facets](http://ankane.github.io/searchkick/facets.png)
|
276
|
+
|
206
277
|
```ruby
|
207
|
-
|
208
|
-
p
|
278
|
+
products = Product.search "chuck taylor", facets: [:product_type, :gender, :brand]
|
279
|
+
p products.facets
|
209
280
|
```
|
210
281
|
|
211
282
|
Advanced
|
@@ -323,13 +394,11 @@ end
|
|
323
394
|
|
324
395
|
## Thanks
|
325
396
|
|
326
|
-
Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire)
|
397
|
+
Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kalistsuk for [zero downtime reindexing](https://gist.github.com/jarosan/3124884), and Alex Leschenko for [Elasticsearch autocomplete](https://github.com/leschenko/elasticsearch_autocomplete).
|
327
398
|
|
328
399
|
## TODO
|
329
400
|
|
330
|
-
- Custom results for each user
|
331
401
|
- Make Searchkick work with any language
|
332
|
-
- Built-in synonyms from WordNet
|
333
402
|
|
334
403
|
## Contributing
|
335
404
|
|
data/lib/searchkick.rb
CHANGED
@@ -1,9 +1,10 @@
|
|
1
|
+
require "tire"
|
1
2
|
require "searchkick/version"
|
2
3
|
require "searchkick/reindex"
|
4
|
+
require "searchkick/results"
|
3
5
|
require "searchkick/search"
|
4
6
|
require "searchkick/model"
|
5
7
|
require "searchkick/tasks"
|
6
|
-
require "tire"
|
7
8
|
|
8
9
|
# TODO find better ActiveModel hook
|
9
10
|
ActiveModel::AttributeMethods::ClassMethods.send(:include, Searchkick::Model)
|
data/lib/searchkick/model.rb
CHANGED
@@ -3,7 +3,6 @@ module Searchkick
|
|
3
3
|
|
4
4
|
def searchkick(options = {})
|
5
5
|
@searchkick_options = options.dup
|
6
|
-
@searchkick_options[:conversions] = true if options[:conversions].nil?
|
7
6
|
|
8
7
|
class_eval do
|
9
8
|
extend Searchkick::Search
|
@@ -24,9 +23,23 @@ module Searchkick
|
|
24
23
|
|
25
24
|
def to_indexed_json
|
26
25
|
source = search_data
|
27
|
-
|
28
|
-
|
26
|
+
|
27
|
+
# stringify fields
|
28
|
+
source = source.inject({}){|memo,(k,v)| memo[k.to_s] = v; memo}
|
29
|
+
|
30
|
+
options = self.class.instance_variable_get("@searchkick_options")
|
31
|
+
|
32
|
+
# conversions
|
33
|
+
conversions_field = options[:conversions]
|
34
|
+
if conversions_field and source[conversions_field]
|
35
|
+
source[conversions_field] = source[conversions_field].map{|k, v| {query: k, count: v} }
|
29
36
|
end
|
37
|
+
|
38
|
+
# hack to prevent generator field doesn't exist error
|
39
|
+
(options[:suggest] || []).map(&:to_s).each do |field|
|
40
|
+
source[field] = "a" if !source[field]
|
41
|
+
end
|
42
|
+
|
30
43
|
source.to_json
|
31
44
|
end
|
32
45
|
end
|
data/lib/searchkick/reindex.rb
CHANGED
@@ -7,7 +7,8 @@ module Searchkick
|
|
7
7
|
new_index = alias_name + "_" + Time.now.strftime("%Y%m%d%H%M%S")
|
8
8
|
index = Tire::Index.new(new_index)
|
9
9
|
|
10
|
-
index.create searchkick_index_options
|
10
|
+
success = index.create searchkick_index_options
|
11
|
+
raise index.response.to_s if !success
|
11
12
|
|
12
13
|
# use scope for import
|
13
14
|
scope = respond_to?(:search_import) ? search_import : self
|
@@ -67,6 +68,22 @@ module Searchkick
|
|
67
68
|
type: "custom",
|
68
69
|
tokenizer: "standard",
|
69
70
|
filter: ["standard", "lowercase", "asciifolding", "stop", "snowball"]
|
71
|
+
},
|
72
|
+
# https://github.com/leschenko/elasticsearch_autocomplete/blob/master/lib/elasticsearch_autocomplete/analyzers.rb
|
73
|
+
searchkick_autocomplete_index: {
|
74
|
+
type: "custom",
|
75
|
+
tokenizer: "searchkick_autocomplete_ngram",
|
76
|
+
filter: ["lowercase", "asciifolding"]
|
77
|
+
},
|
78
|
+
searchkick_autocomplete_search: {
|
79
|
+
type: "custom",
|
80
|
+
tokenizer: "keyword",
|
81
|
+
filter: ["lowercase", "asciifolding"]
|
82
|
+
},
|
83
|
+
searchkick_suggest_index: {
|
84
|
+
type: "custom",
|
85
|
+
tokenizer: "standard",
|
86
|
+
filter: ["lowercase", "asciifolding", "searchkick_suggest_shingle"]
|
70
87
|
}
|
71
88
|
},
|
72
89
|
filter: {
|
@@ -80,28 +97,45 @@ module Searchkick
|
|
80
97
|
token_separator: "",
|
81
98
|
output_unigrams: false,
|
82
99
|
output_unigrams_if_no_shingles: true
|
100
|
+
},
|
101
|
+
searchkick_suggest_shingle: {
|
102
|
+
type: "shingle",
|
103
|
+
max_shingle_size: 5
|
104
|
+
}
|
105
|
+
},
|
106
|
+
tokenizer: {
|
107
|
+
searchkick_autocomplete_ngram: {
|
108
|
+
type: "edgeNGram",
|
109
|
+
min_gram: 1,
|
110
|
+
max_gram: 50
|
83
111
|
}
|
84
112
|
}
|
85
113
|
}
|
86
114
|
}.merge(options[:settings] || {})
|
115
|
+
|
116
|
+
# synonyms
|
87
117
|
synonyms = options[:synonyms] || []
|
88
118
|
if synonyms.any?
|
89
119
|
settings[:analysis][:filter][:searchkick_synonym] = {
|
90
120
|
type: "synonym",
|
91
|
-
|
92
|
-
synonyms: synonyms.select{|s| s.size > 1 }.map{|s| "#{s[0..-2].join(",")} => #{s[-1]}" }
|
121
|
+
synonyms: synonyms.select{|s| s.size > 1 }.map{|s| s.join(",") }
|
93
122
|
}
|
94
123
|
# choosing a place for the synonym filter when stemming is not easy
|
95
124
|
# https://groups.google.com/forum/#!topic/elasticsearch/p7qcQlgHdB8
|
96
125
|
# TODO use a snowball stemmer on synonyms when creating the token filter
|
97
|
-
|
126
|
+
|
127
|
+
# http://elasticsearch-users.115913.n3.nabble.com/synonym-multi-words-search-td4030811.html
|
128
|
+
# I find the following approach effective if you are doing multi-word synonyms (synonym phrases):
|
129
|
+
# - Only apply the synonym expansion at index time
|
130
|
+
# - Don't have the synonym filter applied search
|
131
|
+
# - Use directional synonyms where appropriate. You want to make sure that you're not injecting terms that are too general.
|
132
|
+
settings[:analysis][:analyzer][:default_index][:filter].insert(4, "searchkick_synonym")
|
98
133
|
settings[:analysis][:analyzer][:default_index][:filter] << "searchkick_synonym"
|
99
|
-
settings[:analysis][:analyzer][:searchkick_search][:filter].insert(-4, "searchkick_synonym")
|
100
|
-
settings[:analysis][:analyzer][:searchkick_search][:filter] << "searchkick_synonym"
|
101
|
-
settings[:analysis][:analyzer][:searchkick_search2][:filter] << "searchkick_synonym"
|
102
134
|
end
|
103
135
|
|
104
136
|
mapping = {}
|
137
|
+
|
138
|
+
# conversions
|
105
139
|
if options[:conversions]
|
106
140
|
mapping[:conversions] = {
|
107
141
|
type: "nested",
|
@@ -112,6 +146,26 @@ module Searchkick
|
|
112
146
|
}
|
113
147
|
end
|
114
148
|
|
149
|
+
# autocomplete and suggest
|
150
|
+
autocomplete = (options[:autocomplete] || []).map(&:to_s)
|
151
|
+
suggest = (options[:suggest] || []).map(&:to_s)
|
152
|
+
(autocomplete + suggest).uniq.each do |field|
|
153
|
+
field_mapping = {
|
154
|
+
type: "multi_field",
|
155
|
+
fields: {
|
156
|
+
field => {type: "string", index: "not_analyzed"},
|
157
|
+
"analyzed" => {type: "string", index: "analyzed"}
|
158
|
+
}
|
159
|
+
}
|
160
|
+
if autocomplete.include?(field)
|
161
|
+
field_mapping[:fields]["autocomplete"] = {type: "string", index: "analyzed", analyzer: "searchkick_autocomplete_index"}
|
162
|
+
end
|
163
|
+
if suggest.include?(field)
|
164
|
+
field_mapping[:fields]["suggest"] = {type: "string", index: "analyzed", analyzer: "searchkick_suggest_index"}
|
165
|
+
end
|
166
|
+
mapping[field] = field_mapping
|
167
|
+
end
|
168
|
+
|
115
169
|
mappings = {
|
116
170
|
document_type.to_sym => {
|
117
171
|
properties: mapping,
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Searchkick
|
2
|
+
class Results < Tire::Results::Collection
|
3
|
+
|
4
|
+
def suggestions
|
5
|
+
if @response["suggest"]
|
6
|
+
@response["suggest"].values.flat_map{|v| v.first["options"] }.sort_by{|o| -o["score"] }.map{|o| o["text"] }.uniq
|
7
|
+
else
|
8
|
+
raise "Pass `suggest: true` to the search method for suggestions"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# fixes deprecation warning
|
13
|
+
def __find_records_by_ids(klass, ids)
|
14
|
+
@options[:load] === true ? klass.find(ids) : klass.includes(@options[:load][:include]).find(ids)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
data/lib/searchkick/search.rb
CHANGED
@@ -3,56 +3,93 @@ module Searchkick
|
|
3
3
|
|
4
4
|
def search(term, options = {})
|
5
5
|
term = term.to_s
|
6
|
-
fields =
|
6
|
+
fields =
|
7
|
+
if options[:fields]
|
8
|
+
if options[:autocomplete]
|
9
|
+
options[:fields].map{|f| "#{f}.autocomplete" }
|
10
|
+
else
|
11
|
+
options[:fields].map{|f| "#{f}.analyzed" }
|
12
|
+
end
|
13
|
+
else
|
14
|
+
if options[:autocomplete]
|
15
|
+
(@searchkick_options[:autocomplete] || []).map{|f| "#{f}.autocomplete" }
|
16
|
+
else
|
17
|
+
["_all"]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
7
21
|
operator = options[:partial] ? "or" : "and"
|
22
|
+
|
23
|
+
# model and eagar loading
|
8
24
|
load = options[:load].nil? ? true : options[:load]
|
9
25
|
load = (options[:include] ? {include: options[:include]} : true) if load
|
26
|
+
|
27
|
+
# pagination
|
10
28
|
page = options.has_key?(:page) ? [options[:page].to_i, 1].max : nil
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
29
|
+
per_page = options[:limit] || options[:per_page] || 100000
|
30
|
+
offset = options[:offset] || (page && (page - 1) * per_page)
|
31
|
+
index_name = options[:index_name] || index.name
|
32
|
+
|
33
|
+
conversions_field = @searchkick_options[:conversions]
|
34
|
+
personalize_field = @searchkick_options[:personalize]
|
35
|
+
|
36
|
+
# TODO lose Tire DSL for more flexibility
|
37
|
+
s =
|
38
|
+
Tire::Search::Search.new do
|
20
39
|
query do
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
40
|
+
custom_filters_score do
|
41
|
+
query do
|
42
|
+
boolean do
|
43
|
+
must do
|
44
|
+
if options[:autocomplete]
|
45
|
+
match fields, term, analyzer: "searchkick_autocomplete_search"
|
46
|
+
else
|
47
|
+
dis_max do
|
48
|
+
query do
|
49
|
+
match fields, term, use_dis_max: false, boost: 10, operator: operator, analyzer: "searchkick_search"
|
50
|
+
end
|
51
|
+
query do
|
52
|
+
match fields, term, use_dis_max: false, boost: 10, operator: operator, analyzer: "searchkick_search2"
|
53
|
+
end
|
54
|
+
query do
|
55
|
+
match fields, term, use_dis_max: false, fuzziness: 1, max_expansions: 1, operator: operator, analyzer: "searchkick_search"
|
56
|
+
end
|
57
|
+
query do
|
58
|
+
match fields, term, use_dis_max: false, fuzziness: 1, max_expansions: 1, operator: operator, analyzer: "searchkick_search2"
|
59
|
+
end
|
60
|
+
end
|
38
61
|
end
|
39
62
|
end
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
63
|
+
if conversions_field and options[:conversions] != false
|
64
|
+
should do
|
65
|
+
nested path: conversions_field, score_mode: "total" do
|
66
|
+
query do
|
67
|
+
custom_score script: "doc['count'].value" do
|
68
|
+
match "query", term
|
69
|
+
end
|
70
|
+
end
|
48
71
|
end
|
49
72
|
end
|
50
73
|
end
|
51
74
|
end
|
52
75
|
end
|
76
|
+
if options[:boost]
|
77
|
+
filter do
|
78
|
+
filter :exists, field: options[:boost]
|
79
|
+
script "log(doc['#{options[:boost]}'].value + 2.718281828)"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
if options[:user_id] and personalize_field
|
83
|
+
filter do
|
84
|
+
filter :term, personalize_field => options[:user_id]
|
85
|
+
boost 100
|
86
|
+
end
|
87
|
+
end
|
88
|
+
score_mode "total"
|
53
89
|
end
|
54
90
|
end
|
55
|
-
|
91
|
+
size per_page
|
92
|
+
from offset if offset
|
56
93
|
explain options[:explain] if options[:explain]
|
57
94
|
|
58
95
|
# order
|
@@ -143,7 +180,27 @@ module Searchkick
|
|
143
180
|
end
|
144
181
|
end
|
145
182
|
|
146
|
-
|
183
|
+
payload = s.to_hash
|
184
|
+
|
185
|
+
# suggestions
|
186
|
+
if options[:suggest]
|
187
|
+
suggest_fields = (@searchkick_options[:suggest] || []).map(&:to_s)
|
188
|
+
# intersection
|
189
|
+
suggest_fields = suggest_fields & options[:fields].map(&:to_s) if options[:fields]
|
190
|
+
if suggest_fields.any?
|
191
|
+
payload[:suggest] = {text: term}
|
192
|
+
suggest_fields.each do |field|
|
193
|
+
payload[:suggest][field] = {
|
194
|
+
phrase: {
|
195
|
+
field: "#{field}.suggest"
|
196
|
+
}
|
197
|
+
}
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
search = Tire::Search::Search.new(index_name, load: load, payload: payload)
|
203
|
+
Searchkick::Results.new(search.json, search.options.merge(term: term))
|
147
204
|
end
|
148
205
|
|
149
206
|
end
|
data/lib/searchkick/version.rb
CHANGED
data/test/boost_test.rb
CHANGED
@@ -46,4 +46,14 @@ class TestBoost < Minitest::Unit::TestCase
|
|
46
46
|
assert_order "product", ["Product Conversions", "Product Boost"], boost: "orders_count"
|
47
47
|
end
|
48
48
|
|
49
|
+
def test_user_id
|
50
|
+
store [
|
51
|
+
{name: "Tomato A"},
|
52
|
+
{name: "Tomato B", user_ids: [1, 2, 3]},
|
53
|
+
{name: "Tomato C"},
|
54
|
+
{name: "Tomato D"}
|
55
|
+
]
|
56
|
+
assert_first "tomato", "Tomato B", user_id: 2
|
57
|
+
end
|
58
|
+
|
49
59
|
end
|
data/test/match_test.rb
CHANGED
@@ -60,14 +60,21 @@ class TestMatch < Minitest::Unit::TestCase
|
|
60
60
|
assert_search "fin", ["Finn"]
|
61
61
|
end
|
62
62
|
|
63
|
-
def
|
63
|
+
def test_edit_distance_two
|
64
64
|
store_names ["Bingo"]
|
65
65
|
assert_search "bin", []
|
66
|
+
assert_search "bingooo", []
|
67
|
+
assert_search "mango", []
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_edit_distance_one
|
71
|
+
store_names ["Bingo"]
|
66
72
|
assert_search "bing", ["Bingo"]
|
67
73
|
assert_search "bingoo", ["Bingo"]
|
68
|
-
assert_search "bingooo", []
|
69
74
|
assert_search "ringo", ["Bingo"]
|
70
|
-
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_edit_distance_long_word
|
71
78
|
store_names ["thisisareallylongword"]
|
72
79
|
assert_search "thisisareallylongwor", ["thisisareallylongword"] # missing letter
|
73
80
|
assert_search "thisisareelylongword", [] # edit distance = 2
|
@@ -110,4 +117,21 @@ class TestMatch < Minitest::Unit::TestCase
|
|
110
117
|
assert_search "almondmilks", ["Almond Milk"]
|
111
118
|
end
|
112
119
|
|
120
|
+
# autocomplete
|
121
|
+
|
122
|
+
def test_autocomplete
|
123
|
+
store_names ["Hummus"]
|
124
|
+
assert_search "hum", ["Hummus"], autocomplete: true
|
125
|
+
end
|
126
|
+
|
127
|
+
def test_autocomplete_two_words
|
128
|
+
store_names ["Organic Hummus"]
|
129
|
+
assert_search "hum", [], autocomplete: true
|
130
|
+
end
|
131
|
+
|
132
|
+
def test_autocomplete_fields
|
133
|
+
store_names ["Hummus"]
|
134
|
+
assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name]
|
135
|
+
end
|
136
|
+
|
113
137
|
end
|
data/test/sql_test.rb
CHANGED
@@ -7,6 +7,12 @@ class TestSql < Minitest::Unit::TestCase
|
|
7
7
|
assert_order "product", ["Product A", "Product B"], order: {name: :asc}, limit: 2
|
8
8
|
end
|
9
9
|
|
10
|
+
def test_no_limit
|
11
|
+
names = 20.times.map{|i| "Product #{i}" }
|
12
|
+
store_names names
|
13
|
+
assert_search "product", names
|
14
|
+
end
|
15
|
+
|
10
16
|
def test_offset
|
11
17
|
store_names ["Product A", "Product B", "Product C", "Product D"]
|
12
18
|
assert_order "product", ["Product C", "Product D"], order: {name: :asc}, offset: 2
|
@@ -81,6 +87,19 @@ class TestSql < Minitest::Unit::TestCase
|
|
81
87
|
assert_search "blue", ["red"], fields: ["color"]
|
82
88
|
end
|
83
89
|
|
90
|
+
def test_non_existent_field
|
91
|
+
store_names ["Milk"]
|
92
|
+
assert_search "milk", [], fields: ["not_here"]
|
93
|
+
end
|
94
|
+
|
95
|
+
def test_fields_both_match
|
96
|
+
store [
|
97
|
+
{name: "Blue A", color: "red"},
|
98
|
+
{name: "Blue B", color: "light blue"}
|
99
|
+
]
|
100
|
+
assert_first "blue", "Blue B", fields: [:name, :color]
|
101
|
+
end
|
102
|
+
|
84
103
|
# load
|
85
104
|
|
86
105
|
def test_load_default
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require_relative "test_helper"
|
2
|
+
|
3
|
+
class TestSuggest < Minitest::Unit::TestCase
|
4
|
+
|
5
|
+
def test_basic
|
6
|
+
store_names ["Great White Shark", "Hammerhead Shark", "Tiger Shark"]
|
7
|
+
assert_suggest "How Big is a Tigre Shar", "how big is a tiger shark"
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_perfect
|
11
|
+
store_names ["Tiger Shark", "Great White Shark"]
|
12
|
+
assert_suggest "Tiger Shark", nil # no correction
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_phrase
|
16
|
+
store_names ["Big Tiger Shark", "Tiger Sharp Teeth", "Tiger Sharp Mind"]
|
17
|
+
assert_suggest "How to catch a big tiger shar", "how to catch a big tiger shark"
|
18
|
+
end
|
19
|
+
|
20
|
+
def test_without_option
|
21
|
+
assert_raises(RuntimeError){ Product.search("hi").suggestions }
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_multiple_fields
|
25
|
+
store [
|
26
|
+
{name: "Shark", color: "Sharp"}
|
27
|
+
]
|
28
|
+
assert_suggest_all "shar", ["shark", "sharp"]
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_multiple_fields_highest_score_first
|
32
|
+
store [
|
33
|
+
{name: "Tiger Shark", color: "Sharp"}
|
34
|
+
]
|
35
|
+
assert_suggest "tiger shar", "tiger shark"
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_multiple_fields_same_value
|
39
|
+
store [
|
40
|
+
{name: "Shark", color: "Shark"}
|
41
|
+
]
|
42
|
+
assert_suggest_all "shar", ["shark"]
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_fields_option
|
46
|
+
store [
|
47
|
+
{name: "Shark", color: "Sharp"}
|
48
|
+
]
|
49
|
+
assert_suggest_all "shar", ["shark"], fields: [:name]
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_fields_option_multiple
|
53
|
+
store [
|
54
|
+
{name: "Shark"}
|
55
|
+
]
|
56
|
+
assert_suggest "shar", "shark", fields: [:name, :unknown]
|
57
|
+
end
|
58
|
+
|
59
|
+
protected
|
60
|
+
|
61
|
+
def assert_suggest(term, expected, options = {})
|
62
|
+
assert_equal expected, Product.search(term, options.merge(suggest: true)).suggestions.first
|
63
|
+
end
|
64
|
+
|
65
|
+
# any order
|
66
|
+
def assert_suggest_all(term, expected, options = {})
|
67
|
+
assert_equal expected.sort, Product.search(term, options.merge(suggest: true)).suggestions.sort
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
data/test/test_helper.rb
CHANGED
@@ -44,15 +44,19 @@ class Product < ActiveRecord::Base
|
|
44
44
|
["clorox", "bleach"],
|
45
45
|
["scallion", "greenonion"],
|
46
46
|
["saranwrap", "plasticwrap"],
|
47
|
-
["qtip", "
|
47
|
+
["qtip", "cottonswab"],
|
48
48
|
["burger", "hamburger"],
|
49
49
|
["bandaid", "bandag"]
|
50
|
-
]
|
50
|
+
],
|
51
|
+
autocomplete: [:name],
|
52
|
+
suggest: [:name, :color],
|
53
|
+
conversions: "conversions",
|
54
|
+
personalize: "user_ids"
|
51
55
|
|
52
|
-
attr_accessor :conversions
|
56
|
+
attr_accessor :conversions, :user_ids
|
53
57
|
|
54
58
|
def search_data
|
55
|
-
as_json.merge conversions: conversions
|
59
|
+
as_json.merge conversions: conversions, user_ids: user_ids
|
56
60
|
end
|
57
61
|
end
|
58
62
|
|
@@ -70,7 +74,7 @@ class MiniTest::Unit::TestCase
|
|
70
74
|
protected
|
71
75
|
|
72
76
|
def store(documents)
|
73
|
-
documents.each do |document|
|
77
|
+
documents.shuffle.each do |document|
|
74
78
|
Product.create!(document)
|
75
79
|
end
|
76
80
|
Product.index.refresh
|
@@ -89,4 +93,8 @@ class MiniTest::Unit::TestCase
|
|
89
93
|
assert_equal expected, Product.search(term, options).map(&:name)
|
90
94
|
end
|
91
95
|
|
96
|
+
def assert_first(term, expected, options = {})
|
97
|
+
assert_equal expected, Product.search(term, options).map(&:name).first
|
98
|
+
end
|
99
|
+
|
92
100
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: searchkick
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2013-08-
|
11
|
+
date: 2013-08-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: tire
|
@@ -109,6 +109,7 @@ files:
|
|
109
109
|
- lib/searchkick.rb
|
110
110
|
- lib/searchkick/model.rb
|
111
111
|
- lib/searchkick/reindex.rb
|
112
|
+
- lib/searchkick/results.rb
|
112
113
|
- lib/searchkick/search.rb
|
113
114
|
- lib/searchkick/tasks.rb
|
114
115
|
- lib/searchkick/version.rb
|
@@ -117,6 +118,7 @@ files:
|
|
117
118
|
- test/facets_test.rb
|
118
119
|
- test/match_test.rb
|
119
120
|
- test/sql_test.rb
|
121
|
+
- test/suggest_test.rb
|
120
122
|
- test/synonyms_test.rb
|
121
123
|
- test/test_helper.rb
|
122
124
|
homepage: https://github.com/ankane/searchkick
|
@@ -148,5 +150,6 @@ test_files:
|
|
148
150
|
- test/facets_test.rb
|
149
151
|
- test/match_test.rb
|
150
152
|
- test/sql_test.rb
|
153
|
+
- test/suggest_test.rb
|
151
154
|
- test/synonyms_test.rb
|
152
155
|
- test/test_helper.rb
|