elastic_searchable 1.5 → 1.6

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 07b8465cf211c71d51f8aae0a70050d764ca5b85
4
+ data.tar.gz: 22718204ae3d09fd1e969c1a8410b4d352946ff8
5
+ SHA512:
6
+ metadata.gz: 64be3a3d8766e5a9d3a2d39523172703467572e496b96d92d3167686d04b8522b2aaf8203e32c60d8db4fe40ecedea7eb1fee945d49068ef0de7c47a51c4f490
7
+ data.tar.gz: f7791b7a15d83546061e34773e580d3d4a0fdee66d277f35c793d2515a20d0c0accc4c39f82b0d400a06a2dfd0ff35dc7910a1d3cb35f917c7481af867f1e737
data/.gitignore CHANGED
@@ -18,6 +18,8 @@ pkg
18
18
  # test files
19
19
  test/*.log
20
20
  test/*.sqlite3
21
+ spec/**/*.log
22
+ spec/*.sqlite3
21
23
 
22
24
  # For vim:
23
25
  *.swp
data/.rvmrc CHANGED
@@ -1 +1 @@
1
- rvm use ruby-1.9.3-p125@elastic_searchable --create
1
+ rvm use default@elastic_searchable --create
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.1.1
4
+ services:
5
+ - elasticsearch
data/CONTRIBUTORS.txt CHANGED
@@ -1,4 +1,5 @@
1
1
  Ryan Sonnek - Original Author
2
+ Geoff Hichborn - Implemented search result autoscrubbing
2
3
 
3
4
 
4
5
  Complete list of contributors:
data/Rakefile CHANGED
@@ -1,13 +1,11 @@
1
- require 'bundler'
2
- Bundler::GemHelper.install_tasks
1
+ require "bundler/gem_tasks"
3
2
 
4
3
  require 'rake'
5
4
 
6
- require 'rake/testtask'
7
- Rake::TestTask.new(:test) do |test|
8
- test.libs << 'lib' << 'test'
9
- test.pattern = 'test/**/test_*.rb'
10
- test.verbose = true
11
- end
12
- task :default => :test
5
+ require 'rspec/core/rake_task'
13
6
 
7
+ desc "Run specs"
8
+ RSpec::Core::RakeTask.new do |t|
9
+ end
10
+ task :default => :spec
11
+ task :test => :spec
@@ -23,9 +23,10 @@ Gem::Specification.new do |s|
23
23
  s.add_runtime_dependency(%q<httparty>, [">= 0.6.0"])
24
24
  s.add_runtime_dependency(%q<backgrounded>, ["~> 0.7.0"])
25
25
  s.add_runtime_dependency(%q<multi_json>, [">= 1.0.0"])
26
- s.add_development_dependency(%q<rake>, ["0.9.2.2"])
26
+ s.add_development_dependency(%q<rake>)
27
27
  s.add_development_dependency(%q<sqlite3>)
28
- s.add_development_dependency(%q<pry>, ["0.9.6.2"])
29
- s.add_development_dependency(%q<shoulda>, ["2.11.3"])
30
- s.add_development_dependency(%q<mocha>)
28
+ s.add_development_dependency(%q<pry>)
29
+ s.add_development_dependency(%q<rspec>)
30
+ s.add_development_dependency(%q<byebug>)
31
+ s.add_development_dependency(%q<prefactory>)
31
32
  end
@@ -111,15 +111,15 @@ module ElasticSearchable
111
111
  # see http://www.elasticsearch.org/guide/reference/api/index_.html
112
112
  def reindex(lifecycle = nil)
113
113
  query = {}
114
- query[:percolate] = "*" if _percolate_callbacks.any?
115
- response = ElasticSearchable.request :put, self.class.index_type_path(self.id), :query => query, :json_body => self.as_json_for_index
114
+ response = ElasticSearchable.request :put, self.class.index_type_path(id), :query => query, :json_body => as_json_for_index
116
115
 
117
116
  self.index_lifecycle = lifecycle ? lifecycle.to_sym : nil
118
117
  run_callbacks :index
119
118
 
120
- self.percolations = response['matches'] || []
121
- run_callbacks :percolate if self.percolations.any?
119
+ self.percolate if _percolate_callbacks.any?
120
+ run_callbacks :percolate if percolations.any?
122
121
  end
122
+
123
123
  # document to index in elasticsearch
124
124
  def as_json_for_index
125
125
  original_include_root_in_json = self.class.include_root_in_json
@@ -128,10 +128,12 @@ module ElasticSearchable
128
128
  ensure
129
129
  self.class.include_root_in_json = original_include_root_in_json
130
130
  end
131
+
131
132
  def should_index?
132
133
  [self.class.elastic_options[:if]].flatten.compact.all? { |m| evaluate_elastic_condition(m) } &&
133
134
  ![self.class.elastic_options[:unless]].flatten.compact.any? { |m| evaluate_elastic_condition(m) }
134
135
  end
136
+
135
137
  # percolate this object to see what registered searches match
136
138
  # can be done on transient/non-persisted objects!
137
139
  # can be done automatically when indexing using :percolate => true config option
@@ -140,7 +142,7 @@ module ElasticSearchable
140
142
  body = {:doc => self.as_json_for_index}
141
143
  body[:query] = percolator_query if percolator_query
142
144
  response = ElasticSearchable.request :get, self.class.index_type_path('_percolate'), :json_body => body
143
- self.percolations = response['matches'] || []
145
+ self.percolations = (response['matches'] || []).map { |match| match['_id'] }
144
146
  self.percolations
145
147
  end
146
148
 
@@ -1,3 +1,3 @@
1
1
  module ElasticSearchable
2
- VERSION = '1.5'
2
+ VERSION = '1.6'
3
3
  end
@@ -0,0 +1,433 @@
1
+ require 'spec_helper'
2
+
3
+ describe ElasticSearchable do
4
+ before do
5
+ begin
6
+ ElasticSearchable.delete '/elastic_searchable'
7
+ rescue
8
+ end
9
+ end
10
+
11
+ describe 'an ActiveRecord class that has not invoked elastic_searchable' do
12
+ before do
13
+ stub_const('Parent', Class.new(ActiveRecord::Base))
14
+ end
15
+ let(:clazz) { Parent }
16
+ let(:instance) { clazz.new }
17
+ it do
18
+ expect(clazz).to_not respond_to :elastic_options
19
+ expect(instance).to_not respond_to :percolations
20
+ end
21
+ end
22
+
23
+ describe 'with an ActiveRecord class with elastic_searchable config' do
24
+ let(:clazz) { Post }
25
+ let(:instance) { Post.new }
26
+ it do
27
+ expect(clazz).to respond_to :search
28
+ expect(clazz).to respond_to :elastic_options
29
+ expect(clazz.elastic_options[:unless]).to include :elasticsearch_offline?
30
+ expect(instance).to respond_to :percolations
31
+ expect(instance.percolations).to eq []
32
+ end
33
+
34
+ describe '.request' do
35
+ subject(:sending_request) { ElasticSearchable.request method, url }
36
+ context 'GET' do
37
+ let(:method) { :get }
38
+ context 'with invalid url' do
39
+ let(:url) { '/elastic_searchable/foobar/notfound' }
40
+ it { expect { sending_request }.to raise_error ElasticSearchable::ElasticError }
41
+ end
42
+ end
43
+ end
44
+
45
+ describe '.create_index' do
46
+ let(:index_status) { ElasticSearchable.request :get, '/elastic_searchable/_status' }
47
+ context 'when it has not been called' do
48
+ it { expect { index_status }.to raise_error }
49
+ end
50
+ context 'when it has been called' do
51
+ before do
52
+ Post.create_index
53
+ Post.refresh_index
54
+ end
55
+ it { expect { index_status }.not_to raise_error }
56
+ end
57
+ end
58
+
59
+ describe 'create callbacks' do
60
+ let!(:post) { Post.create :title => 'foo', :body => "bar" }
61
+ it 'fires index callbacks' do
62
+ expect(post).to be_indexed
63
+ expect(post).to be_indexed_on_create
64
+ expect(post).not_to be_indexed_on_update
65
+ end
66
+ end
67
+
68
+ describe 'update callbacks' do
69
+ before do
70
+ Post.create :title => 'foo', :body => 'bar'
71
+ end
72
+ let(:post) do
73
+ Post.last.tap do |post|
74
+ post.title = 'baz'
75
+ post.save
76
+ end
77
+ end
78
+ it do
79
+ expect(post).to be_indexed
80
+ expect(post).not_to be_indexed_on_create
81
+ expect(post).to be_indexed_on_update
82
+ end
83
+ end
84
+
85
+ describe 'ElasticSearchable.offline' do
86
+ let!(:post) do
87
+ ElasticSearchable.offline do
88
+ Post.create :title => 'foo', :body => "bar"
89
+ end
90
+ end
91
+ it do
92
+ expect(post).not_to be_indexed
93
+ expect(post).not_to be_indexed_on_create
94
+ expect(post).not_to be_indexed_on_update
95
+ end
96
+ end
97
+
98
+ context 'with an empty index and multiple database records' do
99
+ before do
100
+ Post.delete_all
101
+ Post.create_index
102
+ Post.create :title => 'foo', :body => "first bar"
103
+ Post.create :title => 'foo', :body => "second bar"
104
+ Post.delete_index
105
+ Post.create_index
106
+ Post.refresh_index
107
+ end
108
+ let!(:first_post) { Post.where(:body => "first bar").first }
109
+ let!(:second_post) { Post.where(:body => "second bar").first }
110
+ it 'does not raise error if an error occurs when reindexing model' do
111
+ expect_any_instance_of(Logger).to receive(:warn).at_least(:once)
112
+ expect(ElasticSearchable).to receive(:request).and_raise(ElasticSearchable::ElasticError.new('faux error'))
113
+ expect { Post.reindex }.not_to raise_error
114
+ end
115
+ it 'does not raise error when destroying one instance' do
116
+ expect_any_instance_of(Logger).to receive(:warn).at_least(:once)
117
+ expect { first_post.destroy }.not_to raise_error
118
+ end
119
+ describe ".reindex" do
120
+ before do
121
+ Post.reindex :per_page => 1, :scope => Post.order('body desc')
122
+ Post.refresh_index
123
+ end
124
+ it do
125
+ expect { ElasticSearchable.request :get, "/elastic_searchable/posts/#{first_post.id}" }.to_not raise_error
126
+ expect { ElasticSearchable.request :get, "/elastic_searchable/posts/#{second_post.id}" }.to_not raise_error
127
+ end
128
+ end
129
+ end
130
+
131
+ context 'with the index containing multiple results' do
132
+ before do
133
+ Post.create_index
134
+ Post.create :title => 'foo', :body => "first bar"
135
+ Post.create :title => 'foo', :body => "second bar"
136
+ Post.refresh_index
137
+ end
138
+ let!(:first_post) { Post.where(:body => "first bar").first }
139
+ let!(:second_post) { Post.where(:body => "second bar").first }
140
+
141
+ context 'searching on a term that returns one result' do
142
+ subject(:results) { Post.search 'first' }
143
+ it do
144
+ is_expected.to include first_post
145
+ expect(results.current_page).to eq 1
146
+ expect(results.per_page).to eq Post.per_page
147
+ expect(results.previous_page).to be_nil
148
+ expect(results.next_page).to be_nil
149
+ expect(results.first.hit['_id']).to eq first_post.id.to_s
150
+ end
151
+ end
152
+ context 'searching on a term that returns multiple results' do
153
+ subject(:results) { Post.search 'foo' }
154
+ it do
155
+ is_expected.to include first_post
156
+ is_expected.to include second_post
157
+ expect(results.current_page).to eq 1
158
+ expect(results.per_page).to eq Post.per_page
159
+ expect(results.previous_page).to be_nil
160
+ expect(results.next_page).to be_nil
161
+ expect(results.first.hit['_id']).to eq first_post.id.to_s
162
+ expect(results.last.hit['_id']).to eq second_post.id.to_s
163
+ end
164
+ end
165
+ context 'searching for results using a query Hash' do
166
+ subject(:results) do
167
+ Post.search({
168
+ :filtered => {
169
+ :query => {
170
+ :term => {:title => 'foo'},
171
+ },
172
+ :filter => {
173
+ :or => [
174
+ {:term => {:body => 'second'}},
175
+ {:term => {:body => 'third'}}
176
+ ]
177
+ }
178
+ }
179
+ })
180
+ end
181
+ it do
182
+ is_expected.to_not include first_post
183
+ is_expected.to include second_post
184
+ end
185
+ end
186
+
187
+ context 'when per_page is a string' do
188
+ subject(:results) { Post.search 'foo', :per_page => 1.to_s, :sort => 'id' }
189
+ it { expect(results).to include first_post }
190
+ end
191
+
192
+ context 'searching for second page using will_paginate params' do
193
+ subject(:results) { Post.search 'foo', :page => 2, :per_page => 1, :sort => 'id' }
194
+ it do
195
+ expect(results).not_to include first_post
196
+ expect(results).to include second_post
197
+ expect(results.current_page).to eq 2
198
+ expect(results.per_page).to eq 1
199
+ expect(results.previous_page).to eq 1
200
+ expect(results.next_page).to be_nil
201
+ end
202
+ end
203
+
204
+ context 'sorting search results' do
205
+ subject(:results) { Post.search 'foo', :sort => 'id:desc' }
206
+ it 'sorts results correctly' do
207
+ expect(results).to eq [second_post, first_post]
208
+ end
209
+ end
210
+
211
+ context 'advanced sort options' do
212
+ subject(:results) { Post.search 'foo', :sort => [{:id => 'desc'}] }
213
+ it 'sorts results correctly' do
214
+ expect(results).to eq [second_post, first_post]
215
+ end
216
+ end
217
+
218
+ context 'destroying one object' do
219
+ before do
220
+ first_post.destroy
221
+ Post.refresh_index
222
+ end
223
+ it 'is removed from the index' do
224
+ expect(ElasticSearchable.get("/elastic_searchable/posts/#{first_post.id}").response).to be_a Net::HTTPNotFound
225
+ end
226
+ end
227
+ end
228
+
229
+ context 'deleting a record without updating the index' do
230
+
231
+ context 'backfilling partial result pages' do
232
+ let!(:posts) do
233
+ posts = (1..8).map do |i|
234
+ Post.create :title => 'foo', :body => "#{i} bar"
235
+ end
236
+ Post.refresh_index
237
+ posts
238
+ end
239
+ subject(:results) { Post.search 'foo', :size => 4, :sort => 'id:desc' }
240
+ it 'backfills the first page with results from other pages' do
241
+ removed_posts = []
242
+ posts.each_with_index do |post, i|
243
+ if i % 2 == 1
244
+ removed_posts << post
245
+ expect(Post).to receive(:delete_id_from_index_backgrounded).with(post.id)
246
+ post.delete
247
+ end
248
+ end
249
+ expect(results).to match_array(posts - removed_posts)
250
+ expect(results.total_entries).to eq 4
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ context 'activerecord class with optional :if=>proc configuration' do
257
+ context 'when creating new instance' do
258
+ it do
259
+ expect_any_instance_of(Blog).to_not receive(:reindex)
260
+ blog = Blog.create! :title => 'foo'
261
+ expect(ElasticSearchable.get("/elastic_searchable/blogs/#{blog.id}").response).to be_a Net::HTTPNotFound
262
+ end
263
+ end
264
+ end
265
+
266
+ context 'activerecord class with :index_options and :mapping' do
267
+ context 'creating index' do
268
+ before do
269
+ User.create_index
270
+ end
271
+ it 'uses custom index_options' do
272
+ settings = ElasticSearchable.request(:get, '/elastic_searchable/_settings')['elastic_searchable']['settings']['index']
273
+ settings.delete('version')
274
+ settings.delete('uuid')
275
+ expect(settings).to eq(
276
+ "analysis" => {
277
+ "analyzer" => {
278
+ "default"=> {
279
+ "filter" => [ "standard", "lowercase", "porterStem"],
280
+ "tokenizer" => "standard"
281
+ }
282
+ }
283
+ },
284
+ "number_of_shards"=>"1",
285
+ "number_of_replicas"=>"0"
286
+ )
287
+ end
288
+ it 'has set mapping' do
289
+ status = ElasticSearchable.request :get, '/elastic_searchable/users/_mapping'
290
+ expect(status['elastic_searchable']['mappings']['users']['properties']).to eq(
291
+ "name"=> {"type"=>"string", "index"=>"not_analyzed"}
292
+ )
293
+ end
294
+ end
295
+ end
296
+
297
+ context 'activerecord class with optional :json config' do
298
+ context 'creating index' do
299
+ let!(:friend) do
300
+ Friend.create_index
301
+ book = Book.create! :isbn => '123abc', :title => 'another world'
302
+ friend = Friend.new :name => 'bob', :favorite_color => 'red'
303
+ friend.book = book
304
+ friend.save!
305
+ Friend.refresh_index
306
+ friend
307
+ end
308
+ subject(:json) { ElasticSearchable.request(:get, "/elastic_searchable/friends/#{friend.id}")['_source'] }
309
+ it 'indexes json with configuration' do
310
+ expect(json['favorite_color']).to be_nil
311
+ expect(json['book'].key?('isbn')).to be_falsey
312
+ expect(json).to eq(
313
+ "name" => 'bob',
314
+ 'book' => { 'title' => 'another world' }
315
+ )
316
+ end
317
+ end
318
+ end
319
+
320
+ context 'updating ElasticSearchable.default_index' do
321
+ before do
322
+ ElasticSearchable.default_index = 'my_new_index'
323
+ end
324
+ after do
325
+ ElasticSearchable.default_index = ElasticSearchable::DEFAULT_INDEX
326
+ end
327
+ it { expect(ElasticSearchable.default_index).to eq 'my_new_index' }
328
+ end
329
+
330
+ context 'Book class with after_percolate callback' do
331
+ context 'with created index and populated fields' do
332
+ before do
333
+ Book.create_index
334
+ Book.create! :title => 'baz'
335
+ end
336
+ context "when index has configured percolation" do
337
+ before do
338
+ ElasticSearchable.request :put, '/elastic_searchable/.percolator/myfilter', :json_body => {:query => {:query_string => {:query => 'foo' }}}
339
+ ElasticSearchable.request :post, '/elastic_searchable/_refresh'
340
+ end
341
+ context 'creating an object that does not match the percolation' do
342
+ it 'does not percolate the record' do
343
+ expect_any_instance_of(Book).to_not receive(:on_percolated)
344
+ Book.create! :title => 'bar'
345
+ end
346
+ end
347
+ context 'creating an object that matches the percolation' do
348
+ let!(:book) do
349
+ Book.create :title => "foo"
350
+ end
351
+ it do
352
+ expect(book.percolated).to eq ['myfilter']
353
+ end
354
+ end
355
+ context 'percolating a non-persisted object' do
356
+ let!(:matches) { Book.new(:title => 'foo').percolate }
357
+ it do
358
+ expect(matches).to eq ['myfilter']
359
+ end
360
+ end
361
+ context "with multiple percolators in the index" do
362
+ before do
363
+ ElasticSearchable.request :put, '/elastic_searchable/.percolator/greenfilter', :json_body => { :color => 'green', :query => {:query_string => {:query => 'foo' }}}
364
+ ElasticSearchable.request :put, '/elastic_searchable/.percolator/bluefilter', :json_body => { :color => 'blue', :query => {:query_string => {:query => 'foo' }}}
365
+ ElasticSearchable.request :post, '/elastic_searchable/_refresh'
366
+ end
367
+ context 'percolating a non-persisted object with no limitation' do
368
+ let!(:matches) { Book.new(:title => 'foo').percolate }
369
+ it 'returns all percolated matches' do
370
+ expect(matches).to match_array ['myfilter', 'greenfilter', 'bluefilter']
371
+ expect(matches.size).to eq 3
372
+ end
373
+ end
374
+ context 'percolating a non-persisted object with limitations' do
375
+ let!(:matches) { Book.new(:title => 'foo').percolate(:term => { :color => 'green' }) }
376
+ it 'returns limited percolated matches' do
377
+ expect(matches).to eq ['greenfilter']
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
384
+
385
+ context 'with 2 MaxPageSizeClass instances' do
386
+ before do
387
+ MaxPageSizeClass.create_index
388
+ MaxPageSizeClass.create! :name => 'foo one'
389
+ MaxPageSizeClass.create! :name => 'foo two'
390
+ MaxPageSizeClass.refresh_index
391
+ end
392
+ let!(:first) { MaxPageSizeClass.where(:name => 'foo one').first }
393
+ let!(:second) { MaxPageSizeClass.where(:name => 'foo two').first }
394
+ subject(:results) { MaxPageSizeClass.search 'foo' }
395
+ context 'MaxPageSizeClass.search with default options and WillPaginate' do
396
+ before do
397
+ ElasticSearchable::Paginator.handler = ElasticSearchable::Pagination::WillPaginate
398
+ end
399
+ it do
400
+ expect(results.per_page).to eq 1
401
+ expect(results.length).to eq 1
402
+ expect(results.total_entries).to eq 2
403
+ end
404
+ end
405
+
406
+ context 'MaxPageSizeClass.search with default options and Kaminari' do
407
+ before do
408
+ ElasticSearchable::Paginator.handler = ElasticSearchable::Pagination::Kaminari
409
+ @results = MaxPageSizeClass.search 'foo'
410
+ end
411
+ it do
412
+ expect(results.per_page).to eq 1
413
+ expect(results.length).to eq 1
414
+ expect(results.total_entries).to eq 2
415
+ expect(results.num_pages).to eq 2
416
+ end
417
+ end
418
+
419
+ describe '.escape_query' do
420
+ let(:backslash) { "\\" }
421
+ shared_examples_for "escaped" do
422
+ it { expect(ElasticSearchable.escape_query(queryString)).to eq(backslash + queryString) }
423
+ end
424
+ %w| ! ^ + - { } [ ] ~ * : ? ( ) "|.each do |mark|
425
+ context "escaping '#{mark}'" do
426
+ let(:queryString) { mark }
427
+ it_behaves_like "escaped"
428
+ end
429
+ end
430
+ end
431
+ end
432
+ end
433
+