3 module ARFerret #:nodoc:
5 # declare the class level helper methods
6 # which will load the relevant instance methods defined below when invoked
9 # helper that defines a method that adds the given field to a lucene
11 def define_to_field_method(field, options = {})
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
21 val = content_for_field_name(field)
23 logger.warn("Error retrieving value for field #{field}: #{$!}")
26 logger.debug("Adding field #{field} with value '#{val}' to index")
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)
36 elsif field_config.respond_to?(:each)
37 field_config.each do |field|
38 define_to_field_method(field)
43 def reloadable?; false end
45 @@ferret_indexes = Hash.new
46 def ferret_indexes; @@ferret_indexes end
48 @@multi_indexes = Hash.new
49 def multi_indexes; @@multi_indexes end
51 # declares a class as ferret-searchable.
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.
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.
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.
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 .
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
80 # ferret_options may be:
81 # or_default:: - whether query terms are required by
82 # default (the default, false), or not (true)
84 # analyzer:: the analyzer to use for query parsing (default: nil,
85 # wihch means the ferret StandardAnalyzer gets used)
87 def acts_as_ferret(options={}, ferret_options={})
89 :index_dir => "#{FerretMixin::Acts::ARFerret::index_dir}/#{self.name.underscore}",
90 :store_class_name => false,
91 :single_index => false,
93 ferret_configuration = {
95 :handle_parser_errors => true
97 #:default_field => '*',
98 #:analyzer => Ferret::Analysis::StandardAnalyzer.new,
99 # :wild_card_downcase => true
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
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],
116 :create_if_missing => true
120 include FerretMixin::Acts::ARFerret::InstanceMethods
123 after_create :ferret_create
124 after_update :ferret_update
125 after_destroy :ferret_destroy
127 cattr_accessor :fields_for_ferret
128 cattr_accessor :configuration
129 cattr_accessor :ferret_configuration
131 @@fields_for_ferret = Hash.new
132 @@configuration = configuration
133 @@ferret_configuration = ferret_configuration
135 if configuration[:fields]
136 add_fields(configuration[:fields])
138 add_fields(self.new.attributes.keys.map { |k| k.to_sym })
139 add_fields(configuration[:additional_fields])
143 FerretMixin::Acts::ARFerret::ensure_directory configuration[:index_dir]
147 configuration[:index_dir]
150 # rebuild the index from all data stored for this model.
151 # This is called automatically when no index exists yet.
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
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)
165 # default attributes for fields
166 fi = Ferret::Index::FieldInfos.new(:store => :no,
171 fi.add_field(:id, :store => :yes, :index => :untokenized)
173 if configuration[:store_class_name]
174 fi.add_field(:class_name, :store => :yes, :index => :untokenized)
176 # collect field options from all models
178 models.each do |model|
179 fields.update(model.fields_for_ferret)
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))
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))
191 models.each do |model|
192 # index in batches of 1000 to limit memory consumption (fixes #24)
194 0.step(model.count, batch_size) do |i|
195 model.find(:all, :limit => batch_size, :offset => i).each do |rec|
201 logger.debug("Created Ferret index in: #{class_index_dir}")
207 # Retrieve the Ferret::Index::Index instance for this model class.
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
213 ferret_indexes[class_index_dir] ||= create_index_instance
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)
224 # Finds instances by contents. Terms are ANDed by default, can be circumvented
225 # by using OR between terms.
227 # offset:: first hit to retrieve (useful for paging)
228 # limit:: number of hits to retrieve, or :all to retrieve
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.
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
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]
243 total_hits = find_id_by_contents(q, options) do |model, id, score|
245 # store index of this id for later ordering of results
246 id_positions[id] = id_array.size
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.
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)
263 result = self.find(:all,
264 find_options.merge(:conditions => conditions))
267 logger.debug "REBUILD YOUR INDEX! One of the id's didn't have an associated record: #{id_array}"
270 # order results as they were found by ferret, unless an AR :order
272 unless find_options[:order]
273 result.sort! { |a, b| id_positions[a.id.to_s] <=> id_positions[b.id.to_s] }
276 logger.debug "Query: #{q}\nResult id_array: #{id_array.inspect},\nresult: #{result}"
277 return SearchResults.new(result, total_hits)
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
288 unable to retrieve field names for class #{self.name}, please
289 consider naming all indexed fields in your call to acts_as_ferret!
291 models.map { |m| m.content_columns.map { |col| col.name } }.flatten
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
301 def single_index_find_by_contents(q, options = {}, find_options = {})
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
311 # work around ferret bug in #process_query (doesn't ensure the
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)
324 q.add_query(model_query, :must)
328 total_hits = find_id_by_contents(q, options) do |model, id, score|
329 result << Object.const_get(model).find(id, find_options.dup)
331 return SearchResults.new(result, total_hits)
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.
339 # Example controller code (not tested):
340 # def multi_search(query)
342 # result << (Model1.find_id_by_contents query)
343 # result << (Model2.find_id_by_contents query)
344 # result << (Model3.find_id_by_contents query)
346 # result.sort! {|element| element[:score]}
347 # # Figure out for yourself how to retreive and present the data from modelname and id
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
358 # first_doc:: first hit to retrieve (useful for paging)
359 # num_docs:: number of hits to retrieve, or :all to retrieve all
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|
365 # scores_by_id[id] = score
367 # NOTE: in case a block is given, the total_hits value will be returned
368 # instead of the result list!
370 def find_id_by_contents(q, options = {})
371 deprecated_options_support(options)
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
378 model = configuration[:store_class_name] ? doc[:class_name] : self.name
380 yield model, doc[:id], score
382 result << { :model => model, :id => doc[:id], :score => score }
385 logger.debug "id_score_model array: #{result.inspect}"
386 return block_given? ? total_hits : result
389 # requires the store_class_name option of acts_as_ferret to be true
390 # for all models queried this way.
392 # TODO: not optimal as each instance is fetched in a db call for it's
394 def multi_search(query, additional_models = [], options = {})
396 total_hits = id_multi_search(query, additional_models, options) do |model, id, score|
397 result << Object.const_get(model).find(id)
399 SearchResults.new(result, total_hits)
402 # returns an array of hashes, each containing :class_name,
403 # :id and :score for a hit.
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.
408 def id_multi_search(query, additional_models = [], options = {})
409 deprecated_options_support(options)
410 additional_models << self
411 searcher = multi_index(additional_models)
413 total_hits = searcher.search_each (query, options) do |hit, score|
416 yield doc[:class_name], doc[:id], score
418 result << { :model => doc[:class_name], :id => doc[:id], :score => score }
421 return block_given? ? total_hits : result
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)
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]
436 if options[:first_doc]
437 logger.warn ":first_doc is deprecated, use :offset instead!"
438 options[:offset] ||= options[:first_doc]