require 'algolia' require 'algoliasearch/version' require 'algoliasearch/utilities' if defined? Rails begin require 'algoliasearch/railtie' rescue LoadError end end begin require 'active_job' rescue LoadError # no queue support, fine end require 'logger' module AlgoliaSearch class NotConfigured < StandardError; end class BadConfiguration < StandardError; end class NoBlockGiven < StandardError; end class MixedSlavesAndReplicas < StandardError; end autoload :Configuration, 'algoliasearch/configuration' extend Configuration autoload :Pagination, 'algoliasearch/pagination' class << self attr_reader :included_in def included(klass) @included_in ||= [] @included_in << klass @included_in.uniq! klass.class_eval do extend ClassMethods include InstanceMethods end end end class IndexSettings DEFAULT_BATCH_SIZE = 1000 # AlgoliaSearch settings OPTIONS = [ # Attributes :searchableAttributes, :attributesForFaceting, :unretrievableAttributes, :attributesToRetrieve, # Ranking :ranking, :customRanking, :relevancyStrictness, # Replicas are handled via `add_replica` # Faceting :maxValuesPerFacet, :sortFacetValuesBy, # Highlighting / Snippeting :attributesToHighlight, :attributesToSnippet, :highlightPreTag, :highlightPostTag, :snippetEllipsisText, :restrictHighlightAndSnippetArrays, # Pagination :hitsPerPage, :paginationLimitedTo, # Typo :minWordSizefor1Typo, :minWordSizefor2Typos, :typoTolerance, :allowTyposOnNumericTokens, :disableTypoToleranceOnAttributes, :disableTypoToleranceOnWords, :separatorsToIndex, # Language :ignorePlurals, :removeStopWords, :camelCaseAttributes, :decompoundedAttributes, :keepDiacriticsOnCharacters, :queryLanguages, :indexLanguages, # Query Rules :enableRules, # Query Strategy :queryType, :removeWordsIfNoResults, :advancedSyntax, :optionalWords, :disablePrefixOnAttributes, :disableExactOnAttributes, :exactOnSingleWordQuery, :alternativesAsExact, # Performance :numericAttributesForFiltering, :allowCompressionOfIntegerArray, # Advanced :attributeForDistinct, :distinct, :replaceSynonymsInHighlight, :minProximity, :responseFields, :maxFacetHits, # Rails-specific :synonyms, :placeholders, :altCorrections, ] OPTIONS.each do |k| define_method k do |v| instance_variable_set("@#{k}", v) end end def initialize(options, &block) @options = options instance_exec(&block) if block_given? end def use_serializer(serializer) @serializer = serializer end def attribute(*names, &block) raise ArgumentError.new('Cannot pass multiple attribute names if block given') if block_given? and names.length > 1 raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica] @attributes ||= {} names.flatten.each do |name| @attributes[name.to_s] = block_given? ? Proc.new { |o| o.instance_eval(&block) } : Proc.new { |o| o.send(name) } end end alias :attributes :attribute def add_attribute(*names, &block) raise ArgumentError.new('Cannot pass multiple attribute names if block given') if block_given? and names.length > 1 raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica] @additional_attributes ||= {} names.each do |name| @additional_attributes[name.to_s] = block_given? ? Proc.new { |o| o.instance_eval(&block) } : Proc.new { |o| o.send(name) } end end alias :add_attributes :add_attribute def is_mongoid?(object) defined?(::Mongoid::Document) && object.class.include?(::Mongoid::Document) end def is_sequel?(object) defined?(::Sequel) && defined?(::Sequel::Model) && object.class < ::Sequel::Model end def is_active_record?(object) !is_mongoid?(object) && !is_sequel?(object) end def get_default_attributes(object) if is_mongoid?(object) # work-around mongoid 2.4's unscoped method, not accepting a block object.attributes elsif is_sequel?(object) object.to_hash else object.class.unscoped do object.attributes end end end def get_attribute_names(object) get_attributes(object).keys end def attributes_to_hash(attributes, object) if attributes Hash[attributes.map { |name, value| [name.to_s, value.call(object) ] }] else {} end end def get_attributes(object) # If a serializer is set, we ignore attributes # everything should be done via the serializer if not @serializer.nil? attributes = @serializer.new(object).attributes else if @attributes.nil? || @attributes.length == 0 # no `attribute ...` have been configured, use the default attributes of the model attributes = get_default_attributes(object) else # at least 1 `attribute ...` has been configured, therefore use ONLY the one configured if is_active_record?(object) object.class.unscoped do attributes = attributes_to_hash(@attributes, object) end else attributes = attributes_to_hash(@attributes, object) end end end attributes.merge!(attributes_to_hash(@additional_attributes, object)) if @additional_attributes attributes = sanitize_attributes(attributes, Rails::Html::FullSanitizer.new) if @options[:sanitize] if @options[:force_utf8_encoding] && Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f > 1.8 attributes = encode_attributes(attributes) end attributes end def sanitize_attributes(v, sanitizer) case v when String sanitizer.sanitize(v) when Hash v.each { |key, value| v[key] = sanitize_attributes(value, sanitizer) } when Array v.map { |x| sanitize_attributes(x, sanitizer) } else v end end def encode_attributes(v) case v when String v.dup.force_encoding('utf-8') when Hash v.each { |key, value| v[key] = encode_attributes(value) } when Array v.map { |x| encode_attributes(x) } else v end end def geoloc(lat_attr = nil, lng_attr = nil, &block) raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica] add_attribute :_geoloc do |o| block_given? ? o.instance_eval(&block) : { :lat => o.send(lat_attr).to_f, :lng => o.send(lng_attr).to_f } end end def tags(*args, &block) raise ArgumentError.new('Cannot specify additional attributes on a replica index') if @options[:replica] add_attribute :_tags do |o| v = block_given? ? o.instance_eval(&block) : args v.is_a?(Array) ? v : [v] end end def get_setting(name) instance_variable_get("@#{name}") end def to_settings settings = to_hash # Remove the synonyms setting since those need to be set separately settings.delete(:synonyms) settings.delete("synonyms") Algolia::Search::IndexSettings.new(settings) end def to_hash settings = {} OPTIONS.each do |k| v = get_setting(k) settings[setting_name(k)] = v if !v.nil? end if !@options[:replica] settings[:replicas] = additional_indexes.select { |opts, s| opts[:replica] }.map do |opts, s| name = opts[:index_name] name = "#{name}_#{Rails.env.to_s}" if opts[:per_environment] name = "virtual(#{name})" if opts[:virtual] name end settings.delete(:replicas) if settings[:replicas].empty? end settings end def setting_name(name) name.to_s.gsub(/::/, '/'). gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). gsub(/([a-z\d])([A-Z])/,'\1_\2'). tr("-", "_"). downcase end def add_index(index_name, options = {}, &block) raise ArgumentError.new('Cannot specify additional index on a replica index') if @options[:replica] raise ArgumentError.new('No block given') if !block_given? raise ArgumentError.new('Options auto_index and auto_remove cannot be set on nested indexes') if options[:auto_index] || options[:auto_remove] @additional_indexes ||= {} options[:index_name] = index_name @additional_indexes[options] = IndexSettings.new(options, &block) end def add_replica(index_name, options = {}, &block) raise ArgumentError.new('Cannot specify additional replicas on a replica index') if @options[:replica] raise ArgumentError.new('No block given') if !block_given? add_index(index_name, options.merge({ :replica => true, :primary_settings => self }), &block) end def additional_indexes @additional_indexes || {} end end # Default queueing system if defined?(::ActiveJob::Base) # lazy load the ActiveJob class to ensure the # queue is initialized before using it # see https://github.com/algolia/algoliasearch-rails/issues/69 autoload :AlgoliaJob, 'algoliasearch/algolia_job' end # these are the class methods added when AlgoliaSearch is included module ClassMethods def self.extended(base) class < algolia_full_const_get(model_name.to_s), :per_page => algoliasearch_settings.get_setting(:hitsPerPage) || 10, :page => 1 }.merge(options) attr_accessor :highlight_result, :snippet_result if options[:synchronous] == true if defined?(::Sequel) && defined?(::Sequel::Model) && self < Sequel::Model class_eval do copy_after_validation = instance_method(:after_validation) define_method(:after_validation) do |*args| super(*args) copy_after_validation.bind(self).call algolia_mark_synchronous end end else after_validation :algolia_mark_synchronous if respond_to?(:after_validation) end end if options[:enqueue] raise ArgumentError.new("Cannot use a enqueue if the `synchronous` option if set") if options[:synchronous] proc = if options[:enqueue] == true Proc.new do |record, remove| AlgoliaJob.perform_later(record, remove ? 'algolia_remove_from_index!' : 'algolia_index!') end elsif options[:enqueue].respond_to?(:call) options[:enqueue] elsif options[:enqueue].is_a?(Symbol) Proc.new { |record, remove| self.send(options[:enqueue], record, remove) } else raise ArgumentError.new("Invalid `enqueue` option: #{options[:enqueue]}") end algoliasearch_options[:enqueue] = Proc.new do |record, remove| proc.call(record, remove) unless algolia_without_auto_index_scope end end unless options[:auto_index] == false if defined?(::Sequel) && defined?(::Sequel::Model) && self < Sequel::Model class_eval do copy_after_validation = instance_method(:after_validation) copy_before_save = instance_method(:before_save) define_method(:after_validation) do |*args| super(*args) copy_after_validation.bind(self).call algolia_mark_must_reindex end define_method(:before_save) do |*args| copy_before_save.bind(self).call algolia_mark_for_auto_indexing super(*args) end sequel_version = Gem::Version.new(Sequel.version) if sequel_version >= Gem::Version.new('4.0.0') && sequel_version < Gem::Version.new('5.0.0') copy_after_commit = instance_method(:after_commit) define_method(:after_commit) do |*args| super(*args) copy_after_commit.bind(self).call algolia_perform_index_tasks end else copy_after_save = instance_method(:after_save) define_method(:after_save) do |*args| super(*args) copy_after_save.bind(self).call self.db.after_commit do algolia_perform_index_tasks end end end end else after_validation :algolia_mark_must_reindex if respond_to?(:after_validation) before_save :algolia_mark_for_auto_indexing if respond_to?(:before_save) if respond_to?(:after_commit) after_commit :algolia_perform_index_tasks elsif respond_to?(:after_save) after_save :algolia_perform_index_tasks end end end unless options[:auto_remove] == false if defined?(::Sequel) && defined?(::Sequel::Model) && self < Sequel::Model class_eval do copy_after_destroy = instance_method(:after_destroy) define_method(:after_destroy) do |*args| copy_after_destroy.bind(self).call algolia_enqueue_remove_from_index!(algolia_synchronous?) super(*args) end end else after_destroy { |searchable| searchable.algolia_enqueue_remove_from_index!(algolia_synchronous?) } if respond_to?(:after_destroy) end end end def algolia_without_auto_index(&block) self.algolia_without_auto_index_scope = true begin yield ensure self.algolia_without_auto_index_scope = false end end def algolia_without_auto_index_scope=(value) Thread.current["algolia_without_auto_index_scope_for_#{self.model_name}"] = value end def algolia_without_auto_index_scope Thread.current["algolia_without_auto_index_scope_for_#{self.model_name}"] end def algolia_reindex!(batch_size = AlgoliaSearch::IndexSettings::DEFAULT_BATCH_SIZE, synchronous = false) return if algolia_without_auto_index_scope algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) algolia_ensure_init(options, settings) index_name = algolia_index_name(options) next if options[:replica] last_task = nil algolia_find_in_batches(batch_size) do |group| if algolia_conditional_index?(options) # delete non-indexable objects ids = group.select { |o| !algolia_indexable?(o, options) }.map { |o| algolia_object_id_of(o, options) } AlgoliaSearch.client.delete_objects(index_name, ids.select { |id| !id.blank? }) # select only indexable objects group = group.select { |o| algolia_indexable?(o, options) } end objects = group.map do |o| attributes = settings.get_attributes(o) unless attributes.class == Hash attributes = attributes.to_hash end attributes.merge 'objectID' => algolia_object_id_of(o, options) end last_task = AlgoliaSearch.client.save_objects(index_name, objects).last.task_id end AlgoliaSearch.client.wait_for_task(index_name, last_task) if last_task and (synchronous || options[:synchronous]) end nil end # reindex whole database using a extra temporary index + move operation def algolia_reindex(batch_size = AlgoliaSearch::IndexSettings::DEFAULT_BATCH_SIZE, synchronous = false) return if algolia_without_auto_index_scope algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) next if options[:replica] algolia_ensure_init(options, settings) index_name = algolia_index_name(options) # fetch the master settings master_settings = AlgoliaSearch.client.get_settings(index_name).to_hash rescue {} # if master doesn't exist yet master_exists = master_settings != {} master_settings.merge!(settings.to_hash) # remove the replicas of the temporary index master_settings.delete :replicas master_settings.delete 'replicas' # init temporary index tmp_index_name = "#{index_name}.tmp" tmp_options = options.merge({ :index_name => tmp_index_name }) tmp_options.delete(:per_environment) # already included in the temporary index_name tmp_settings = settings.dup if options[:check_settings] == false && master_exists task_id = AlgoliaSearch.client.operation_index( index_name, Algolia::Search::OperationIndexParams.new(operation: Algolia::Search::OperationType::COPY, destination: tmp_index_name, scope: %w[settings synonyms rules]) ).task_id AlgoliaSearch.client.wait_for_task(index_name, task_id) end algolia_find_in_batches(batch_size) do |group| if algolia_conditional_index?(options) # select only indexable objects group = group.select { |o| algolia_indexable?(o, tmp_options) } end objects = group.map { |o| tmp_settings.get_attributes(o).merge 'objectID' => algolia_object_id_of(o, tmp_options) } AlgoliaSearch.client.save_objects(tmp_index_name, objects) end task_id = AlgoliaSearch.client.operation_index( tmp_index_name, Algolia::Search::OperationIndexParams.new(operation: "move", destination: index_name) ).task_id AlgoliaSearch.client.wait_for_task(index_name, task_id) if synchronous || options[:synchronous] end nil end def algolia_set_settings(synchronous = false) algolia_configurations.each do |options, settings| if options[:primary_settings] && options[:inherit] primary = options[:primary_settings].to_settings.to_hash primary.delete :replicas primary.delete 'replicas' final_settings = primary.merge(settings.to_settings.to_hash) else final_settings = settings.to_settings.to_hash end s = final_settings.map do |k, v| [settings.setting_name(k), v] end.to_h synonyms = s.delete("synonyms") || s.delete(:synonyms) unless synonyms.nil? || synonyms.empty? resp = AlgoliaSearch.client.save_synonyms(index_name,synonyms.map {|s| Algolia::Search::SynonymHit.new({object_id: s.join("-"), synonyms: s, type: "synonym"}) } ) AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) if synchronous || options[:synchronous] end resp = AlgoliaSearch.client.set_settings(index_name, Algolia::Search::IndexSettings.new(s)) AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) if synchronous || options[:synchronous] end end def algolia_index_objects(objects, synchronous = false) algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) algolia_ensure_init(options, settings) index_name = algolia_index_name(options) next if options[:replica] tasks = AlgoliaSearch.client.save_objects(index_name, objects.map { |o| settings.get_attributes(o).merge 'objectID' => algolia_object_id_of(o, options) }) tasks.each do |task| AlgoliaSearch.client.wait_for_task(index_name, task.task_id) if synchronous || options[:synchronous] end end end def algolia_index!(object, synchronous = false) return if algolia_without_auto_index_scope algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) object_id = algolia_object_id_of(object, options) index_name = algolia_index_name(options) algolia_ensure_init(options, settings) next if options[:replica] if algolia_indexable?(object, options) raise ArgumentError.new("Cannot index a record with a blank objectID") if object_id.blank? resp = AlgoliaSearch.client.save_object(index_name, settings.get_attributes(object).merge({ 'objectID' => algolia_object_id_of(object, options) })) if synchronous || options[:synchronous] AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) end elsif algolia_conditional_index?(options) && !object_id.blank? # remove non-indexable objects resp = AlgoliaSearch.client.delete_object(index_name, object_id) if synchronous || options[:synchronous] AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) end end end nil end def algolia_remove_from_index!(object, synchronous = false) return if algolia_without_auto_index_scope object_id = algolia_object_id_of(object) raise ArgumentError.new("Cannot index a record with a blank objectID") if object_id.blank? algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) algolia_ensure_init(options, settings) index_name = algolia_index_name(options) next if options[:replica] resp = AlgoliaSearch.client.delete_object(index_name, object_id) if synchronous || options[:synchronous] AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) end end nil end def algolia_clear_index!(synchronous = false) algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) || options[:replica] algolia_ensure_init(options, settings) index_name = algolia_index_name(options) res = AlgoliaSearch.client.clear_objects(index_name) if synchronous || options[:synchronous] AlgoliaSearch.client.wait_for_task(index_name, res.task_id) end end nil end def algolia_raw_search(q, params = {}) index_name_base = params.delete(:index) || params.delete('index') || params.delete(:replica) || params.delete('replica') opts = algoliasearch_options unless index_name_base.nil? algolia_configurations.each do |o, s| if o[:index_name].to_s == index_name_base.to_s opts = o ensure_algolia_index(index_name_base) end end end index_name = algolia_index_name(opts, index_name_base) AlgoliaSearch.client.search_single_index(index_name,Hash[params.to_h.map { |k,v| [k.to_s, v.to_s] }].merge({query: q})).to_hash end module AdditionalMethods def self.extended(base) class < 0 end json = algolia_raw_search(q, params) hit_ids = json[:hits].map { |hit| hit[:objectID] } if defined?(::Mongoid::Document) && self.include?(::Mongoid::Document) condition_key = algolia_object_id_method.in else condition_key = algolia_object_id_method end results_by_id = algoliasearch_options[:type].where(condition_key => hit_ids).index_by do |hit| algolia_object_id_of(hit) end results = json[:hits].map do |hit| o = results_by_id[hit[:objectID].to_s] if o o.highlight_result = hit[:_highlightResult] o.snippet_result = hit[:_snippetResult] o end end.compact # Algolia has a default limit of 1000 retrievable hits total_hits = json[:nbHits].to_i < json[:nbPages].to_i * json[:hitsPerPage].to_i ? json[:nbHits].to_i: json[:nbPages].to_i * json[:hitsPerPage].to_i res = AlgoliaSearch::Pagination.create(results, total_hits, algoliasearch_options.merge({ :page => json[:page].to_i + 1, :per_page => json[:hitsPerPage] })) res.extend(AdditionalMethods) res.send(:algolia_init_raw_answer, json) res end def algolia_search_for_facet_values(facet, text, params = {}) index_name = params.delete(:index) || params.delete('index') || params.delete(:replica) || params.delete('replicas') index_name ||= algolia_index_name(algoliasearch_options) req = Algolia::Search::SearchForFacetValuesRequest.new({facet_query: text, params: params.to_query}) AlgoliaSearch.client.search_for_facet_values(index_name, facet, req).facet_hits end # deprecated (renaming) alias :algolia_search_facet :algolia_search_for_facet_values def ensure_algolia_index(name = nil) if name algolia_configurations.each do |o, s| return algolia_ensure_init(o, s) if o[:index_name].to_s == name.to_s end raise ArgumentError.new("Invalid index/replica name: #{name}") end algolia_ensure_init end def algolia_index_name(options = nil, index_name = nil) options ||= algoliasearch_options name = index_name || options[:index_name] || model_name.to_s.gsub('::', '_') name = "#{name}_#{Rails.env.to_s}" if options[:per_environment] name end def algolia_must_reindex?(object) # use +algolia_dirty?+ method if implemented return object.send(:algolia_dirty?) if (object.respond_to?(:algolia_dirty?)) # Loop over each index to see if a attribute used in records has changed algolia_configurations.each do |options, settings| next if algolia_indexing_disabled?(options) next if options[:replica] return true if algolia_object_id_changed?(object, options) settings.get_attribute_names(object).each do |k| return true if algolia_attribute_changed?(object, k, true) end [options[:if], options[:unless]].each do |condition| case condition when nil when String, Symbol return true if algolia_attribute_changed?(object, condition, true) else # if the :if, :unless condition is a anything else, # we have no idea whether we should reindex or not # let's always reindex then return true end end end # By default, we don't reindex return false end protected def algolia_ensure_init(options = nil, settings = nil, index_settings_hash = nil) raise ArgumentError.new('No `algoliasearch` block found in your model.') if algoliasearch_settings.nil? @algolia_indexes_init ||= {} options ||= algoliasearch_options settings ||= algoliasearch_settings return if @algolia_indexes_init[settings] index_name = algolia_index_name(options) index_settings_hash ||= settings.to_settings.to_hash index_settings_hash = options[:primary_settings].to_settings.to_hash.merge(index_settings_hash) if options[:inherit] replicas = index_settings_hash.delete(:replicas) || index_settings_hash.delete('replicas') index_settings_hash[:replicas] = replicas unless replicas.nil? || options[:inherit] options[:check_settings] = true if options[:check_settings].nil? current_settings = if options[:check_settings] && !algolia_indexing_disabled?(options) AlgoliaSearch.client.get_settings(index_name, {:getVersion => 1}).to_hash rescue nil # if the index doesn't exist end if !algolia_indexing_disabled?(options) && options[:check_settings] && algoliasearch_settings_changed?(current_settings, index_settings_hash) s = index_settings_hash.map do |k, v| [settings.setting_name(k), v] end.to_h synonyms = s.delete("synonyms") || s.delete(:synonyms) unless synonyms.nil? || synonyms.empty? resp = AlgoliaSearch.client.save_synonyms(index_name,synonyms.map {|s| Algolia::Search::SynonymHit.new({object_id: s.join("-"), synonyms: s, type: "synonym"}) } ) AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) if options[:synchronous] end resp = AlgoliaSearch.client.set_settings(index_name, Algolia::Search::IndexSettings.new(s)) AlgoliaSearch.client.wait_for_task(index_name, resp.task_id) if options[:synchronous] end return end private def algolia_configurations raise ArgumentError.new('No `algoliasearch` block found in your model.') if algoliasearch_settings.nil? if @configurations.nil? @configurations = {} @configurations[algoliasearch_options] = algoliasearch_settings algoliasearch_settings.additional_indexes.each do |k,v| @configurations[k] = v if v.additional_indexes.any? v.additional_indexes.each do |options, index| @configurations[options] = index end end end end @configurations end def algolia_object_id_method(options = nil) options ||= algoliasearch_options options[:id] || options[:object_id] || :id end def algolia_object_id_of(o, options = nil) o.send(algolia_object_id_method(options)).to_s end def algolia_object_id_changed?(o, options = nil) changed = algolia_attribute_changed?(o, algolia_object_id_method(options), false) changed.nil? ? false : changed end def algoliasearch_settings_changed?(prev, current) return true if prev.nil? current.each do |k, v| prev_v = prev[k.to_sym] || prev[k.to_s] if v.is_a?(Array) and prev_v.is_a?(Array) # compare array of strings, avoiding symbols VS strings comparison return true if v.map { |x| x.to_s } != prev_v.map { |x| x.to_s } elsif v.blank? # blank? check is needed to compare [] and null return true unless prev_v.blank? else return true if prev_v != v end end false end def algolia_full_const_get(name) list = name.split('::') list.shift if list.first.blank? obj = Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9 ? Object : self list.each do |x| # This is required because const_get tries to look for constants in the # ancestor chain, but we only want constants that are HERE obj = obj.const_defined?(x) ? obj.const_get(x) : obj.const_missing(x) end obj end def algolia_conditional_index?(options = nil) options ||= algoliasearch_options options[:if].present? || options[:unless].present? end def algolia_indexable?(object, options = nil) options ||= algoliasearch_options if_passes = options[:if].blank? || algolia_constraint_passes?(object, options[:if]) unless_passes = options[:unless].blank? || !algolia_constraint_passes?(object, options[:unless]) if_passes && unless_passes end def algolia_constraint_passes?(object, constraint) case constraint when Symbol object.send(constraint) when String object.send(constraint.to_sym) when Enumerable # All constraints must pass constraint.all? { |inner_constraint| algolia_constraint_passes?(object, inner_constraint) } else if constraint.respond_to?(:call) # Proc constraint.call(object) else raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})" end end end def algolia_indexing_disabled?(options = nil) options ||= algoliasearch_options constraint = options[:disable_indexing] || options['disable_indexing'] case constraint when nil return false when true, false return constraint when String, Symbol return send(constraint) else return constraint.call if constraint.respond_to?(:call) # Proc end raise ArgumentError, "Unknown constraint type: #{constraint} (#{constraint.class})" end def algolia_find_in_batches(batch_size, &block) if (defined?(::ActiveRecord) && ancestors.include?(::ActiveRecord::Base)) || respond_to?(:find_in_batches) find_in_batches(:batch_size => batch_size, &block) elsif defined?(::Sequel) && defined?(::Sequel::Model) && self < Sequel::Model dataset.extension(:pagination).each_page(batch_size, &block) else # don't worry, mongoid has its own underlying cursor/streaming mechanism items = [] all.each do |item| items << item if items.length % batch_size == 0 yield items items = [] end end yield items unless items.empty? end end def algolia_attribute_changed?(object, attr_name, default) # if one of two method is implemented, we return its result # true/false means whether it has changed or not # +#{attr_name}_changed?+ always defined for automatic attributes but deprecated after Rails 5.2 # +will_save_change_to_#{attr_name}?+ should be use instead for Rails 5.2+, also defined for automatic attributes. # If none of the method are defined, it's a dynamic attribute method_name = "#{attr_name}_changed?" if object.respond_to?(method_name) # If +#{attr_name}_changed?+ respond we want to see if the method is user defined or if it's automatically # defined by Rails. # If it's user-defined, we call it. # If it's automatic we check ActiveRecord version to see if this method is deprecated # and try to call +will_save_change_to_#{attr_name}?+ instead. # See: https://github.com/algolia/algoliasearch-rails/pull/338 # This feature is not compatible with Ruby 1.8 # In this case, we always call #{attr_name}_changed? if Object.const_defined?(:RUBY_VERSION) && RUBY_VERSION.to_f < 1.9 return object.send(method_name) end unless automatic_changed_method?(object, method_name) && automatic_changed_method_deprecated? return object.send(method_name) end end if object.respond_to?("will_save_change_to_#{attr_name}?") return object.send("will_save_change_to_#{attr_name}?") end # We don't know if the attribute has changed, so return the default passed default end def automatic_changed_method?(object, method_name) raise ArgumentError.new("Method #{method_name} doesn't exist on #{object.class.name}") unless object.respond_to?(method_name) file = object.method(method_name).source_location[0] file.end_with?("active_model/attribute_methods.rb") end def automatic_changed_method_deprecated? (defined?(::ActiveRecord) && ActiveRecord::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 1) || (defined?(::ActiveRecord) && ActiveRecord::VERSION::MAJOR > 5) end end # these are the instance methods included module InstanceMethods def self.included(base) base.instance_eval do alias_method :index!, :algolia_index! unless method_defined? :index! alias_method :remove_from_index!, :algolia_remove_from_index! unless method_defined? :remove_from_index! end end def algolia_index!(synchronous = false) self.class.algolia_index!(self, synchronous || algolia_synchronous?) end def algolia_remove_from_index!(synchronous = false) self.class.algolia_remove_from_index!(self, synchronous || algolia_synchronous?) end def algolia_enqueue_remove_from_index!(synchronous) if algoliasearch_options[:enqueue] algoliasearch_options[:enqueue].call(self, true) unless self.class.send(:algolia_indexing_disabled?, algoliasearch_options) else algolia_remove_from_index!(synchronous || algolia_synchronous?) end end def algolia_enqueue_index!(synchronous) if algoliasearch_options[:enqueue] algoliasearch_options[:enqueue].call(self, false) unless self.class.send(:algolia_indexing_disabled?, algoliasearch_options) else algolia_index!(synchronous) end end private def algolia_synchronous? @algolia_synchronous == true end def algolia_mark_synchronous @algolia_synchronous = true end def algolia_mark_for_auto_indexing @algolia_auto_indexing = true end def algolia_mark_must_reindex # algolia_must_reindex flag is reset after every commit as part. If we must reindex at any point in # a stransaction, keep flag set until it is explicitly unset @algolia_must_reindex ||= if defined?(::Sequel) && defined?(::Sequel::Model) && is_a?(Sequel::Model) new? || self.class.algolia_must_reindex?(self) else new_record? || self.class.algolia_must_reindex?(self) end true end def algolia_perform_index_tasks return if !@algolia_auto_indexing || @algolia_must_reindex == false algolia_enqueue_index!(algolia_synchronous?) remove_instance_variable(:@algolia_auto_indexing) if instance_variable_defined?(:@algolia_auto_indexing) remove_instance_variable(:@algolia_synchronous) if instance_variable_defined?(:@algolia_synchronous) remove_instance_variable(:@algolia_must_reindex) if instance_variable_defined?(:@algolia_must_reindex) end end end