#194
[acts_as_ferret.git] / lib / class_methods.rb
blobc3e077607ab9293ab4f6d1669e75459b370a1eb2
1 module ActsAsFerret
2         
3   module ClassMethods
5     # Disables ferret index updates for this model. When a block is given,
6     # Ferret will be re-enabled again after executing the block.
7     def disable_ferret
8       aaf_configuration[:enabled] = false
9       if block_given?
10         yield
11         enable_ferret
12       end
13     end
15     def enable_ferret
16       aaf_configuration[:enabled] = true
17     end
19     def ferret_enabled?
20       aaf_configuration[:enabled]
21     end
23     # rebuild the index from all data stored for this model.
24     # This is called automatically when no index exists yet.
25     #
26     # When calling this method manually, you can give any additional 
27     # model classes that should also go into this index as parameters. 
28     # Useful when using the :single_index option.
29     # Note that attributes named the same in different models will share
30     # the same field options in the shared index.
31     def rebuild_index(*models)
32       models << self unless models.include?(self)
33       aaf_index.rebuild_index models.map(&:to_s)
34       self.index_dir = find_last_index_version(aaf_configuration[:index_base_dir]) unless aaf_configuration[:remote]
35     end
37     # re-index a number records specified by the given ids. Use for large
38     # indexing jobs i.e. after modifying a lot of records with Ferret disabled.
39     # Please note that the state of Ferret (enabled or disabled at class or
40     # record level) is not checked by this method, so if you need to do so
41     # (e.g. because of a custom ferret_enabled? implementation), you have to do
42     # so yourself.
43     def bulk_index(*ids)
44       options = Hash === ids.last ? ids.pop : {}
45       ids = ids.first if ids.size == 1 && ids.first.is_a?(Enumerable)
46       aaf_index.bulk_index(ids, options)
47     end
49     # true if our db and table appear to be suitable for the mysql fast batch
50     # hack (see
51     # http://weblog.jamisbuck.org/2007/4/6/faking-cursors-in-activerecord)
52     def use_fast_batches?
53       if connection.class.name =~ /Mysql/ && primary_key == 'id' && aaf_configuration[:mysql_fast_batches]
54         logger.info "using mysql specific batched find :all. Turn off with  :mysql_fast_batches => false if you encounter problems (i.e. because of non-integer UUIDs in the id column)"
55         true
56       end
57     end
59     # Returns all records modified or created after the specified time.
60     # Used by the rake rebuild task to find models that need to be updated in
61     # the index after the rebuild finished because they changed while the
62     # rebuild was running.
63     # Override if your models don't stick to the created_at/updated_at
64     # convention.
65     def records_modified_since(time)
66       condition = []
67       %w(updated_at created_at).each do |col|
68         condition << "#{col} >= ?" if column_names.include? col
69       end
70       if condition.empty?
71         logger.warn "#{self.name}: Override records_modified_since(time) to keep the index up to date with records changed during rebuild."
72         []
73       else
74         find :all, :conditions => [ condition.join(' AND '), *([time]*condition.size) ]
75       end
76     end
78     # runs across all records yielding those to be indexed when the index is rebuilt
79     def records_for_rebuild(batch_size = 1000)
80       transaction do
81         if use_fast_batches?
82           offset = 0
83           while (rows = find :all, :conditions => [ "#{table_name}.id > ?", offset ], :limit => batch_size).any?
84             offset = rows.last.id
85             yield rows, offset
86           end
87         else
88           # sql server adapter won't batch correctly without defined ordering
89           order = "#{primary_key} ASC" if connection.class.name =~ /SQLServer/
90           0.step(self.count, batch_size) do |offset|
91             yield find( :all, :limit => batch_size, :offset => offset, :order => order ), offset
92           end
93         end
94       end
95     end
97     # yields the records with the given ids, in batches of batch_size
98     def records_for_bulk_index(ids, batch_size = 1000)
99       transaction do
100         offset = 0
101         ids.each_slice(batch_size) do |id_slice|
102           logger.debug "########## slice: #{id_slice.join(',')}"
103           records = find( :all, :conditions => ["id in (?)", id_slice] )
104           logger.debug "########## slice records: #{records.inspect}"
105           #yield records, offset
106           yield find( :all, :conditions => ["id in (?)", id_slice] ), offset
107           offset += batch_size
108         end
109       end
110     end
112     # Switches this class to a new index located in dir.
113     # Used by the DRb server when switching to a new index version.
114     def index_dir=(dir)
115       logger.debug "changing index dir to #{dir}"
116       
117       # store index with the new dir as key. This prevents the aaf_index method
118       # from opening another index instance later on.
119       ActsAsFerret::ferret_indexes[dir] = aaf_index
120       old_dir = aaf_configuration[:index_dir]
121       aaf_configuration[:index_dir] = aaf_configuration[:ferret][:path] = dir
122       # clean old reference to index
123       ActsAsFerret::ferret_indexes.delete old_dir
124       aaf_index.reopen!
125       logger.debug "index dir is now #{dir}"
126     end
127     
128     # Retrieve the index instance for this model class. This can either be a
129     # LocalIndex, or a RemoteIndex instance.
130     # 
131     # Index instances are stored in a hash, using the index directory
132     # as the key. So model classes sharing a single index will share their
133     # Index object, too.
134     def aaf_index
135       ActsAsFerret::ferret_indexes[aaf_configuration[:index_dir]] ||= create_index_instance
136     end 
137     
138     # Finds instances by searching the Ferret index. Terms are ANDed by default, use 
139     # OR between terms for ORed queries. Or specify +:or_default => true+ in the
140     # +:ferret+ options hash of acts_as_ferret.
141     #
142     # You may either use the +offset+ and +limit+ options to implement your own
143     # pagination logic, or use the +page+ and +per_page+ options to use the
144     # built in pagination support which is compatible with will_paginate's view
145     # helpers. If +page+ and +per_page+ are given, +offset+ and +limit+ will be
146     # ignored.
147     #
148     # == options:
149     # page::        page of search results to retrieve
150     # per_page::    number of search results that are displayed per page
151     # offset::      first hit to retrieve (useful for paging)
152     # limit::       number of hits to retrieve, or :all to retrieve
153     #               all results
154     # lazy::        Array of field names whose contents should be read directly
155     #               from the index. Those fields have to be marked 
156     #               +:store => :yes+ in their field options. Give true to get all
157     #               stored fields. Note that if you have a shared index, you have 
158     #               to explicitly state the fields you want to fetch, true won't
159     #               work here)
160     # models::      only for single_index scenarios: an Array of other Model classes to 
161     #               include in this search. Use :all to query all models.
162     # multi::       Specify additional model classes to search through. Each of
163     #               these, as well as this class, has to have the
164     #               :store_class_name option set to true. This option replaces the
165     #               multi_search method.
166     #
167     # +find_options+ is a hash passed on to active_record's find when
168     # retrieving the data from db, useful to i.e. prefetch relationships with
169     # :include or to specify additional filter criteria with :conditions.
170     #
171     # This method returns a +SearchResults+ instance, which really is an Array that has 
172     # been decorated with a total_hits attribute holding the total number of hits.
173     # Additionally, SearchResults is compatible with the pagination helper
174     # methods of the will_paginate plugin.
175     #
176     # Please keep in mind that the number of results delivered might be less than 
177     # +limit+ if you specify any active record conditions that further limit 
178     # the result. Use +limit+ and +offset+ as AR find_options instead.
179     # +page+ and +per_page+ are supposed to work regardless of any 
180     # +conitions+ present in +find_options+.
181     def find_with_ferret(q, options = {}, find_options = {})
182       if options[:per_page]
183         options[:page] = options[:page] ? options[:page].to_i : 1
184         limit = options[:per_page]
185         offset = (options[:page] - 1) * limit
186         if find_options[:conditions] && !options[:multi]
187           find_options[:limit] = limit
188           find_options[:offset] = offset
189           options[:limit] = :all
190           options.delete :offset
191         else
192           # do pagination with ferret (or after everything is done in the case
193           # of multi_search)
194           options[:limit] = limit
195           options[:offset] = offset
196         end
197       elsif find_options[:conditions]
198         if options[:multi]
199           # multisearch ignores find_options limit and offset
200           options[:limit] ||= find_options.delete(:limit)
201           options[:offset] ||= find_options.delete(:offset)
202         else
203           # let the db do the limiting and offsetting for single-table searches
204           unless options[:limit] == :all
205             find_options[:limit] ||= options.delete(:limit)
206           end
207           find_options[:offset] ||= options.delete(:offset)
208           options[:limit] = :all
209         end
210       end
212       total_hits, result = if options[:multi].blank?
213         find_records_lazy_or_not q, options, find_options
214       else
215         _multi_search q, options.delete(:multi), options, find_options
216       end
217       logger.debug "Query: #{q}\ntotal hits: #{total_hits}, results delivered: #{result.size}"
218       SearchResults.new(result, total_hits, options[:page], options[:per_page])
219     end 
220     alias find_by_contents find_with_ferret
222    
224     # Returns the total number of hits for the given query 
225     # To count the results of a query across multiple models, specify an array of 
226     # class names with the :multi option.
227     #
228     # Note that since we don't query the database here, this method won't deliver 
229     # the expected results when used on an AR association.
230     def total_hits(q, options={})
231       if options[:models]
232         # backwards compatibility
233         logger.warn "the :models option of total_hits is deprecated, please use :multi instead"
234         options[:multi] = options[:models] 
235       end
236       if models = options[:multi]
237         options[:multi] = add_self_to_model_list_if_necessary(models).map(&:to_s)
238       end
239       aaf_index.total_hits(q, options)
240     end
242     # Finds instance model name, ids and scores by contents. 
243     # Useful e.g. if you want to search across models or do not want to fetch
244     # all result records (yet).
245     #
246     # Options are the same as for find_by_contents
247     #
248     # A block can be given too, it will be executed with every result:
249     # find_id_by_contents(q, options) do |model, id, score|
250     #    id_array << id
251     #    scores_by_id[id] = score 
252     # end
253     # NOTE: in case a block is given, only the total_hits value will be returned
254     # instead of the [total_hits, results] array!
255     # 
256     def find_id_by_contents(q, options = {}, &block)
257       deprecated_options_support(options)
258       aaf_index.find_id_by_contents(q, options, &block)
259     end
261     
262     # returns an array of hashes, each containing :class_name,
263     # :id and :score for a hit.
264     #
265     # if a block is given, class_name, id and score of each hit will 
266     # be yielded, and the total number of hits is returned.
267     def id_multi_search(query, additional_models = [], options = {}, &proc)
268       deprecated_options_support(options)
269       models = add_self_to_model_list_if_necessary(additional_models)
270       aaf_index.id_multi_search(query, models.map(&:to_s), options, &proc)
271     end
272     
274     protected
276     def _multi_search(query, additional_models = [], options = {}, find_options = {})
277       result = []
279       rank = 0
280       if options[:lazy]
281         logger.warn "find_options #{find_options} are ignored because :lazy => true" unless find_options.empty?
282         total_hits = id_multi_search(query, additional_models, options) do |model, id, score, data|
283           result << FerretResult.new(model, id, score, rank += 1, data)
284         end
285       else
286         id_arrays = {}
288         limit = options.delete(:limit)
289         offset = options.delete(:offset) || 0
290         options[:limit] = :all
291         total_hits = id_multi_search(query, additional_models, options) do |model, id, score, data|
292           id_arrays[model] ||= {}
293           id_arrays[model][id] = [ rank += 1, score ]
294         end
295         result = retrieve_records(id_arrays, find_options)
296         total_hits = result.size if find_options[:conditions]
297         if limit && limit != :all
298           result = result[offset..limit+offset-1]
299         end
300       end
301       [total_hits, result]
302     end
304     def add_self_to_model_list_if_necessary(models)
305       models = [ models ] unless models.is_a? Array
306       models << self unless models.include?(self)
307       models
308     end
310     def find_records_lazy_or_not(q, options = {}, find_options = {})
311       if options[:lazy]
312         logger.warn "find_options #{find_options} are ignored because :lazy => true" unless find_options.empty?
313         lazy_find_by_contents q, options
314       else
315         ar_find_by_contents q, options, find_options
316       end
317     end
319     def ar_find_by_contents(q, options = {}, find_options = {})
320       result_ids = {}
321       total_hits = find_id_by_contents(q, options) do |model, id, score, data|
322         # stores ids, index and score of each hit for later ordering of
323         # results
324         result_ids[id] = [ result_ids.size + 1, score ]
325       end
327       result = retrieve_records( { self.name => result_ids }, find_options )
328       
329       # count total_hits via sql when using conditions or when we're called
330       # from an ActiveRecord association.
331       if find_options[:conditions] or caller.find{ |call| call =~ %r{active_record/associations} }
332         # chances are the ferret result count is not our total_hits value, so
333         # we correct this here.
334         if options[:limit] != :all || options[:page] || options[:offset] || find_options[:limit] || find_options[:offset]
335           # our ferret result has been limited, so we need to re-run that
336           # search to get the full result set from ferret.
337           result_ids = {}
338           find_id_by_contents(q, options.update(:limit => :all, :offset => 0)) do |model, id, score, data|
339             result_ids[id] = [ result_ids.size + 1, score ]
340           end
341           # Now ask the database for the total size of the final result set.
342           total_hits = count_records( { self.name => result_ids }, find_options )
343         else
344           # what we got from the database is our full result set, so take
345           # it's size
346           total_hits = result.length
347         end
348       end
350       [ total_hits, result ]
351     end
353     def lazy_find_by_contents(q, options = {})
354       result = []
355       rank   = 0
356       total_hits = find_id_by_contents(q, options) do |model, id, score, data|
357         result << FerretResult.new(model, id, score, rank += 1, data)
358       end
359       [ total_hits, result ]
360     end
363     def model_find(model, id, find_options = {})
364       model.constantize.find(id, find_options)
365     end
367     # retrieves search result records from a data structure like this:
368     # { 'Model1' => { '1' => [ rank, score ], '2' => [ rank, score ] }
369     #
370     # TODO: in case of STI AR will filter out hits from other 
371     # classes for us, but this
372     # will lead to less results retrieved --> scoping of ferret query
373     # to self.class is still needed.
374     # from the ferret ML (thanks Curtis Hatter)
375     # > I created a method in my base STI class so I can scope my query. For scoping
376     # > I used something like the following line:
377     # > 
378     # > query << " role:#{self.class.eql?(Contents) '*' : self.class}"
379     # > 
380     # > Though you could make it more generic by simply asking
381     # > "self.descends_from_active_record?" which is how rails decides if it should
382     # > scope your "find" query for STI models. You can check out "base.rb" in
383     # > activerecord to see that.
384     # but maybe better do the scoping in find_id_by_contents...
385     def retrieve_records(id_arrays, find_options = {})
386       result = []
387       # get objects for each model
388       id_arrays.each do |model, id_array|
389         next if id_array.empty?
390         model_class = begin
391           model.constantize
392         rescue
393           raise "Please use ':store_class_name => true' if you want to use multi_search.\n#{$!}"
394         end
396         # check for per-model conditions and take these if provided
397         if conditions = find_options[:conditions]
398           key = model.underscore.to_sym
399           conditions = conditions[key] if Hash === conditions
400         end
402         # merge conditions
403         conditions = combine_conditions([ "#{model_class.table_name}.#{model_class.primary_key} in (?)", 
404                                           id_array.keys ], 
405                                         conditions)
408         # check for include association that might only exist on some models in case of multi_search
409         filtered_include_options = []
410         if include_options = find_options[:include]
411           include_options = [ include_options ] unless include_options.respond_to?(:each)
412           include_options.each do |include_option|
413             filtered_include_options << include_option if model_class.reflections.has_key?(include_option.is_a?(Hash) ? include_option.keys[0].to_sym : include_option.to_sym)
414           end
415         end
416         filtered_include_options = nil if filtered_include_options.empty?
418         # fetch
419         tmp_result = model_class.find(:all, find_options.merge(:conditions => conditions, 
420                                                                :include    => filtered_include_options))
422         # set scores and rank
423         tmp_result.each do |record|
424           record.ferret_rank, record.ferret_score = id_array[record.id.to_s]
425         end
426         # merge with result array
427         result.concat tmp_result
428       end
429       
430       # order results as they were found by ferret, unless an AR :order
431       # option was given
432       result.sort! { |a, b| a.ferret_rank <=> b.ferret_rank } unless find_options[:order]
433       return result
434     end
436     def count_records(id_arrays, find_options = {})
437       count_options = find_options.dup
438       count_options.delete :limit
439       count_options.delete :offset
440       count = 0
441       id_arrays.each do |model, id_array|
442         next if id_array.empty?
443         begin
444           model = model.constantize
445           # merge conditions
446           conditions = combine_conditions([ "#{model.table_name}.#{model.primary_key} in (?)", id_array.keys ], 
447                                           find_options[:conditions])
448           opts = find_options.merge :conditions => conditions
449           opts.delete :limit; opts.delete :offset
450           count += model.count opts
451         rescue TypeError
452           raise "#{model} must use :store_class_name option if you want to use multi_search against it.\n#{$!}"
453         end
454       end
455       count
456     end
458     def deprecated_options_support(options)
459       if options[:num_docs]
460         logger.warn ":num_docs is deprecated, use :limit instead!"
461         options[:limit] ||= options[:num_docs]
462       end
463       if options[:first_doc]
464         logger.warn ":first_doc is deprecated, use :offset instead!"
465         options[:offset] ||= options[:first_doc]
466       end
467     end
469     # creates a new Index instance.
470     def create_index_instance
471       if aaf_configuration[:remote]
472        RemoteIndex
473       elsif aaf_configuration[:single_index]
474         SharedIndex
475       else
476         LocalIndex
477       end.new(aaf_configuration)
478     end
480     # combine our conditions with those given by user, if any
481     def combine_conditions(conditions, additional_conditions = [])
482       returning conditions do
483         if additional_conditions && additional_conditions.any?
484           cust_opts = additional_conditions.respond_to?(:shift) ? additional_conditions.dup : [ additional_conditions ]
485           conditions.first << " and " << cust_opts.shift
486           conditions.concat(cust_opts)
487         end
488       end
489     end
491   end
492