#199
[acts_as_ferret.git] / lib / local_index.rb
blob139483b191814aca0d6ddd39c1da8fd39f02f981
1 module ActsAsFerret
2   class LocalIndex < AbstractIndex
3     include MoreLikeThis::IndexMethods
5     def initialize(aaf_configuration)
6       super
7       ensure_index_exists
8     end
10     def reopen!
11       if @ferret_index
12         @ferret_index.close
13         @ferret_index = nil
14       end
15       logger.debug "reopening index at #{aaf_configuration[:ferret][:path]}"
16       ferret_index
17     end
19     # The 'real' Ferret Index instance
20     def ferret_index
21       ensure_index_exists
22       returning @ferret_index ||= Ferret::Index::Index.new(aaf_configuration[:ferret]) do
23         @ferret_index.batch_size = aaf_configuration[:reindex_batch_size]
24         @ferret_index.logger = logger
25       end
26     end
28     # Checks for the presence of a segments file in the index directory
29     # Rebuilds the index if none exists.
30     def ensure_index_exists
31       logger.debug "LocalIndex: ensure_index_exists at #{aaf_configuration[:index_dir]}"
32       unless File.file? "#{aaf_configuration[:index_dir]}/segments"
33         ActsAsFerret::ensure_directory(aaf_configuration[:index_dir])
34         close
35         rebuild_index 
36       end
37     end
39     # Closes the underlying index instance
40     def close
41       @ferret_index.close if @ferret_index
42     rescue StandardError 
43       # is raised when index already closed
44     ensure
45       @ferret_index = nil
46     end
48     # rebuilds the index from all records of the model class this index belongs
49     # to. Arguments can be given in shared index scenarios to name multiple
50     # model classes to include in the index
51     def rebuild_index(*models)
52       models << aaf_configuration[:class_name] unless models.include?(aaf_configuration[:class_name])
53       models = models.flatten.uniq.map(&:constantize)
54       logger.debug "rebuild index: #{models.inspect}"
55       index = Ferret::Index::Index.new(aaf_configuration[:ferret].dup.update(:auto_flush  => false, 
56                                                                              :field_infos => ActsAsFerret::field_infos(models),
57                                                                              :create      => true))
58       index.batch_size = aaf_configuration[:reindex_batch_size]
59       index.logger = logger
60       index.index_models models
61     end
63     def bulk_index(ids, options)
64       ferret_index.bulk_index(aaf_configuration[:class_name].constantize, ids, options)
65     end
67     # Parses the given query string into a Ferret Query object.
68     def process_query(query)
69       # work around ferret bug in #process_query (doesn't ensure the
70       # reader is open)
71       ferret_index.synchronize do
72         ferret_index.send(:ensure_reader_open)
73         original_query = ferret_index.process_query(query)
74       end
75     end
77     # Total number of hits for the given query. 
78     # To count the results of a multi_search query, specify an array of 
79     # class names with the :multi option.
80     def total_hits(query, options = {})
81       index = (models = options.delete(:multi)) ? multi_index(models) : ferret_index
82       index.search(query, options).total_hits
83     end
85     def determine_lazy_fields(options = {})
86       stored_fields = options[:lazy]
87       if stored_fields && !(Array === stored_fields)
88         stored_fields = aaf_configuration[:ferret_fields].select { |field, config| config[:store] == :yes }.map(&:first)
89       end
90       logger.debug "stored_fields: #{stored_fields}"
91       return stored_fields
92     end
94     # loads data for fields declared as :lazy from the Ferret document
95     def extract_lazy_fields(doc, lazy_fields) 
96       fields = aaf_configuration[:ferret_fields] 
97       data = {} 
98       lazy_fields.each { |field| data[fields[field][:via]] = doc[field] } if lazy_fields 
99       data 
100     end
102     # Queries the Ferret index to retrieve model class, id, score and the
103     # values of any fields stored in the index for each hit.
104     # If a block is given, these are yielded and the number of total hits is
105     # returned. Otherwise [total_hits, result_array] is returned.
106     def find_id_by_contents(query, options = {})
107       result = []
108       index = ferret_index
109       logger.debug "query: #{ferret_index.process_query query}" if logger.debug?
110       lazy_fields = determine_lazy_fields options
112       total_hits = index.search_each(query, options) do |hit, score|
113         doc = index[hit]
114         model = aaf_configuration[:store_class_name] ? doc[:class_name] : aaf_configuration[:class_name]
115         # fetch stored fields if lazy loading
116         data = extract_lazy_fields(doc, lazy_fields)
117         if block_given?
118           yield model, doc[:id], score, data
119         else
120           result << { :model => model, :id => doc[:id], :score => score, :data => data }
121         end
122       end
123       #logger.debug "id_score_model array: #{result.inspect}"
124       return block_given? ? total_hits : [total_hits, result]
125     end
127     # Queries multiple Ferret indexes to retrieve model class, id and score for 
128     # each hit. Use the models parameter to give the list of models to search.
129     # If a block is given, model, id and score are yielded and the number of 
130     # total hits is returned. Otherwise [total_hits, result_array] is returned.
131     def id_multi_search(query, models, options = {})
132       index = multi_index(models)
133       result = []
134       lazy_fields = determine_lazy_fields options
135       total_hits = index.search_each(query, options) do |hit, score|
136         doc = index[hit]
137         # fetch stored fields if lazy loading
138         data = extract_lazy_fields(doc, lazy_fields)
139         raise "':store_class_name => true' required for multi_search to work" if doc[:class_name].blank?
140         if block_given?
141           yield doc[:class_name], doc[:id], score, doc, data
142         else
143           result << { :model => doc[:class_name], :id => doc[:id], :score => score, :data => data }
144         end
145       end
146       return block_given? ? total_hits : [ total_hits, result ]
147     end
149     ######################################
150     # methods working on a single record
151     # called from instance_methods, here to simplify interfacing with the
152     # remote ferret server
153     # TODO having to pass id and class_name around like this isn't nice
154     ######################################
156     # add record to index
157     # record may be the full AR object, a Ferret document instance or a Hash
158     def add(record, analyzer = nil)
159       unless Hash === record || Ferret::Document === record
160         analyzer = record.ferret_analyzer
161         record = record.to_doc 
162       end
163       ferret_index.add_document(record, analyzer)
164     end
165     alias << add
167     # delete record from index
168     def remove(id, class_name)
169       ferret_index.query_delete query_for_record(id, class_name)
170     end
172     # highlight search terms for the record with the given id.
173     def highlight(id, class_name, query, options = {})
174       options.reverse_merge! :num_excerpts => 2, :pre_tag => '<em>', :post_tag => '</em>'
175       highlights = []
176       ferret_index.synchronize do
177         doc_num = document_number(id, class_name)
178         if options[:field]
179           highlights << ferret_index.highlight(query, doc_num, options)
180         else
181           query = process_query(query) # process only once
182           aaf_configuration[:ferret_fields].each_pair do |field, config|
183             next if config[:store] == :no || config[:highlight] == :no
184             options[:field] = field
185             highlights << ferret_index.highlight(query, doc_num, options)
186           end
187         end
188       end
189       return highlights.compact.flatten[0..options[:num_excerpts]-1]
190     end
192     # retrieves the ferret document number of the record with the given id.
193     def document_number(id, class_name)
194       hits = ferret_index.search(query_for_record(id, class_name))
195       return hits.hits.first.doc if hits.total_hits == 1
196       raise "cannot determine document number for class #{class_name} / primary key: #{id}\nresult was: #{hits.inspect}"
197     end
199     # build a ferret query matching only the record with the given id
200     # the class name only needs to be given in case of a shared index configuration
201     def query_for_record(id, class_name = nil)
202       Ferret::Search::TermQuery.new(:id, id.to_s)
203     end
206     protected
208     # returns a MultiIndex instance operating on a MultiReader
209     def multi_index(model_classes)
210       model_classes.map!(&:constantize) if String === model_classes.first
211       model_classes.sort! { |a, b| a.name <=> b.name }
212       key = model_classes.inject("") { |s, clazz| s + clazz.name }
213       multi_config = aaf_configuration[:ferret].dup
214       multi_config.delete :default_field  # we don't want the default field list of *this* class for multi_searching
215       ActsAsFerret::multi_indexes[key] ||= MultiIndex.new(model_classes, multi_config)
216     end
218   end