Remove dated config.gem
[gitorious:pkong23s-mainline.git] / vendor / plugins / ultrasphinx / lib / ultrasphinx / search.rb
1
2 module Ultrasphinx
3
4 =begin rdoc
5 Command-interface Search object.
6
7 == Basic usage
8   
9 To set up a search, instantiate an Ultrasphinx::Search object with a hash of parameters. Only the <tt>:query</tt> key is mandatory.
10   @search = Ultrasphinx::Search.new(
11     :query => @query, 
12     :sort_mode => 'descending', 
13     :sort_by => 'created_at'
14   )
15     
16 Now, to run the query, call its <tt>run</tt> method. Your results will be available as ActiveRecord instances via the <tt>results</tt> method. Example:  
17   @search.run
18   @search.results
19
20 = Options
21
22 == Query format
23
24 The query string supports boolean operation, parentheses, phrases, and field-specific search. Query words are stemmed and joined by an implicit <tt>AND</tt> by default.
25
26 * Valid boolean operators are <tt>AND</tt>, <tt>OR</tt>, and <tt>NOT</tt>.
27 * Field-specific searches should be formatted as <tt>fieldname:contents</tt>. (This will only work for text fields. For numeric and date fields, see the <tt>:filters</tt> parameter, below.)
28 * Phrases must be enclosed in double quotes.
29     
30 A Sphinx::SphinxInternalError will be raised on invalid queries. In general, queries can only be nested to one level. 
31   @query = 'dog OR cat OR "white tigers" NOT (lions OR bears) AND title:animals'
32
33 == Hash parameters
34
35 The hash lets you customize internal aspects of the search.
36
37 <tt>:per_page</tt>:: An integer. How many results per page.
38 <tt>:page</tt>:: An integer. Which page of the results to return.
39 <tt>:class_names</tt>:: An array or string. The class name of the model you want to search, an array of model names to search, or <tt>nil</tt> for all available models.    
40 <tt>:sort_mode</tt>:: <tt>'relevance'</tt> or <tt>'ascending'</tt> or <tt>'descending'</tt>. How to order the result set. Note that <tt>'time'</tt> and <tt>'extended'</tt> modes are available, but not tested.  
41 <tt>:sort_by</tt>:: A field name. What field to order by for <tt>'ascending'</tt> or <tt>'descending'</tt> mode. Has no effect for <tt>'relevance'</tt>.
42 <tt>:weights</tt>:: A hash. Text-field names and associated query weighting. The default weight for every field is 1.0. Example: <tt>:weights => {'title' => 2.0}</tt>
43 <tt>:filters</tt>:: A hash. Names of numeric or date fields and associated values. You can use a single value, an array of values, or a range. (See the bottom of the ActiveRecord::Base page for an example.)
44 <tt>:facets</tt>:: An array of fields for grouping/faceting. You can access the returned facet values and their result counts with the <tt>facets</tt> method.
45 <tt>:indexes</tt>:: An array of indexes to search. Currently only <tt>Ultrasphinx::MAIN_INDEX</tt> and <tt>Ultrasphinx::DELTA_INDEX</tt> are available. Defaults to both; changing this is rarely needed.
46
47 Note that you can set up your own query defaults in <tt>environment.rb</tt>: 
48   
49   self.class.query_defaults = HashWithIndifferentAccess.new({
50     :per_page => 10,
51     :sort_mode => 'relevance',
52     :weights => {'title' => 2.0}
53   })
54
55 = Advanced features
56
57 == Interlock integration
58   
59 Ultrasphinx uses the <tt>find_all_by_id</tt> method to instantiate records. If you set <tt>with_finders: true</tt> in {Interlock's}[http://blog.evanweaver.com/files/doc/fauna/interlock] <tt>config/memcached.yml</tt>, Interlock overrides <tt>find_all_by_id</tt> with a caching version.
60
61 == Will_paginate integration
62
63 The Search instance responds to the same methods as a WillPaginate::Collection object, so once you have called <tt>run</tt> or <tt>excerpt</tt> you can use it directly in your views:
64
65   will_paginate(@search)
66
67 == Excerpt mode
68
69 You can have Sphinx excerpt and highlight the matched sections in the associated fields. Instead of calling <tt>run</tt>, call <tt>excerpt</tt>. 
70   
71   @search.excerpt
72
73 The returned models will be frozen and have their field contents temporarily changed to the excerpted and highlighted results. 
74   
75 You need to set the <tt>content_methods</tt> key on Ultrasphinx::Search.excerpting_options to whatever groups of methods you need the excerpter to try to excerpt. The first responding method in each group for each record will be excerpted. This way Ruby-only methods are supported (for example, a metadata method which combines various model fields, or an aliased field so that the original record contents are still available).
76   
77 There are some other keys you can set, such as excerpt size, HTML tags to highlight with, and number of words on either side of each excerpt chunk. Example (in <tt>environment.rb</tt>):
78   
79   Ultrasphinx::Search.excerpting_options = HashWithIndifferentAccess.new({
80     :before_match => '<strong>', 
81     :after_match => '</strong>',
82     :chunk_separator => "...",
83     :limit => 256,
84     :around => 3,
85     :content_methods => [['title'], ['body', 'description', 'content'], ['metadata']] 
86   })
87   
88 Note that your database is never changed by anything Ultrasphinx does.
89
90 =end    
91
92   class Search  
93   
94     include Internals
95     include Parser
96     
97     cattr_accessor :query_defaults  
98     self.query_defaults ||= HashWithIndifferentAccess.new({
99       :query => nil,
100       :page => 1,
101       :per_page => 20,
102       :sort_by => nil,
103       :sort_mode => 'relevance',
104       :indexes => [
105           MAIN_INDEX, 
106           (DELTA_INDEX if Ultrasphinx.delta_index_present?)
107         ].compact,
108       :weights => {},
109       :class_names => [],
110       :filters => {},
111       :facets => []
112     })
113     
114     cattr_accessor :excerpting_options
115     self.excerpting_options ||= HashWithIndifferentAccess.new({
116       :before_match => "<strong>", :after_match => "</strong>",
117       :chunk_separator => "...",
118       :limit => 256,
119       :around => 3,
120       # Results should respond to one in each group of these, in precedence order, for the 
121       # excerpting to fire
122       :content_methods => [['title', 'name'], ['body', 'description', 'content'], ['metadata']] 
123     })
124     
125     cattr_accessor :client_options
126     self.client_options ||= HashWithIndifferentAccess.new({ 
127       :with_subtotals => false, 
128       :ignore_missing_records => false,
129       # Has no effect if :ignore_missing_records => false
130       :max_missing_records => 5, 
131       :max_retries => 4,
132       :retry_sleep_time => 0.5,
133       :max_facets => 1000,
134       :max_matches_offset => 1000,
135       # Whether to add an accessor to each returned result that specifies its global rank in 
136       # the search.
137       :with_global_rank => false,
138       # Which method names to try to use for loading records. You can define your own (for 
139       # example, with :includes) and then attach it here. Each method must accept an Array 
140       # of ids, but do not have to preserve order. If the class does not respond_to? any 
141       # method name in the array, :find_all_by_id will be used.
142       :finder_methods => [] 
143     })
144     
145     # Friendly sort mode mappings    
146     SPHINX_CLIENT_PARAMS = { 
147       'sort_mode' => {
148         'relevance' => :relevance,
149         'descending' => :attr_desc, 
150         'ascending' => :attr_asc, 
151         'time' => :time_segments,
152         'extended' => :extended,
153       }
154     }
155     
156     INTERNAL_KEYS = ['parsed_query'] #:nodoc:
157
158     MODELS_TO_IDS = Ultrasphinx.get_models_to_class_ids || {} 
159
160     IDS_TO_MODELS = MODELS_TO_IDS.invert #:nodoc:
161     
162     MAX_MATCHES = DAEMON_SETTINGS["max_matches"].to_i 
163
164     FACET_CACHE = {} #:nodoc: 
165     
166     # Returns the options hash.
167     def options
168       @options
169     end
170     
171     #  Returns the query string used.
172     def query
173       # Redundant with method_missing
174       @options['query']
175     end
176     
177     def parsed_query #:nodoc:
178       # Redundant with method_missing
179       @options['parsed_query']
180     end
181     
182     # Returns an array of result objects.
183     def results
184       require_run
185       @results
186     end
187     
188     # Returns the facet map for this query, if facets were used.
189     def facets
190       raise UsageError, "No facet field was configured" unless @options['facets']
191       require_run
192       @facets
193     end      
194           
195     # Returns the raw response from the Sphinx client.
196     def response
197       require_run
198       @response
199     end
200     
201     # Returns a hash of total result counts, scoped to each available model. Set <tt>Ultrasphinx::Search.client_options[:with_subtotals] = true</tt> to enable.
202     # 
203     # The subtotals are implemented as a special type of facet.
204     def subtotals
205       raise UsageError, "Subtotals are not enabled" unless self.class.client_options['with_subtotals']
206       require_run
207       @subtotals
208     end
209
210     # Returns the total result count.
211     def total_entries
212       require_run
213       [response[:total_found] || 0, MAX_MATCHES].min
214     end  
215   
216     # Returns the response time of the query, in milliseconds.
217     def time
218       require_run
219       response[:time]
220     end
221
222     # Returns whether the query has been run.  
223     def run?
224       !@response.blank?
225     end
226  
227     # Returns the current page number of the result set. (Page indexes begin at 1.) 
228     def current_page
229       @options['page']
230     end
231   
232     # Returns the number of records per page.
233     def per_page
234       @options['per_page']
235     end
236         
237     # Returns the last available page number in the result set.  
238     def page_count
239       require_run    
240       (total_entries / per_page.to_f).ceil
241     end
242             
243     # Returns the previous page number.
244     def previous_page 
245       current_page > 1 ? (current_page - 1) : nil
246     end
247
248     # Returns the next page number.
249     def next_page
250       current_page < page_count ? (current_page + 1) : nil
251     end
252     
253     # Returns the global index position of the first result on this page.
254     def offset 
255       (current_page - 1) * per_page
256     end
257     
258     # Builds a new command-interface Search object.
259     def initialize opts = {} 
260
261       # Change to normal hashes with String keys for speed
262       opts = Hash[HashWithIndifferentAccess.new(opts._deep_dup._coerce_basic_types)]
263       unless self.class.query_defaults.instance_of? Hash
264         self.class.query_defaults = Hash[self.class.query_defaults]
265         self.class.client_options = Hash[self.class.client_options]
266         self.class.excerpting_options = Hash[self.class.excerpting_options]
267         self.class.excerpting_options['content_methods'].map! {|ary| ary.map {|m| m.to_s}}
268       end    
269       
270       @options = self.class.query_defaults.merge(opts)            
271       @options['query'] = @options['query'].to_s
272       @options['class_names'] = Array(@options['class_names'])
273       @options['facets'] = Array(@options['facets'])
274       @options['indexes'] = Array(@options['indexes']).join(" ")
275             
276       raise UsageError, "Weights must be a Hash" unless @options['weights'].is_a? Hash
277       raise UsageError, "Filters must be a Hash" unless @options['filters'].is_a? Hash
278       
279       @options['parsed_query'] = parse(query)
280   
281       @results, @subtotals, @facets, @response = [], {}, {}, {}
282         
283       extra_keys = @options.keys - (self.class.query_defaults.keys + INTERNAL_KEYS)
284       log "discarded invalid keys: #{extra_keys * ', '}" if extra_keys.any? and RAILS_ENV != "test" 
285     end
286     
287     # Run the search, filling results with an array of ActiveRecord objects. Set the parameter to false 
288     # if you only want the ids returned.
289     def run(reify = true)
290       @request = build_request_with_options(@options)
291
292       log "searching for #{@options.inspect}"
293
294       perform_action_with_retries do
295         @response = @request.query(parsed_query, @options['indexes'])
296         log "search returned #{total_entries}/#{response[:total_found].to_i} in #{time.to_f} seconds."
297           
298         if self.class.client_options['with_subtotals']        
299           @subtotals = get_subtotals(@request, parsed_query) 
300           
301           # If the original query has a filter on this class, we will use its more accurate total rather the facet's 
302           # less accurate total.
303           if @options['class_names'].size == 1
304             @subtotals[@options['class_names'].first] = response[:total_found]
305           end
306           
307         end
308         
309         Array(@options['facets']).each do |facet|
310           @facets[facet] = get_facets(@request, parsed_query, facet)
311         end        
312         
313         @results = convert_sphinx_ids(response[:matches])
314         @results = reify_results(@results) if reify
315         
316         say "warning; #{response[:warning]}" if response[:warning]
317         raise UsageError, response[:error] if response[:error]
318         
319       end      
320       self
321     end
322   
323   
324     # Overwrite the configured content attributes with excerpted and highlighted versions of themselves.
325     # Runs run if it hasn't already been done.
326     def excerpt
327     
328       require_run         
329       return if results.empty?
330     
331       # See what fields in each result might respond to our excerptable methods
332       results_with_content_methods = results.map do |result|
333         [result, 
334           self.class.excerpting_options['content_methods'].map do |methods|
335             methods.detect do |this| 
336               result.respond_to? this
337             end
338           end
339         ]
340       end
341   
342       # Fetch the actual field contents
343       docs = results_with_content_methods.map do |result, methods|
344         methods.map do |method| 
345           method and strip_bogus_characters(result.send(method)) or ""
346         end
347       end.flatten
348       
349       excerpting_options = {
350         :docs => docs,         
351         :index => MAIN_INDEX, # http://www.sphinxsearch.com/forum/view.html?id=100
352         :words => strip_query_commands(parsed_query)
353       }
354       self.class.excerpting_options.except('content_methods').each do |key, value|
355         # Riddle only wants symbols
356         excerpting_options[key.to_sym] ||= value
357       end
358       
359       responses = perform_action_with_retries do 
360         # Ship to Sphinx to highlight and excerpt
361         @request.excerpts(excerpting_options)
362       end
363       
364       responses = responses.in_groups_of(self.class.excerpting_options['content_methods'].size)
365       
366       results_with_content_methods.each_with_index do |result_and_methods, i|
367         # Override the individual model accessors with the excerpted data
368         result, methods = result_and_methods
369         methods.each_with_index do |method, j|
370           data = responses[i][j]
371           if method
372             result._metaclass.send('define_method', method) { data }
373             attributes = result.instance_variable_get('@attributes')
374             attributes[method] = data if attributes[method]
375           end
376         end
377       end
378   
379       @results = results_with_content_methods.map do |result_and_content_method| 
380         result_and_content_method.first.freeze
381       end
382       
383       self
384     end  
385     
386             
387     # Delegates enumerable methods to @results, if possible. This allows us to behave directly like a WillPaginate::Collection. Failing that, we delegate to the options hash if a key is set. This lets us use <tt>self</tt> directly in view helpers.
388     def method_missing(*args, &block)
389       if @results.respond_to? args.first
390         @results.send(*args, &block)
391       elsif options.has_key? args.first.to_s
392         @options[args.first.to_s]
393       else
394         super
395       end
396     end
397   
398     def log msg #:nodoc:
399       Ultrasphinx.log msg
400     end
401
402     def say msg #:nodoc:
403       Ultrasphinx.say msg
404     end
405     
406     private
407     
408     def require_run
409       run unless run?
410     end
411     
412   end
413 end