elastictastic 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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