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,382 @@
1
+ require File.expand_path('../spec_helper', __FILE__)
2
+
3
+ #
4
+ # Here we describe the functionality for constructing queries, which is (sort of)
5
+ # what the Search module does. See spec/examples/scope_spec to test running the
6
+ # search and dealing with the results
7
+ #
8
+ describe Elastictastic::Search do
9
+ describe '#to_params' do
10
+ {
11
+ 'query' => { 'match_all' => {} },
12
+ 'filter' => { 'ids' => { 'values' => 1 }},
13
+ 'from' => 10,
14
+ 'sort' => { 'created_at' => 'desc' },
15
+ 'highlight' => { 'fields' => { 'body' => {}}},
16
+ 'fields' => %w(title body),
17
+ 'script_fields' => { 'rtitle' => { 'script' => "_source.title.reverse()" }},
18
+ 'preference' => '_local',
19
+ 'facets' => { 'tags' => { 'terms' => { 'field' => 'tags' }}}
20
+ }.each_pair do |method, value|
21
+ it "should build scope for #{method} param" do
22
+ Post.__send__(method, value).params.should == { method => value }
23
+ end
24
+ end
25
+
26
+ it 'should raise ArgumentError if no value passed' do
27
+ expect { Post.query }.to raise_error(ArgumentError)
28
+ end
29
+
30
+ it 'should not cast single arg to an array' do
31
+ Post.fields('title').params.should == { 'fields' => 'title' }
32
+ end
33
+
34
+ it 'should take multiple args and convert them to an array' do
35
+ Post.fields('title', 'body').params.should ==
36
+ { 'fields' => %w(title body) }
37
+ end
38
+
39
+ it 'should chain scopes' do
40
+ Post.from(30).size(15).params.should == { 'from' => 30, 'size' => 15 }
41
+ end
42
+
43
+ it 'should not chain destructively' do
44
+ scope = Post.from(20)
45
+ scope.size(10)
46
+ scope.params.should == { 'from' => 20 }
47
+ end
48
+ end
49
+
50
+ describe 'merging' do
51
+ let(:scope) { Post }
52
+
53
+ it 'should merge two simple queries into a bool' do
54
+ scope.query { term(:title => 'Pizza') }.query { term(:comments_count => 0) }.params.should == {
55
+ 'query' => {
56
+ 'bool' => {
57
+ 'must' => [
58
+ { 'term' => { 'title' => 'Pizza' }},
59
+ { 'term' => { 'comments_count' => 0 }}
60
+ ]
61
+ }
62
+ }
63
+ }
64
+ end
65
+
66
+ it 'should merge three simple queries into a bool' do
67
+ scope = self.scope.query { term(:title => 'pizza') }
68
+ scope = scope.query { term(:comments_count => 1) }
69
+ scope = scope.query { term(:tags => 'delicious') }
70
+ scope.params.should == {
71
+ 'query' => {
72
+ 'bool' => {
73
+ 'must' => [
74
+ { 'term' => { 'title' => 'pizza' }},
75
+ { 'term' => { 'comments_count' => 1 }},
76
+ { 'term' => { 'tags' => 'delicious' }}
77
+ ]
78
+ }
79
+ }
80
+ }
81
+ end
82
+
83
+ it 'should merge a new query into an existing boolean query' do
84
+ scope = self.scope.query { bool { must({ 'term' => { 'title' => 'pizza' }}, { 'term' => { 'comments_count' => 1 }}) }}
85
+ scope = scope.query { term(:tags => 'delicious') }
86
+ scope.params.should == {
87
+ 'query' => {
88
+ 'bool' => {
89
+ 'must' => [
90
+ { 'term' => { 'title' => 'pizza' }},
91
+ { 'term' => { 'comments_count' => 1 }},
92
+ { 'term' => { 'tags' => 'delicious' }}
93
+ ]
94
+ }
95
+ }
96
+ }
97
+ end
98
+
99
+ it 'should merge a new query into an existing filtered query' do
100
+ scope = self.scope.query do
101
+ filtered do
102
+ query { term('title' => 'pizza') }
103
+ filter { term('comments_count' => 1) }
104
+ end
105
+ end
106
+ scope = scope.query { term('tags' => 'delicious') }
107
+ scope.params['query'].should == {
108
+ 'filtered' => {
109
+ 'query' => { 'bool' => { 'must' => [{ 'term' => { 'title' => 'pizza' }}, { 'term' => { 'tags' => 'delicious' }}] }},
110
+ 'filter' => { 'term' => { 'comments_count' => 1 }}
111
+ }
112
+ }
113
+ end
114
+
115
+ it 'should merge two filtered queries' do
116
+ scope = self.scope.query do
117
+ filtered do
118
+ query { term('title' => 'pizza') }
119
+ filter { term('comments_count' => 1) }
120
+ end
121
+ end
122
+ scope = scope.query do
123
+ filtered do
124
+ query { term('title' => 'pepperoni') }
125
+ filter { term('tags' => 'delicious') }
126
+ end
127
+ end
128
+ scope.params.should == {
129
+ 'query' => {
130
+ 'filtered' => {
131
+ 'query' => { 'bool' => { 'must' => [
132
+ { 'term' => { 'title' => 'pizza' }},
133
+ { 'term' => { 'title' => 'pepperoni' }}
134
+ ]}},
135
+ 'filter' => { 'and' => [
136
+ { 'term' => { 'comments_count' => 1 }},
137
+ { 'term' => { 'tags' => 'delicious' }}
138
+ ]}
139
+ }
140
+ }
141
+ }
142
+ end
143
+
144
+ it 'should merge a regular query with a constant-score filter query' do
145
+ scope = self.scope.query('term' => { 'title' => 'pizza' })
146
+ scope = scope.query { constant_score { filter { term('tags' => 'delicious') }}}
147
+ scope.params.should == {
148
+ 'query' => {
149
+ 'filtered' => {
150
+ 'query' => { 'term' => { 'title' => 'pizza' }},
151
+ 'filter' => { 'term' => { 'tags' => 'delicious' }}
152
+ }
153
+ }
154
+ }
155
+ end
156
+
157
+ it 'should merge two constant-score filter queries' do
158
+ scope = self.scope.query { constant_score { filter { term('tags' => 'delicious') }}}
159
+ scope = scope.query { constant_score { filter { term('comments_count' => 0) }}}
160
+ scope.params.should == {
161
+ 'query' => {
162
+ 'constant_score' => {
163
+ 'filter' => {
164
+ 'and' => [
165
+ { 'term' => { 'tags' => 'delicious' }},
166
+ { 'term' => { 'comments_count' => 0 }}
167
+ ]
168
+ }
169
+ }
170
+ }
171
+ }
172
+ end
173
+
174
+ it 'should merge a constant-score filter query into a constant-score conjunction query' do
175
+ scope = self.scope.query { constant_score { filter('and' => [{ 'term' => { 'title' => 'pizza' }}, { 'term' => { 'tags' => 'delicious' }}]) }}
176
+ scope = scope.query { constant_score { filter { term('comments_count' => 0) }}}
177
+ scope.params.should == {
178
+ 'query' => {
179
+ 'constant_score' => {
180
+ 'filter' => {
181
+ 'and' => [
182
+ { 'term' => { 'title' => 'pizza' }},
183
+ { 'term' => { 'tags' => 'delicious' }},
184
+ { 'term' => { 'comments_count' => 0 }}
185
+ ]
186
+ }
187
+ }
188
+ }
189
+ }
190
+ end
191
+
192
+ it 'should merge two filters' do
193
+ scope = self.scope.filter('term' => { 'comments_count' => 0 })
194
+ scope = scope.filter('term' => { 'tags' => 'delicious' })
195
+ scope.params.should == {
196
+ 'filter' => { 'and' => [
197
+ { 'term' => { 'comments_count' => 0 }},
198
+ { 'term' => { 'tags' => 'delicious' }}
199
+ ]}
200
+ }
201
+ end
202
+
203
+ it 'should merge filter into conjunction' do
204
+ scope = self.scope.filter('and' => [{ 'term' => { 'title' => 'pizza' }}, { 'term' => { 'comments_count' => 0 }}])
205
+ scope = scope.filter('term' => { 'tags' => 'delicious' })
206
+ scope.params.should == {
207
+ 'filter' => { 'and' => [
208
+ { 'term' => { 'title' => 'pizza' }},
209
+ { 'term' => { 'comments_count' => 0 }},
210
+ { 'term' => { 'tags' => 'delicious' }}
211
+ ]}
212
+ }
213
+ end
214
+
215
+ it 'should override from when chaining' do
216
+ scope.from(10).from(20).params.should == { 'from' => 20 }
217
+ end
218
+
219
+ it 'should not override from when not given' do
220
+ scope.from(10).size(10).params.should == { 'from' => 10, 'size' => 10 }
221
+ end
222
+
223
+ it 'should override size when chaining' do
224
+ scope.size(10).size(15).params.should == { 'size' => 15 }
225
+ end
226
+
227
+ it 'should concatenate chained sorts' do
228
+ scope.sort('title' => 'asc').sort('comments_count' => 'desc').params.should == {
229
+ 'sort' => [{ 'title' => 'asc' }, { 'comments_count' => 'desc' }]
230
+ }
231
+ end
232
+
233
+ it 'should concatenate chained sort onto multiple sorts' do
234
+ scope.sort({ 'title' => 'asc' }, { 'comments_count' => 'desc' }).sort('created_at' => 'desc').params.should == {
235
+ 'sort' => [{ 'title' => 'asc' }, { 'comments_count' => 'desc' }, { 'created_at' => 'desc' }]
236
+ }
237
+ end
238
+
239
+ it 'should merge highlight fields' do
240
+ scope.highlight { fields('title' => {}) }.highlight { fields('tags' => {}) }.params.should == {
241
+ 'highlight' => { 'fields' => { 'title' => {}, 'tags' => {} }}
242
+ }
243
+ end
244
+
245
+ it 'should move global highlight settings into fields when merging' do
246
+ scope = self.scope.highlight do
247
+ fields('title' => {})
248
+ number_of_fragments(0)
249
+ end
250
+ scope = scope.highlight do
251
+ fields('tags' => {})
252
+ number_of_fragments(1)
253
+ end
254
+ scope.params.should == {
255
+ 'highlight' => {
256
+ 'fields' => {
257
+ 'title' => { 'number_of_fragments' => 0 },
258
+ 'tags' => { 'number_of_fragments' => 1 }
259
+ }
260
+ }
261
+ }
262
+ end
263
+
264
+ it 'should not override field-specific highlight settings when moving global settings' do
265
+ scope = self.scope.highlight do
266
+ fields(:title => { :number_of_fragments => 0 }, :tags => {})
267
+ number_of_fragments 1
268
+ end
269
+ scope = scope.highlight do
270
+ fields('comments.body' => {})
271
+ number_of_fragments 2
272
+ end
273
+ scope.params.should == {
274
+ 'highlight' => {
275
+ 'fields' => {
276
+ 'title' => { 'number_of_fragments' => 0 },
277
+ 'tags' => { 'number_of_fragments' => 1 },
278
+ 'comments.body' => { 'number_of_fragments' => 2 }
279
+ }
280
+ }
281
+ }
282
+ end
283
+
284
+ it 'should concatenate fields' do
285
+ scope.fields('title').fields('tags').params.should == {
286
+ 'fields' => %w(title tags)
287
+ }
288
+ end
289
+
290
+ it 'should concatenate onto arrays of fields' do
291
+ scope.fields('title', 'comments.body').fields('tags').params.should == {
292
+ 'fields' => %w(title comments.body tags)
293
+ }
294
+ end
295
+
296
+ it 'should merge script fields' do
297
+ scope.script_fields(:test1 => { 'script' => '1' }).script_fields(:test2 => { 'script' => '2' }).params.should == {
298
+ 'script_fields' => { 'test1' => { 'script' => '1' }, 'test2' => { 'script' => '2' }}
299
+ }
300
+ end
301
+
302
+ it 'should overwrite chained preference' do
303
+ scope.preference('_local').preference('_primary').params.should ==
304
+ { 'preference' => '_primary' }
305
+ end
306
+
307
+ it 'should merge facets' do
308
+ scope = self.scope.facets(:title => { :terms => { :field => 'title' }})
309
+ scope = scope.facets(:tags => { :terms => { :field => 'tags' }})
310
+ scope.params.should == {
311
+ 'facets' => {
312
+ 'title' => { 'terms' => { 'field' => 'title' }},
313
+ 'tags' => { 'terms' => { 'field' => 'tags' }}
314
+ }
315
+ }
316
+ end
317
+ end
318
+
319
+ describe 'block builder' do
320
+ it 'should build scopes with a block' do
321
+ Post.sort { title 'asc' }.params.should ==
322
+ { 'sort' => { 'title' => 'asc' }}
323
+ end
324
+
325
+ it 'should accept multiple calls within a block' do
326
+ scope = Post.sort do
327
+ title 'asc'
328
+ created_at 'desc'
329
+ end
330
+ scope.params.should ==
331
+ { 'sort' => { 'title' => 'asc', 'created_at' => 'desc' }}
332
+ end
333
+
334
+ it 'should turn varargs into array' do
335
+ Post.highlight { fields 'body', 'comments.body' }.params.should ==
336
+ { 'highlight' => { 'fields' => %w(body comments.body) }}
337
+ end
338
+
339
+ it 'should accept nested calls within a block' do
340
+ scope = Post.query do
341
+ query_string { query 'test testor' }
342
+ end
343
+ scope.params.should ==
344
+ { 'query' => { 'query_string' => { 'query' => 'test testor' }}}
345
+ end
346
+
347
+ it 'should set value to empty object (Hash) if none passed' do
348
+ scope = Post.query { match_all }.params.should ==
349
+ { 'query' => { 'match_all' => {} }}
350
+ end
351
+ end
352
+
353
+ describe 'class methods' do
354
+ let(:named_scope) do
355
+ Post.from(10).search_keywords('hey guy')
356
+ end
357
+
358
+ it 'should delegate to class singleton' do
359
+ named_scope.params['query'].should == {
360
+ 'query_string' => { 'query' => 'hey guy', 'fields' => %w(title body) }
361
+ }
362
+ end
363
+
364
+ it 'should retain current scope' do
365
+ named_scope.params['from'].should == 10
366
+ end
367
+ end
368
+
369
+ describe 'class methods directly on type-in-index' do
370
+ let(:named_scope) { Post.in_index('my_index').search_keywords('hey guy') }
371
+
372
+ it 'should delegate to class singleton when called on type_in_index' do
373
+ named_scope.params['query'].should == {
374
+ 'query_string' => { 'query' => 'hey guy', 'fields' => %w(title body) }
375
+ }
376
+ end
377
+
378
+ it 'should use proper index in scope' do
379
+ named_scope.index.name.should == 'my_index'
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,15 @@
1
+ require File.expand_path('../../environment', __FILE__)
2
+ require 'fakeweb'
3
+
4
+ require File.expand_path('../../support/fakeweb_request_history', __FILE__)
5
+
6
+ RSpec.configure do |config|
7
+ config.before(:all) do
8
+ FakeWeb.allow_net_connect = false
9
+ end
10
+
11
+ config.after(:each) do
12
+ FakeWeb.requests.clear
13
+ FakeWeb.clean_registry
14
+ end
15
+ end
@@ -0,0 +1,65 @@
1
+ require File.expand_path('../spec_helper', __FILE__)
2
+
3
+ describe Elastictastic::Validations do
4
+ include Elastictastic::TestHelpers
5
+
6
+ let(:post) { Post.new(:title => 'INVALID') }
7
+
8
+ describe 'with invalid data' do
9
+ it 'should not be valid' do
10
+ post.should_not be_valid
11
+ end
12
+
13
+ it 'should not persist to ElasticSearch on save' do
14
+ expect { post.save }.to_not raise_error(FakeWeb::NetConnectNotAllowedError)
15
+ end
16
+
17
+ it 'should return false for save' do
18
+ post.save.should be_false
19
+ end
20
+
21
+ it 'should raise Elastictastic::RecordInvalid for save!' do
22
+ expect { post.save! }.to raise_error(Elastictastic::RecordInvalid)
23
+ end
24
+ end
25
+
26
+ describe 'with valid data' do
27
+ let(:post) { Post.new }
28
+
29
+ before { stub_elasticsearch_create('default', 'post') }
30
+
31
+ it 'should be valid' do
32
+ post.should be_valid
33
+ end
34
+
35
+ it 'should persist to ElasticSearch on save' do
36
+ post.save
37
+ FakeWeb.last_request.should be
38
+ end
39
+
40
+ it 'should return true from save' do
41
+ post.save.should be_true
42
+ end
43
+
44
+ it 'should persist to ElasticSearch without error on save!' do
45
+ post.save!
46
+ FakeWeb.last_request.should be
47
+ end
48
+ end
49
+
50
+ context 'with invalid nested document' do
51
+ let(:post) do
52
+ Post.new.tap do |post|
53
+ post.author = Author.new(:name => 'INVALID')
54
+ end
55
+ end
56
+
57
+ it 'should not be valid' do
58
+ post.should_not be_valid
59
+ end
60
+
61
+ it 'should have an error for author' do
62
+ post.errors['author'].should be
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,9 @@
1
+ class Author
2
+ include Elastictastic::NestedDocument
3
+
4
+ field :id, :type => 'integer'
5
+ field :name
6
+ field :email, :index => 'not_analyzed'
7
+
8
+ validates :name, :exclusion => %w(INVALID)
9
+ end
@@ -0,0 +1,5 @@
1
+ class Blog
2
+ include Elastictastic::Document
3
+
4
+ has_many :posts
5
+ end
@@ -0,0 +1,5 @@
1
+ class Comment
2
+ include Elastictastic::NestedDocument
3
+
4
+ field :body
5
+ end
@@ -0,0 +1,41 @@
1
+ class Post
2
+ include Elastictastic::Document
3
+
4
+ field :title
5
+ field :comments_count, :type => 'integer'
6
+ field :tags, :index => 'analyzed' do
7
+ field :non_analyzed, :index => 'not_analyzed'
8
+ end
9
+ field :created_at, :type => 'date'
10
+ field :published_at, :type => 'date'
11
+
12
+ embed :author
13
+ embed :comments
14
+
15
+ belongs_to :blog
16
+
17
+ attr_accessible :title
18
+
19
+ validates :title, :exclusion => %w(INVALID)
20
+
21
+ def observers_that_ran
22
+ @observers_that_ran ||= Set[]
23
+ end
24
+
25
+ def self.search_keywords(keywords)
26
+ query do
27
+ query_string do
28
+ query(keywords)
29
+ fields 'title', 'body'
30
+ end
31
+ end
32
+ end
33
+
34
+ def self.from_hash(hash)
35
+ new.tap do |post|
36
+ hash.each_pair do |field, value|
37
+ post.__send__("#{field}=", value)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ class PostObserver < Elastictastic::Observer
2
+ %w(create update save destroy).each do |lifecycle|
3
+ %w(before after).each do |phase|
4
+ module_eval <<-RUBY, __FILE__, __LINE__ + 1
5
+ def #{phase}_#{lifecycle}(post)
6
+ post.observers_that_ran << :#{phase}_#{lifecycle}
7
+ end
8
+ RUBY
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ class <<FakeWeb
2
+ def last_request=(request)
3
+ requests << request
4
+ end
5
+
6
+ def last_request
7
+ requests.last
8
+ end
9
+
10
+ def requests
11
+ @requests ||= []
12
+ end
13
+ end