lib/tanker.rb in tanker-1.0.0 vs lib/tanker.rb in tanker-1.1.0

- old
+ new

@@ -1,6 +1,5 @@ - begin require "rubygems" require "bundler" Bundler.setup :default @@ -10,39 +9,43 @@ require 'indextank_client' require 'tanker/configuration' require 'tanker/utilities' require 'will_paginate/collection' - if defined? Rails begin require 'tanker/railtie' rescue LoadError end end module Tanker class NotConfigured < StandardError; end + class BadConfiguration < StandardError; end class NoBlockGiven < StandardError; end + class NoIndexName < StandardError; end autoload :Configuration, 'tanker/configuration' extend Configuration + autoload :KaminariPaginatedArray, 'tanker/paginated_array' + class << self attr_reader :included_in def api @api ||= IndexTank::ApiClient.new(Tanker.configuration[:url]) end def included(klass) + configuration # raises error if not defined + @included_in ||= [] @included_in << klass @included_in.uniq! - configuration # raises error if not defined klass.send :include, InstanceMethods klass.extend ClassMethods class << klass define_method(:per_page) { 10 } unless respond_to?(:per_page) @@ -60,20 +63,20 @@ end def search(models, query, options = {}) ids = [] models = [models].flatten.uniq - page = (options.delete(:page) || 1).to_i - per_page = (options.delete(:per_page) || models.first.per_page).to_i index = models.first.tanker_index query = query.join(' ') if Array === query + snippets = options.delete(:snippets) + fetch = options.delete(:fetch) + paginate = extract_setup_paginate_options(options, :page => 1, :per_page => models.first.per_page) if (index_names = models.map(&:tanker_config).map(&:index_name).uniq).size > 1 raise "You can't search across multiple indexes in one call (#{index_names.inspect})" end - # move conditions into the query body if conditions = options.delete(:conditions) conditions.each do |field, value| value = [value].flatten.compact value.each do |item| @@ -94,71 +97,159 @@ filter_docvars.each do |var_number, ranges| options[:"filter_docvar#{var_number}"] = ranges.map{|r|r.join(':')}.join(',') end end - query = "__any:(#{query.to_s}) __type:(#{models.map(&:name).join(' OR ')})" - options = { :start => per_page * (page - 1), :len => per_page }.merge(options) - results = index.search(query, options) + # fetch values from index tank or just the type and id to instace results localy + options[:fetch] = "__type,__id" + options[:fetch] += ",#{fetch.join(',')}" if fetch + options[:snippet] = snippets.join(',') if snippets + + search_on_fields = models.map{|model| model.tanker_config.indexes.map{|arr| arr[0]}.uniq}.flatten.uniq.join(":(#{query.to_s}) OR ") + query = "#{search_on_fields}:(#{query.to_s}) __type:(#{models.map(&:name).map {|name| "\"#{name.split('::').join(' ')}\"" }.join(' OR ')})" - @entries = WillPaginate::Collection.create(page, per_page) do |pager| - # inject the result array into the paginated collection: - pager.replace instantiate_results(results) - - unless pager.total_entries - # the pager didn't manage to guess the total count, do it manually - pager.total_entries = results["matches"] - end + options = { :start => paginate[:per_page] * (paginate[:page] - 1), :len => paginate[:per_page] }.merge(options) if paginate + results = index.search(query, options) + + instantiated_results = if (fetch || snippets) + instantiate_results_from_results(results, fetch, snippets) + else + instantiate_results_from_db(results) end + paginate === false ? instantiated_results : paginate_results(instantiated_results, paginate, results['matches']) end protected - def instantiate_results(index_result) + def instantiate_results_from_db(index_result) results = index_result['results'] return [] if results.empty? id_map = results.inject({}) do |acc, result| - model, id = result["docid"].split(" ", 2) + model = result["__type"] + id = constantize(model).tanker_parse_doc_id(result) acc[model] ||= [] - acc[model] << id.to_i + acc[model] << id acc end + + id_map.each do |klass, ids| + # replace the id list with an eager-loaded list of records for this model + id_map[klass] = constantize(klass).find(ids) + end + # return them in order + results.map do |result| + model, id = result["__type"], result["__id"] + id_map[model].detect {|record| id == record.id.to_s } + end + end - if 1 == id_map.size # check for simple case, just one model involved - klass = constantize(id_map.keys.first) - # eager-load and return just this model's records - klass.find(id_map.values.flatten) - else # complex case, multiple models involved - id_map.each do |klass, ids| - # replace the id list with an eager-loaded list of records for this model - id_map[klass] = constantize(klass).find(ids) + def paginate_results(results, pagination_options, total_hits) + case Tanker.configuration[:pagination_backend] + when :will_paginate + WillPaginate::Collection.create(pagination_options[:page], + pagination_options[:per_page], + total_hits) { |pager| pager.replace results } + when :kaminari + Tanker::KaminariPaginatedArray.new(results, + pagination_options[:per_page], + pagination_options[:page]-1, + total_hits) + else + raise(BadConfiguration, "Unknown pagination backend") + end + end + + def instantiate_results_from_results(index_result, fetch = false, snippets = false) + results = index_result['results'] + return [] if results.empty? + instances = [] + id_map = results.inject({}) do |acc, result| + model = result["__type"] + instance = constantize(model).new() + result.each do |key, value| + case key + when /snippet/ + # create snippet reader attribute (method) + instance.create_snippet_attribute(key, value) + when '__id' + # assign id attribute to the model + instance.id = value + when '__type', 'docid' + # do nothing + else + #assign attributes that are fetched if they match attributes in the model + if instance.respond_to?("#{key}=".to_sym) + instance.send("#{key}=", value) + end + end end - # return them in order - results.map do |result| - model, id = result["docid"].split(" ", 2) - id_map[model].detect {|record| id.to_i == record.id } - end + instances << instance end + instances end def constantize(klass_name) Object.const_defined?(klass_name) ? - Object.const_get(klass_name) : - Object.const_missing(klass_name) + Object.const_get(klass_name) : + Object.const_missing(klass_name) end + + # borrowed from Rails' ActiveSupport::Inflector + def constantize(camel_cased_word) + names = camel_cased_word.split('::') + names.shift if names.empty? || names.first.empty? + + constant = Object + names.each do |name| + constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name) + end + constant + end + + def extract_setup_paginate_options(options, defaults) + # extract + paginate_options = if options[:paginate] or options[:paginate] === false + options.delete(:paginate) + else + { :page => options.delete(:page), :per_page => options.delete(:per_page) } + end + # setup defaults and ensure we got integer values + unless paginate_options === false + paginate_options[:page] = defaults[:page] unless paginate_options[:page] + paginate_options[:per_page] = defaults[:per_page] unless paginate_options[:per_page] + paginate_options.each { |key, value| paginate_options[key] = value.to_i } + end + paginate_options + end end # these are the class methods added when Tanker is included # They're kept to a minimum to prevent namespace pollution module ClassMethods attr_accessor :tanker_config - def tankit(name, &block) + def tankit(name = nil, options = {}, &block) if block_given? - self.tanker_config = ModelConfig.new(name, block) + raise(NoIndexName, 'Please provide an index name') if name.nil? && self.tanker_config.nil? + + self.tanker_config ||= ModelConfig.new(name, options, Proc.new) + name ||= self.tanker_config.index_name + + self.tanker_config.index_name = name + + config = ModelConfig.new(name, block) + config.indexes.each do |key, value| + self.tanker_config.indexes << [key, value] + end + + unless config.variables.empty? + self.tanker_config.variables do + instance_exec &config.variables.first + end + end else raise(NoBlockGiven, 'Please provide a block') end end @@ -174,43 +265,51 @@ puts "Indexing #{self} model" batches = [] options[:batch_size] ||= 200 records = options[:scope] ? send(options[:scope]).all : all - record_size = records.size + record_size = 0 records.each_with_index do |model_instance, idx| batch_num = idx / options[:batch_size] (batches[batch_num] ||= []) << model_instance + record_size += 1 end timer = Time.now batches.each_with_index do |batch, idx| Tanker.batch_update(batch) puts "Indexed #{batch.size} records #{(idx * options[:batch_size]) + batch.size}/#{record_size}" end puts "Indexed #{record_size} #{self} records in #{Time.now - timer} seconds" end + + def tanker_parse_doc_id(result) + result['docid'].split(' ').last + end end class ModelConfig - attr_reader :index_name + attr_accessor :index_name + attr_accessor :options - def initialize(index_name, block) + def initialize(index_name, options = {}, block) @index_name = index_name + @options = options @indexes = [] + @variables = [] @functions = {} instance_exec &block end def indexes(field = nil, &block) @indexes << [field, block] if field @indexes end def variables(&block) - @variables = block if block + @variables << block if block @variables end def functions(&block) @functions = block.call if block @@ -263,26 +362,41 @@ val = val.join(' ') if Array === val data[field.to_sym] = val.to_s unless val.nil? end data[:__any] = data.values.sort_by{|v| v.to_s}.join " . " - data[:__type] = self.class.name + data[:__type] = type_name + data[:__id] = self.id data end + #dynamically create a snippet read attribute (method) + def create_snippet_attribute(key, value) + # the method name should something_snippet not snippet_something as the api returns it + self.class.send(:define_method, "#{key.match(/snippet_(\w+)/)[1]}_snippet") do + value + end + end + def tanker_index_options options = {} - if tanker_variables - options[:variables] = instance_exec(&tanker_variables) + unless tanker_variables.empty? + options[:variables] = tanker_variables.inject({}) do |hash, variables| + hash.merge(instance_exec(&variables)) + end end options end # create a unique index based on the model name and unique id def it_doc_id - self.class.name + ' ' + self.id.to_s + type_name + ' ' + self.id.to_s + end + + def type_name + tanker_config.options[:as] || self.class.name end end end