sunspot_matchers 1.1.0.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.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ pkg/*
2
+ *.gem
3
+ .bundle
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source :gemcutter
2
+
3
+ # Specify your gem's dependencies in sunspot_matchers.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,33 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sunspot_matchers (1.1.0.0)
5
+
6
+ GEM
7
+ remote: http://rubygems.org/
8
+ specs:
9
+ builder (3.0.0)
10
+ diff-lcs (1.1.2)
11
+ escape (0.0.4)
12
+ rsolr (0.12.1)
13
+ builder (>= 2.1.2)
14
+ rspec (2.1.0)
15
+ rspec-core (~> 2.1.0)
16
+ rspec-expectations (~> 2.1.0)
17
+ rspec-mocks (~> 2.1.0)
18
+ rspec-core (2.1.0)
19
+ rspec-expectations (2.1.0)
20
+ diff-lcs (~> 1.1.2)
21
+ rspec-mocks (2.1.0)
22
+ sunspot (1.1.0)
23
+ escape (= 0.0.4)
24
+ rsolr (= 0.12.1)
25
+
26
+ PLATFORMS
27
+ ruby
28
+
29
+ DEPENDENCIES
30
+ bundler (>= 1.0.0)
31
+ rspec (~> 2.1.0)
32
+ sunspot (~> 1.1.0)
33
+ sunspot_matchers!
data/README.markdown ADDED
@@ -0,0 +1,229 @@
1
+ # Sunspot Matchers
2
+
3
+ [Sunspot](http://outoftime.github.com/sunspot/) is a great Ruby library for constructing searches against Solr. However,
4
+ because of the way the Sunspot DSL is constructed, it can be difficult to do simple assertions about your searches
5
+ without doing full integration tests.
6
+
7
+ The goal of these matchers are to make it easier to unit test search logic without having to construct the individual
8
+ fixture scenarios inside of Solr and then actually perform a search against Solr.
9
+
10
+ # Installation
11
+
12
+ You will need to replace the Sunspot Session object with the spy provided. You can do this globally by putting the
13
+ following in your spec_helper.
14
+
15
+ config.before do
16
+ Sunspot.session = SunspotMatchers::SunspotSessionSpy.new(Sunspot.session)
17
+ end
18
+
19
+ Keep in mind, this will prevent any test from actually hitting Solr, so if you have integration tests, you'll either
20
+ need to be more careful which tests you replace the session for, or you'll need to restore the original session before
21
+ those tests
22
+
23
+ Sunspot.session = Sunspot.session.original_session
24
+
25
+ You will also need to include the matchers in your specs. Again, this can be done globally in your spec_helper.
26
+
27
+ config.include SunspotMatchers
28
+
29
+ Alternately, you could include them into individual tests if needed.
30
+
31
+ # Matchers
32
+
33
+ ## be_a_search_for
34
+
35
+ If you perform a search against your Post model, you could write this expectation:
36
+
37
+ `Sunspot.session.should be_a_search_for(Post)`
38
+
39
+ Individual searches are stored in an array, so if you perform multiple, you'll have to match against them manually. Without
40
+ an explicit search specified, it will use the last one.
41
+
42
+ `Sunspot.session.searches.first.should be_a_search_for(Post)`
43
+
44
+ ## have_search_params
45
+
46
+ This is where the bulk of the functionality lies. There are seven types of search matches you can perform: `keywords`,
47
+ `with`, `without`, `paginate`, `order_by`, `facet`, and `boost`.
48
+
49
+ In all of the examples below, the expectation fully matches the search terms. This is not expected or required. You can
50
+ have a dozen `with` restrictions on a search and still write an expectation on a single one of them.
51
+
52
+ Negative expectations also work correctly. `should_not` will fail if the search actually includes the expectation.
53
+
54
+ With all matchers, you can specify a `Proc` as the second argument, and perform multi statement expectations inside the
55
+ Proc. Keep in mind, that only the expectation type specified in the first argument will actually be checked. So if
56
+ you specify `keywords` and `with` restrictions in the same Proc, but you said `have_search_params(:keywords, ...`
57
+ the `with` restrictions are simply ignored.
58
+
59
+ ### wildcard matching
60
+
61
+ keywords, with, and without support wildcard expectations using the `any_param` parameter:
62
+
63
+ Sunspot.search(Post) do
64
+ with :blog_id, 4
65
+ end
66
+
67
+ Sunspot.session.should have_search_params(:with, :blog_id, any_param)
68
+
69
+ ### :keywords
70
+
71
+ You can match against a keyword search:
72
+
73
+ Sunspot.search(Post) do
74
+ keywords 'great pizza'
75
+ end
76
+
77
+ Sunspot.session.should have_search_params(:keywords, 'great pizza')
78
+
79
+ ### :with
80
+
81
+ You can match against a with restriction:
82
+
83
+ Sunspot.search(Post) do
84
+ with :author_name, 'Mark Twain'
85
+ end
86
+
87
+ Sunspot.session.should have_search_params(:with, :author_name, 'Mark Twain')
88
+
89
+ Complex conditions can be matched by using a Proc instead of a value. Be aware that order does matter, not for
90
+ the actual results that would come out of Solr, but the matcher will fail of the order of `with` restrictions is
91
+ different.
92
+
93
+ Sunspot.search(Post) do
94
+ any_of do
95
+ with :category_ids, 1
96
+ with :category_ids, 2
97
+ end
98
+ end
99
+
100
+ Sunspot.session.should have_search_params(:with, Proc.new {
101
+ any_of do
102
+ with :category_ids, 1
103
+ with :category_ids, 2
104
+ end
105
+ })
106
+
107
+ ### :without
108
+
109
+ Without is nearly identical to with:
110
+
111
+ Sunspot.search(Post) do
112
+ without :author_name, 'Mark Twain'
113
+ end
114
+
115
+ Sunspot.session.should have_search_params(:without, :author_name, 'Mark Twain')
116
+
117
+ ### :paginate
118
+
119
+ You can also specify only page or per_page, both are not required.
120
+
121
+ Sunspot.search(Post) do
122
+ paginate :page => 3, :per_page => 15
123
+ end
124
+
125
+ Sunspot.session.should have_search_params(:paginate, :page => 3, :per_page => 15)
126
+
127
+ ### :order_by
128
+
129
+ Expectations on multiple orderings are supported using using the Proc format mentioned above.
130
+
131
+ Sunspot.search(Post) do
132
+ order_by :published_at, :desc
133
+ end
134
+
135
+ Sunspot.session.should have_search_params(:order_by, :published_at, :desc)
136
+
137
+ ### :facet
138
+
139
+ Standard faceting expectation:
140
+
141
+ Sunspot.search(Post) do
142
+ facet :category_ids
143
+ end
144
+
145
+ Sunspot.session.should have_search_params(:facet, :category_ids)
146
+
147
+ Faceting where a query is excluded:
148
+
149
+ Sunspot.search(Post) do
150
+ category_filter = with(:category_ids, 2)
151
+ facet(:category_ids, :exclude => category_filter)
152
+ end
153
+
154
+ Sunspot.session.should have_search_params(:facet, Proc.new {
155
+ category_filter = with(:category_ids, 2)
156
+ facet(:category_ids, :exclude => category_filter)
157
+ })
158
+
159
+ Query faceting:
160
+
161
+ Sunspot.search(Post) do
162
+ facet(:average_rating) do
163
+ row(1.0..2.0) do
164
+ with(:average_rating, 1.0..2.0)
165
+ end
166
+ row(2.0..3.0) do
167
+ with(:average_rating, 2.0..3.0)
168
+ end
169
+ end
170
+ end
171
+
172
+ Sunspot.session.should have_search_params(:facet, Proc.new {
173
+ facet(:average_rating) do
174
+ row(1.0..2.0) do
175
+ with(:average_rating, 1.0..2.0)
176
+ end
177
+ row(2.0..3.0) do
178
+ with(:average_rating, 2.0..3.0)
179
+ end
180
+ end
181
+ })
182
+
183
+ ### :boost
184
+
185
+ Field boost matching:
186
+
187
+ Sunspot.search(Post) do
188
+ keywords 'great pizza' do
189
+ boost_fields :body => 2.0
190
+ end
191
+ end
192
+
193
+ Sunspot.session.should have_search_params(:boost, Proc.new {
194
+ keywords 'great pizza' do
195
+ boost_fields :body => 2.0
196
+ end
197
+ })
198
+
199
+ Boost query matching:
200
+
201
+ Sunspot.search(Post) do
202
+ keywords 'great pizza' do
203
+ boost(2.0) do
204
+ with :blog_id, 4
205
+ end
206
+ end
207
+ end
208
+
209
+ Sunspot.session.should have_search_params(:boost, Proc.new {
210
+ keywords 'great pizza' do
211
+ boost(2.0) do
212
+ with :blog_id, 4
213
+ end
214
+ end
215
+ })
216
+
217
+ Boost function matching:
218
+
219
+ Sunspot.search(Post) do
220
+ keywords 'great pizza' do
221
+ boost(function { sum(:average_rating, product(:popularity, 10)) })
222
+ end
223
+ end
224
+
225
+ Sunspot.session.should have_search_params(:boost, Proc.new {
226
+ keywords 'great pizza' do
227
+ boost(function { sum(:average_rating, product(:popularity, 10)) })
228
+ end
229
+ })
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
@@ -0,0 +1,237 @@
1
+ module SunspotMatchers
2
+ class BaseMatcher
3
+ def initialize(actual_search, comparison_search, args)
4
+ @args = args
5
+ @actual_search, @comparison_search = actual_search, comparison_search
6
+ end
7
+
8
+ def search_tuple
9
+ search_tuple = @actual.is_a?(Array) ? @actual : @actual.searches.last
10
+ raise 'no search found' unless search_tuple
11
+ search_tuple
12
+ end
13
+
14
+ def actual_search
15
+ search_tuple.last
16
+ end
17
+
18
+ def search_types
19
+ search_tuple.first
20
+ end
21
+
22
+ def wildcard?
23
+ @args && @args.last == any_param
24
+ end
25
+
26
+ def field
27
+ @args && @args.first
28
+ end
29
+
30
+ def query_params_for_search(search)
31
+ search.instance_variable_get(:@query).to_params
32
+ end
33
+
34
+ def actual_params
35
+ @actual_params ||= query_params_for_search(@actual_search)
36
+ end
37
+
38
+ def comparison_params
39
+ @comparison_params ||= query_params_for_search(@comparison_search)
40
+ end
41
+
42
+ def match?
43
+ differences.empty?
44
+ end
45
+
46
+ def missing_param_error_message
47
+ missing_params = differences
48
+ actual_values = missing_params.keys.collect {|key| "#{key} => #{actual_params[key]}"}
49
+ missing_values = missing_params.collect{ |key, value| "#{key} => #{value}"}
50
+ "expected search params: #{actual_values.join(' and ')} to match expected: #{missing_values.join(' and ')}"
51
+ end
52
+
53
+ def unexpected_match_error_message
54
+ actual_values = keys_to_compare.collect {|key| "#{key} => #{actual_params[key]}"}
55
+ comparison_values = keys_to_compare.collect {|key| "#{key} => #{comparison_params[key]}"}
56
+ "expected search params: #{actual_values.join(' and ')} NOT to match expected: #{comparison_values.join(' and ')}"
57
+ end
58
+
59
+ def differences
60
+ keys_to_compare.inject({}) do |hsh, key|
61
+ result = compare_key(key)
62
+ hsh[key] = result unless result.empty?
63
+ hsh
64
+ end
65
+ end
66
+
67
+ def compare_key(key)
68
+ if(actual_params[key].is_a?(Array) || comparison_params[key].is_a?(Array))
69
+ compare_multi_value(actual_params[key], comparison_params[key])
70
+ else
71
+ compare_single_value(actual_params[key], comparison_matcher_for_key(key))
72
+ end
73
+ end
74
+
75
+ def comparison_matcher_for_key(key)
76
+ if wildcard? && wildcard_matcher_for_keys.has_key?(key)
77
+ wildcard_matcher_for_keys[key]
78
+ else
79
+ comparison_params[key]
80
+ end
81
+ end
82
+
83
+ def compare_single_value(actual, comparison)
84
+ if comparison.is_a?(Regexp)
85
+ return [] if comparison =~ actual
86
+ return [comparison]
87
+ end
88
+ return [comparison] unless actual == comparison
89
+ []
90
+ end
91
+
92
+ def compare_multi_value(actual, comparison)
93
+ filter_values(comparison).reject do |value|
94
+ next false unless actual
95
+ value_matcher = Regexp.new(Regexp.escape(value))
96
+ actual.any?{ |actual_value| actual_value =~ value_matcher }
97
+ end
98
+ end
99
+
100
+ def normalize_value(value)
101
+
102
+ end
103
+
104
+ def filter_values(values)
105
+ return values unless wildcard?
106
+ field_matcher = Regexp.new(field.to_s)
107
+ values.select{ |value| field_matcher =~ value }.collect{|value| value.gsub(/:.*/, '')}
108
+ end
109
+
110
+ def wildcard_matcher_for_keys
111
+ {}
112
+ end
113
+ end
114
+
115
+ class HaveSearchParams < BaseMatcher
116
+ def initialize(method, *args)
117
+ @method = method
118
+ @args = args
119
+ end
120
+
121
+ def matches?(actual)
122
+ @actual = actual
123
+ @matcher = build_matcher
124
+ @matcher.match?
125
+ end
126
+
127
+ def failure_message_for_should
128
+ @matcher.missing_param_error_message
129
+ end
130
+
131
+ def failure_message_for_should_not
132
+ @matcher.unexpected_match_error_message
133
+ end
134
+
135
+ def build_matcher
136
+ comparison_search = if(@args.last.is_a?(Proc))
137
+ SunspotMatchers::SunspotSessionSpy.new(nil).build_search(search_types, &@args.last)
138
+ else
139
+ method = @method
140
+ args = @args
141
+ SunspotMatchers::SunspotSessionSpy.new(nil).build_search(search_types) do
142
+ send(method, *args)
143
+ end
144
+ end
145
+
146
+ get_matcher.new(actual_search, comparison_search, @args)
147
+ end
148
+
149
+ def get_matcher
150
+ case @method
151
+ when :with, :without
152
+ WithMatcher
153
+ when :keywords
154
+ KeywordsMatcher
155
+ when :boost
156
+ BoostMatcher
157
+ when :facet
158
+ FacetMatcher
159
+ when :order_by
160
+ SortMatcher
161
+ when :paginate
162
+ PaginationMatcher
163
+ end
164
+ end
165
+ end
166
+
167
+ def have_search_params(method, *args)
168
+ HaveSearchParams.new(method, *args)
169
+ end
170
+
171
+ class WithMatcher < BaseMatcher
172
+ def keys_to_compare
173
+ [:fq]
174
+ end
175
+ end
176
+
177
+ class KeywordsMatcher < BaseMatcher
178
+ def keys_to_compare
179
+ [:q, :qf]
180
+ end
181
+
182
+ def wildcard_matcher_for_keys
183
+ {:q => /./, :qf => /./}
184
+ end
185
+ end
186
+
187
+ class BoostMatcher < BaseMatcher
188
+ def keys_to_compare
189
+ [:qf, :bq, :bf]
190
+ end
191
+ end
192
+
193
+ class FacetMatcher < BaseMatcher
194
+ def keys_to_compare
195
+ comparison_params.keys.select {|key| /facet/ =~ key.to_s}
196
+ end
197
+ end
198
+
199
+ class SortMatcher < BaseMatcher
200
+ def keys_to_compare
201
+ [:sort]
202
+ end
203
+ end
204
+
205
+ class PaginationMatcher < BaseMatcher
206
+ def keys_to_compare
207
+ [:rows, :start]
208
+ end
209
+ end
210
+
211
+ class BeASearchFor < BaseMatcher
212
+ def initialize(expected_class)
213
+ @expected_class = expected_class
214
+ end
215
+
216
+ def matches?(actual)
217
+ @actual = actual
218
+ search_types.include?(@expected_class)
219
+ end
220
+
221
+ def failure_message_for_should
222
+ "expected search class: #{search_types.join(' and ')} to match expected class: #{@expected_class}"
223
+ end
224
+
225
+ def failure_message_for_should_not
226
+ "expected search class: #{search_types.join(' and ')} NOT to match expected class: #{@expected_class}"
227
+ end
228
+ end
229
+
230
+ def be_a_search_for(expected_class)
231
+ BeASearchFor.new(expected_class)
232
+ end
233
+ end
234
+
235
+ def any_param
236
+ "ANY_PARAM"
237
+ end
@@ -0,0 +1,111 @@
1
+ module SunspotMatchers
2
+ class SunspotSearchSpy < Sunspot::Search::StandardSearch
3
+ def execute
4
+ self
5
+ end
6
+ def solr_response
7
+ {}
8
+ end
9
+ def facet_response
10
+ {'facet_queries' => {}}
11
+ end
12
+ end
13
+
14
+ class SunspotSessionSpy < Sunspot::Session
15
+ attr_reader :original_session
16
+ attr_reader :current_search_class
17
+
18
+ attr_accessor :searches
19
+
20
+ def initialize(original_session)
21
+ @searches = []
22
+ @original_session = original_session
23
+ @config = Sunspot::Configuration.build
24
+ end
25
+
26
+ def inspect
27
+ 'Solr Search'
28
+ end
29
+
30
+ def index(*objects)
31
+ end
32
+
33
+ def index!(*objects)
34
+ end
35
+
36
+ def remove(*objects)
37
+ end
38
+
39
+ def remove!(*objects)
40
+ end
41
+
42
+ def remove_by_id(clazz, id)
43
+ end
44
+
45
+ def remove_by_id!(clazz, id)
46
+ end
47
+
48
+ def remove_all(clazz = nil)
49
+ end
50
+
51
+ def remove_all!(clazz = nil)
52
+ end
53
+
54
+ def dirty?
55
+ false
56
+ end
57
+
58
+ def delete_dirty?
59
+ false
60
+ end
61
+
62
+ def commit_if_dirty
63
+ end
64
+
65
+ def commit_if_delete_dirty
66
+ end
67
+
68
+ def commit
69
+ end
70
+
71
+ def search(*types, &block)
72
+ new_search(*types, &block)
73
+ end
74
+
75
+ def new_search(*types, &block)
76
+ search = build_search(*types, &block)
77
+ @searches << [types, search]
78
+ search
79
+ end
80
+
81
+ def build_search(*types, &block)
82
+ types.flatten!
83
+ search = SunspotSearchSpy.new(
84
+ nil,
85
+ setup_for_types(types),
86
+ Sunspot::Query::StandardQuery.new(types),
87
+ @config
88
+ )
89
+ search.build(&block) if block
90
+ search
91
+ end
92
+
93
+ def setup_for_types(types)
94
+ if types.empty?
95
+ raise(ArgumentError, "You must specify at least one type to search")
96
+ end
97
+ if types.length == 1
98
+ Sunspot::Setup.for(types.first)
99
+ else
100
+ CompositeSetup.for(types)
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # Support Sunspot random field in test -- Sunspot originally generate a random number for the field
107
+ class Sunspot::Query::Sort::RandomSort < Sunspot::Query::Sort::Abstract
108
+ def to_param
109
+ "random #{direction_for_solr}"
110
+ end
111
+ end
@@ -0,0 +1,3 @@
1
+ module SunspotMatchers
2
+ VERSION = "1.1.0.0"
3
+ end
@@ -0,0 +1,2 @@
1
+ require 'sunspot_matchers/matchers'
2
+ require 'sunspot_matchers/sunspot_session_spy'