r1107@monsoon: jk | 2006-09-09 19:03:52 +0200
[acts_as_ferret.git] / lib / class_methods.rb
blob973badb6621e5ee993c2ab9fe1db548b3cad31a9
1 module FerretMixin
2   module Acts #:nodoc:
3     module ARFerret #:nodoc:
4       
5       # declare the class level helper methods
6       # which will load the relevant instance methods defined below when invoked
7       module ClassMethods
8         
9         # helper that defines a method that adds the given field to a lucene 
10         # document instance
11         def define_to_field_method(field, options = {})         
12           options = { 
13             :store => :no, 
14             :highlight => :yes, 
15             :index => :yes, 
16             :term_vector => :with_positions_offsets,
17             :boost => 1.0 }.update(options)
18           fields_for_ferret[field] = options
19           define_method("#{field}_to_ferret".to_sym) do
20             begin
21               val = content_for_field_name(field)
22             rescue
23               logger.warn("Error retrieving value for field #{field}: #{$!}")
24               val = ''
25             end
26             logger.debug("Adding field #{field} with value '#{val}' to index")
27             val
28           end
29         end
31         def add_fields(field_config)
32           if field_config.respond_to?(:each_pair)
33             field_config.each_pair do |key,val|
34               define_to_field_method(key,val)                  
35             end
36           elsif field_config.respond_to?(:each)
37             field_config.each do |field| 
38               define_to_field_method(field)
39             end                
40           end
41         end
42         
43         def reloadable?; false end
44         
45         @@ferret_indexes = Hash.new
46         def ferret_indexes; @@ferret_indexes end
47         
48         @@multi_indexes = Hash.new
49         def multi_indexes; @@multi_indexes end
50         
51         # declares a class as ferret-searchable. 
52         #
53         # options are:
54         #
55         # fields:: names all fields to include in the index. If not given,
56         #   all attributes of the class will be indexed. You may also give
57         #   symbols pointing to instance methods of your model here, i.e. 
58         #   to retrieve and index data from a related model. 
59         #
60         # additional_fields:: names fields to include in the index, in addition 
61         #   to those derived from the db scheme. use if you want to add
62         #   custom fields derived from methods to the db fields (which will be picked 
63         #   by aaf). This option will be ignored when the fields option is given, in 
64         #   that case additional fields get specified there.
65         #
66         # index_dir:: declares the directory where to put the index for this class.
67         #   The default is RAILS_ROOT/index/RAILS_ENV/CLASSNAME. 
68         #   The index directory will be created if it doesn't exist.
69         #
70         # single_index:: set this to true to let this class use a Ferret
71         # index that is shared by all classes having :single_index set to true.
72         # :store_class_name is set to true implicitly, as well as index_dir, so 
73         # don't bother setting these when using this option. the shared index
74         # will be located in index/<RAILS_ENV>/shared .
75         #
76         # store_class_name:: to make search across multiple models useful, set
77         # this to true. the model class name will be stored in a keyword field 
78         # named class_name
79         #
80         # max_results:: number of results to retrieve for :num_docs => :all,
81         # default value is 1000
82         #
83         # ferret_options may be:
84         # or_default:: - whether query terms are required by
85         #   default (the default, false), or not (true)
86         # 
87         # analyzer:: the analyzer to use for query parsing (default: nil,
88         #   wihch means the ferret StandardAnalyzer gets used)
89         #
90         def acts_as_ferret(options={}, ferret_options={})
91           configuration = { 
92             :index_dir => "#{FerretMixin::Acts::ARFerret::index_dir}/#{self.name.underscore}",
93             :store_class_name => false,
94             :single_index => false,
95             :max_results => 1000
96           }
97           ferret_configuration = {
98             :or_default => false, 
99             :handle_parser_errors => true
100             #:max_clauses => 512,
101             #:default_field => '*',
102             #:analyzer => Ferret::Analysis::StandardAnalyzer.new,
103             # :wild_card_downcase => true
104           }
105           configuration.update(options) if options.is_a?(Hash)
107           # apply appropriate settings for shared index
108           if configuration[:single_index] 
109             configuration[:index_dir] = "#{FerretMixin::Acts::ARFerret::index_dir}/shared" 
110             configuration[:store_class_name] = true 
111           end
112           ferret_configuration.update(ferret_options) if ferret_options.is_a?(Hash)
113           # these properties are somewhat vital to the plugin and shouldn't
114           # be overwritten by the user:
115           ferret_configuration.update(
117             :key               => (configuration[:single_index] ? [:id, :class_name] : :id),
118             :path              => configuration[:index_dir],
119             :auto_flush        => true,
120             :create_if_missing => true
121           )
122           
123           class_eval <<-EOV
124               include FerretMixin::Acts::ARFerret::InstanceMethods
127               after_create :ferret_create
128               after_update :ferret_update
129               after_destroy :ferret_destroy      
130               
131               cattr_accessor :fields_for_ferret   
132               cattr_accessor :configuration
133               cattr_accessor :ferret_configuration
134               
135               @@fields_for_ferret = Hash.new
136               @@configuration = configuration
137               @@ferret_configuration = ferret_configuration
139               if configuration[:fields]
140                 add_fields(configuration[:fields])
141               else
142                 add_fields(self.new.attributes.keys.map { |k| k.to_sym })
143                 add_fields(configuration[:additional_fields])
144               end
146             EOV
147           FerretMixin::Acts::ARFerret::ensure_directory configuration[:index_dir]
148         end
149         
150         def class_index_dir
151           configuration[:index_dir]
152         end
153         
154         # rebuild the index from all data stored for this model.
155         # This is called automatically when no index exists yet.
156         #
157         # TODO: the automatic index initialization only works if 
158         # every model class has it's 
159         # own index, otherwise the index will get populated only
160         # with instances from the first model loaded
161         #
162         # When calling this method manually, you can give any additional 
163         # model classes that should also go into this index as parameters. 
164         # Useful when using the :single_index option.
165         # Note that attributes named the same in different models will share
166         # the same field options in the shared index.
167         def rebuild_index(*models)
168           models << self
169           # default attributes for fields
170           fi = Ferret::Index::FieldInfos.new(:store => :no, 
171                                              :index => :yes, 
172                                              :term_vector => :no,
173                                              :boost => 1.0)
174           # primary key
175           fi.add_field(:id, :store => :yes, :index => :untokenized) 
176           # class_name
177           if configuration[:store_class_name]
178             fi.add_field(:class_name, :store => :yes, :index => :untokenized) 
179           end
180           # collect field options from all models
181           fields = {}
182           models.each do |model|
183             fields.update(model.fields_for_ferret)
184           end
185           logger.debug("class #{self.name}: fields for index: #{fields.keys.join(',')}")
186           fields.each_pair do |field, options|
187             fi.add_field(field, { :store => :no, 
188                                   :index => :yes }.update(options)) 
189           end
190           fi.create_index(ferret_configuration[:path])
192           index = Ferret::Index::Index.new(ferret_configuration.dup.update(:auto_flush => false))
193           batch_size = 1000
194           models.each do |model|
195             # index in batches of 1000 to limit memory consumption (fixes #24)
196             model.transaction do
197               0.step(model.count, batch_size) do |i|
198                 model.find(:all, :limit => batch_size, :offset => i).each do |rec|
199                   index << rec.to_doc
200                 end
201               end
202             end
203           end
204           logger.debug("Created Ferret index in: #{class_index_dir}")
205           index.flush
206           index.optimize
207           index.close
208         end                                                            
209         
210         # Retrieve the Ferret::Index::Index instance for this model class.
211         # 
212         # Index instances are stored in a hash, using the index directory
213         # as the key. So model classes sharing a single index will share their
214         # Index object, too.
215         def ferret_index
216           ferret_indexes[class_index_dir] ||= create_index_instance
217         end 
218         
219         # creates a new Index::Index instance. Before that, a check is done
220         # to see if the index exists in the file system. If not, index rebuild
221         # from all model data retrieved by find(:all) is triggered.
222         def create_index_instance
223           rebuild_index unless File.file? "#{class_index_dir}/segments"
224           Ferret::Index::Index.new(ferret_configuration)
225         end
226         
227         # Finds instances by contents. Terms are ANDed by default, can be circumvented 
228         # by using OR between terms. 
229         # options:
230         # :first_doc - first hit to retrieve (useful for paging)
231         # :num_docs - number of hits to retrieve, or :all to retrieve
232         # max_results results, which by default is 1000 and can be changed in
233         # the call to acts_as_ferret or on demand like this:
234         # Model.configuration[:max_results] = 1000000
235          #
236         # find_options is a hash passed on to active_record's find when
237         # retrieving the data from db, useful to i.e. prefetch relationships.
238         #
239         # this method returns a SearchResults instance, which really is an Array that has 
240         # been decorated with a total_hits accessor that delivers the total
241         # number of hits (including those not fetched because of a low num_docs
242         # value).
243         def find_by_contents(q, options = {}, find_options = {})
244           # handle shared index
245           return single_index_find_by_contents(q, options, find_options) if configuration[:single_index]
246           id_array = []
247           id_positions = {}
248           total_hits = find_id_by_contents(q, options) do |model, id, score|
249             id_array << id
250             # store index of this id for later ordering of results
251             id_positions[id] = id_array.size
252           end
253           begin
254             # TODO: in case of STI AR will filter out hits from other 
255             # classes for us, but this
256             # will lead to less results retrieved --> scoping of ferret query
257             # to self.class is still needed.
258             if id_array.empty?
259               result = []
260             else
261               conditions = [ "#{self.table_name}.id in (?)", id_array ]
262               # combine our conditions with those given by user, if any
263               if find_options[:conditions]
264                 cust_opts = find_options[:conditions].dup
265                 conditions.first << " and " << cust_opts.shift
266                 conditions.concat(cust_opts)
267               end
268               result = self.find(:all, 
269                                  find_options.merge(:conditions => conditions))
270             end
271           rescue
272             logger.debug "REBUILD YOUR INDEX! One of the id's didn't have an associated record: #{id_array}"
273           end
275           # order results as they were found by ferret, unless an AR :order
276           # option was given
277           unless find_options[:order]
278             result.sort! { |a, b| id_positions[a.id] <=> id_positions[b.id] }
279           end
280           
281           logger.debug "Query: #{q}\nResult id_array: #{id_array.inspect},\nresult: #{result}"
282           return SearchResults.new(result, total_hits)
283         end 
285         # determine all field names in the shared index
286         def single_index_field_names(models)
287           @single_index_field_names ||= (
288               searcher = Ferret::Search::Searcher.new(class_index_dir)
289               if searcher.reader.respond_to?(:get_field_names)
290                 (searcher.reader.send(:get_field_names) - ['id', 'class_name']).to_a
291               else
292                 puts <<-END
293   unable to retrieve field names for class #{self.name}, please 
294   consider naming all indexed fields in your call to acts_as_ferret!
295                 END
296                 models.map { |m| m.content_columns.map { |col| col.name } }.flatten
297               end
298           )
300         end
301         
302         # weiter: checken ob ferret-bug, dass wir die queries so selber bauen
303         # muessen - liegt am downcasen des qparsers ? - gucken ob jetzt mit
304         # ferret geht (content_cols) und dave um zugriff auf qp bitten, oder
305         # auf reader
306         def single_index_find_by_contents(q, options = {}, find_options = {})
307           result = []
309           unless options[:models] == :all # search needs to be restricted by one or more class names
310             options[:models] ||= [] 
311             # add this class to the list of given models
312             options[:models] << self unless options[:models].include?(self)
313             # keep original query 
314             original_query = q
315             
316             # work around ferret bug in #process_query (doesn't ensure the
317             # reader is open)
318             ferret_index.synchronize do
319               ferret_index.send(:ensure_reader_open)
320               original_query = ferret_index.process_query(q)
321             end if q.is_a? String
323             q = Ferret::Search::BooleanQuery.new
324             q.add_query(original_query, :must)
325             model_query = Ferret::Search::BooleanQuery.new
326             options[:models].each do |model|
327               model_query.add_query(Ferret::Search::TermQuery.new(:class_name, model.name), :should)
328             end
329             q.add_query(model_query, :must)
330             #end
331           end
332           #puts q.to_s
333           total_hits = find_id_by_contents(q, options) do |model, id, score|
334             result << Object.const_get(model).find(id, find_options.dup)
335           end
336           return SearchResults.new(result, total_hits)
337         end
338         protected :single_index_find_by_contents
340         # Finds instance model name, ids and scores by contents. 
341         # Useful if you want to search across models
342         # Terms are ANDed by default, can be circumvented by using OR between terms.
343         #
344         # Example controller code (not tested):
345         # def multi_search(query)
346         #   result = []
347         #   result << (Model1.find_id_by_contents query)
348         #   result << (Model2.find_id_by_contents query)
349         #   result << (Model3.find_id_by_contents query)
350         #   result.flatten!
351         #   result.sort! {|element| element[:score]}
352         #   # Figure out for yourself how to retreive and present the data from modelname and id 
353         # end
354         #
355         # Note that the scores retrieved this way aren't normalized across
356         # indexes, so that the order of results after sorting by score will
357         # differ from the order you would get when running the same query
358         # on a single index containing all the data from Model1, Model2 
359         # and Model
360         #
361         # options:
362         # :first_doc - first hit to retrieve (useful for paging)
363         # :num_docs - number of hits to retrieve, or :all to retrieve
364         # max_results results, which by default is 1000 and can be changed in
365         # the call to acts_as_ferret or on demand like this:
366         # Model.configuration[:max_results] = 1000000
367         #
368         # a block can be given too, it will be executed with every result:
369         # find_id_by_contents(q, options) do |model, id, score|
370         #    id_array << id
371         #    scores_by_id[id] = score 
372         # end
373         # NOTE: in case a block is given, the total_hits value will be returned
374         # instead of the result list!
375         # 
376         def find_id_by_contents(q, options = {})
377           deprecated_options_support(options)
378           options[:limit] = configuration[:max_results] if options[:limit] == :all
380           result = []
381           index = self.ferret_index
382           #hits = index.search(q, options)
383           #hits.each do |hit, score|
384           total_hits = index.search_each(q, options) do |hit, score|
385             # only collect result data if we intend to return it
386             doc = index[hit]
387             model = configuration[:store_class_name] ? doc[:class_name] : self.name
388             if block_given?
389               yield model, doc[:id].to_i, score
390             else
391               result << { :model => model, :id => doc[:id], :score => score }
392             end
393           end
394           logger.debug "id_score_model array: #{result.inspect}"
395           return block_given? ? total_hits : result
396         end
397         
398         # requires the store_class_name option of acts_as_ferret to be true
399         # for all models queried this way.
400         #
401         # TODO: not optimal as each instance is fetched in a db call for it's
402         # own.
403         def multi_search(query, additional_models = [], options = {})
404           result = []
405           total_hits = id_multi_search(query, additional_models, options) do |model, id, score|
406             result << Object.const_get(model).find(id)
407           end
408           SearchResults.new(result, total_hits)
409         end
410         
411         # returns an array of hashes, each containing :class_name,
412         # :id and :score for a hit.
413         #
414         # if a block is given, class_name, id and score of each hit will 
415         # be yielded, and the total number of hits is returned.
416         #
417         def id_multi_search(query, additional_models = [], options = {})
418           deprecated_options_support(options)
419           options[:limit] = configuration[:max_results] if options[:limit] == :all
420           additional_models << self
421           searcher = multi_index(additional_models)
422           result = []
423           total_hits = searcher.search_each (query, options) do |hit, score|
424             doc = searcher[hit]
425             if block_given?
426               yield doc[:class_name], doc[:id].to_i, score
427             else
428               result << { :model => doc[:class_name], :id => doc[:id], :score => score }
429             end
430           end
431           return block_given? ? total_hits : result
432         end
433         
434         # returns a MultiIndex instance operating on a MultiReader
435         def multi_index(model_classes)
436           model_classes.sort! { |a, b| a.name <=> b.name }
437           key = model_classes.inject("") { |s, clazz| s << clazz.name }
438           @@multi_indexes[key] ||= MultiIndex.new(model_classes, ferret_configuration)
439         end
441         def deprecated_options_support(options)
442           if options[:num_docs]
443             logger.warn ":num_docs is deprecated, use :limit instead!"
444             options[:limit] ||= options[:num_docs]
445           end
446           if options[:first_doc]
447             logger.warn ":first_doc is deprecated, use :offset instead!"
448             options[:offset] ||= options[:first_doc]
449           end
450         end
452       end
453       
454     end
455   end