elastictastic 0.5.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.
Files changed (57) hide show
  1. data/LICENSE +19 -0
  2. data/README.md +326 -0
  3. data/lib/elastictastic/association.rb +21 -0
  4. data/lib/elastictastic/bulk_persistence_strategy.rb +70 -0
  5. data/lib/elastictastic/callbacks.rb +30 -0
  6. data/lib/elastictastic/child_collection_proxy.rb +56 -0
  7. data/lib/elastictastic/client.rb +101 -0
  8. data/lib/elastictastic/configuration.rb +35 -0
  9. data/lib/elastictastic/dirty.rb +130 -0
  10. data/lib/elastictastic/discrete_persistence_strategy.rb +52 -0
  11. data/lib/elastictastic/document.rb +98 -0
  12. data/lib/elastictastic/errors.rb +7 -0
  13. data/lib/elastictastic/field.rb +38 -0
  14. data/lib/elastictastic/index.rb +19 -0
  15. data/lib/elastictastic/mass_assignment_security.rb +15 -0
  16. data/lib/elastictastic/middleware.rb +119 -0
  17. data/lib/elastictastic/nested_document.rb +29 -0
  18. data/lib/elastictastic/new_relic_instrumentation.rb +26 -0
  19. data/lib/elastictastic/observer.rb +3 -0
  20. data/lib/elastictastic/observing.rb +21 -0
  21. data/lib/elastictastic/parent_child.rb +115 -0
  22. data/lib/elastictastic/persistence.rb +67 -0
  23. data/lib/elastictastic/properties.rb +236 -0
  24. data/lib/elastictastic/railtie.rb +35 -0
  25. data/lib/elastictastic/resource.rb +4 -0
  26. data/lib/elastictastic/scope.rb +283 -0
  27. data/lib/elastictastic/scope_builder.rb +32 -0
  28. data/lib/elastictastic/scoped.rb +20 -0
  29. data/lib/elastictastic/search.rb +180 -0
  30. data/lib/elastictastic/server_error.rb +15 -0
  31. data/lib/elastictastic/test_helpers.rb +172 -0
  32. data/lib/elastictastic/util.rb +63 -0
  33. data/lib/elastictastic/validations.rb +45 -0
  34. data/lib/elastictastic/version.rb +3 -0
  35. data/lib/elastictastic.rb +82 -0
  36. data/spec/environment.rb +6 -0
  37. data/spec/examples/active_model_lint_spec.rb +20 -0
  38. data/spec/examples/bulk_persistence_strategy_spec.rb +233 -0
  39. data/spec/examples/callbacks_spec.rb +96 -0
  40. data/spec/examples/dirty_spec.rb +238 -0
  41. data/spec/examples/document_spec.rb +600 -0
  42. data/spec/examples/mass_assignment_security_spec.rb +13 -0
  43. data/spec/examples/middleware_spec.rb +92 -0
  44. data/spec/examples/observing_spec.rb +141 -0
  45. data/spec/examples/parent_child_spec.rb +308 -0
  46. data/spec/examples/properties_spec.rb +92 -0
  47. data/spec/examples/scope_spec.rb +491 -0
  48. data/spec/examples/search_spec.rb +382 -0
  49. data/spec/examples/spec_helper.rb +15 -0
  50. data/spec/examples/validation_spec.rb +65 -0
  51. data/spec/models/author.rb +9 -0
  52. data/spec/models/blog.rb +5 -0
  53. data/spec/models/comment.rb +5 -0
  54. data/spec/models/post.rb +41 -0
  55. data/spec/models/post_observer.rb +11 -0
  56. data/spec/support/fakeweb_request_history.rb +13 -0
  57. metadata +227 -0
@@ -0,0 +1,491 @@
1
+ require File.expand_path('../spec_helper', __FILE__)
2
+
3
+ # This spec describes the behavior of Elastictastic when interacting with
4
+ # ElasticSearch to perform searches and related lookups. For behavior relating
5
+ # to the construction of search scopes, see spec/examples/search_spec
6
+ describe Elastictastic::Scope do
7
+ include Elastictastic::TestHelpers
8
+
9
+ let(:last_request) { FakeWeb.last_request }
10
+ let(:last_request_body) { JSON.parse(last_request.body) }
11
+ let(:last_request_path) { last_request.path.split('?', 2)[0] }
12
+ let(:last_request_params) { last_request.path.split('?', 2)[1].try(:split, '&') }
13
+
14
+ describe '#each' do
15
+ let(:noop) { proc { |arg| } }
16
+
17
+ context 'with query only' do
18
+ let(:scope) { Post.all.fields('title') }
19
+ let(:scan_request) { FakeWeb.requests[0] }
20
+ let(:scroll_requests) { FakeWeb.requests[1..-1] }
21
+
22
+ before do
23
+ @scroll_ids = stub_elasticsearch_scan(
24
+ 'default', 'post', 2, *make_hits(3)
25
+ )
26
+ end
27
+
28
+ it 'should return all contact documents' do
29
+ scope.map { |doc| doc.id }.should == %w(1 2 3)
30
+ end
31
+
32
+ it 'should mark contact documents persisted' do
33
+ scope.each { |doc| doc.should be_persisted }
34
+ end
35
+
36
+ describe 'initiating scan search' do
37
+ before { scope.to_a }
38
+
39
+ it 'should make request to index/type search endpoint' do
40
+ scan_request.path.split('?').first.should == '/default/post/_search'
41
+ end
42
+
43
+ it 'should send query in data for initial search' do
44
+ scan_request.body.should == scope.params.to_json
45
+ end
46
+
47
+ it 'should send POST request initially' do
48
+ scan_request.method.should == 'POST'
49
+ end
50
+ end # describe 'initiating scan search'
51
+
52
+ context 'with options specified' do
53
+ before { scope.find_each(:batch_size => 20, :ttl => 30, &noop) }
54
+
55
+ it 'should make request to index/type search endpoint with batch size and TTL' do
56
+ scan_request.path.split('?').last.split('&').should =~
57
+ %w(search_type=scan scroll=30s size=20)
58
+ end
59
+ end # context 'with options specified'
60
+
61
+ describe 'paging through cursor' do
62
+ before { scope.to_a }
63
+
64
+ it 'should make request to scan search endpoint' do
65
+ scroll_requests.each { |request| request.path.split('?').first.should == '/_search/scroll' }
66
+ end
67
+
68
+ it 'should send id in body' do
69
+ scroll_requests.map { |request| request.body }.should == @scroll_ids
70
+ end
71
+
72
+ it 'should include scroll param in each request' do
73
+ scroll_requests.each do |request|
74
+ request.path.split('?')[1].split('&').should include('scroll=60s')
75
+ end
76
+ end
77
+ end # describe 'paging through cursor'
78
+ end # context 'with query only'
79
+
80
+ context 'with from/size' do
81
+ let(:scope) { Post.from(10).size(10) }
82
+
83
+ before do
84
+ stub_elasticsearch_search(
85
+ 'default', 'post', 'hits' => {
86
+ 'total' => 2,
87
+ 'hits' => make_hits(2)
88
+ }
89
+ )
90
+ end
91
+
92
+ it 'should send request to search endpoint' do
93
+ scope.to_a
94
+ last_request_path.should == '/default/post/_search'
95
+ end
96
+
97
+ it 'should send query in body' do
98
+ scope.to_a
99
+ last_request_body.should == scope.params
100
+ end
101
+
102
+ it 'should perform query_then_fetch search' do
103
+ scope.to_a
104
+ last_request_params.should include('search_type=query_then_fetch')
105
+ end
106
+
107
+ it 'should return documents' do
108
+ scope.map { |post| post.title }.should == ['Post 1', 'Post 2']
109
+ end
110
+ end # context 'with from/size'
111
+
112
+ context 'with sort but no from/size' do
113
+ let(:scope) { Post.sort(:title => 'asc') }
114
+ let(:requests) { FakeWeb.requests }
115
+ let(:request_bodies) do
116
+ requests.map do |request|
117
+ JSON.parse(request.body)
118
+ end
119
+ end
120
+
121
+ before do
122
+ Elastictastic.config.default_batch_size = 2
123
+ stub_elasticsearch_search(
124
+ 'default', 'post',
125
+ make_hits(3).each_slice(2).map { |batch| { 'hits' => { 'hits' => batch, 'total' => 3 }} }
126
+ )
127
+ end
128
+
129
+ after { Elastictastic.config.default_batch_size = nil }
130
+
131
+ it 'should send two requests' do
132
+ scope.to_a
133
+ requests.length.should == 2
134
+ end
135
+
136
+ it 'should send requests to search endpoint' do
137
+ scope.to_a
138
+ requests.each do |request|
139
+ request.path.split('?').first.should == '/default/post/_search'
140
+ end
141
+ end
142
+
143
+ it 'should send from' do
144
+ scope.to_a
145
+ request_bodies.each_with_index do |body, i|
146
+ body['from'].should == i * 2
147
+ end
148
+ end
149
+
150
+ it 'should send size' do
151
+ scope.to_a
152
+ request_bodies.each do |body|
153
+ body['size'].should == 2
154
+ end
155
+ end
156
+
157
+ it 'should perform query_then_fetch search' do
158
+ scope.to_a
159
+ requests.each do |request|
160
+ request.path.should =~ /[\?&]search_type=query_then_fetch(&|$)/
161
+ end
162
+ end
163
+
164
+ it 'should return all results' do
165
+ scope.map { |post| post.id }.should == (1..3).map(&:to_s)
166
+ end
167
+
168
+ it 'should mark documents persisent' do
169
+ scope.each { |post| post.should be_persisted }
170
+ end
171
+ end # context 'with sort but no from/size'
172
+ end # describe '#each'
173
+
174
+ describe 'hit metadata' do
175
+ before { Elastictastic.config.default_batch_size = 2 }
176
+ after { Elastictastic.config.default_batch_size = nil }
177
+
178
+ let(:hits) do
179
+ make_hits(3) do |hit, i|
180
+ hit.merge('highlight' => { 'title' => ["pizza #{i}"] })
181
+ end
182
+ end
183
+
184
+ shared_examples_for 'enumerator with hit metadata' do
185
+ it 'should yield from each batch #find_in_batches' do
186
+ i = -1
187
+ scope.find_in_batches do |batch|
188
+ batch.each do |post, hit|
189
+ hit.highlight['title'].first.should == "pizza #{i += 1}"
190
+ end
191
+ end
192
+ end
193
+
194
+ it 'should yield from #find_each' do
195
+ i = -1
196
+ scope.find_each do |post, hit|
197
+ hit.highlight['title'].first.should == "pizza #{i += 1}"
198
+ end
199
+ end
200
+ end
201
+
202
+ context 'in scan search' do
203
+ let(:scope) { Post }
204
+
205
+ before do
206
+ stub_elasticsearch_scan('default', 'post', 2, *hits)
207
+ end
208
+
209
+ it_should_behave_like 'enumerator with hit metadata'
210
+ end
211
+
212
+ context 'in paginated search' do
213
+ let(:scope) { Post.sort('title' => 'desc') }
214
+
215
+ before do
216
+ batches = hits.each_slice(2).map { |batch| { 'hits' => { 'hits' => batch, 'total' => 3 }} }
217
+ stub_elasticsearch_search('default', 'post', batches)
218
+ end
219
+
220
+ it_should_behave_like 'enumerator with hit metadata'
221
+ end
222
+
223
+ context 'in single-page search' do
224
+ let(:scope) { Post.size(3) }
225
+
226
+ before do
227
+ stub_elasticsearch_search('default', 'post', 'hits' => { 'hits' => hits, 'total' => 3 })
228
+ end
229
+
230
+ it_should_behave_like 'enumerator with hit metadata'
231
+ end
232
+ end
233
+
234
+ describe '#count' do
235
+ context 'with no operations performed yet' do
236
+ let!(:count) do
237
+ stub_elasticsearch_search('default', 'post', 'hits' => { 'total' => 3 })
238
+ Post.all.count
239
+ end
240
+
241
+ it 'should send search_type as count' do
242
+ last_request_params.should include('search_type=count')
243
+ end
244
+
245
+ it 'should get count' do
246
+ count.should == 3
247
+ end
248
+ end # context 'with no operations performed yet'
249
+
250
+ context 'with scan search performed' do
251
+ let!(:count) do
252
+ stub_elasticsearch_scan(
253
+ 'default', 'post', 2, *make_hits(3)
254
+ )
255
+ scope = Post.all
256
+ scope.to_a
257
+ scope.count
258
+ end
259
+
260
+ it 'should get count from scan request' do
261
+ count.should == 3
262
+ end
263
+
264
+ it 'should not send count request' do
265
+ FakeWeb.should have(4).requests
266
+ end
267
+ end
268
+
269
+ context 'with paginated search performed' do
270
+ let!(:count) do
271
+ stub_elasticsearch_search(
272
+ 'default', 'post', 'hits' => {
273
+ 'hits' => make_hits(3),
274
+ 'total' => 3
275
+ }
276
+ )
277
+ scope = Post.size(10)
278
+ scope.to_a
279
+ scope.count
280
+ end
281
+
282
+ it 'should get count from query_then_fetch search' do
283
+ count.should == 3
284
+ end
285
+
286
+ it 'should not perform extra request' do
287
+ FakeWeb.should have(1).request
288
+ end
289
+ end
290
+
291
+ context 'with paginated scan performed' do
292
+ let!(:count) do
293
+ stub_elasticsearch_search(
294
+ 'default', 'post', 'hits' => {
295
+ 'hits' => make_hits(2),
296
+ 'total' => 2
297
+ }
298
+ )
299
+ scope = Post.sort('title' => 'asc')
300
+ scope.to_a
301
+ scope.count
302
+ end
303
+
304
+ it 'should return count from internal paginated request' do
305
+ count.should == 2
306
+ end
307
+
308
+ it 'should not perform extra request' do
309
+ FakeWeb.should have(1).request
310
+ end
311
+ end # context 'with paginated scan performed'
312
+ end # describe '#count'
313
+
314
+ describe '#all_facets' do
315
+ let(:facet_response) do
316
+ {
317
+ 'comments_count' => {
318
+ '_type' => 'terms', 'total' => 2,
319
+ 'terms' => [
320
+ { 'term' => 4, 'count' => 1 },
321
+ { 'term' => 2, 'count' => 1 }
322
+ ]
323
+ }
324
+ }
325
+ end
326
+ let(:base_scope) { Post.facets(:comments_count => { :terms => { :field => :comments_count }}) }
327
+
328
+ context 'with no requests performed' do
329
+ let!(:facets) do
330
+ stub_elasticsearch_search(
331
+ 'default', 'post',
332
+ 'hits' => { 'hits' => [], 'total' => 2 },
333
+ 'facets' => facet_response
334
+ )
335
+ base_scope.all_facets
336
+ end
337
+
338
+ it 'should make count search_type' do
339
+ last_request_params.should include("search_type=count")
340
+ end
341
+
342
+ it 'should expose facets with object traversal' do
343
+ facets.comments_count.terms.first.term.should == 4
344
+ end
345
+ end # context 'with no requests performed'
346
+
347
+ context 'with count request performed' do
348
+ let!(:facets) do
349
+ stub_elasticsearch_search(
350
+ 'default', 'post',
351
+ 'hits' => { 'hits' => [], 'total' => 2 },
352
+ 'facets' => facet_response
353
+ )
354
+ base_scope.count
355
+ base_scope.all_facets
356
+ end
357
+
358
+ it 'should only perform one request' do
359
+ FakeWeb.should have(1).request
360
+ end
361
+
362
+ it 'should set facets' do
363
+ facets.comments_count.should be
364
+ end
365
+ end # context 'with count request performed'
366
+
367
+ context 'with single-page search performed' do
368
+ let!(:facets) do
369
+ stub_elasticsearch_search(
370
+ 'default', 'post',
371
+ 'hits' => { 'hits' => make_hits(2), 'total' => 2 },
372
+ 'facets' => facet_response
373
+ )
374
+ scope = base_scope.size(10)
375
+ scope.to_a
376
+ scope.all_facets
377
+ end
378
+
379
+ it 'should only perform one request' do
380
+ FakeWeb.should have(1).request
381
+ end
382
+
383
+ it 'should get facets' do
384
+ facets.comments_count.should be
385
+ end
386
+ end # context 'with single-page search performed'
387
+
388
+ context 'with multi-page search performed' do
389
+ let!(:facets) do
390
+ stub_elasticsearch_search(
391
+ 'default', 'post',
392
+ 'hits' => { 'hits' => make_hits(2), 'total' => 2 },
393
+ 'facets' => facet_response
394
+ )
395
+ scope = base_scope.sort(:comments_count => :asc)
396
+ scope.to_a
397
+ scope.all_facets
398
+ end
399
+
400
+ it 'should only peform one request' do
401
+ FakeWeb.should have(1).request
402
+ end
403
+
404
+ it 'should populate facets' do
405
+ facets.comments_count.should be
406
+ end
407
+ end # context 'with multi-page search performed'
408
+ end # describe '#all_facets'
409
+
410
+ describe '#first' do
411
+ shared_examples_for 'first method' do
412
+ before do
413
+ stub_elasticsearch_search(
414
+ index, 'post', 'hits' => {
415
+ 'total' => 12,
416
+ 'hits' => make_hits(1)
417
+ }
418
+ )
419
+ end
420
+
421
+ it 'should retrieve first document' do
422
+ scope.first.id.should == '1'
423
+ end
424
+
425
+ it 'should mark document persisted' do
426
+ scope.first.should be_persisted
427
+ end
428
+
429
+ it 'should send size param' do
430
+ scope.first
431
+ last_request_body['size'].should == 1
432
+ end
433
+
434
+ it 'should send scope params' do
435
+ scope.first
436
+ last_request_body['query'].should == scope.all.params['query']
437
+ end
438
+
439
+ it 'should send from param' do
440
+ scope.first
441
+ last_request_body['from'].should == 0
442
+ end
443
+
444
+ it 'should override from and size param in scope' do
445
+ scope.from(10).size(10).first
446
+ last_request_body['from'].should == 0
447
+ last_request_body['size'].should == 1
448
+ end
449
+ end
450
+
451
+ describe 'called on class singleton' do
452
+ let(:scope) { Post }
453
+ let(:index) { 'default' }
454
+
455
+ it_should_behave_like 'first method'
456
+ end
457
+
458
+ describe 'called on index proxy' do
459
+ let(:scope) { Post.in_index('my_index') }
460
+ let(:index) { 'my_index' }
461
+
462
+ it_should_behave_like 'first method'
463
+ end
464
+
465
+ describe 'called on scope' do
466
+ let(:scope) { Post.query { match_all }}
467
+ let(:index) { 'default' }
468
+
469
+ it_should_behave_like 'first method'
470
+ end
471
+
472
+ describe 'called on scope with index proxy' do
473
+ let(:scope) { Post.in_index('my_index').query { match_all }}
474
+ let(:index) { 'my_index' }
475
+
476
+ it_should_behave_like 'first method'
477
+ end
478
+ end # describe '#first'
479
+
480
+ def make_hits(count)
481
+ Array.new(count) do |i|
482
+ hit = {
483
+ '_id' => (i + 1).to_s,
484
+ '_type' => 'post',
485
+ '_index' => 'default',
486
+ '_source' => { 'title' => "Post #{i + 1}" }
487
+ }
488
+ block_given? ? yield(hit, i) : hit
489
+ end
490
+ end
491
+ end