outoftime-sunspot 0.0.2 → 0.7.0

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