Commit c72ef2c16f74ab468bfce4445747c62d9e747368
Exists in
master
and in
21 other branches
Test mongoid release
Showing
14 changed files
with
225 additions
and
83 deletions
Show diff stats
README.md
... | ... | @@ -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,7 +585,7 @@ class Product < ActiveRecord::Base |
579 | 585 | end |
580 | 586 | ``` |
581 | 587 | |
582 | -## Experimental [master] | |
588 | +## Experimental | |
583 | 589 | |
584 | 590 | Modify the query generated by Searchkick. |
585 | 591 | ... | ... |
gemfiles/mongoid4.gemfile
lib/searchkick.rb
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" |
5 | 8 | require "searchkick/query" |
... | ... | @@ -7,9 +10,15 @@ require "searchkick/search" |
7 | 10 | require "searchkick/similar" |
8 | 11 | require "searchkick/model" |
9 | 12 | require "searchkick/tasks" |
10 | -require "searchkick/logger" if defined?(Rails) | |
13 | +# TODO add logger | |
14 | +# require "searchkick/logger" if defined?(Rails) | |
11 | 15 | |
12 | 16 | module Searchkick |
17 | + | |
18 | + def self.client | |
19 | + @client ||= Elasticsearch::Client.new(url: ENV["ELASTICSEARCH_URL"]) | |
20 | + end | |
21 | + | |
13 | 22 | @callbacks = true |
14 | 23 | |
15 | 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 | ... | ... |
lib/searchkick/logger.rb
... | ... | @@ -1,19 +0,0 @@ |
1 | -require "tire/rails/logger" | |
2 | -require "tire/rails/logger/log_subscriber" | |
3 | - | |
4 | -class Tire::Rails::LogSubscriber | |
5 | - | |
6 | - # better output format | |
7 | - def search(event) | |
8 | - self.class.runtime += event.duration | |
9 | - return unless logger.debug? | |
10 | - | |
11 | - payload = event.payload | |
12 | - | |
13 | - name = "%s (%.1fms)" % [payload[:name], event.duration] | |
14 | - query = payload[:search].to_s | |
15 | - | |
16 | - debug " #{color(name, YELLOW, true)} #{query}" | |
17 | - end | |
18 | - | |
19 | -end |
lib/searchkick/model.rb
... | ... | @@ -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 | ... | ... |
lib/searchkick/query.rb
... | ... | @@ -35,10 +35,6 @@ module Searchkick |
35 | 35 | |
36 | 36 | operator = options[:operator] || (options[:partial] ? "or" : "and") |
37 | 37 | |
38 | - # model and eagar loading | |
39 | - load = options[:load].nil? ? true : options[:load] | |
40 | - load = (options[:include] ? {include: options[:include]} : true) if load | |
41 | - | |
42 | 38 | # pagination |
43 | 39 | page = [options[:page].to_i, 1].max |
44 | 40 | per_page = (options[:limit] || options[:per_page] || 100000).to_i |
... | ... | @@ -278,18 +274,22 @@ module Searchkick |
278 | 274 | end |
279 | 275 | end |
280 | 276 | |
277 | + # model and eagar loading | |
278 | + load = options[:load].nil? ? true : options[:load] | |
279 | + | |
281 | 280 | # An empty array will cause only the _id and _type for each hit to be returned |
282 | 281 | # http://www.elasticsearch.org/guide/reference/api/search/fields/ |
283 | 282 | payload[:fields] = [] if load |
284 | 283 | |
285 | - tire_options = {load: load, size: per_page, from: offset} | |
286 | 284 | if options[:type] or klass != searchkick_klass |
287 | - tire_options[:type] = [options[:type] || klass].flatten.map(&:document_type) | |
285 | + @type = [options[:type] || klass].flatten.map(&:document_type) | |
288 | 286 | end |
289 | 287 | |
290 | - @search = Tire::Search::Search.new(index_name, tire_options) | |
291 | 288 | @body = payload |
292 | 289 | @facet_limits = facet_limits |
290 | + @page = page | |
291 | + @per_page = per_page | |
292 | + @load = load | |
293 | 293 | end |
294 | 294 | |
295 | 295 | def searchkick_index |
... | ... | @@ -309,14 +309,18 @@ module Searchkick |
309 | 309 | end |
310 | 310 | |
311 | 311 | def execute |
312 | - @search.options[:payload] = body | |
312 | + params = { | |
313 | + index: searchkick_index.name, | |
314 | + body: body | |
315 | + } | |
316 | + params.merge!(type: @type) if @type | |
313 | 317 | begin |
314 | - response = @search.json | |
315 | - rescue Tire::Search::SearchRequestFailed => e | |
318 | + response = Searchkick.client.search(params) | |
319 | + rescue => e # TODO rescue type | |
316 | 320 | status_code = e.message[1..3].to_i |
317 | 321 | if status_code == 404 |
318 | 322 | raise "Index missing - run #{searchkick_klass.name}.reindex" |
319 | - elsif status_code == 500 and (e.message.include?("IllegalArgumentException[minimumSimilarity >= 1]") or e.message.include?("No query registered for [multi_match]")) | |
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]]")) | |
320 | 324 | raise "Upgrade Elasticsearch to 0.90.0 or greater" |
321 | 325 | else |
322 | 326 | raise e |
... | ... | @@ -332,7 +336,13 @@ module Searchkick |
332 | 336 | response["facets"][field]["other"] = facet["total"] - facet["terms"].sum{|term| term["count"] } |
333 | 337 | end |
334 | 338 | |
335 | - Searchkick::Results.new(response, @search.options.merge(term: term, model_name: searchkick_klass.model_name)) | |
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) | |
336 | 346 | end |
337 | 347 | |
338 | 348 | private | ... | ... |
lib/searchkick/reindex.rb
... | ... | @@ -2,36 +2,28 @@ module Searchkick |
2 | 2 | module Reindex |
3 | 3 | |
4 | 4 | # https://gist.github.com/jarosan/3124884 |
5 | + # http://www.elasticsearch.org/blog/changing-mapping-with-zero-downtime/ | |
5 | 6 | def reindex |
6 | 7 | alias_name = searchkick_index.name |
7 | - new_index = alias_name + "_" + Time.now.strftime("%Y%m%d%H%M%S%L") | |
8 | - index = Tire::Index.new(new_index) | |
8 | + new_name = alias_name + "_" + Time.now.strftime("%Y%m%d%H%M%S%L") | |
9 | + index = Searchkick::Index.new(new_name) | |
9 | 10 | |
10 | 11 | clean_indices |
11 | 12 | |
12 | - success = index.create searchkick_index_options | |
13 | - raise index.response.to_s if !success | |
13 | + index.create searchkick_index_options | |
14 | 14 | |
15 | - if a = Tire::Alias.find(alias_name) | |
15 | + # check if alias exists | |
16 | + if Searchkick.client.indices.exists_alias(name: alias_name) | |
16 | 17 | searchkick_import(index) # import before swap |
17 | 18 | |
18 | - a.indices.each do |i| | |
19 | - a.indices.delete i | |
20 | - end | |
21 | - | |
22 | - a.indices.add new_index | |
23 | - response = a.save | |
24 | - | |
25 | - if response.success? | |
26 | - clean_indices | |
27 | - else | |
28 | - raise response.to_s | |
29 | - end | |
19 | + # get existing indices to remove | |
20 | + old_indices = Searchkick.client.indices.get_alias(name: alias_name).keys | |
21 | + actions = old_indices.map{|name| {remove: {index: name, alias: alias_name}} } + [{add: {index: new_name, alias: alias_name}}] | |
22 | + Searchkick.client.indices.update_aliases body: {actions: actions} | |
23 | + clean_indices | |
30 | 24 | else |
31 | 25 | searchkick_index.delete if searchkick_index.exists? |
32 | - response = Tire::Alias.create(name: alias_name, indices: [new_index]) | |
33 | - raise response.to_s if !response.success? | |
34 | - | |
26 | + Searchkick.client.indices.update_aliases body: {actions: [{add: {index: new_name, alias: alias_name}}]} | |
35 | 27 | searchkick_import(index) # import after swap |
36 | 28 | end |
37 | 29 | |
... | ... | @@ -42,10 +34,10 @@ module Searchkick |
42 | 34 | |
43 | 35 | # remove old indices that start w/ index_name |
44 | 36 | def clean_indices |
45 | - all_indices = JSON.parse(Tire::Configuration.client.get("#{Tire::Configuration.url}/_aliases").body) | |
37 | + all_indices = Searchkick.client.indices.get_aliases | |
46 | 38 | indices = all_indices.select{|k, v| v["aliases"].empty? && k =~ /\A#{Regexp.escape(searchkick_index.name)}_\d{14,17}\z/ }.keys |
47 | 39 | indices.each do |index| |
48 | - Tire::Index.new(index).delete | |
40 | + Searchkick::Index.new(index).delete | |
49 | 41 | end |
50 | 42 | indices |
51 | 43 | end |
... | ... | @@ -73,7 +65,7 @@ module Searchkick |
73 | 65 | items = [] |
74 | 66 | scope.all.each do |item| |
75 | 67 | items << item if item.should_index? |
76 | - if items.length % batch_size == 0 | |
68 | + if items.length == batch_size | |
77 | 69 | index.import items |
78 | 70 | items = [] |
79 | 71 | end | ... | ... |
lib/searchkick/results.rb
1 | 1 | module Searchkick |
2 | - class Results < Tire::Results::Collection | |
3 | - attr_reader :response | |
2 | + class Results | |
3 | + include Enumerable | |
4 | + extend Forwardable | |
5 | + | |
6 | + attr_reader :klass, :response, :options | |
7 | + | |
8 | + def_delegators :results, :each, :empty?, :size, :slice, :[], :to_ary | |
9 | + | |
10 | + def initialize(klass, response, options = {}) | |
11 | + @klass = klass | |
12 | + @response = response | |
13 | + @options = options | |
14 | + end | |
15 | + | |
16 | + def results | |
17 | + @results ||= begin | |
18 | + if options[:load] | |
19 | + hit_ids = hits.map{|hit| hit["_id"] } | |
20 | + records = klass | |
21 | + if options[:includes] | |
22 | + records = records.includes(options[:includes]) | |
23 | + end | |
24 | + records = records.find(hit_ids) | |
25 | + hit_ids = hit_ids.map(&:to_s) | |
26 | + records.sort_by{|r| hit_ids.index(r.id.to_s) } | |
27 | + else | |
28 | + hits | |
29 | + end | |
30 | + end | |
31 | + end | |
4 | 32 | |
5 | 33 | def suggestions |
6 | - if @response["suggest"] | |
7 | - @response["suggest"].values.flat_map{|v| v.first["options"] }.sort_by{|o| -o["score"] }.map{|o| o["text"] }.uniq | |
34 | + if response["suggest"] | |
35 | + response["suggest"].values.flat_map{|v| v.first["options"] }.sort_by{|o| -o["score"] }.map{|o| o["text"] }.uniq | |
8 | 36 | else |
9 | 37 | raise "Pass `suggest: true` to the search method for suggestions" |
10 | 38 | end |
11 | 39 | end |
12 | 40 | |
41 | + def each_with_hit(&block) | |
42 | + results.zip(hits).each(&block) | |
43 | + end | |
44 | + | |
13 | 45 | def with_details |
14 | 46 | each_with_hit.map do |model, hit| |
15 | 47 | details = {} |
... | ... | @@ -20,13 +52,50 @@ module Searchkick |
20 | 52 | end |
21 | 53 | end |
22 | 54 | |
23 | - # fixes deprecation warning | |
24 | - def __find_records_by_ids(klass, ids) | |
25 | - @options[:load] === true ? klass.find(ids) : klass.includes(@options[:load][:include]).find(ids) | |
55 | + def facets | |
56 | + response["facets"] | |
26 | 57 | end |
27 | 58 | |
28 | 59 | def model_name |
29 | - @options[:model_name] | |
60 | + klass.model_name | |
61 | + end | |
62 | + | |
63 | + def total_count | |
64 | + response["hits"]["total"] | |
65 | + end | |
66 | + | |
67 | + def current_page | |
68 | + options[:page] | |
69 | + end | |
70 | + | |
71 | + def per_page | |
72 | + options[:per_page] | |
73 | + end | |
74 | + | |
75 | + def total_pages | |
76 | + (total_count / per_page.to_f).ceil | |
77 | + end | |
78 | + | |
79 | + def limit_value | |
80 | + per_page | |
81 | + end | |
82 | + | |
83 | + def offset_value | |
84 | + current_page * per_page | |
85 | + end | |
86 | + | |
87 | + def previous_page | |
88 | + current_page > 1 ? (current_page - 1) : nil | |
89 | + end | |
90 | + | |
91 | + def next_page | |
92 | + current_page < total_pages ? (current_page + 1) : nil | |
93 | + end | |
94 | + | |
95 | + protected | |
96 | + | |
97 | + def hits | |
98 | + @response["hits"]["hits"] | |
30 | 99 | end |
31 | 100 | |
32 | 101 | end | ... | ... |
lib/searchkick/similar.rb
... | ... | @@ -3,7 +3,7 @@ module Searchkick |
3 | 3 | |
4 | 4 | def similar(options = {}) |
5 | 5 | like_text = self.class.searchkick_index.retrieve(document_type, id).to_hash |
6 | - .keep_if{|k,v| k[0] != "_" and (!options[:fields] or options[:fields].map(&:to_sym).include?(k)) } | |
6 | + .keep_if{|k,v| !options[:fields] || options[:fields].map(&:to_s).include?(k) } | |
7 | 7 | .values.compact.join(" ") |
8 | 8 | |
9 | 9 | # TODO deep merge method | ... | ... |
searchkick.gemspec
... | ... | @@ -18,8 +18,9 @@ Gem::Specification.new do |spec| |
18 | 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) |
19 | 19 | spec.require_paths = ["lib"] |
20 | 20 | |
21 | - spec.add_dependency "tire" | |
22 | - spec.add_dependency "tire-contrib" | |
21 | + spec.add_dependency "activemodel" | |
22 | + spec.add_dependency "elasticsearch" | |
23 | + spec.add_dependency "patron" # persistent http connections for performance | |
23 | 24 | |
24 | 25 | spec.add_development_dependency "bundler", "~> 1.3" |
25 | 26 | spec.add_development_dependency "rake" | ... | ... |
test/index_test.rb
... | ... | @@ -3,8 +3,11 @@ require_relative "test_helper" |
3 | 3 | class TestIndex < Minitest::Unit::TestCase |
4 | 4 | |
5 | 5 | def test_clean_indices |
6 | - old_index = Tire::Index.new("products_test_20130801000000000") | |
7 | - different_index = Tire::Index.new("items_test_20130801000000000") | |
6 | + old_index = Searchkick::Index.new("products_test_20130801000000000") | |
7 | + different_index = Searchkick::Index.new("items_test_20130801000000000") | |
8 | + | |
9 | + old_index.delete if old_index.exists? | |
10 | + different_index.delete if different_index.exists? | |
8 | 11 | |
9 | 12 | # create indexes |
10 | 13 | old_index.create |
... | ... | @@ -18,7 +21,7 @@ class TestIndex < Minitest::Unit::TestCase |
18 | 21 | end |
19 | 22 | |
20 | 23 | def test_clean_indices_old_format |
21 | - old_index = Tire::Index.new("products_test_20130801000000") | |
24 | + old_index = Searchkick::Index.new("products_test_20130801000000") | |
22 | 25 | old_index.create |
23 | 26 | |
24 | 27 | Product.clean_indices | ... | ... |
test/sql_test.rb
... | ... | @@ -26,6 +26,8 @@ class TestSql < Minitest::Unit::TestCase |
26 | 26 | assert_equal 2, products.per_page |
27 | 27 | assert_equal 3, products.total_pages |
28 | 28 | assert_equal 5, products.total_count |
29 | + assert_equal 2, products.limit_value | |
30 | + assert_equal 4, products.offset_value | |
29 | 31 | end |
30 | 32 | |
31 | 33 | def test_pagination_nil_page |
... | ... | @@ -245,12 +247,12 @@ class TestSql < Minitest::Unit::TestCase |
245 | 247 | |
246 | 248 | def test_load_false |
247 | 249 | store_names ["Product A"] |
248 | - assert_kind_of Tire::Results::Item, Product.search("product", load: false).first | |
250 | + assert_kind_of Hash, Product.search("product", load: false).first | |
249 | 251 | end |
250 | 252 | |
251 | 253 | def test_load_false_with_include |
252 | 254 | store_names ["Product A"] |
253 | - assert_kind_of Tire::Results::Item, Product.search("product", load: false, include: [:store]).first | |
255 | + assert_kind_of Hash, Product.search("product", load: false, include: [:store]).first | |
254 | 256 | end |
255 | 257 | |
256 | 258 | # TODO see if Mongoid is loaded | ... | ... |
test/test_helper.rb
... | ... | @@ -2,14 +2,12 @@ require "bundler/setup" |
2 | 2 | Bundler.require(:default) |
3 | 3 | require "minitest/autorun" |
4 | 4 | require "minitest/pride" |
5 | +require "logger" | |
5 | 6 | |
6 | 7 | ENV["RACK_ENV"] = "test" |
7 | 8 | |
8 | 9 | File.delete("elasticsearch.log") if File.exists?("elasticsearch.log") |
9 | -Tire.configure do | |
10 | - logger "elasticsearch.log", :level => "debug" | |
11 | - pretty true | |
12 | -end | |
10 | +Searchkick.client.transport.logger = Logger.new("elasticsearch.log") | |
13 | 11 | |
14 | 12 | if defined?(Mongoid) |
15 | 13 | Mongoid.configure do |config| | ... | ... |