outoftime-sunspot 0.0.2 → 0.7.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/lib/sunspot/query.rb CHANGED
@@ -1,96 +1,232 @@
1
1
  module Sunspot
2
- class Query
3
- attr_accessor :keywords, :conditions, :rows, :start, :sort
2
+ #
3
+ # This class encapsulates a query that is to be sent to Solr. The query is
4
+ # constructed in the block passed to the Sunspot.search method, using the
5
+ # Sunspot::DSL::Query interface. Instances of Query, as well as all of the
6
+ # components it contains, respond to the #to_params method, which returns
7
+ # a hash of parameters in the format recognized by the solr-ruby API.
8
+ #
9
+ class Query #:nodoc:
10
+ attr_writer :keywords # <String> full-text keyword boolean phrase
4
11
 
5
12
  def initialize(types, params, configuration)
6
13
  @types, @configuration = types, configuration
7
- paginate
14
+ @rows = @configuration.pagination.default_per_page
15
+ apply_params(params)
8
16
  end
9
17
 
10
- def to_solr
18
+ #
19
+ # Representation of this query as solr-ruby parameters. Constructs the hash
20
+ # by deep-merging scope and facet parameters, adding in various other
21
+ # parameters from instance data.
22
+ #
23
+ # Note that solr-ruby takes the :q parameter as a separate argument; for
24
+ # the sake of consistency, the Query object ignores this fact (the Search
25
+ # object extracts it back out).
26
+ #
27
+ # ==== Returns
28
+ #
29
+ # Hash:: Representation of query in solr-ruby form
30
+ #
31
+ def to_params
32
+ params = {}
11
33
  query_components = []
12
- query_components << keywords if keywords
13
- query_components << types_query if types_query
14
- query_components.map { |component| "(#{component})"} * ' AND '
34
+ query_components << @keywords if @keywords
35
+ query_components << types_phrase if types_phrase
36
+ params[:q] = query_components.map { |component| "(#{component})"} * ' AND '
37
+ params[:sort] = @sort if @sort
38
+ params[:start] = @start if @start
39
+ params[:rows] = @rows if @rows
40
+ for component in components
41
+ Util.deep_merge!(params, component.to_params)
42
+ end
43
+ params
15
44
  end
16
45
 
17
- def filter_queries
18
- scope_queries
46
+ #
47
+ # Add a query component
48
+ #
49
+ # ==== Parameters
50
+ #
51
+ # component<~to_params>:: A restriction query component
52
+ #
53
+ def add_component(component)
54
+ components << component
19
55
  end
20
56
 
21
- def add_scope(condition)
22
- scope << condition
57
+ #
58
+ # Add instance of Sunspot::Restriction::Base to query components. This
59
+ # method is exposed to the DSL because the Query instance holds field
60
+ # definitions and is able to translate field names into full field
61
+ # definitions, and memoize # the result.
62
+ #
63
+ # ==== Parameters
64
+ #
65
+ # field_name<Symbol>:: Name of the field to which the restriction applies
66
+ # restriction_clazz<Class>::
67
+ # Subclass of Sunspot::Restriction::Base to instantiate
68
+ # value<Object>::
69
+ # Value against which the restriction applies (e.g. less_than(2) has a
70
+ # value of 2)
71
+ # negative:: Whether this restriction should be negated
72
+ #
73
+ # ==== Returns
74
+ #
75
+ # Sunspot::Restriction::Base:: Restriction instance
76
+ #
77
+ def add_restriction(field_name, restriction_clazz, value, negative = false)
78
+ add_component(restriction_clazz.new(field(field_name), value, negative))
23
79
  end
24
80
 
25
- def build_condition(field_name, condition_clazz, value)
26
- condition_clazz.new(field(field_name), value)
81
+ #
82
+ # Add a field facet
83
+ #
84
+ # ==== Parameters
85
+ #
86
+ # field_name<Symbol>:: Name of the field on which to get a facet
87
+ #
88
+ def add_field_facet(field_name)
89
+ add_component(Facets::FieldFacet.new(field(field_name)))
27
90
  end
28
91
 
29
- def paginate(page = nil, per_page = nil)
30
- page ||= 1
31
- per_page ||= configuration.pagination.default_per_page
92
+ #
93
+ # Sets @start and @rows instance variables using pagination semantics
94
+ #
95
+ # ==== Parameters
96
+ #
97
+ # page<Integer>:: Page on which to start
98
+ # per_page<Integer>::
99
+ # How many rows to display per page. Default taken from
100
+ # Sunspot.config.pagination.default_per_page
101
+ #
102
+ def paginate(page, per_page = nil)
103
+ per_page ||= @configuration.pagination.default_per_page
32
104
  @start = (page - 1) * per_page
33
105
  @rows = per_page
34
106
  end
35
107
 
36
- def order=(order)
37
- order_by(*order.split(' '))
38
- end
39
-
108
+ #
109
+ # Set result ordering.
110
+ #
111
+ # ==== Parameters
112
+ #
113
+ # field_name<Symbol>:: Name of the field on which to order
114
+ # direction<Symbol>:: :asc or :desc (default :asc)
115
+ #
40
116
  def order_by(field_name, direction = nil)
41
117
  direction ||= :asc
42
- @sort = [{ field(field_name).indexed_name.to_sym => (direction.to_s == 'asc' ? :ascending : :descending) }] #TODO should support multiple order columns
118
+ (@sort ||= []) << { field(field_name).indexed_name.to_sym => (direction.to_s == 'asc' ? :ascending : :descending) }
43
119
  end
44
120
 
121
+ #
122
+ # Page that this query will return (used by Sunspot::Search to expose
123
+ # pagination)
124
+ #
125
+ # ==== Returns
126
+ #
127
+ # Integer:: Page number
128
+ #
45
129
  def page
46
- return nil unless start && rows
47
- start / rows + 1
130
+ if @start && @rows
131
+ @start / @rows + 1
132
+ else
133
+ 1
134
+ end
48
135
  end
49
136
 
50
- def dsl
51
- @dsl ||= ::Sunspot::DSL::Query.new(self)
137
+ #
138
+ # Number of rows per page that this query will return (used by
139
+ # Sunspot::Search to expose pagination)
140
+ #
141
+ # ==== Returns
142
+ #
143
+ # Integer:: Rows per page
144
+ #
145
+ def per_page
146
+ @rows
52
147
  end
53
148
 
54
- def build_with(builder_class, *args)
55
- builder_class.new(dsl, types, fields_hash.keys, *args)
149
+ #
150
+ # Get a DSL instance for building this query.
151
+ #
152
+ # ==== Returns
153
+ #
154
+ # Sunspot::DSL::Query:: DSL instance
155
+ #
156
+ def dsl
157
+ @dsl ||= DSL::Query.new(self)
56
158
  end
57
159
 
58
- alias_method :per_page, :rows
59
-
60
- protected
61
- attr_accessor :types, :configuration
160
+ #
161
+ # Get a Sunspot::Field::Base instance corresponding to the given field name
162
+ #
163
+ # ==== Parameters
164
+ #
165
+ # field_name<Symbol>:: The field name for which to find a field
166
+ #
167
+ # ==== Returns
168
+ #
169
+ # Sunspot::Field::Base:: The field object corresponding to the given name
170
+ #
171
+ # ==== Raises
172
+ #
173
+ # ArgumentError::
174
+ # If the given field name is not configured for the types being queried
175
+ #
176
+ def field(field_name)
177
+ fields_hash[field_name.to_sym] || raise(UnrecognizedFieldError, "No field configured for #{@types * ', '} with name '#{field_name}'")
178
+ end
62
179
 
63
180
  private
64
181
 
65
- def scope
66
- @scope ||= []
182
+ # ==== Returns
183
+ #
184
+ # Array:: Collection of query components
185
+ #
186
+ def components
187
+ @components ||= []
67
188
  end
68
189
 
69
- def scope_queries
70
- scope.map { |condition| condition.to_solr_query }
71
- end
72
-
73
- def types_query
74
- if types.nil? || types.empty? then "type:[* TO *]"
75
- elsif types.length == 1 then "type:#{types.first}"
76
- else "type:(#{types * ' OR '})"
190
+ #
191
+ # Boolean phrase that restricts results to objects of the type(s) under
192
+ # query. If this is an open query (no types specified) then it sends a
193
+ # no-op phrase because Solr requires that the :q parameter not be empty.
194
+ #
195
+ # TODO don't send a noop if we have a keyword phrase
196
+ # TODO this should be sent as a filter query when possible, especially
197
+ # if there is a single type, so that Solr can cache it
198
+ #
199
+ # ==== Returns
200
+ #
201
+ # String:: Boolean phrase for type restriction
202
+ #
203
+ def types_phrase
204
+ if @types.nil? || @types.empty? then "type:[* TO *]"
205
+ elsif @types.length == 1 then "type:#{@types.first}"
206
+ else "type:(#{@types * ' OR '})"
77
207
  end
78
208
  end
79
209
 
80
- def field(field_name)
81
- fields_hash[field_name.to_s] || raise(ArgumentError, "No field configured for #{types * ', '} with name '#{field_name}'")
82
- end
83
-
210
+ #
211
+ # Return a hash of field names to field objects, containing all fields
212
+ # that are common to all of the classes under search. In order for fields
213
+ # to be common, they must be of the same type and have the same
214
+ # value for allow_multiple?. This method is memoized.
215
+ #
216
+ # ==== Returns
217
+ #
218
+ # Hash:: field names keyed to field objects
219
+ #
84
220
  def fields_hash
85
221
  @fields_hash ||= begin
86
- fields_hash = types.inject({}) do |hash, type|
87
- ::Sunspot::Field.for(type).each do |field|
88
- (hash[field.name.to_s] ||= {})[type.name] = field
222
+ fields_hash = @types.inject({}) do |hash, type|
223
+ Setup.for(type).fields.each do |field|
224
+ (hash[field.name.to_sym] ||= {})[type.name] = field
89
225
  end
90
226
  hash
91
227
  end
92
228
  fields_hash.each_pair do |field_name, field_configurations_hash|
93
- if types.any? { |type| field_configurations_hash[type.name].nil? } # at least one type doesn't have this field configured
229
+ if @types.any? { |type| field_configurations_hash[type.name].nil? } # at least one type doesn't have this field configured
94
230
  fields_hash.delete(field_name)
95
231
  elsif field_configurations_hash.values.map { |configuration| configuration.indexed_name }.uniq.length != 1 # fields with this name have different configs
96
232
  fields_hash.delete(field_name)
@@ -100,5 +236,37 @@ module Sunspot
100
236
  end
101
237
  end
102
238
  end
239
+
240
+ def apply_params(params)
241
+ if params.has_key?(:keywords)
242
+ self.keywords = params[:keywords]
243
+ end
244
+ if params.has_key?(:conditions)
245
+ params[:conditions].each_pair do |field_name, value|
246
+ begin
247
+ restriction_type =
248
+ case value
249
+ when Array
250
+ Restriction::AnyOf
251
+ when Range
252
+ Restriction::Between
253
+ else
254
+ Restriction::EqualTo
255
+ end
256
+ add_restriction(field_name, restriction_type, value)
257
+ rescue UnrecognizedFieldError
258
+ # ignore fields we don't recognize
259
+ end
260
+ end
261
+ end
262
+ if params.has_key?(:order)
263
+ for order in Array(params[:order])
264
+ order_by(*order.split(' '))
265
+ end
266
+ end
267
+ if params.has_key?(:page)
268
+ paginate(params[:page], params[:per_page])
269
+ end
270
+ end
103
271
  end
104
272
  end
@@ -1,27 +1,141 @@
1
1
  module Sunspot
2
- module Restriction
3
- class Base
4
- def initialize(field, value)
5
- @field, @value = field, value
2
+ module Restriction #:nodoc:
3
+ class <<self
4
+ #
5
+ # Return the names of all of the restriction classes that should be made
6
+ # available to the DSL.
7
+ #
8
+ # ==== Returns
9
+ #
10
+ # Array:: Collection of restriction class names
11
+ #
12
+ def names
13
+ constants - %w(Base SameAs) #XXX this seems ugly
14
+ end
15
+ end
16
+
17
+ #
18
+ # Subclasses of this class represent restrictions that can be applied to
19
+ # a Sunspot query. The Sunspot::DSL::Restriction class presents a builder
20
+ # API for instances of this class.
21
+ #
22
+ # Implementations of this class must respond to #to_params and
23
+ # #to_negative_params. Instead of implementing those methods, they may
24
+ # choose to implement any of:
25
+ #
26
+ # * #to_positive_boolean_phrase, and optionally #to_negative_boolean_phrase
27
+ # * #to_solr_conditional
28
+ #
29
+ class Base #:nodoc:
30
+ def initialize(field, value, negative = false)
31
+ @field, @value, @negative = field, value, negative
32
+ end
33
+
34
+ #
35
+ # A hash representing this restriction in solr-ruby's parameter format.
36
+ # All restriction implementations must respond to this method; however,
37
+ # the base implementation delegates to the #to_positive_boolean_phrase method, so
38
+ # subclasses may (and probably should) choose to implement that method
39
+ # instead.
40
+ #
41
+ # ==== Returns
42
+ #
43
+ # Hash:: Representation of this restriction as solr-ruby parameters
44
+ #
45
+ def to_params
46
+ { :filter_queries => [to_boolean_phrase] }
47
+ end
48
+
49
+ #
50
+ # Return the boolean phrase associated with this restriction object.
51
+ # Differentiates between positive and negative boolean phrases depending
52
+ # on whether this restriction is negated.
53
+ #
54
+ def to_boolean_phrase
55
+ unless negative?
56
+ to_positive_boolean_phrase
57
+ else
58
+ to_negative_boolean_phrase
59
+ end
60
+ end
61
+
62
+ #
63
+ # Boolean phrase representing this restriction in the positive. Subclasses
64
+ # may choose to implement this method rather than #to_params; however,
65
+ # this method delegates to the abstract #to_solr_conditional method, which
66
+ # in most cases will be what subclasses will want to implement.
67
+ # #to_solr_conditional contains the boolean phrase representing the
68
+ # condition but leaves out the field name (see built-in implementations
69
+ # for examples)
70
+ #
71
+ # ==== Returns
72
+ #
73
+ # String:: Boolean phrase for restriction in the positive
74
+ #
75
+ def to_positive_boolean_phrase
76
+ "#{@field.indexed_name}:#{to_solr_conditional}"
6
77
  end
7
78
 
8
- def to_solr_query
9
- "#{field.indexed_name}:#{to_solr_conditional}"
79
+ #
80
+ # Boolean phrase representing this restriction in the negative. Subclasses
81
+ # may choose to implement this method, but it is not necessary, as the
82
+ # base implementation delegates to #to_positive_boolean_phrase.
83
+ #
84
+ # ==== Returns
85
+ #
86
+ # String:: Boolean phrase for restriction in the negative
87
+ #
88
+ def to_negative_boolean_phrase
89
+ "-#{to_positive_boolean_phrase}"
10
90
  end
11
91
 
12
92
  protected
13
- attr_accessor :field, :value
14
93
 
15
- def solr_value(value = self.value)
16
- escape field.to_indexed(value)
94
+ #
95
+ # Whether this restriction should be negated from its original meaning
96
+ #
97
+ def negative?
98
+ !!@negative
17
99
  end
18
100
 
19
- def escape(value)
20
- Solr::Util.query_parser_escape value
101
+ #
102
+ # Return escaped Solr API representation of given value
103
+ #
104
+ # ==== Parameters
105
+ #
106
+ # value<Object>::
107
+ # value to convert to Solr representation (default: @value)
108
+ #
109
+ # ==== Returns
110
+ #
111
+ # String:: Solr API representation of given value
112
+ #
113
+ def solr_value(value = @value)
114
+ Solr::Util.query_parser_escape(@field.to_indexed(value))
21
115
  end
22
116
  end
23
117
 
118
+ #
119
+ # Results must have field with value equal to given value. If the value
120
+ # is nil, results must have no value for the given field.
121
+ #
24
122
  class EqualTo < Base
123
+ def to_positive_boolean_phrase
124
+ unless @value.nil?
125
+ super
126
+ else
127
+ "-#{@field.indexed_name}:[* TO *]"
128
+ end
129
+ end
130
+
131
+ def to_negative_boolean_phrase
132
+ unless @value.nil?
133
+ super
134
+ else
135
+ "#{@field.indexed_name}:[* TO *]"
136
+ end
137
+ end
138
+
25
139
  private
26
140
 
27
141
  def to_solr_conditional
@@ -29,6 +143,9 @@ module Sunspot
29
143
  end
30
144
  end
31
145
 
146
+ #
147
+ # Results must have field with value less than given value
148
+ #
32
149
  class LessThan < Base
33
150
  private
34
151
 
@@ -37,6 +154,9 @@ module Sunspot
37
154
  end
38
155
  end
39
156
 
157
+ #
158
+ # Results must have field with value greater than given value
159
+ #
40
160
  class GreaterThan < Base
41
161
  private
42
162
 
@@ -45,27 +165,51 @@ module Sunspot
45
165
  end
46
166
  end
47
167
 
168
+ #
169
+ # Results must have field with value in given range
170
+ #
48
171
  class Between < Base
49
172
  private
50
173
 
51
174
  def to_solr_conditional
52
- "[#{solr_value value.first} TO #{solr_value value.last}]"
175
+ "[#{solr_value(@value.first)} TO #{solr_value(@value.last)}]"
53
176
  end
54
177
  end
55
178
 
179
+ #
180
+ # Results must have field with value included in given collection
181
+ #
56
182
  class AnyOf < Base
57
183
  private
58
184
 
59
185
  def to_solr_conditional
60
- "(#{value.map { |v| solr_value v } * ' OR '})"
186
+ "(#{@value.map { |v| solr_value v } * ' OR '})"
61
187
  end
62
188
  end
63
189
 
190
+ #
191
+ # Results must have field with values matching all values in given
192
+ # collection (only makes sense for fields with multiple values)
193
+ #
64
194
  class AllOf < Base
65
195
  private
66
196
 
67
197
  def to_solr_conditional
68
- "(#{value.map { |v| solr_value v } * ' AND '})"
198
+ "(#{@value.map { |v| solr_value v } * ' AND '})"
199
+ end
200
+ end
201
+
202
+ #
203
+ # Result must be the exact instance given (only useful when negated).
204
+ #
205
+ class SameAs < Base
206
+ def initialize(object, negative = false)
207
+ @object, @negative = object, negative
208
+ end
209
+
210
+ def to_positive_boolean_phrase
211
+ adapter = Adapters::InstanceAdapter.adapt(@object)
212
+ "id:#{Solr::Util.query_parser_escape(adapter.index_id)}"
69
213
  end
70
214
  end
71
215
  end
@@ -1,33 +1,41 @@
1
1
  module Sunspot
2
+ #
3
+ # This class encapsulates the results of a Solr search. It provides access
4
+ # to search results, total result count, facets, and pagination information.
5
+ # Instances of Search are returned by the Sunspot.search method.
6
+ #
2
7
  class Search
3
- attr_reader :builder
8
+ RawResult = Struct.new(:class_name, :primary_key)
4
9
 
5
- def initialize(connection, configuration, *types, &block)
10
+ def initialize(connection, configuration, *types, &block) #:nodoc:
6
11
  @connection = connection
7
12
  params = types.last.is_a?(Hash) ? types.pop : {}
8
- @query = Sunspot::Query.new(types, params, configuration)
9
- @builder = build_with(::Sunspot::Builder::StandardBuilder, params)
13
+ @query = Query.new(types, params, configuration)
10
14
  @query.dsl.instance_eval(&block) if block
11
15
  @types = types
12
16
  end
13
17
 
14
- def build_with(builder_class, *args)
15
- @query.build_with(builder_class, *args)
16
- end
17
-
18
- def execute!
19
- query_options = {}
20
- query_options[:filter_queries] = query.filter_queries
21
- query_options[:rows] = query.rows
22
- query_options[:start] = query.start if query.start
23
- query_options[:sort] = query.sort if query.sort
24
- @solr_result = connection.query(query.to_solr, query_options)
18
+ #
19
+ # Execute the search on the Solr instance and store the results
20
+ #
21
+ def execute! #:nodoc:
22
+ params = @query.to_params
23
+ @solr_result = @connection.query(params.delete(:q), params)
25
24
  self
26
25
  end
27
26
 
27
+ #
28
+ # Get the collection of results as instantiated objects. If WillPaginate is
29
+ # available, the results will be a WillPaginate::Collection instance; if
30
+ # not, it will be a vanilla Array.
31
+ #
32
+ # ==== Returns
33
+ #
34
+ # WillPaginate::Collection or Array:: Instantiated result objects
35
+ #
28
36
  def results
29
- @results ||= if query.page && defined?(WillPaginate::Collection)
30
- WillPaginate::Collection.create(query.page, query.per_page, @solr_result.total_hits) do |pager|
37
+ @results ||= if @query.page && defined?(WillPaginate::Collection)
38
+ WillPaginate::Collection.create(@query.page, @query.per_page, @solr_result.total_hits) do |pager|
31
39
  pager.replace(result_objects)
32
40
  end
33
41
  else
@@ -35,32 +43,75 @@ module Sunspot
35
43
  end
36
44
  end
37
45
 
46
+ #
47
+ # Access raw results without instantiating objects from persistent storage.
48
+ # This may be useful if you are using search as an intermediate step in data
49
+ # retrieval. Returns an ordered collection of objects that respond to
50
+ # #class_name and #primary_key
51
+ #
52
+ # ==== Returns
53
+ #
54
+ # Array:: Ordered collection of raw results
55
+ #
56
+ def raw_results
57
+ @raw_results ||= hit_ids.map { |hit_id| RawResult.new(*hit_id.match(/([^ ]+) (.+)/)[1..2]) }
58
+ end
59
+
60
+ #
61
+ # The total number of documents matching the query parameters
62
+ #
63
+ # ==== Returns
64
+ #
65
+ # Integer:: Total matching documents
66
+ #
38
67
  def total
39
68
  @total ||= @solr_result.total_hits
40
69
  end
41
70
 
42
- protected
43
- attr_reader :query, :types, :connection
71
+ #
72
+ # Get the facet object for the given field. This field will need to have
73
+ # been requested as a field facet inside the search block.
74
+ #
75
+ # ==== Parameters
76
+ #
77
+ # field_name<Symbol>:: field name for which to get the facet
78
+ #
79
+ # ==== Returns
80
+ #
81
+ # Sunspot::Facet:: Facet object for the given field
82
+ #
83
+ def facet(field_name)
84
+ (@facets_cache ||= {})[field_name.to_sym] ||=
85
+ begin
86
+ field = @query.field(field_name)
87
+ Facet.new(@solr_result.field_facets(field.indexed_name), field)
88
+ end
89
+ end
44
90
 
45
91
  private
46
92
 
93
+ #
94
+ # Collection of instantiated result objects corresponding to the results
95
+ # returned by Solr.
96
+ #
97
+ # ==== Returns
98
+ #
99
+ # Array:: Collection of instantiated result objects
100
+ #
47
101
  def result_objects
48
- hit_ids = @solr_result.hits.map { |hit| hit['id'] }
49
- hit_ids.inject({}) do |type_id_hash, hit_id|
50
- match = /([^ ]+) (.+)/.match hit_id
51
- (type_id_hash[match[1]] ||= []) << match[2]
102
+ raw_results.inject({}) do |type_id_hash, raw_result|
103
+ (type_id_hash[raw_result.class_name] ||= []) << raw_result.primary_key
52
104
  type_id_hash
53
105
  end.inject([]) do |results, pair|
54
106
  type_name, ids = pair
55
- results.concat ::Sunspot::Adapters.adapt_class(type_with_name(type_name)).load_all(ids)
107
+ results.concat(Adapters::DataAccessor.create(Util.full_const_get(type_name)).load_all(ids))
56
108
  end.sort_by do |result|
57
- hit_ids.index(::Sunspot::Adapters.adapt_instance(result).index_id)
109
+ hit_ids.index(Adapters::InstanceAdapter.adapt(result).index_id)
58
110
  end
59
111
  end
60
112
 
61
- def type_with_name(type_name)
62
- @types_cache ||= {}
63
- @types_cache[type_name] ||= type_name.split('::').inject(Module) { |namespace, name| namespace.const_get(name) }
113
+ def hit_ids
114
+ @hit_ids ||= @solr_result.hits.map { |hit| hit['id'] }
64
115
  end
65
116
  end
66
117
  end