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