module Searchlogic # A class that acts like a model, creates attr_accessors for named_scopes, and then # chains together everything when an "action" method is called. It basically makes # implementing search forms in your application effortless: # # search = User.search # search.username_like = "bjohnson" # search.all # # Is equivalent to: # # User.search(:username_like => "bjohnson").all # # Is equivalent to: # # User.username_like("bjohnson").all class Search # Responsible for adding a "search" method into your models. module Implementation # Additional method, gets aliases as "search" if that method # is available. A lot of other libraries like to use "search" # as well, so if you have a conflict like this, you can use # this method directly. def searchlogic(conditions = {}) Search.new(self, scope(:find), conditions) end end # Is an invalid condition is used this error will be raised. Ex: # # User.search(:unkown => true) # # Where unknown is not a valid named scope for the User model. class UnknownConditionError < StandardError def initialize(condition) msg = "The #{condition} is not a valid condition. You may only use conditions that map to a named scope" super(msg) end end attr_accessor :klass, :current_scope, :conditions undef :id if respond_to?(:id) # Creates a new search object for the given class. Ex: # # Searchlogic::Search.new(User, {}, {:username_like => "bjohnson"}) def initialize(klass, current_scope, conditions = {}) self.klass = klass self.current_scope = current_scope self.conditions = conditions if conditions.is_a?(Hash) end def clone self.class.new(klass, current_scope.clone, conditions.clone) end # Returns a hash of the current conditions set. def conditions @conditions ||= {} end # Accepts a hash of conditions. def conditions=(values) values.each do |condition, value| value.delete_if { |v| v.blank? } if value.is_a?(Array) next if value.blank? send("#{condition}=", value) end end # Delete a condition from the search. Since conditions map to named scopes, # if a named scope accepts a parameter there is no way to actually delete # the scope if you do not want it anymore. A nil value might be meaningful # to that scope. def delete(*names) names.each { |name| @conditions.delete(name.to_sym) } self end private def method_missing(name, *args, &block) if name.to_s =~ /(\w+)=$/ condition = $1.to_sym scope_name = normalize_scope_name($1) if scope?(scope_name) conditions[condition] = type_cast(args.first, cast_type(scope_name)) else raise UnknownConditionError.new(name) end elsif scope?(normalize_scope_name(name)) if args.size > 0 send("#{name}=", *args) self else conditions[name] end else scope = conditions.inject(klass.scoped(current_scope)) do |scope, condition| scope_name, value = condition scope_name = normalize_scope_name(scope_name) klass.send(scope_name, value) if !klass.respond_to?(scope_name) arity = klass.named_scope_arity(scope_name) if !arity || arity == 0 if value == true scope.send(scope_name) else scope end else scope.send(scope_name, value) end end scope.send(name, *args, &block) end end def normalize_scope_name(scope_name) klass.column_names.include?(scope_name.to_s) ? "#{scope_name}_equals".to_sym : scope_name.to_sym end def scope?(scope_name) klass.scopes.key?(scope_name) || klass.condition?(scope_name) end def cast_type(name) klass.send(name, nil) if !klass.respond_to?(name) # We need to set up the named scope if it doesn't exist, so we can get a value for named_ssope_options named_scope_options = klass.named_scope_options(name) arity = klass.named_scope_arity(name) if !arity || arity == 0 :boolean else named_scope_options.respond_to?(:searchlogic_arg_type) ? named_scope_options.searchlogic_arg_type : :string end end def type_cast(value, type) case value when Array value.collect { |v| type_cast(v, type) } else # Let's leverage ActiveRecord's type casting, so that casting is consistent # with the other models. column_for_type_cast = ActiveRecord::ConnectionAdapters::Column.new("", nil) column_for_type_cast.instance_variable_set(:@type, type) value = column_for_type_cast.type_cast(value) Time.zone && value.is_a?(Time) ? value.in_time_zone : value end end end end