searchkick 0.5.3 → 0.6.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: 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