slingshot-rb 0.0.8 → 0.0.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. data/.gitignore +1 -0
  2. data/README.markdown +276 -50
  3. data/examples/rails-application-template.rb +144 -0
  4. data/examples/slingshot-dsl.rb +272 -102
  5. data/lib/slingshot.rb +13 -0
  6. data/lib/slingshot/client.rb +10 -1
  7. data/lib/slingshot/dsl.rb +17 -1
  8. data/lib/slingshot/index.rb +109 -7
  9. data/lib/slingshot/model/callbacks.rb +23 -0
  10. data/lib/slingshot/model/import.rb +18 -0
  11. data/lib/slingshot/model/indexing.rb +50 -0
  12. data/lib/slingshot/model/naming.rb +30 -0
  13. data/lib/slingshot/model/persistence.rb +34 -0
  14. data/lib/slingshot/model/persistence/attributes.rb +60 -0
  15. data/lib/slingshot/model/persistence/finders.rb +61 -0
  16. data/lib/slingshot/model/persistence/storage.rb +75 -0
  17. data/lib/slingshot/model/search.rb +97 -0
  18. data/lib/slingshot/results/collection.rb +35 -10
  19. data/lib/slingshot/results/item.rb +10 -7
  20. data/lib/slingshot/results/pagination.rb +30 -0
  21. data/lib/slingshot/rubyext/symbol.rb +11 -0
  22. data/lib/slingshot/search.rb +3 -2
  23. data/lib/slingshot/search/facet.rb +8 -6
  24. data/lib/slingshot/search/filter.rb +7 -8
  25. data/lib/slingshot/search/highlight.rb +1 -3
  26. data/lib/slingshot/search/query.rb +4 -0
  27. data/lib/slingshot/search/sort.rb +5 -0
  28. data/lib/slingshot/tasks.rb +88 -0
  29. data/lib/slingshot/version.rb +1 -1
  30. data/slingshot.gemspec +17 -4
  31. data/test/integration/active_model_searchable_test.rb +80 -0
  32. data/test/integration/active_record_searchable_test.rb +193 -0
  33. data/test/integration/highlight_test.rb +1 -1
  34. data/test/integration/index_mapping_test.rb +1 -1
  35. data/test/integration/index_store_test.rb +27 -0
  36. data/test/integration/persistent_model_test.rb +35 -0
  37. data/test/integration/query_string_test.rb +3 -3
  38. data/test/integration/sort_test.rb +2 -2
  39. data/test/models/active_model_article.rb +31 -0
  40. data/test/models/active_model_article_with_callbacks.rb +49 -0
  41. data/test/models/active_model_article_with_custom_index_name.rb +5 -0
  42. data/test/models/active_record_article.rb +12 -0
  43. data/test/models/persistent_article.rb +11 -0
  44. data/test/models/persistent_articles_with_custom_index_name.rb +10 -0
  45. data/test/models/supermodel_article.rb +22 -0
  46. data/test/models/validated_model.rb +11 -0
  47. data/test/test_helper.rb +4 -0
  48. data/test/unit/active_model_lint_test.rb +17 -0
  49. data/test/unit/client_test.rb +4 -0
  50. data/test/unit/configuration_test.rb +4 -0
  51. data/test/unit/index_test.rb +240 -17
  52. data/test/unit/model_callbacks_test.rb +90 -0
  53. data/test/unit/model_import_test.rb +71 -0
  54. data/test/unit/model_persistence_test.rb +400 -0
  55. data/test/unit/model_search_test.rb +289 -0
  56. data/test/unit/results_collection_test.rb +69 -7
  57. data/test/unit/results_item_test.rb +8 -14
  58. data/test/unit/rubyext_hash_test.rb +19 -0
  59. data/test/unit/search_facet_test.rb +25 -7
  60. data/test/unit/search_filter_test.rb +3 -0
  61. data/test/unit/search_query_test.rb +11 -0
  62. data/test/unit/search_sort_test.rb +8 -0
  63. data/test/unit/search_test.rb +14 -0
  64. data/test/unit/slingshot_test.rb +38 -0
  65. metadata +133 -26
@@ -0,0 +1,289 @@
1
+ require 'test_helper'
2
+
3
+ module Slingshot
4
+ module Model
5
+
6
+ class SearchTest < Test::Unit::TestCase
7
+
8
+ context "Model::Search" do
9
+
10
+ setup do
11
+ @stub = stub('search') { stubs(:query).returns(self); stubs(:perform).returns(self); stubs(:results).returns([]) }
12
+ end
13
+
14
+ teardown do
15
+ ActiveModelArticleWithCustomIndexName.index_name 'custom-index-name'
16
+ end
17
+
18
+ should "have the search method" do
19
+ assert_respond_to Model::Search, :search
20
+ assert_respond_to ActiveModelArticle, :search
21
+ end
22
+
23
+ should "search in index named after class name by default" do
24
+ i = 'active_model_articles'
25
+ Slingshot::Search::Search.expects(:new).with(i, {}).returns(@stub)
26
+
27
+ ActiveModelArticle.search 'foo'
28
+ end
29
+
30
+ should "search in custom name" do
31
+ first = 'custom-index-name'
32
+ second = 'another-custom-index-name'
33
+
34
+ Slingshot::Search::Search.expects(:new).with(first, {}).returns(@stub)
35
+ ActiveModelArticleWithCustomIndexName.index_name 'custom-index-name'
36
+ ActiveModelArticleWithCustomIndexName.search 'foo'
37
+
38
+ Slingshot::Search::Search.expects(:new).with(second, {}).returns(@stub)
39
+ ActiveModelArticleWithCustomIndexName.index_name 'another-custom-index-name'
40
+ ActiveModelArticleWithCustomIndexName.search 'foo'
41
+
42
+ Slingshot::Search::Search.expects(:new).with(first, {}).returns(@stub)
43
+ ActiveModelArticleWithCustomIndexName.index_name 'custom-index-name'
44
+ ActiveModelArticleWithCustomIndexName.search 'foo'
45
+ end
46
+
47
+ should "allow to refresh index" do
48
+ Index.any_instance.expects(:refresh)
49
+
50
+ ActiveModelArticle.index.refresh
51
+ end
52
+
53
+ should "wrap results in proper class with ID and score and not change the original wrapper" do
54
+ response = { 'hits' => { 'hits' => [{'_id' => 1, '_score' => 0.8, '_source' => { 'title' => 'Article' }}] } }
55
+ Configuration.client.expects(:post).returns(mock_response(response.to_json))
56
+
57
+ collection = ActiveModelArticle.search 'foo'
58
+ assert_instance_of Results::Collection, collection
59
+
60
+ assert_equal Results::Item, Slingshot::Configuration.wrapper
61
+
62
+ document = collection.first
63
+
64
+ assert_instance_of ActiveModelArticle, document
65
+ assert_not_nil document.score
66
+ assert_equal 1, document.id
67
+ assert_equal 'Article', document.title
68
+ end
69
+
70
+ context "searching with a block" do
71
+
72
+ should "pass on whatever block it received" do
73
+ Slingshot::Search::Search.any_instance.expects(:perform).returns(@stub)
74
+ Slingshot::Search::Query.any_instance.expects(:string).with('foo').returns(@stub)
75
+
76
+ ActiveModelArticle.search { query { string 'foo' } }
77
+ end
78
+
79
+ should "allow to pass block with argument to query, allowing to use local variables from outer scope" do
80
+ Slingshot::Search::Query.any_instance.expects(:instance_eval).never
81
+ Slingshot::Search::Search.any_instance.expects(:perform).returns(@stub)
82
+ Slingshot::Search::Query.any_instance.expects(:string).with('foo').returns(@stub)
83
+
84
+ my_query = 'foo'
85
+ ActiveModelArticle.search do
86
+ query do |query|
87
+ query.string(my_query)
88
+ end
89
+ end
90
+ end
91
+
92
+ end
93
+
94
+ context "searching with query string" do
95
+
96
+ setup do
97
+ @q = 'foo AND bar'
98
+
99
+ Slingshot::Search::Query.any_instance.expects(:string).with( @q ).returns(@stub)
100
+ Slingshot::Search::Search.any_instance.expects(:perform).returns(@stub)
101
+ end
102
+
103
+ should "search for query string" do
104
+ ActiveModelArticle.search @q
105
+ end
106
+
107
+ should "allow to pass :order option" do
108
+ Slingshot::Search::Sort.any_instance.expects(:title)
109
+
110
+ ActiveModelArticle.search @q, :order => 'title'
111
+ end
112
+
113
+ should "allow to pass :sort option as :order option" do
114
+ Slingshot::Search::Sort.any_instance.expects(:title)
115
+
116
+ ActiveModelArticle.search @q, :sort => 'title'
117
+ end
118
+
119
+ should "allow to specify sort direction" do
120
+ Slingshot::Search::Sort.any_instance.expects(:title).with('DESC')
121
+
122
+ ActiveModelArticle.search @q, :order => 'title DESC'
123
+ end
124
+
125
+ should "allow to specify more fields to sort on" do
126
+ Slingshot::Search::Sort.any_instance.expects(:title).with('DESC')
127
+ Slingshot::Search::Sort.any_instance.expects(:field).with('author.name', nil)
128
+
129
+ ActiveModelArticle.search @q, :order => ['title DESC', 'author.name']
130
+ end
131
+
132
+ should "allow to specify number of results per page" do
133
+ Slingshot::Search::Search.any_instance.expects(:size).with(20)
134
+
135
+ ActiveModelArticle.search @q, :per_page => 20
136
+ end
137
+
138
+ should "allow to specify first page in paginated results" do
139
+ Slingshot::Search::Search.any_instance.expects(:size).with(10)
140
+ Slingshot::Search::Search.any_instance.expects(:from).with(0)
141
+
142
+ ActiveModelArticle.search @q, :per_page => 10, :page => 1
143
+ end
144
+
145
+ should "allow to specify page further in paginated results" do
146
+ Slingshot::Search::Search.any_instance.expects(:size).with(10)
147
+ Slingshot::Search::Search.any_instance.expects(:from).with(20)
148
+
149
+ ActiveModelArticle.search @q, :per_page => 10, :page => 3
150
+ end
151
+
152
+ end
153
+
154
+ should "not set callback when hooks are missing" do
155
+ @model = ActiveModelArticle.new
156
+ @model.expects(:update_elastic_search_index).never
157
+
158
+ @model.save
159
+ end
160
+
161
+ should "fire :after_save callbacks" do
162
+ @model = ActiveModelArticleWithCallbacks.new
163
+ @model.expects(:update_elastic_search_index)
164
+
165
+ @model.save
166
+ end
167
+
168
+ should "fire :after_destroy callbacks" do
169
+ @model = ActiveModelArticleWithCallbacks.new
170
+ @model.expects(:update_elastic_search_index)
171
+
172
+ @model.destroy
173
+ end
174
+
175
+ should "store the record in index on :update_elastic_search_index when saved" do
176
+ @model = ActiveModelArticleWithCallbacks.new
177
+ Slingshot::Index.any_instance.expects(:store)
178
+
179
+ @model.save
180
+ end
181
+
182
+ should "remove the record from index on :update_elastic_search_index when destroyed" do
183
+ @model = ActiveModelArticleWithCallbacks.new
184
+ i = mock('index') { expects(:remove) }
185
+ Slingshot::Index.expects(:new).with('active_model_article_with_callbacks').returns(i)
186
+
187
+ @model.destroy
188
+ end
189
+
190
+ context "with custom mapping" do
191
+
192
+ should "create the index with mapping" do
193
+ expected_mapping = {
194
+ :mappings => { :model_with_custom_mapping => {
195
+ :properties => { :title => { :type => 'string', :analyzer => 'snowball', :boost => 10 } }
196
+ }}
197
+ }
198
+
199
+ Slingshot::Index.any_instance.expects(:create).with(expected_mapping)
200
+
201
+ class ::ModelWithCustomMapping
202
+ extend ActiveModel::Naming
203
+
204
+ include Slingshot::Model::Search
205
+ include Slingshot::Model::Callbacks
206
+
207
+ mapping do
208
+ indexes :title, :type => 'string', :analyzer => 'snowball', :boost => 10
209
+ end
210
+
211
+ end
212
+
213
+ assert_equal 'snowball', ModelWithCustomMapping.mapping[:title][:analyzer]
214
+ end
215
+
216
+ end
217
+
218
+ context "serialization" do
219
+ setup { Slingshot::Index.any_instance.stubs(:create).returns(true) }
220
+
221
+ should "serialize itself into JSON without 'root'" do
222
+ @model = ActiveModelArticle.new 'title' => 'Test'
223
+ assert_equal({'title' => 'Test'}.to_json, @model.to_indexed_json)
224
+ end
225
+
226
+ should "serialize itself with serializable_hash when no mapping is set" do
227
+
228
+ class ::ModelWithoutMapping
229
+ extend ActiveModel::Naming
230
+ include ActiveModel::Serialization
231
+ include Slingshot::Model::Search
232
+ include Slingshot::Model::Callbacks
233
+
234
+ # Do NOT configure any mapping
235
+
236
+ attr_reader :attributes
237
+
238
+ def initialize(attributes = {}); @attributes = attributes; end
239
+
240
+ def method_missing(name, *args, &block)
241
+ attributes[name.to_sym] || attributes[name.to_s] || super
242
+ end
243
+ end
244
+
245
+ model = ::ModelWithoutMapping.new :one => 1, :two => 2
246
+ assert_equal( {:one => 1, :two => 2}, model.serializable_hash )
247
+
248
+ # Bot properties are returned
249
+ assert_equal( {:one => 1, :two => 2}.to_json, model.to_indexed_json )
250
+ end
251
+
252
+ should "serialize only mapped properties when mapping is set" do
253
+
254
+ class ::ModelWithMapping
255
+ extend ActiveModel::Naming
256
+ include ActiveModel::Serialization
257
+ include Slingshot::Model::Search
258
+ include Slingshot::Model::Callbacks
259
+
260
+ mapping do
261
+ # ONLY index the 'one' attribute
262
+ indexes :one, :type => 'string', :analyzer => 'keyword'
263
+ end
264
+
265
+ attr_reader :attributes
266
+
267
+ def initialize(attributes = {}); @attributes = attributes; end
268
+
269
+ def method_missing(name, *args, &block)
270
+ attributes[name.to_sym] || attributes[name.to_s] || super
271
+ end
272
+ end
273
+
274
+ model = ::ModelWithMapping.new :one => 1, :two => 2
275
+ assert_equal( {:one => 1, :two => 2}, model.serializable_hash )
276
+
277
+ # Only the mapped property is returned
278
+ assert_equal( {:one => 1}.to_json, model.to_indexed_json )
279
+
280
+ end
281
+
282
+ end
283
+
284
+ end
285
+
286
+ end
287
+
288
+ end
289
+ end
@@ -7,17 +7,24 @@ module Slingshot
7
7
  context "Collection" do
8
8
  setup do
9
9
  Configuration.reset :wrapper
10
- @default_response = { 'hits' => { 'hits' => [{:_id => 1}, {:_id => 2}, {:_id => 3}] } }
10
+ @default_response = { 'hits' => { 'hits' => [{'_id' => 1, '_score' => 1, '_source' => {:title => 'Test'}},
11
+ {'_id' => 2},
12
+ {'_id' => 3}] } }
11
13
  end
12
14
 
13
15
  should "be iterable" do
14
16
  assert_respond_to Results::Collection.new(@default_response), :each
17
+ assert_respond_to Results::Collection.new(@default_response), :size
15
18
  assert_nothing_raised do
16
- Results::Collection.new(@default_response).each { |item| item[:_id] + 1 }
17
- Results::Collection.new(@default_response).map { |item| item[:_id] + 1 }
19
+ Results::Collection.new(@default_response).each { |item| item.id + 1 }
20
+ Results::Collection.new(@default_response).map { |item| item.id + 1 }
18
21
  end
19
22
  end
20
23
 
24
+ should "have size" do
25
+ assert_equal 3, Results::Collection.new(@default_response).size
26
+ end
27
+
21
28
  should "be initialized with parsed json" do
22
29
  assert_nothing_raised do
23
30
  collection = Results::Collection.new( @default_response )
@@ -25,10 +32,16 @@ module Slingshot
25
32
  end
26
33
  end
27
34
 
35
+ should "store passed options" do
36
+ collection = Results::Collection.new( @default_response, :per_page => 20, :page => 2 )
37
+ assert_equal 20, collection.options[:per_page]
38
+ assert_equal 2, collection.options[:page]
39
+ end
40
+
28
41
  context "wrapping results" do
29
42
 
30
43
  setup do
31
- @response = { 'hits' => { 'hits' => [ { '_id' => 1, '_source' => { :title => 'Test', :body => 'Lorem' } } ] } }
44
+ @response = { 'hits' => { 'hits' => [ { '_id' => 1, '_score' => 0.5, '_source' => { :title => 'Test', :body => 'Lorem' } } ] } }
32
45
  end
33
46
 
34
47
  should "wrap hits in Item by default" do
@@ -37,10 +50,10 @@ module Slingshot
37
50
  assert_equal 'Test', document.title
38
51
  end
39
52
 
40
- should "allow access to raw underlying Hash in Item" do
53
+ should "NOT allow access to raw underlying Hash in Item" do
41
54
  document = Results::Collection.new(@response).first
42
- assert_not_nil document[:_source][:title]
43
- assert_equal 'Test', document[:_source][:title]
55
+ assert_nil document[:_source]
56
+ assert_nil document['_source']
44
57
  end
45
58
 
46
59
  should "allow wrapping hits in a Hash" do
@@ -60,6 +73,55 @@ module Slingshot
60
73
  assert_equal 'Test', article.title
61
74
  end
62
75
 
76
+ should "return score" do
77
+ document = Results::Collection.new(@response).first
78
+ assert_equal 0.5, document._score
79
+ end
80
+
81
+ should "return id" do
82
+ document = Results::Collection.new(@response).first
83
+ assert_equal 1, document.id
84
+ end
85
+
86
+ end
87
+
88
+ context "while paginating results" do
89
+
90
+ setup do
91
+ @default_response = { 'hits' => { 'hits' => [{'_id' => 1, '_score' => 1, '_source' => {:title => 'Test'}},
92
+ {'_id' => 2},
93
+ {'_id' => 3}],
94
+ 'total' => 3,
95
+ 'took' => 1 } }
96
+ @collection = Results::Collection.new( @default_response, :per_page => 1, :page => 2 )
97
+ end
98
+
99
+ should "return total entries" do
100
+ assert_equal 3, @collection.total
101
+ assert_equal 3, @collection.total_entries
102
+ end
103
+
104
+ should "return total pages" do
105
+ assert_equal 3, @collection.total_pages
106
+ end
107
+
108
+ should "return total pages when per_page option not set" do
109
+ collection = Results::Collection.new( @default_response, :page => 1 )
110
+ assert_equal 1, collection.total_pages
111
+ end
112
+
113
+ should "return current page" do
114
+ assert_equal 2, @collection.current_page
115
+ end
116
+
117
+ should "return previous page" do
118
+ assert_equal 1, @collection.previous_page
119
+ end
120
+
121
+ should "return next page" do
122
+ assert_equal 3, @collection.next_page
123
+ end
124
+
63
125
  end
64
126
 
65
127
  end
@@ -11,17 +11,11 @@ module Slingshot
11
11
  end
12
12
 
13
13
  should "be initialized with a Hash" do
14
- assert_nothing_raised do
15
- d = Results::Item.new(:id => 1)
16
- assert_instance_of Results::Item, d
17
- end
18
- end
14
+ assert_raise(ArgumentError) { Results::Item.new('FUUUUUUU') }
19
15
 
20
- should "delegate non-Hash params to Hash when initializing" do
21
16
  assert_nothing_raised do
22
- d = Results::Item.new('foo')
17
+ d = Results::Item.new(:id => 1)
23
18
  assert_instance_of Results::Item, d
24
- assert_equal 'foo', d[:bar] # See http://www.ruby-doc.org/core/classes/Hash.html#M000718
25
19
  end
26
20
  end
27
21
 
@@ -29,11 +23,15 @@ module Slingshot
29
23
  assert_respond_to Results::Item.new, :to_indexed_json
30
24
  end
31
25
 
32
- should "retrieve the values from underlying hash" do
26
+ should "retrieve simple values from underlying hash" do
33
27
  assert_equal 'Test', @document[:title]
34
28
  end
35
29
 
36
- should "allow to retrieve the values by methods" do
30
+ should "retrieve hash values from underlying hash" do
31
+ assert_equal 'Kafka', @document[:author][:name]
32
+ end
33
+
34
+ should "allow to retrieve value by methods" do
37
35
  assert_not_nil @document.title
38
36
  assert_equal 'Test', @document.title
39
37
  end
@@ -49,10 +47,6 @@ module Slingshot
49
47
  assert_equal 'Test', @document.title
50
48
  end
51
49
 
52
- should "allow to retrieve hashes" do
53
- assert_equal 'Kafka', @document.author[:name]
54
- end
55
-
56
50
  should "allow to retrieve values from nested hashes" do
57
51
  assert_not_nil @document.author.name
58
52
  assert_equal 'Kafka', @document.author.name