searchkick 0.0.2 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f37d2d366a7f03a6e2757c688385aed25678836c
4
- data.tar.gz: e4648bd3692a88ccd7d9669376f8fa89af8e7431
3
+ metadata.gz: edbfa8b338d8ddc3b4bcef4755151c98c7afeb30
4
+ data.tar.gz: aa1e1fbda8e995dfeb22cb005b5fc755020980bf
5
5
  SHA512:
6
- metadata.gz: 071e83eab034feb8784f32791728fee4cb61e3670ef9f36f97ad66961e7c12c299214e44bde1c5aec45f56b92a5d639591e2f9845a3fd2a1c8aee9e3fb14c7a9
7
- data.tar.gz: e42658adc41be4c3e2232bfa510f9e2026da8c6a1303b192e5f03692d9c0d15068e3d92252955bba136bea21bfe830674ae84251e7134dc280f642d7b7f9bea6
6
+ metadata.gz: 531313b35dae913d8d999fe3283c2a2535ada666c58c567cc385ac6574dbec0015bf65f2dbacdc1ac07b18eaca31d40aa776d968d1cb36db16f06dc34de633af
7
+ data.tar.gz: c3bd0aaee05a28b95d2884104144bc7058f26bd3b2c5eba75c68b02022c203bd3326fdfe99702139d264d386f92879467afb69f783a354662f188ead85788086
data/README.md CHANGED
@@ -2,19 +2,39 @@
2
2
 
3
3
  :rocket: Search made easy
4
4
 
5
- Searchkick provides sensible search defaults out of the box. It handles:
5
+ Searchkick provides sensible search defaults. It handles:
6
6
 
7
7
  - stemming - `tomatoes` matches `tomato`
8
- - special characters - `jalapenos` matches `jalapeños`
8
+ - special characters - `jalapeno` matches `jalapeño`
9
9
  - extra whitespace - `dishwasher` matches `dish washer`
10
10
  - misspellings - `zuchini` matches `zucchini`
11
11
  - custom synonyms - `qtip` matches `cotton swab`
12
12
 
13
- Runs on Elasticsearch
13
+ Plus:
14
+
15
+ - query like SQL - no need to learn a new query language
16
+ - reindex without downtime
17
+ - continually improve results from conversions - **pretty awesome**
18
+
19
+ Powered by Elasticsearch
14
20
 
15
21
  :tangerine: Battle-tested at [Instacart](https://www.instacart.com)
16
22
 
17
- ## Usage
23
+ ## Get Started
24
+
25
+ [Install Elasticsearch](http://www.elasticsearch.org/guide/reference/setup/installation/). For Homebrew, use:
26
+
27
+ ```sh
28
+ brew install elasticsearch
29
+ ```
30
+
31
+ Add this line to your application’s Gemfile:
32
+
33
+ ```ruby
34
+ gem "searchkick"
35
+ ```
36
+
37
+ Add searchkick to models you want to search.
18
38
 
19
39
  ```ruby
20
40
  class Product < ActiveRecord::Base
@@ -22,25 +42,37 @@ class Product < ActiveRecord::Base
22
42
  end
23
43
  ```
24
44
 
25
- And to query, use:
45
+ Add data to the search index.
26
46
 
27
47
  ```ruby
28
- Product.search "2% Milk"
48
+ Product.reindex
29
49
  ```
30
50
 
31
- or only search specific fields:
51
+ And to query, use:
32
52
 
33
53
  ```ruby
34
- Product.search "Butter", fields: [:name, :brand]
54
+ products = Product.search "2% Milk"
55
+ products.each do |product|
56
+ puts product.name
57
+ puts product._score # added by searchkick - between 0 and 1
58
+ end
35
59
  ```
36
60
 
37
- ### Query Like SQL
61
+ ### Queries
62
+
63
+ Query like SQL
38
64
 
39
65
  ```ruby
40
66
  Product.search "2% Milk", where: {in_stock: true}, limit: 10, offset: 50
41
67
  ```
42
68
 
43
- #### Where
69
+ Search specific fields
70
+
71
+ ```ruby
72
+ fields: [:name, :brand]
73
+ ```
74
+
75
+ Where
44
76
 
45
77
  ```ruby
46
78
  where: {
@@ -55,28 +87,48 @@ where: {
55
87
  }
56
88
  ```
57
89
 
58
- #### Order
90
+ Order
59
91
 
60
92
  ```ruby
61
93
  order: {_score: :desc} # most relevant first - default
62
94
  ```
63
95
 
64
- #### Explain
96
+ Limit / offset
65
97
 
66
98
  ```ruby
67
- explain: true
99
+ limit: 20, offset: 40
68
100
  ```
69
101
 
70
- ### Facets
102
+ Boost by a field
71
103
 
72
104
  ```ruby
73
- Product.search "2% Milk", facets: [:store_id, :aisle_id]
105
+ boost: "orders_count" # give popular documents a little boost
74
106
  ```
75
107
 
76
- Advanced
108
+ ### Pagination
109
+
110
+ Plays nicely with kaminari and will_paginate.
77
111
 
78
112
  ```ruby
79
- Product.search "2% Milk", facets: {store_id: {where: {in_stock: true}}}
113
+ # controller
114
+ @products = Product.search "milk", page: params[:page], per_page: 20
115
+
116
+ # view
117
+ <%= paginate @products %>
118
+ ```
119
+
120
+ ### Partial Matches
121
+
122
+ By default, results must match all words in the query.
123
+
124
+ ```ruby
125
+ Product.search "fresh honey" # fresh AND honey
126
+ ```
127
+
128
+ To change this, use:
129
+
130
+ ```ruby
131
+ Product.search "fresh honey", partial: true # fresh OR honey
80
132
  ```
81
133
 
82
134
  ### Synonyms
@@ -89,11 +141,39 @@ end
89
141
 
90
142
  You must call `Product.reindex` after changing synonyms.
91
143
 
92
- ### Make Searches Better Over Time
144
+ ### Indexing
145
+
146
+ Choose what data is indexed.
147
+
148
+ ```ruby
149
+ class Product < ActiveRecord::Base
150
+ def _source
151
+ as_json only: [:name, :active], include: {brand: {only: [:city]}}
152
+ # or equivalently
153
+ {
154
+ name: name,
155
+ active: active,
156
+ brand: {
157
+ city: brand.city
158
+ }
159
+ }
160
+ end
161
+ end
162
+ ```
163
+
164
+ Searchkick uses `find_in_batches` to import documents. To eager load associations, use the `searchkick_import` scope.
165
+
166
+ ```ruby
167
+ class Product < ActiveRecord::Base
168
+ scope :searchkick_import, includes(:searches)
169
+ end
170
+ ```
171
+
172
+ ### Continually Improve Results
93
173
 
94
- Improve results with analytics on conversions and give popular documents a little boost.
174
+ Use data on search conversions to improve results. This can make a **huge** difference.
95
175
 
96
- First, you must keep track of search conversions. The database works well for low volume, but feel free to use redis or another datastore.
176
+ First, keep track of conversions. The database works well for low volume, but feel free to use Redis or another datastore.
97
177
 
98
178
  ```ruby
99
179
  class Search < ActiveRecord::Base
@@ -102,101 +182,138 @@ class Search < ActiveRecord::Base
102
182
  end
103
183
  ```
104
184
 
105
- Add the conversions to the index.
185
+ Add conversions to the index.
106
186
 
107
187
  ```ruby
108
188
  class Product < ActiveRecord::Base
109
189
  has_many :searches
110
190
 
111
- searchkick conversions: true
112
-
113
- def to_indexed_json
191
+ def _source
114
192
  {
115
193
  name: name,
116
- conversions: searches.group("query").count.map{|query, count| {query: query, count: count} }, # TODO fix
117
- _boost: Math.log(orders_count) # boost more popular products a bit
194
+ conversions: searches.group("query").count
118
195
  }
119
196
  end
120
197
  end
121
198
  ```
122
199
 
123
- After the reindex is complete (to prevent errors), tell the search method to use conversions.
200
+ Reindex and tell the search method to use conversions.
124
201
 
125
202
  ```ruby
126
203
  Product.search "Fat Free Milk", conversions: true
127
204
  ```
128
205
 
129
- ### Zero Downtime Changes
206
+ ### Facets
130
207
 
131
208
  ```ruby
132
- Product.reindex
209
+ search = Product.search "2% Milk", facets: [:store_id, :aisle_id]
210
+ p search.facets
211
+ ```
212
+
213
+ Advanced
214
+
215
+ ```ruby
216
+ Product.search "2% Milk", facets: {store_id: {where: {in_stock: true}}}
133
217
  ```
134
218
 
135
- Behind the scenes, this creates a new index `products_20130714181054` and points the `products` alias to the new index when complete - an atomic operation :)
219
+ ## Deployment
220
+
221
+ ### Bonsai on Heroku
222
+
223
+ Install the add-on:
224
+
225
+ ```sh
226
+ heroku addons:add bonsai
227
+ ```
136
228
 
137
- Searchkick uses `find_in_batches` to import documents. To filter documents or eagar load associations, use the `searchkick_import` scope.
229
+ And create an initializer `config/initializers/bonsai.rb` with:
138
230
 
139
231
  ```ruby
140
- class Product < ActiveRecord::Base
141
- scope :searchkick_import, where(active: true).includes(:searches)
142
- end
232
+ ENV["ELASTICSEARCH_URL"] = ENV["BONSAI_URL"]
143
233
  ```
144
234
 
145
- There is also a rake task.
235
+ Then deploy and reindex:
146
236
 
147
237
  ```sh
148
- rake searchkick:reindex CLASS=Product
238
+ heroku run rake searchkick:reindex CLASS=Product
149
239
  ```
150
240
 
151
- Thanks to Jaroslav Kalistsuk for the [original implementation](https://gist.github.com/jarosan/3124884).
241
+ ## Reference
152
242
 
153
- ### Reference
243
+ Reindex one record
244
+
245
+ ```ruby
246
+ product = Product.find 10
247
+ product.reindex
248
+ ```
154
249
 
155
- Reindex one item
250
+ Use a different index name
156
251
 
157
252
  ```ruby
158
- product = Product.find(1)
159
- product.update_index
253
+ class Product < ActiveRecord::Base
254
+ searchkick index_name: "products_v2"
255
+ end
160
256
  ```
161
257
 
162
- Partial matches (needs better name)
258
+ Eagar load associations
163
259
 
164
260
  ```ruby
165
- Item.search "fresh honey", partial: true # matches organic honey
261
+ Product.search "milk", include: [:brand, :stores]
166
262
  ```
167
263
 
168
- ## Elasticsearch Gotchas
264
+ Do not load models
169
265
 
170
- ### Inconsistent Scores
266
+ ```ruby
267
+ Product.search "milk", load: false
268
+ ```
269
+
270
+ ## Migrating from Tire
271
+
272
+ 1. Change `search` methods to `tire.search` and add index name in existing search calls
273
+
274
+ ```ruby
275
+ Product.search "fruit"
276
+ ```
277
+
278
+ should be replaced with
279
+
280
+ ```ruby
281
+ Product.tire.search "fruit", index: "products"
282
+ ```
283
+
284
+ 2. Replace tire mapping w/ searchkick method
171
285
 
172
- Due to the distributed nature of Elasticsearch, you can get incorrect results when the number of documents in the index is low. You can [read more about it here](http://www.elasticsearch.org/blog/understanding-query-then-fetch-vs-dfs-query-then-fetch/). To fix this, set the search type to `dfs_query_and_fetch`. Alternatively, you can just use one shard with `settings: {number_of_shards: 1}`.
286
+ ```ruby
287
+ searchkick index_name: "products_v2"
288
+ ```
173
289
 
174
- ## Installation
290
+ 3. Deploy and reindex
175
291
 
176
- Add this line to your application's Gemfile:
292
+ ```ruby
293
+ rake searchkick:reindex CLASS=Product # or Product.reindex in the console
294
+ ```
295
+
296
+ 4. Once it finishes, replace search calls w/ searchkick calls
297
+
298
+ ## Elasticsearch Gotchas
299
+
300
+ ### Inconsistent Scores
301
+
302
+ Due to the distributed nature of Elasticsearch, you can get incorrect results when the number of documents in the index is low. You can [read more about it here](http://www.elasticsearch.org/blog/understanding-query-then-fetch-vs-dfs-query-then-fetch/). To fix this, do:
177
303
 
178
304
  ```ruby
179
- gem "searchkick"
305
+ class Product < ActiveRecord::Base
306
+ searchkick settings: {number_of_shards: 1}
307
+ end
180
308
  ```
181
309
 
182
- And then execute:
310
+ ## Thanks
183
311
 
184
- ```sh
185
- bundle
186
- ```
312
+ Thanks to [Karel Minarik](https://github.com/karmi) for Tire and [Jaroslav Kalistsuk](https://github.com/jarosan) for zero downtime reindexing.
187
313
 
188
314
  ## TODO
189
315
 
190
- - Autocomplete
191
- - Option to turn off fuzzy matching (should this be default?)
192
- - Exact phrase matches (in order)
193
- - Focus on results format (load: true?)
194
- - Test helpers - everyone should test their own search
195
316
  - Built-in synonyms from WordNet
196
- - Dashboard w/ real-time analytics?
197
- - [Suggest API](http://www.elasticsearch.org/guide/reference/api/search/suggest/) "Did you mean?"
198
- - Allow for "exact search" with quotes
199
- - Make updates to old and new index while reindexing [possibly with an another alias](http://www.kickstarter.com/backing-and-hacking)
200
317
 
201
318
  ## Contributing
202
319
 
data/lib/searchkick.rb CHANGED
@@ -4,6 +4,7 @@ require "searchkick/search"
4
4
  require "searchkick/model"
5
5
  require "searchkick/tasks"
6
6
  require "tire"
7
- require "active_record" # TODO only require active_model
8
7
 
9
- ActiveRecord::Base.send(:extend, Searchkick::Model)
8
+ # TODO find better ActiveModel hook
9
+ ActiveModel::AttributeMethods::ClassMethods.send(:include, Searchkick::Model)
10
+ ActiveRecord::Base.send(:extend, Searchkick::Model) if defined?(ActiveRecord)
@@ -2,79 +2,36 @@ module Searchkick
2
2
  module Model
3
3
 
4
4
  def searchkick(options = {})
5
- custom_settings = {
6
- analysis: {
7
- analyzer: {
8
- searchkick_keyword: {
9
- type: "custom",
10
- tokenizer: "keyword",
11
- filter: ["lowercase", "snowball"]
12
- },
13
- default_index: {
14
- type: "custom",
15
- tokenizer: "standard",
16
- # synonym should come last, after stemming and shingle
17
- # shingle must come before snowball
18
- filter: ["standard", "lowercase", "asciifolding", "stop", "snowball", "searchkick_index_shingle"]
19
- },
20
- searchkick_search: {
21
- type: "custom",
22
- tokenizer: "standard",
23
- filter: ["standard", "lowercase", "asciifolding", "stop", "snowball", "searchkick_search_shingle"]
24
- },
25
- searchkick_search2: {
26
- type: "custom",
27
- tokenizer: "standard",
28
- filter: ["standard", "lowercase", "asciifolding", "stop", "snowball"] #, "searchkick_search_shingle"]
29
- }
30
- },
31
- filter: {
32
- searchkick_index_shingle: {
33
- type: "shingle",
34
- token_separator: ""
35
- },
36
- # lucky find http://web.archiveorange.com/archive/v/AAfXfQ17f57FcRINsof7
37
- searchkick_search_shingle: {
38
- type: "shingle",
39
- token_separator: "",
40
- output_unigrams: false,
41
- output_unigrams_if_no_shingles: true
42
- }
43
- }
44
- }
45
- }.merge(options[:settings] || {})
46
- synonyms = options[:synonyms] || []
47
- if synonyms.any?
48
- custom_settings[:analysis][:filter][:searchkick_synonym] = {
49
- type: "synonym",
50
- ignore_case: true,
51
- synonyms: synonyms.map{|s| s.join(" => ") } # TODO support more than 2 synonyms on a line
52
- }
53
- custom_settings[:analysis][:analyzer][:default_index][:filter] << "searchkick_synonym"
54
- custom_settings[:analysis][:analyzer][:searchkick_search][:filter].insert(-2, "searchkick_synonym")
55
- custom_settings[:analysis][:analyzer][:searchkick_search][:filter] << "searchkick_synonym"
56
- custom_settings[:analysis][:analyzer][:searchkick_search2][:filter] << "searchkick_synonym"
57
- end
5
+ @searchkick_options = options.dup
6
+ @searchkick_options[:conversions] = true if options[:conversions].nil?
58
7
 
59
8
  class_eval do
60
9
  extend Searchkick::Search
61
10
  extend Searchkick::Reindex
62
11
  include Tire::Model::Search
63
12
  include Tire::Model::Callbacks
64
-
65
13
  tire do
66
- settings custom_settings
67
- mapping do
68
- # indexes field, analyzer: "searchkick"
69
- if options[:conversions]
70
- indexes :conversions, type: "nested" do
71
- indexes :query, analyzer: "searchkick_keyword"
72
- indexes :count, type: "integer"
73
- end
74
- end
14
+ index_name options[:index_name] || [klass.model_name.plural, ENV["RACK_ENV"] || "development"].join("_")
15
+ end
16
+ attr_accessor :_score
17
+
18
+ def reindex
19
+ update_index
20
+ end
21
+
22
+ def _source
23
+ as_json
24
+ end
25
+
26
+ def to_indexed_json
27
+ source = _source
28
+ if self.class.instance_variable_get("@searchkick_options")[:conversions] and source[:conversions]
29
+ source[:conversions] = source[:conversions].map{|k, v| {query: k, count: v} }
75
30
  end
31
+ source.to_json
76
32
  end
77
33
  end
78
34
  end
35
+
79
36
  end
80
37
  end
@@ -5,17 +5,18 @@ module Searchkick
5
5
  def reindex
6
6
  alias_name = tire.index.name
7
7
  new_index = alias_name + "_" + Time.now.strftime("%Y%m%d%H%M%S")
8
-
9
- # Rake::Task["tire:import"].invoke
10
8
  index = Tire::Index.new(new_index)
11
- Tire::Tasks::Import.create_index(index, self) # TODO remove puts
9
+
10
+ index.create searchkick_index_options
11
+
12
+ # use scope for import
12
13
  scope = respond_to?(:searchkick_import) ? searchkick_import : self
13
14
  scope.find_in_batches do |batch|
14
15
  index.import batch
15
16
  end
16
17
 
17
18
  if a = Tire::Alias.find(alias_name)
18
- old_indices = Tire::Alias.find(alias_name).indices
19
+ old_indices = a.indices.dup
19
20
  old_indices.each do |index|
20
21
  a.indices.delete index
21
22
  end
@@ -25,7 +26,7 @@ module Searchkick
25
26
 
26
27
  old_indices.each do |index|
27
28
  i = Tire::Index.new(index)
28
- i.delete if i.exists?
29
+ i.delete
29
30
  end
30
31
  else
31
32
  i = Tire::Index.new(alias_name)
@@ -36,5 +37,87 @@ module Searchkick
36
37
  true
37
38
  end
38
39
 
40
+ private
41
+
42
+ def searchkick_index_options
43
+ options = @searchkick_options
44
+
45
+ settings = {
46
+ analysis: {
47
+ analyzer: {
48
+ searchkick_keyword: {
49
+ type: "custom",
50
+ tokenizer: "keyword",
51
+ filter: ["lowercase", "snowball"]
52
+ },
53
+ default_index: {
54
+ type: "custom",
55
+ tokenizer: "standard",
56
+ # synonym should come last, after stemming and shingle
57
+ # shingle must come before snowball
58
+ filter: ["standard", "lowercase", "asciifolding", "stop", "snowball", "searchkick_index_shingle"]
59
+ },
60
+ searchkick_search: {
61
+ type: "custom",
62
+ tokenizer: "standard",
63
+ filter: ["standard", "lowercase", "asciifolding", "stop", "snowball", "searchkick_search_shingle"]
64
+ },
65
+ searchkick_search2: {
66
+ type: "custom",
67
+ tokenizer: "standard",
68
+ filter: ["standard", "lowercase", "asciifolding", "stop", "snowball"]
69
+ }
70
+ },
71
+ filter: {
72
+ searchkick_index_shingle: {
73
+ type: "shingle",
74
+ token_separator: ""
75
+ },
76
+ # lucky find http://web.archiveorange.com/archive/v/AAfXfQ17f57FcRINsof7
77
+ searchkick_search_shingle: {
78
+ type: "shingle",
79
+ token_separator: "",
80
+ output_unigrams: false,
81
+ output_unigrams_if_no_shingles: true
82
+ }
83
+ }
84
+ }
85
+ }.merge(options[:settings] || {})
86
+ synonyms = options[:synonyms] || []
87
+ if synonyms.any?
88
+ settings[:analysis][:filter][:searchkick_synonym] = {
89
+ type: "synonym",
90
+ ignore_case: true,
91
+ synonyms: synonyms.select{|s| s.size > 1 }.map{|s| "#{s[0..-2].join(",")} => #{s[-1]}" }
92
+ }
93
+ settings[:analysis][:analyzer][:default_index][:filter] << "searchkick_synonym"
94
+ settings[:analysis][:analyzer][:searchkick_search][:filter].insert(-2, "searchkick_synonym")
95
+ settings[:analysis][:analyzer][:searchkick_search][:filter] << "searchkick_synonym"
96
+ settings[:analysis][:analyzer][:searchkick_search2][:filter] << "searchkick_synonym"
97
+ end
98
+
99
+ mapping = {}
100
+ if options[:conversions]
101
+ mapping[:conversions] = {
102
+ type: "nested",
103
+ properties: {
104
+ query: {type: "string", analyzer: "searchkick_keyword"},
105
+ count: {type: "integer"}
106
+ }
107
+ }
108
+ end
109
+
110
+ mappings = {
111
+ document_type.to_sym => {
112
+ properties: mapping
113
+ }
114
+ }
115
+
116
+ {
117
+ settings: settings,
118
+ mappings: mappings
119
+ }
120
+ end
121
+
39
122
  end
40
123
  end
@@ -1,137 +1,147 @@
1
1
  module Searchkick
2
- # can't check mapping for conversions since the new index may not be built
3
2
  module Search
4
- def index_types
5
- Hash[ (((Product.index.mapping || {})["product"] || {})["properties"] || {}).map{|k, v| [k, v["type"]] } ].reject{|k, v| k == "conversions" || k[0] == "_" }
6
- end
7
3
 
8
4
  def search(term, options = {})
5
+ term = term.to_s
9
6
  fields = options[:fields] || ["_all"]
10
7
  operator = options[:partial] ? "or" : "and"
11
- tire.search do
12
- query do
13
- boolean do
14
- must do
15
- dis_max do
16
- query do
17
- match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search"
18
- end
19
- query do
20
- match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search2"
21
- end
22
- query do
23
- match fields, term, use_dis_max: false, fuzziness: 0.7, max_expansions: 1, prefix_length: 1, operator: operator, analyzer: "searchkick_search"
24
- end
25
- query do
26
- match fields, term, use_dis_max: false, fuzziness: 0.7, max_expansions: 1, prefix_length: 1, operator: operator, analyzer: "searchkick_search2"
8
+ load = options[:load].nil? ? true : options[:load]
9
+ load = (options[:include] || true) if load
10
+
11
+ collection =
12
+ tire.search load: load, page: options[:page], per_page: options[:per_page] do
13
+ query do
14
+ boolean do
15
+ must do
16
+ # TODO escape boost field
17
+ score_script = options[:boost] ? "_score * log(doc['#{options[:boost]}'].value + 2.718281828)" : "_score"
18
+ custom_score script: score_script do
19
+ dis_max do
20
+ query do
21
+ match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search"
22
+ end
23
+ query do
24
+ match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search2"
25
+ end
26
+ query do
27
+ match fields, term, use_dis_max: false, fuzziness: 0.7, max_expansions: 1, prefix_length: 1, operator: operator, analyzer: "searchkick_search"
28
+ end
29
+ query do
30
+ match fields, term, use_dis_max: false, fuzziness: 0.7, max_expansions: 1, prefix_length: 1, operator: operator, analyzer: "searchkick_search2"
31
+ end
32
+ end
27
33
  end
28
34
  end
29
- end
30
- if options[:conversions]
31
- should do
32
- nested path: "conversions", score_mode: "total" do
33
- query do
34
- custom_score script: "log(doc['count'].value)" do
35
- match "query", term
35
+ # can't check mapping for conversions since the new index may not be built
36
+ if options[:conversions]
37
+ should do
38
+ nested path: "conversions", score_mode: "total" do
39
+ query do
40
+ custom_score script: "log(doc['count'].value)" do
41
+ match "query", term
42
+ end
36
43
  end
37
44
  end
38
45
  end
39
46
  end
40
47
  end
41
48
  end
42
- end
43
- size options[:limit] || 100000 # return all - like sql query
44
- from options[:offset] if options[:offset]
45
- explain options[:explain] if options[:explain]
49
+ size options[:limit] || 100000 # return all - like sql query
50
+ from options[:offset] if options[:offset]
51
+ explain options[:explain] if options[:explain]
46
52
 
47
- # order
48
- if options[:order]
49
- sort do
50
- options[:order].each do |k, v|
51
- by k, v
53
+ # order
54
+ if options[:order]
55
+ sort do
56
+ options[:order].each do |k, v|
57
+ by k, v
58
+ end
52
59
  end
53
60
  end
54
- end
55
61
 
56
- # where
57
- # TODO expand or
58
-
59
- where_filters =
60
- proc do |where|
61
- filters = []
62
- (where || {}).each do |field, value|
63
- if field == :or
64
- value.each do |or_clause|
65
- filters << {or: or_clause.map{|or_statement| {term: or_statement} }}
66
- end
67
- else
68
- # expand ranges
69
- if value.is_a?(Range)
70
- value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
71
- end
62
+ # where
63
+ # TODO expand or
64
+ where_filters =
65
+ proc do |where|
66
+ filters = []
67
+ (where || {}).each do |field, value|
68
+ if field == :or
69
+ value.each do |or_clause|
70
+ filters << {or: or_clause.map{|or_statement| {term: or_statement} }}
71
+ end
72
+ else
73
+ # expand ranges
74
+ if value.is_a?(Range)
75
+ value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
76
+ end
72
77
 
73
- if value.is_a?(Array) # in query
74
- filters << {terms: {field => value}}
75
- elsif value.is_a?(Hash)
76
- value.each do |op, op_value|
77
- if op == :not # not equal
78
- if op_value.is_a?(Array)
79
- filters << {not: {terms: {field => op_value}}}
80
- else
81
- filters << {not: {term: {field => op_value}}}
82
- end
83
- else
84
- range_query =
85
- case op
86
- when :gt
87
- {from: op_value, include_lower: false}
88
- when :gte
89
- {from: op_value, include_lower: true}
90
- when :lt
91
- {to: op_value, include_upper: false}
92
- when :lte
93
- {to: op_value, include_upper: true}
78
+ if value.is_a?(Array) # in query
79
+ filters << {terms: {field => value}}
80
+ elsif value.is_a?(Hash)
81
+ value.each do |op, op_value|
82
+ if op == :not # not equal
83
+ if op_value.is_a?(Array)
84
+ filters << {not: {terms: {field => op_value}}}
94
85
  else
95
- raise "Unknown where operator"
86
+ filters << {not: {term: {field => op_value}}}
96
87
  end
97
- filters << {range: {field => range_query}}
88
+ else
89
+ range_query =
90
+ case op
91
+ when :gt
92
+ {from: op_value, include_lower: false}
93
+ when :gte
94
+ {from: op_value, include_lower: true}
95
+ when :lt
96
+ {to: op_value, include_upper: false}
97
+ when :lte
98
+ {to: op_value, include_upper: true}
99
+ else
100
+ raise "Unknown where operator"
101
+ end
102
+ filters << {range: {field => range_query}}
103
+ end
98
104
  end
105
+ else
106
+ filters << {term: {field => value}}
99
107
  end
100
- else
101
- filters << {term: {field => value}}
102
108
  end
103
109
  end
110
+ filters
104
111
  end
105
- filters
106
- end
107
-
108
- where_filters.call(options[:where]).each do |f|
109
- type, value = f.first
110
- filter type, value
111
- end
112
112
 
113
- # facets
114
- if options[:facets]
115
- facets = options[:facets] || {}
116
- if facets.is_a?(Array) # convert to more advanced syntax
117
- facets = Hash[ facets.map{|f| [f, {}] } ]
113
+ where_filters.call(options[:where]).each do |f|
114
+ type, value = f.first
115
+ filter type, value
118
116
  end
119
117
 
120
- facets.each do |field, facet_options|
121
- facet_filters = where_filters.call(facet_options[:where])
122
- facet field do
123
- terms field
124
- if facet_filters.size == 1
125
- type, value = facet_filters.first.first
126
- facet_filter type, value
127
- elsif facet_filters.size > 1
128
- facet_filter :and, *facet_filters
118
+ # facets
119
+ if options[:facets]
120
+ facets = options[:facets] || {}
121
+ if facets.is_a?(Array) # convert to more advanced syntax
122
+ facets = Hash[ facets.map{|f| [f, {}] } ]
123
+ end
124
+
125
+ facets.each do |field, facet_options|
126
+ facet_filters = where_filters.call(facet_options[:where])
127
+ facet field do
128
+ terms field
129
+ if facet_filters.size == 1
130
+ type, value = facet_filters.first.first
131
+ facet_filter type, value
132
+ elsif facet_filters.size > 1
133
+ facet_filter :and, *facet_filters
134
+ end
129
135
  end
130
136
  end
131
137
  end
132
138
  end
133
139
 
140
+ collection.each_with_hit do |model, hit|
141
+ model._score = hit["_score"].to_f / collection.max_score
134
142
  end
143
+ collection
135
144
  end
145
+
136
146
  end
137
147
  end
@@ -4,6 +4,6 @@ namespace :searchkick do
4
4
  desc "re-index elasticsearch"
5
5
  task :reindex => :environment do
6
6
  klass = ENV["CLASS"].constantize
7
- klass.tire.reindex
7
+ klass.reindex
8
8
  end
9
9
  end
@@ -1,3 +1,3 @@
1
1
  module Searchkick
2
- VERSION = "0.0.2"
2
+ VERSION = "0.1.0"
3
3
  end
@@ -1,6 +1,8 @@
1
1
  require "test_helper"
2
2
 
3
3
  class Product < ActiveRecord::Base
4
+ has_many :searches
5
+
4
6
  searchkick \
5
7
  synonyms: [
6
8
  ["clorox", "bleach"],
@@ -15,24 +17,22 @@ class Product < ActiveRecord::Base
15
17
  },
16
18
  conversions: true
17
19
 
18
- # searchkick do
19
- # string :name
20
- # boolean :visible
21
- # integer :orders_count
22
- # end
20
+ def _source
21
+ as_json.merge conversions: searches.group("query").count
22
+ end
23
23
  end
24
24
 
25
- p Product.index_types
25
+ class Search < ActiveRecord::Base
26
+ belongs_to :product
27
+ end
28
+
29
+ Product.reindex
26
30
 
27
31
  class TestSearchkick < Minitest::Unit::TestCase
28
32
 
29
33
  def setup
30
- Product.index.delete
31
- Product.create_elasticsearch_index
32
- end
33
-
34
- def test_reindex
35
- assert Product.reindex
34
+ Search.delete_all
35
+ Product.destroy_all
36
36
  end
37
37
 
38
38
  # exact
@@ -106,16 +106,16 @@ class TestSearchkick < Minitest::Unit::TestCase
106
106
  # conversions
107
107
 
108
108
  def test_conversions
109
- store [
110
- {name: "Tomato Sauce", conversions: [{query: "tomato sauce", count: 5}, {query: "tomato", count: 200}]},
109
+ store_conversions [
110
+ {name: "Tomato Sauce", conversions: [{query: "tomato sauce", count: 5}, {query: "tomato", count: 20}]},
111
111
  {name: "Tomato Paste", conversions: []},
112
- {name: "Tomatoes", conversions: [{query: "tomato", count: 100}, {query: "tomato sauce", count: 2}]}
112
+ {name: "Tomatoes", conversions: [{query: "tomato", count: 10}, {query: "tomato sauce", count: 2}]}
113
113
  ]
114
114
  assert_search "tomato", ["Tomato Sauce", "Tomatoes", "Tomato Paste"]
115
115
  end
116
116
 
117
117
  def test_conversions_stemmed
118
- store [
118
+ store_conversions [
119
119
  {name: "Tomato A", conversions: [{query: "tomato", count: 2}, {query: "tomatos", count: 2}, {query: "Tomatoes", count: 2}]},
120
120
  {name: "Tomato B", conversions: [{query: "tomato", count: 4}]}
121
121
  ]
@@ -178,27 +178,17 @@ class TestSearchkick < Minitest::Unit::TestCase
178
178
 
179
179
  def test_boost
180
180
  store [
181
- {name: "Organic Tomato A", _boost: 10},
182
- {name: "Tomato B"}
181
+ {name: "Organic Tomato A"},
182
+ {name: "Tomato B", orders_count: 10}
183
183
  ]
184
- assert_search "tomato", ["Organic Tomato A", "Tomato B"]
184
+ assert_search "tomato", ["Tomato B", "Organic Tomato A"], boost: "orders_count"
185
185
  end
186
186
 
187
187
  def test_boost_zero
188
188
  store [
189
- {name: "Zero Boost", _boost: 0}
189
+ {name: "Zero Boost", orders_count: 0}
190
190
  ]
191
- assert_search "zero", ["Zero Boost"]
192
- end
193
-
194
- # default to 1
195
- def test_boost_null
196
- store [
197
- {name: "Zero Boost A", _boost: 1.1},
198
- {name: "Zero Boost B"},
199
- {name: "Zero Boost C", _boost: 0.9},
200
- ]
201
- assert_search "zero", ["Zero Boost A", "Zero Boost B", "Zero Boost C"]
191
+ assert_search "zero", ["Zero Boost"], boost: "orders_count"
202
192
  end
203
193
 
204
194
  # search method
@@ -216,10 +206,10 @@ class TestSearchkick < Minitest::Unit::TestCase
216
206
  def test_where
217
207
  now = Time.now
218
208
  store [
219
- {name: "Product A", store_id: 1, in_stock: true, backordered: true, created_at: now, _boost: 4},
220
- {name: "Product B", store_id: 2, in_stock: true, backordered: false, created_at: now - 1, _boost: 3},
221
- {name: "Product C", store_id: 3, in_stock: false, backordered: true, created_at: now - 2, _boost: 2},
222
- {name: "Product D", store_id: 4, in_stock: false, backordered: false, created_at: now - 3, _boost: 1},
209
+ {name: "Product A", store_id: 1, in_stock: true, backordered: true, created_at: now, orders_count: 4},
210
+ {name: "Product B", store_id: 2, in_stock: true, backordered: false, created_at: now - 1, orders_count: 3},
211
+ {name: "Product C", store_id: 3, in_stock: false, backordered: true, created_at: now - 2, orders_count: 2},
212
+ {name: "Product D", store_id: 4, in_stock: false, backordered: false, created_at: now - 3, orders_count: 1},
223
213
  ]
224
214
  assert_search "product", ["Product A", "Product B"], where: {in_stock: true}
225
215
  # date
@@ -267,7 +257,7 @@ class TestSearchkick < Minitest::Unit::TestCase
267
257
 
268
258
  def store(documents)
269
259
  documents.each do |document|
270
- Product.index.store ({_type: "product"}).merge(document)
260
+ Product.create!(document)
271
261
  end
272
262
  Product.index.refresh
273
263
  end
@@ -276,6 +266,20 @@ class TestSearchkick < Minitest::Unit::TestCase
276
266
  store names.map{|name| {name: name} }
277
267
  end
278
268
 
269
+ def store_conversions(documents)
270
+ documents.each do |document|
271
+ conversions = document.delete(:conversions)
272
+ product = Product.create!(document)
273
+ conversions.each do |c|
274
+ c[:count].times do
275
+ product.searches.create!(query: c[:query])
276
+ end
277
+ end
278
+ end
279
+ Product.reindex
280
+ Product.index.refresh
281
+ end
282
+
279
283
  def assert_search(term, expected, options = {})
280
284
  assert_equal expected, Product.search(term, options.merge(fields: [:name], conversions: true)).map(&:name)
281
285
  end
data/test/test_helper.rb CHANGED
@@ -19,8 +19,17 @@ ActiveRecord::Migration.create_table :products, :force => true do |t|
19
19
  t.integer :store_id
20
20
  t.boolean :in_stock
21
21
  t.boolean :backordered
22
+ t.integer :orders_count
23
+ t.string :color
22
24
  t.timestamps
23
25
  end
24
26
 
27
+ ActiveRecord::Migration.create_table :searches, :force => true do |t|
28
+ t.string :query
29
+ t.timestamp :searched_at
30
+ t.timestamp :converted_at
31
+ t.references :product
32
+ end
33
+
25
34
  File.delete("elasticsearch.log") if File.exists?("elasticsearch.log")
26
35
  Tire.configure { logger "elasticsearch.log", :level => "debug" }
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.0.2
4
+ version: 0.1.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-07-18 00:00:00.000000000 Z
11
+ date: 2013-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: tire