searchkick 0.5.3 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6b20e3f5f346c6ef75c257cf2b2aafa23884ee2b
4
- data.tar.gz: f8c6e426340f0602037a3204e8d38a40d28cf430
3
+ metadata.gz: 633b6ec4127ea2638b8c05db6c1c1c0b536c4193
4
+ data.tar.gz: 69d65f9ecc57e55a954482e03a0998a1f10023dc
5
5
  SHA512:
6
- metadata.gz: 210eecb919e7491f84acdc1f4f69f2c1bf898b808033ed31f8adc63cc4ac636d0e242cd9136cf1467842aafcd0e358b570b02e9eb0f2e1a7d158807c096e6b3d
7
- data.tar.gz: b4907fb7bde4d2a7c13a68b26b84527f8deb00bd41da7216a0637f15f62923f39d0589151edbcd44b1bc0f1eec61e3ae8e5e0be1cc95e440f09b6a694d10f007
6
+ metadata.gz: 669dd3262ff71f967b6d0266851eca6f769befc5317289bb058781cf11d6e46af2e47bf9193a60fbc19117b04c5653551b4966bb349d6a9f88f79baa424b0116
7
+ data.tar.gz: 024fb4884e8a4d08e3a8954e3d2de0fce7bc7968311da0b64d6c9fe3f24e9b9c6488fa37d11c4c5f4674bdd5e60f72ac4da0ab8d5122c8c60fc07ef8542790c6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.6.0
2
+
3
+ - Moved to elasticsearch-ruby
4
+ - Added support for modifying the query and viewing the response
5
+ - Added support for page_entries_info method
6
+
1
7
  ## 0.5.3
2
8
 
3
9
  - Fixed bug w/ word_* queries
data/Gemfile CHANGED
@@ -3,5 +3,7 @@ source 'https://rubygems.org'
3
3
  # Specify your gem's dependencies in searchkick.gemspec
4
4
  gemspec
5
5
 
6
+ gem "sqlite3"
7
+ gem "activerecord"
6
8
  # gem "activerecord", "~> 3.2.0"
7
9
  # gem "activerecord", "~> 3.1.0"
data/README.md CHANGED
@@ -147,7 +147,7 @@ Product.search "fresh honey" # fresh AND honey
147
147
  To change this, use:
148
148
 
149
149
  ```ruby
150
- Product.search "fresh honey", partial: true # fresh OR honey
150
+ Product.search "fresh honey", operator: "or" # fresh OR honey
151
151
  ```
152
152
 
153
153
  By default, results must match the entire word - `back` will not match `backpack`. You can change this behavior with:
@@ -496,7 +496,7 @@ And to search, use:
496
496
  ```ruby
497
497
  Animal.search "*" # all animals
498
498
  Dog.search "*" # just dogs
499
- Animal.search "*", type: [Dog, Cat] # just cats and dogs [master]
499
+ Animal.search "*", type: [Dog, Cat] # just cats and dogs
500
500
  ```
501
501
 
502
502
  **Note:** The `suggest` option retrieves suggestions from the parent at the moment.
@@ -568,7 +568,13 @@ end
568
568
  And use the `query` option to search:
569
569
 
570
570
  ```ruby
571
- Product.search query: {match: {name: "milk"}}
571
+ products = Product.search query: {match: {name: "milk"}}
572
+ ```
573
+
574
+ View the response with:
575
+
576
+ ```ruby
577
+ products.response
572
578
  ```
573
579
 
574
580
  To keep the mappings and settings generated by Searchkick, use:
@@ -579,6 +585,14 @@ class Product < ActiveRecord::Base
579
585
  end
580
586
  ```
581
587
 
588
+ To modify the query generated by Searchkick, use:
589
+
590
+ ```ruby
591
+ query = Product.search "2% Milk", execute: false
592
+ query.body[:query] = {match_all: {}}
593
+ products = query.execute
594
+ ```
595
+
582
596
  ## Reference
583
597
 
584
598
  Searchkick requires Elasticsearch `0.90.0` or higher.
@@ -3,4 +3,4 @@ source 'https://rubygems.org'
3
3
  # Specify your gem's dependencies in searchkick.gemspec
4
4
  gemspec path: "../"
5
5
 
6
- gem "mongoid", github: "mongoid/mongoid"
6
+ gem "mongoid", "4.0.0.beta1"
data/lib/searchkick.rb CHANGED
@@ -1,14 +1,24 @@
1
- require "tire"
1
+ require "active_model"
2
+ require "patron"
3
+ require "elasticsearch"
2
4
  require "searchkick/version"
5
+ require "searchkick/index"
3
6
  require "searchkick/reindex"
4
7
  require "searchkick/results"
8
+ require "searchkick/query"
5
9
  require "searchkick/search"
6
10
  require "searchkick/similar"
7
11
  require "searchkick/model"
8
12
  require "searchkick/tasks"
9
- require "searchkick/logger" if defined?(Rails)
13
+ # TODO add logger
14
+ # require "searchkick/logger" if defined?(Rails)
10
15
 
11
16
  module Searchkick
17
+
18
+ def self.client
19
+ @client ||= Elasticsearch::Client.new(url: ENV["ELASTICSEARCH_URL"])
20
+ end
21
+
12
22
  @callbacks = true
13
23
 
14
24
  def self.enable_callbacks
@@ -0,0 +1,67 @@
1
+ module Searchkick
2
+ class Index
3
+ attr_reader :name
4
+
5
+ def initialize(name)
6
+ @name = name
7
+ end
8
+
9
+ def create(options = {})
10
+ client.indices.create index: name, body: options
11
+ end
12
+
13
+ def delete
14
+ client.indices.delete index: name
15
+ end
16
+
17
+ def exists?
18
+ client.indices.exists index: name
19
+ end
20
+
21
+ def refresh
22
+ client.indices.refresh index: name
23
+ end
24
+
25
+ def store(record)
26
+ client.index(
27
+ index: name,
28
+ type: record.document_type,
29
+ id: record.id,
30
+ body: record.as_indexed_json
31
+ )
32
+ end
33
+
34
+ def remove(record)
35
+ client.delete(
36
+ index: name,
37
+ type: record.document_type,
38
+ id: record.id
39
+ )
40
+ end
41
+
42
+ def import(records)
43
+ if records.any?
44
+ client.bulk(
45
+ index: name,
46
+ type: records.first.document_type,
47
+ body: records.map{|r| data = r.as_indexed_json; {index: {_id: data["_id"] || data["id"] || r.id, data: data}} }
48
+ )
49
+ end
50
+ end
51
+
52
+ def retrieve(document_type, id)
53
+ client.get_source(
54
+ index: name,
55
+ type: document_type,
56
+ id: id
57
+ )
58
+ end
59
+
60
+ protected
61
+
62
+ def client
63
+ Searchkick.client
64
+ end
65
+
66
+ end
67
+ end
@@ -13,7 +13,7 @@ module Searchkick
13
13
  # set index name
14
14
  # TODO support proc
15
15
  index_name = options[:index_name] || [options[:index_prefix], model_name.plural, searchkick_env].compact.join("_")
16
- class_variable_set :@@searchkick_index, Tire::Index.new(index_name)
16
+ class_variable_set :@@searchkick_index, Searchkick::Index.new(index_name)
17
17
 
18
18
  extend Searchkick::Search
19
19
  extend Searchkick::Reindex
@@ -45,7 +45,11 @@ module Searchkick
45
45
  def reindex
46
46
  index = self.class.searchkick_index
47
47
  if destroyed? or !should_index?
48
- index.remove self
48
+ begin
49
+ index.remove self
50
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
51
+ # do nothing
52
+ end
49
53
  else
50
54
  index.store self
51
55
  end
@@ -55,7 +59,7 @@ module Searchkick
55
59
  respond_to?(:to_hash) ? to_hash : serializable_hash
56
60
  end
57
61
 
58
- def to_indexed_json
62
+ def as_indexed_json
59
63
  source = search_data
60
64
 
61
65
  # stringify fields
@@ -115,7 +119,7 @@ module Searchkick
115
119
 
116
120
  # p search_data
117
121
 
118
- source.to_json
122
+ source.as_json
119
123
  end
120
124
 
121
125
  # TODO remove
@@ -0,0 +1,441 @@
1
+ module Searchkick
2
+ class Query
3
+ attr_reader :klass, :term, :options
4
+ attr_accessor :body
5
+
6
+ def initialize(klass, term, options = {})
7
+ if term.is_a?(Hash)
8
+ options = term
9
+ term = nil
10
+ else
11
+ term = term.to_s
12
+ end
13
+
14
+ @klass = klass
15
+ @term = term
16
+ @options = options
17
+
18
+ fields =
19
+ if options[:fields]
20
+ if options[:autocomplete]
21
+ options[:fields].map{|f| "#{f}.autocomplete" }
22
+ else
23
+ options[:fields].map do |value|
24
+ k, v = value.is_a?(Hash) ? value.to_a.first : [value, :word]
25
+ "#{k}.#{v == :word ? "analyzed" : v}"
26
+ end
27
+ end
28
+ else
29
+ if options[:autocomplete]
30
+ (searchkick_options[:autocomplete] || []).map{|f| "#{f}.autocomplete" }
31
+ else
32
+ ["_all"]
33
+ end
34
+ end
35
+
36
+ operator = options[:operator] || (options[:partial] ? "or" : "and")
37
+
38
+ # pagination
39
+ page = [options[:page].to_i, 1].max
40
+ per_page = (options[:limit] || options[:per_page] || 100000).to_i
41
+ offset = options[:offset] || (page - 1) * per_page
42
+ index_name = options[:index_name] || searchkick_index.name
43
+
44
+ conversions_field = searchkick_options[:conversions]
45
+ personalize_field = searchkick_options[:personalize]
46
+
47
+ all = term == "*"
48
+
49
+ if options[:query]
50
+ payload = options[:query]
51
+ elsif options[:similar]
52
+ payload = {
53
+ more_like_this: {
54
+ fields: fields,
55
+ like_text: term,
56
+ min_doc_freq: 1,
57
+ min_term_freq: 1,
58
+ analyzer: "searchkick_search2"
59
+ }
60
+ }
61
+ elsif all
62
+ payload = {
63
+ match_all: {}
64
+ }
65
+ else
66
+ if options[:autocomplete]
67
+ payload = {
68
+ multi_match: {
69
+ fields: fields,
70
+ query: term,
71
+ analyzer: "searchkick_autocomplete_search"
72
+ }
73
+ }
74
+ else
75
+ queries = []
76
+ fields.each do |field|
77
+ if field == "_all" or field.end_with?(".analyzed")
78
+ shared_options = {
79
+ fields: [field],
80
+ query: term,
81
+ use_dis_max: false,
82
+ operator: operator,
83
+ cutoff_frequency: 0.001
84
+ }
85
+ queries.concat [
86
+ {multi_match: shared_options.merge(boost: 10, analyzer: "searchkick_search")},
87
+ {multi_match: shared_options.merge(boost: 10, analyzer: "searchkick_search2")}
88
+ ]
89
+ if options[:misspellings] != false
90
+ distance = (options[:misspellings].is_a?(Hash) && options[:misspellings][:distance]) || 1
91
+ queries.concat [
92
+ {multi_match: shared_options.merge(fuzziness: distance, max_expansions: 3, analyzer: "searchkick_search")},
93
+ {multi_match: shared_options.merge(fuzziness: distance, max_expansions: 3, analyzer: "searchkick_search2")}
94
+ ]
95
+ end
96
+ else
97
+ analyzer = field.match(/\.word_(start|middle|end)\z/) ? "searchkick_word_search" : "searchkick_autocomplete_search"
98
+ queries << {
99
+ multi_match: {
100
+ fields: [field],
101
+ query: term,
102
+ analyzer: analyzer
103
+ }
104
+ }
105
+ end
106
+ end
107
+
108
+ payload = {
109
+ dis_max: {
110
+ queries: queries
111
+ }
112
+ }
113
+ end
114
+
115
+ if conversions_field and options[:conversions] != false
116
+ # wrap payload in a bool query
117
+ payload = {
118
+ bool: {
119
+ must: payload,
120
+ should: {
121
+ nested: {
122
+ path: conversions_field,
123
+ score_mode: "total",
124
+ query: {
125
+ custom_score: {
126
+ query: {
127
+ match: {
128
+ query: term
129
+ }
130
+ },
131
+ script: "doc['count'].value"
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ end
139
+ end
140
+
141
+ custom_filters = []
142
+
143
+ if options[:boost]
144
+ custom_filters << {
145
+ filter: {
146
+ exists: {
147
+ field: options[:boost]
148
+ }
149
+ },
150
+ script: "log(doc['#{options[:boost]}'].value + 2.718281828)"
151
+ }
152
+ end
153
+
154
+ if options[:user_id] and personalize_field
155
+ custom_filters << {
156
+ filter: {
157
+ term: {
158
+ personalize_field => options[:user_id]
159
+ }
160
+ },
161
+ boost: 100
162
+ }
163
+ end
164
+
165
+ if options[:personalize]
166
+ custom_filters << {
167
+ filter: {
168
+ term: options[:personalize]
169
+ },
170
+ boost: 100
171
+ }
172
+ end
173
+
174
+ if custom_filters.any?
175
+ payload = {
176
+ custom_filters_score: {
177
+ query: payload,
178
+ filters: custom_filters,
179
+ score_mode: "total"
180
+ }
181
+ }
182
+ end
183
+
184
+ payload = {
185
+ query: payload,
186
+ size: per_page,
187
+ from: offset
188
+ }
189
+ payload[:explain] = options[:explain] if options[:explain]
190
+
191
+ # order
192
+ if options[:order]
193
+ order = options[:order].is_a?(Enumerable) ? options[:order] : {options[:order] => :asc}
194
+ payload[:sort] = Hash[ order.map{|k, v| [k.to_s == "id" ? :_id : k, v] } ]
195
+ end
196
+
197
+ # filters
198
+ filters = where_filters(options[:where])
199
+ if filters.any?
200
+ payload[:filter] = {
201
+ and: filters
202
+ }
203
+ end
204
+
205
+ # facets
206
+ facet_limits = {}
207
+ if options[:facets]
208
+ facets = options[:facets] || {}
209
+ if facets.is_a?(Array) # convert to more advanced syntax
210
+ facets = Hash[ facets.map{|f| [f, {}] } ]
211
+ end
212
+
213
+ payload[:facets] = {}
214
+ facets.each do |field, facet_options|
215
+ # ask for extra facets due to
216
+ # https://github.com/elasticsearch/elasticsearch/issues/1305
217
+
218
+ if facet_options[:ranges]
219
+ payload[:facets][field] = {
220
+ range: {
221
+ field.to_sym => facet_options[:ranges]
222
+ }
223
+ }
224
+ else
225
+ payload[:facets][field] = {
226
+ terms: {
227
+ field: field,
228
+ size: facet_options[:limit] ? facet_options[:limit] + 150 : 100000
229
+ }
230
+ }
231
+ end
232
+
233
+ facet_limits[field] = facet_options[:limit] if facet_options[:limit]
234
+
235
+ # offset is not possible
236
+ # http://elasticsearch-users.115913.n3.nabble.com/Is-pagination-possible-in-termsStatsFacet-td3422943.html
237
+
238
+ facet_filters = where_filters(facet_options[:where])
239
+ if facet_filters.any?
240
+ payload[:facets][field][:facet_filter] = {
241
+ and: {
242
+ filters: facet_filters
243
+ }
244
+ }
245
+ end
246
+ end
247
+ end
248
+
249
+ # suggestions
250
+ if options[:suggest]
251
+ suggest_fields = (searchkick_options[:suggest] || []).map(&:to_s)
252
+ # intersection
253
+ suggest_fields = suggest_fields & options[:fields].map(&:to_s) if options[:fields]
254
+ if suggest_fields.any?
255
+ payload[:suggest] = {text: term}
256
+ suggest_fields.each do |field|
257
+ payload[:suggest][field] = {
258
+ phrase: {
259
+ field: "#{field}.suggest"
260
+ }
261
+ }
262
+ end
263
+ end
264
+ end
265
+
266
+ # highlight
267
+ if options[:highlight]
268
+ payload[:highlight] = {
269
+ fields: Hash[ fields.map{|f| [f, {}] } ]
270
+ }
271
+ if options[:highlight].is_a?(Hash) and tag = options[:highlight][:tag]
272
+ payload[:highlight][:pre_tags] = [tag]
273
+ payload[:highlight][:post_tags] = [tag.to_s.gsub(/\A</, "</")]
274
+ end
275
+ end
276
+
277
+ # model and eagar loading
278
+ load = options[:load].nil? ? true : options[:load]
279
+
280
+ # An empty array will cause only the _id and _type for each hit to be returned
281
+ # http://www.elasticsearch.org/guide/reference/api/search/fields/
282
+ payload[:fields] = [] if load
283
+
284
+ if options[:type] or klass != searchkick_klass
285
+ @type = [options[:type] || klass].flatten.map(&:document_type)
286
+ end
287
+
288
+ @body = payload
289
+ @facet_limits = facet_limits
290
+ @page = page
291
+ @per_page = per_page
292
+ @load = load
293
+ end
294
+
295
+ def searchkick_index
296
+ klass.searchkick_index
297
+ end
298
+
299
+ def searchkick_options
300
+ klass.searchkick_options
301
+ end
302
+
303
+ def searchkick_klass
304
+ klass.searchkick_klass
305
+ end
306
+
307
+ def document_type
308
+ klass.document_type
309
+ end
310
+
311
+ def execute
312
+ params = {
313
+ index: searchkick_index.name,
314
+ body: body
315
+ }
316
+ params.merge!(type: @type) if @type
317
+ begin
318
+ response = Searchkick.client.search(params)
319
+ rescue => e # TODO rescue type
320
+ status_code = e.message[1..3].to_i
321
+ if status_code == 404
322
+ raise "Index missing - run #{searchkick_klass.name}.reindex"
323
+ elsif status_code == 500 and (e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") or e.message.include?("No query registered for [multi_match]") or e.message.include?("[match] query does not support [cutoff_frequency]]"))
324
+ raise "Upgrade Elasticsearch to 0.90.0 or greater"
325
+ else
326
+ raise e
327
+ end
328
+ end
329
+
330
+ # apply facet limit in client due to
331
+ # https://github.com/elasticsearch/elasticsearch/issues/1305
332
+ @facet_limits.each do |field, limit|
333
+ field = field.to_s
334
+ facet = response["facets"][field]
335
+ response["facets"][field]["terms"] = facet["terms"].first(limit)
336
+ response["facets"][field]["other"] = facet["total"] - facet["terms"].sum{|term| term["count"] }
337
+ end
338
+
339
+ opts = {
340
+ page: @page,
341
+ per_page: @per_page,
342
+ load: @load,
343
+ includes: options[:include] || options[:includes]
344
+ }
345
+ Searchkick::Results.new(searchkick_klass, response, opts)
346
+ end
347
+
348
+ private
349
+
350
+ def where_filters(where)
351
+ filters = []
352
+ (where || {}).each do |field, value|
353
+ field = :_id if field.to_s == "id"
354
+
355
+ if field == :or
356
+ value.each do |or_clause|
357
+ filters << {or: or_clause.map{|or_statement| {and: where_filters(or_statement)} }}
358
+ end
359
+ else
360
+ # expand ranges
361
+ if value.is_a?(Range)
362
+ value = {gte: value.first, (value.exclude_end? ? :lt : :lte) => value.last}
363
+ end
364
+
365
+ if value.is_a?(Array)
366
+ value = {in: value}
367
+ end
368
+
369
+ if value.is_a?(Hash)
370
+ value.each do |op, op_value|
371
+ case op
372
+ when :within, :bottom_right
373
+ # do nothing
374
+ when :near
375
+ filters << {
376
+ geo_distance: {
377
+ field => op_value.map(&:to_f).reverse,
378
+ distance: value[:within] || "50mi"
379
+ }
380
+ }
381
+ when :top_left
382
+ filters << {
383
+ geo_bounding_box: {
384
+ field => {
385
+ top_left: op_value.map(&:to_f).reverse,
386
+ bottom_right: value[:bottom_right].map(&:to_f).reverse
387
+ }
388
+ }
389
+ }
390
+ when :not # not equal
391
+ filters << {not: term_filters(field, op_value)}
392
+ when :all
393
+ filters << {terms: {field => op_value, execution: "and"}}
394
+ when :in
395
+ filters << term_filters(field, op_value)
396
+ else
397
+ range_query =
398
+ case op
399
+ when :gt
400
+ {from: op_value, include_lower: false}
401
+ when :gte
402
+ {from: op_value, include_lower: true}
403
+ when :lt
404
+ {to: op_value, include_upper: false}
405
+ when :lte
406
+ {to: op_value, include_upper: true}
407
+ else
408
+ raise "Unknown where operator"
409
+ end
410
+ # issue 132
411
+ if existing = filters.find{ |f| f[:range] && f[:range][field] }
412
+ existing[:range][field].merge!(range_query)
413
+ else
414
+ filters << {range: {field => range_query}}
415
+ end
416
+ end
417
+ end
418
+ else
419
+ filters << term_filters(field, value)
420
+ end
421
+ end
422
+ end
423
+ filters
424
+ end
425
+
426
+ def term_filters(field, value)
427
+ if value.is_a?(Array) # in query
428
+ if value.any?
429
+ {or: value.map{|v| term_filters(field, v) }}
430
+ else
431
+ {terms: {field => value}} # match nothing
432
+ end
433
+ elsif value.nil?
434
+ {missing: {"field" => field, existence: true, null_value: true}}
435
+ else
436
+ {term: {field => value}}
437
+ end
438
+ end
439
+
440
+ end
441
+ end