slingshot-rb 0.0.8 → 0.0.9

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 (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