diff options
Diffstat (limited to 'activemodel/lib')
-rw-r--r-- | activemodel/lib/active_model/attribute_methods.rb | 54 | ||||
-rw-r--r-- | activemodel/lib/active_model/dirty.rb | 2 | ||||
-rw-r--r-- | activemodel/lib/active_model/errors.rb | 25 | ||||
-rw-r--r-- | activemodel/lib/active_model/lint.rb | 4 | ||||
-rw-r--r-- | activemodel/lib/active_model/mass_assignment_security.rb | 117 | ||||
-rw-r--r-- | activemodel/lib/active_model/naming.rb | 15 | ||||
-rw-r--r-- | activemodel/lib/active_model/observer_array.rb | 98 | ||||
-rw-r--r-- | activemodel/lib/active_model/observing.rb | 35 | ||||
-rw-r--r-- | activemodel/lib/active_model/secure_password.rb | 7 | ||||
-rw-r--r-- | activemodel/lib/active_model/translation.rb | 2 | ||||
-rw-r--r-- | activemodel/lib/active_model/validations/exclusion.rb | 27 | ||||
-rw-r--r-- | activemodel/lib/active_model/validations/format.rb | 46 | ||||
-rw-r--r-- | activemodel/lib/active_model/validations/inclusion.rb | 26 | ||||
-rw-r--r-- | activemodel/lib/active_model/validations/length.rb | 3 | ||||
-rw-r--r-- | activemodel/lib/active_model/validator.rb | 7 |
15 files changed, 368 insertions, 100 deletions
diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index 2a99450a3d..6ee5e04267 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -56,6 +56,8 @@ module ActiveModel module AttributeMethods extend ActiveSupport::Concern + COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?=]?\z/ + included do class_attribute :attribute_method_matchers, :instance_writer => false self.attribute_method_matchers = [] @@ -106,11 +108,17 @@ module ActiveModel if block_given? sing.send :define_method, name, &block else - # use eval instead of a block to work around a memory leak in dev - # mode in fcgi - sing.class_eval <<-eorb, __FILE__, __LINE__ + 1 - def #{name}; #{value.nil? ? 'nil' : value.to_s.inspect}; end - eorb + # If we can compile the method name, do it. Otherwise use define_method. + # This is an important *optimization*, please don't change it. define_method + # has slower dispatch and consumes more memory. + if name =~ COMPILABLE_REGEXP + sing.class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{name}; #{value.nil? ? 'nil' : value.to_s.inspect}; end + RUBY + else + value = value.to_s if value + sing.send(:define_method, name) { value } + end end end @@ -229,8 +237,19 @@ module ActiveModel def alias_attribute(new_name, old_name) attribute_method_matchers.each do |matcher| - define_method(matcher.method_name(new_name)) do |*args| - send(matcher.method_name(old_name), *args) + matcher_new = matcher.method_name(new_name).to_s + matcher_old = matcher.method_name(old_name).to_s + + if matcher_new =~ COMPILABLE_REGEXP && matcher_old =~ COMPILABLE_REGEXP + module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{matcher_new}(*args) + send(:#{matcher_old}, *args) + end + RUBY + else + define_method(matcher_new) do |*args| + send(matcher_old, *args) + end end end end @@ -273,14 +292,25 @@ module ActiveModel else method_name = matcher.method_name(attr_name) - generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1 + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 if method_defined?('#{method_name}') undef :'#{method_name}' end - define_method('#{method_name}') do |*args| - send('#{matcher.method_missing_target}', '#{attr_name}', *args) - end - STR + RUBY + + if method_name.to_s =~ COMPILABLE_REGEXP + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{method_name}(*args) + send(:#{matcher.method_missing_target}, '#{attr_name}', *args) + end + RUBY + else + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + define_method('#{method_name}') do |*args| + send('#{matcher.method_missing_target}', '#{attr_name}', *args) + end + RUBY + end end end end diff --git a/activemodel/lib/active_model/dirty.rb b/activemodel/lib/active_model/dirty.rb index a479795d51..5ede78617a 100644 --- a/activemodel/lib/active_model/dirty.rb +++ b/activemodel/lib/active_model/dirty.rb @@ -8,7 +8,7 @@ module ActiveModel # Provides a way to track changes in your object in the same way as # Active Record does. # - # The requirements to implement ActiveModel::Dirty are to: + # The requirements for implementing ActiveModel::Dirty are: # # * <tt>include ActiveModel::Dirty</tt> in your object # * Call <tt>define_attribute_methods</tt> passing each method you want to diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index c2f0228785..22ca3efa2b 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -278,25 +278,24 @@ module ActiveModel # When using inheritance in your models, it will check all the inherited # models too, but only if the model itself hasn't been found. Say you have # <tt>class Admin < User; end</tt> and you wanted the translation for - # the <tt>:blank</tt> error +message+ for the <tt>title</tt> +attribute+, + # the <tt>:blank</tt> error message for the <tt>title</tt> attribute, # it looks for these translations: # - # <ol> - # <li><tt>activemodel.errors.models.admin.attributes.title.blank</tt></li> - # <li><tt>activemodel.errors.models.admin.blank</tt></li> - # <li><tt>activemodel.errors.models.user.attributes.title.blank</tt></li> - # <li><tt>activemodel.errors.models.user.blank</tt></li> - # <li>any default you provided through the +options+ hash (in the activemodel.errors scope)</li> - # <li><tt>activemodel.errors.messages.blank</tt></li> - # <li><tt>errors.attributes.title.blank</tt></li> - # <li><tt>errors.messages.blank</tt></li> - # </ol> + # * <tt>activemodel.errors.models.admin.attributes.title.blank</tt> + # * <tt>activemodel.errors.models.admin.blank</tt> + # * <tt>activemodel.errors.models.user.attributes.title.blank</tt> + # * <tt>activemodel.errors.models.user.blank</tt> + # * any default you provided through the +options+ hash (in the <tt>activemodel.errors</tt> scope) + # * <tt>activemodel.errors.messages.blank</tt> + # * <tt>errors.attributes.title.blank</tt> + # * <tt>errors.messages.blank</tt> + # def generate_message(attribute, type = :invalid, options = {}) type = options.delete(:message) if options[:message].is_a?(Symbol) defaults = @base.class.lookup_ancestors.map do |klass| - [ :"#{@base.class.i18n_scope}.errors.models.#{klass.model_name.underscore}.attributes.#{attribute}.#{type}", - :"#{@base.class.i18n_scope}.errors.models.#{klass.model_name.underscore}.#{type}" ] + [ :"#{@base.class.i18n_scope}.errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}", + :"#{@base.class.i18n_scope}.errors.models.#{klass.model_name.i18n_key}.#{type}" ] end defaults << options.delete(:message) diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb index 957d1b9d70..b71ef4b22e 100644 --- a/activemodel/lib/active_model/lint.rb +++ b/activemodel/lib/active_model/lint.rb @@ -23,7 +23,7 @@ module ActiveModel def test_to_key assert model.respond_to?(:to_key), "The model should respond to to_key" def model.persisted?() false end - assert model.to_key.nil? + assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false" end # == Responds to <tt>to_param</tt> @@ -40,7 +40,7 @@ module ActiveModel assert model.respond_to?(:to_param), "The model should respond to to_param" def model.to_key() [1] end def model.persisted?() false end - assert model.to_param.nil? + assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false" end # == Responds to <tt>valid?</tt> diff --git a/activemodel/lib/active_model/mass_assignment_security.rb b/activemodel/lib/active_model/mass_assignment_security.rb index be48415739..01eef762fd 100644 --- a/activemodel/lib/active_model/mass_assignment_security.rb +++ b/activemodel/lib/active_model/mass_assignment_security.rb @@ -24,10 +24,7 @@ module ActiveModel # include ActiveModel::MassAssignmentSecurity # # attr_accessible :first_name, :last_name - # - # def self.admin_accessible_attributes - # accessible_attributes + [ :plan_id ] - # end + # attr_accessible :first_name, :last_name, :plan_id, :as => :admin # # def update # ... @@ -38,18 +35,17 @@ module ActiveModel # protected # # def account_params - # sanitize_for_mass_assignment(params[:account]) - # end - # - # def mass_assignment_authorizer - # admin ? admin_accessible_attributes : super + # scope = admin ? :admin : :default + # sanitize_for_mass_assignment(params[:account], scope) # end # # end # module ClassMethods # Attributes named in this macro are protected from mass-assignment - # whenever attributes are sanitized before assignment. + # whenever attributes are sanitized before assignment. A scope for the + # attributes is optional, if no scope is provided then :default is used. + # A scope can be defined by using the :as option. # # Mass-assignment to these attributes will simply be ignored, to assign # to them you can use direct writer methods. This is meant to protect @@ -60,36 +56,58 @@ module ActiveModel # include ActiveModel::MassAssignmentSecurity # # attr_accessor :name, :credit_rating - # attr_protected :credit_rating # - # def attributes=(values) - # sanitize_for_mass_assignment(values).each do |k, v| + # attr_protected :credit_rating, :last_login + # attr_protected :last_login, :as => :admin + # + # def assign_attributes(values, options = {}) + # sanitize_for_mass_assignment(values, options[:as]).each do |k, v| # send("#{k}=", v) # end # end # end # + # When using a :default scope : + # # customer = Customer.new - # customer.attributes = { "name" => "David", "credit_rating" => "Excellent" } + # customer.assign_attributes({ "name" => "David", "credit_rating" => "Excellent", :last_login => 1.day.ago }, :as => :default) # customer.name # => "David" # customer.credit_rating # => nil + # customer.last_login # => nil # # customer.credit_rating = "Average" # customer.credit_rating # => "Average" # + # And using the :admin scope : + # + # customer = Customer.new + # customer.assign_attributes({ "name" => "David", "credit_rating" => "Excellent", :last_login => 1.day.ago }, :as => :admin) + # customer.name # => "David" + # customer.credit_rating # => "Excellent" + # customer.last_login # => nil + # # To start from an all-closed default and enable attributes as needed, # have a look at +attr_accessible+. # # Note that using <tt>Hash#except</tt> or <tt>Hash#slice</tt> in place of +attr_protected+ # to sanitize attributes won't provide sufficient protection. - def attr_protected(*names) - self._protected_attributes = self.protected_attributes + names + def attr_protected(*args) + options = args.extract_options! + scope = options[:as] || :default + + self._protected_attributes = protected_attributes_configs.dup + self._protected_attributes[scope] = self.protected_attributes(scope) + args + self._active_authorizer = self._protected_attributes end # Specifies a white list of model attributes that can be set via # mass-assignment. # + # Like +attr_protected+, a scope for the attributes is optional, + # if no scope is provided then :default is used. A scope can be defined by + # using the :as option. + # # This is the opposite of the +attr_protected+ macro: Mass-assignment # will only set attributes in this list, to assign to the rest of # attributes you can use direct writer methods. This is meant to protect @@ -102,57 +120,90 @@ module ActiveModel # include ActiveModel::MassAssignmentSecurity # # attr_accessor :name, :credit_rating + # # attr_accessible :name + # attr_accessible :name, :credit_rating, :as => :admin # - # def attributes=(values) - # sanitize_for_mass_assignment(values).each do |k, v| + # def assign_attributes(values, options = {}) + # sanitize_for_mass_assignment(values, options[:as]).each do |k, v| # send("#{k}=", v) # end # end # end # + # When using a :default scope : + # # customer = Customer.new - # customer.attributes = { :name => "David", :credit_rating => "Excellent" } + # customer.assign_attributes({ "name" => "David", "credit_rating" => "Excellent", :last_login => 1.day.ago }, :as => :default) # customer.name # => "David" # customer.credit_rating # => nil # # customer.credit_rating = "Average" # customer.credit_rating # => "Average" # + # And using the :admin scope : + # + # customer = Customer.new + # customer.assign_attributes({ "name" => "David", "credit_rating" => "Excellent", :last_login => 1.day.ago }, :as => :admin) + # customer.name # => "David" + # customer.credit_rating # => "Excellent" + # # Note that using <tt>Hash#except</tt> or <tt>Hash#slice</tt> in place of +attr_accessible+ # to sanitize attributes won't provide sufficient protection. - def attr_accessible(*names) - self._accessible_attributes = self.accessible_attributes + names + def attr_accessible(*args) + options = args.extract_options! + scope = options[:as] || :default + + self._accessible_attributes = accessible_attributes_configs.dup + self._accessible_attributes[scope] = self.accessible_attributes(scope) + args + self._active_authorizer = self._accessible_attributes end - def protected_attributes - self._protected_attributes ||= BlackList.new(attributes_protected_by_default).tap do |w| - w.logger = self.logger if self.respond_to?(:logger) - end + def protected_attributes(scope = :default) + protected_attributes_configs[scope] end - def accessible_attributes - self._accessible_attributes ||= WhiteList.new.tap { |w| w.logger = self.logger if self.respond_to?(:logger) } + def accessible_attributes(scope = :default) + accessible_attributes_configs[scope] end - def active_authorizer - self._active_authorizer ||= protected_attributes + def active_authorizers + self._active_authorizer ||= protected_attributes_configs end + alias active_authorizer active_authorizers def attributes_protected_by_default [] end + + private + + def protected_attributes_configs + self._protected_attributes ||= begin + default_black_list = BlackList.new(attributes_protected_by_default).tap do |w| + w.logger = self.logger if self.respond_to?(:logger) + end + Hash.new(default_black_list) + end + end + + def accessible_attributes_configs + self._accessible_attributes ||= begin + default_white_list = WhiteList.new.tap { |w| w.logger = self.logger if self.respond_to?(:logger) } + Hash.new(default_white_list) + end + end end protected - def sanitize_for_mass_assignment(attributes) - mass_assignment_authorizer.sanitize(attributes) + def sanitize_for_mass_assignment(attributes, scope = :default) + mass_assignment_authorizer(scope).sanitize(attributes) end - def mass_assignment_authorizer - self.class.active_authorizer + def mass_assignment_authorizer(scope = :default) + self.class.active_authorizer[scope] end end end diff --git a/activemodel/lib/active_model/naming.rb b/activemodel/lib/active_model/naming.rb index eb9b847509..315ccd40e9 100644 --- a/activemodel/lib/active_model/naming.rb +++ b/activemodel/lib/active_model/naming.rb @@ -4,7 +4,7 @@ require 'active_support/core_ext/module/introspection' module ActiveModel class Name < String - attr_reader :singular, :plural, :element, :collection, :partial_path, :route_key, :param_key + attr_reader :singular, :plural, :element, :collection, :partial_path, :route_key, :param_key, :i18n_key alias_method :cache_key, :collection def initialize(klass, namespace = nil) @@ -20,6 +20,7 @@ module ActiveModel @partial_path = "#{@collection}/#{@element}".freeze @param_key = (namespace ? _singularize(@unnamespaced) : @singular).freeze @route_key = (namespace ? ActiveSupport::Inflector.pluralize(@param_key) : @plural).freeze + @i18n_key = self.underscore.to_sym end # Transform the model name into a more humane format, using I18n. By default, @@ -33,7 +34,7 @@ module ActiveModel @klass.respond_to?(:i18n_scope) defaults = @klass.lookup_ancestors.map do |klass| - klass.model_name.underscore.to_sym + klass.model_name.i18n_key end defaults << options[:default] if options[:default] @@ -44,9 +45,10 @@ module ActiveModel end private - def _singularize(str) - ActiveSupport::Inflector.underscore(str).tr('/', '_') - end + + def _singularize(string, replacement='_') + ActiveSupport::Inflector.underscore(string).tr('/', replacement) + end end # == Active Model Naming @@ -62,6 +64,9 @@ module ActiveModel # BookCover.model_name # => "BookCover" # BookCover.model_name.human # => "Book cover" # + # BookCover.model_name.i18n_key # => "book_cover" + # BookModule::BookCover.model_name.i18n_key # => "book_module.book_cover" + # # Providing the functionality that ActiveModel::Naming provides in your object # is required to pass the Active Model Lint test. So either extending the provided # method below, or rolling your own is required. diff --git a/activemodel/lib/active_model/observer_array.rb b/activemodel/lib/active_model/observer_array.rb new file mode 100644 index 0000000000..b8aa9cc1e2 --- /dev/null +++ b/activemodel/lib/active_model/observer_array.rb @@ -0,0 +1,98 @@ +require 'set' + +module ActiveModel + # Stores the enabled/disabled state of individual observers for + # a particular model classes. + class ObserverArray < Array + INSTANCES = Hash.new do |hash, model_class| + hash[model_class] = new(model_class) + end + + def self.for(model_class) + return nil unless model_class < ActiveModel::Observing + INSTANCES[model_class] + end + + # returns false if: + # - the ObserverArray for the given model's class has the given observer + # in its disabled_observers set. + # - or that is the case at any level of the model's superclass chain. + def self.observer_enabled?(observer, model) + klass = model.class + observer_class = observer.class + + loop do + break unless array = self.for(klass) + return false if array.disabled_observers.include?(observer_class) + klass = klass.superclass + end + + true # observers are enabled by default + end + + def disabled_observers + @disabled_observers ||= Set.new + end + + attr_reader :model_class + def initialize(model_class, *args) + @model_class = model_class + super(*args) + end + + def disable(*observers, &block) + set_enablement(false, observers, &block) + end + + def enable(*observers, &block) + set_enablement(true, observers, &block) + end + + private + + def observer_class_for(observer) + return observer if observer.is_a?(Class) + + if observer.respond_to?(:to_sym) # string/symbol + observer.to_s.camelize.constantize + else + raise ArgumentError, "#{observer} was not a class or a " + + "lowercase, underscored class name as expected." + end + end + + def transaction + orig_disabled_observers = disabled_observers.dup + + begin + yield + ensure + @disabled_observers = orig_disabled_observers + end + end + + def set_enablement(enabled, observers) + if block_given? + transaction do + set_enablement(enabled, observers) + yield + end + else + observers = ActiveModel::Observer.all_observers if observers == [:all] + observers.each do |obs| + klass = observer_class_for(obs) + + unless klass < ActiveModel::Observer + raise ArgumentError.new("#{obs} does not refer to a valid observer") + end + + if enabled + disabled_observers.delete(klass) + else + disabled_observers << klass + end + end + end + end + end +end diff --git a/activemodel/lib/active_model/observing.rb b/activemodel/lib/active_model/observing.rb index ef36f80bec..e1a2ce218d 100644 --- a/activemodel/lib/active_model/observing.rb +++ b/activemodel/lib/active_model/observing.rb @@ -1,8 +1,10 @@ require 'singleton' +require 'active_model/observer_array' require 'active_support/core_ext/array/wrap' require 'active_support/core_ext/module/aliasing' require 'active_support/core_ext/module/remove_method' require 'active_support/core_ext/string/inflections' +require 'active_support/core_ext/enumerable' module ActiveModel module Observing @@ -30,12 +32,12 @@ module ActiveModel # +instantiate_observers+ is called during startup, and before # each development request. def observers=(*values) - @observers = values.flatten + observers.replace(values.flatten) end # Gets the current observers. def observers - @observers ||= [] + @observers ||= ObserverArray.for(self) end # Gets the current observer instances. @@ -43,7 +45,7 @@ module ActiveModel @observer_instances ||= [] end - # Instantiate the global Active Record observers. + # Instantiate the global observers. def instantiate_observers observers.each { |o| instantiate_observer(o) } end @@ -76,7 +78,11 @@ module ActiveModel elsif observer.respond_to?(:instance) observer.instance else - raise ArgumentError, "#{observer} must be a lowercase, underscored class name (or an instance of the class itself) responding to the instance method. Example: Person.observers = :big_brother # calls BigBrother.instance" + raise ArgumentError, + "#{observer} must be a lowercase, underscored class name (or an " + + "instance of the class itself) responding to the instance " + + "method. Example: Person.observers = :big_brother # calls " + + "BigBrother.instance" end end @@ -197,6 +203,23 @@ module ActiveModel nil end end + + def subclasses + @subclasses ||= [] + end + + # List of all observer subclasses, sub-subclasses, etc. + # Necessary so we can disable or enable all observers. + def all_observers + subclasses.each_with_object(subclasses.dup) do |subclass, array| + array.concat(subclass.all_observers) + end + end + end + + def self.inherited(subclass) + subclasses << subclass + super end # Start observing the declared classes and their subclasses. @@ -210,7 +233,9 @@ module ActiveModel # Send observed_method(object) if the method exists. def update(observed_method, object) #:nodoc: - send(observed_method, object) if respond_to?(observed_method) + if respond_to?(observed_method) && ObserverArray.observer_enabled?(self, object) + send(observed_method, object) + end end # Special method sent by the observed class when it is inherited. diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 957d0ddaaa..ee94ad66cf 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -31,11 +31,10 @@ module ActiveModel # User.find_by_name("david").try(:authenticate, "mUc3m00RsqyRe") # => user def has_secure_password attr_reader :password - attr_accessor :password_confirmation validates_confirmation_of :password validates_presence_of :password_digest - + include InstanceMethodsOnActivation if respond_to?(:attributes_protected_by_default) @@ -59,7 +58,9 @@ module ActiveModel # Encrypts the password into the password_digest attribute. def password=(unencrypted_password) @password = unencrypted_password - self.password_digest = BCrypt::Password.create(unencrypted_password) + unless unencrypted_password.blank? + self.password_digest = BCrypt::Password.create(unencrypted_password) + end end end end diff --git a/activemodel/lib/active_model/translation.rb b/activemodel/lib/active_model/translation.rb index dbb76244e4..920a133159 100644 --- a/activemodel/lib/active_model/translation.rb +++ b/activemodel/lib/active_model/translation.rb @@ -44,7 +44,7 @@ module ActiveModel # Specify +options+ with additional translating options. def human_attribute_name(attribute, options = {}) defaults = lookup_ancestors.map do |klass| - :"#{self.i18n_scope}.attributes.#{klass.model_name.underscore}.#{attribute}" + :"#{self.i18n_scope}.attributes.#{klass.model_name.i18n_key}.#{attribute}" end defaults << :"attributes.#{attribute}" diff --git a/activemodel/lib/active_model/validations/exclusion.rb b/activemodel/lib/active_model/validations/exclusion.rb index e38e565d09..a85c23f725 100644 --- a/activemodel/lib/active_model/validations/exclusion.rb +++ b/activemodel/lib/active_model/validations/exclusion.rb @@ -1,18 +1,35 @@ +require 'active_support/core_ext/range.rb' + module ActiveModel # == Active Model Exclusion Validator module Validations class ExclusionValidator < EachValidator + ERROR_MESSAGE = "An object with the method #include? or a proc or lambda is required, " << + "and must be supplied as the :in option of the configuration hash" + def check_validity! - raise ArgumentError, "An object with the method include? is required must be supplied as the " << - ":in option of the configuration hash" unless options[:in].respond_to?(:include?) + unless [:include?, :call].any? { |method| options[:in].respond_to?(method) } + raise ArgumentError, ERROR_MESSAGE + end end def validate_each(record, attribute, value) - if options[:in].include?(value) + delimiter = options[:in] + exclusions = delimiter.respond_to?(:call) ? delimiter.call(record) : delimiter + if exclusions.send(inclusion_method(exclusions), value) record.errors.add(attribute, :exclusion, options.except(:in).merge!(:value => value)) end end + + private + + # In Ruby 1.9 <tt>Range#include?</tt> on non-numeric ranges checks all possible values in the + # range for equality, so it may be slow for large ranges. The new <tt>Range#cover?</tt> + # uses the previous logic of comparing a value with the range endpoints. + def inclusion_method(enumerable) + enumerable.is_a?(Range) ? :cover? : :include? + end end module HelperMethods @@ -22,10 +39,14 @@ module ActiveModel # validates_exclusion_of :username, :in => %w( admin superuser ), :message => "You don't belong here" # validates_exclusion_of :age, :in => 30..60, :message => "This site is only for under 30 and over 60" # validates_exclusion_of :format, :in => %w( mov avi ), :message => "extension %{value} is not allowed" + # validates_exclusion_of :password, :in => lambda { |p| [p.username, p.first_name] }, :message => "should not be the same as your username or first name" # end # # Configuration options: # * <tt>:in</tt> - An enumerable object of items that the value shouldn't be part of. + # This can be supplied as a proc or lambda which returns an enumerable. If the enumerable + # is a range the test is performed with <tt>Range#cover?</tt> + # (backported in Active Support for 1.8), otherwise with <tt>include?</tt>. # * <tt>:message</tt> - Specifies a custom error message (default is: "is reserved"). # * <tt>:allow_nil</tt> - If set to true, skips this validation if the attribute is +nil+ (default is +false+). # * <tt>:allow_blank</tt> - If set to true, skips this validation if the attribute is blank (default is +false+). diff --git a/activemodel/lib/active_model/validations/format.rb b/activemodel/lib/active_model/validations/format.rb index 541f53a834..6f23d492eb 100644 --- a/activemodel/lib/active_model/validations/format.rb +++ b/activemodel/lib/active_model/validations/format.rb @@ -4,10 +4,12 @@ module ActiveModel module Validations class FormatValidator < EachValidator def validate_each(record, attribute, value) - if options[:with] && value.to_s !~ options[:with] - record.errors.add(attribute, :invalid, options.except(:with).merge!(:value => value)) - elsif options[:without] && value.to_s =~ options[:without] - record.errors.add(attribute, :invalid, options.except(:without).merge!(:value => value)) + if options[:with] + regexp = option_call(record, :with) + record_error(record, attribute, :with, value) if value.to_s !~ regexp + elsif options[:without] + regexp = option_call(record, :without) + record_error(record, attribute, :without, value) if value.to_s =~ regexp end end @@ -16,12 +18,25 @@ module ActiveModel raise ArgumentError, "Either :with or :without must be supplied (but not both)" end - if options[:with] && !options[:with].is_a?(Regexp) - raise ArgumentError, "A regular expression must be supplied as the :with option of the configuration hash" - end + check_options_validity(options, :with) + check_options_validity(options, :without) + end + + private - if options[:without] && !options[:without].is_a?(Regexp) - raise ArgumentError, "A regular expression must be supplied as the :without option of the configuration hash" + def option_call(record, name) + option = options[name] + option.respond_to?(:call) ? option.call(record) : option + end + + def record_error(record, attribute, name, value) + record.errors.add(attribute, :invalid, options.except(name).merge!(:value => value)) + end + + def check_options_validity(options, name) + option = options[name] + if option && !option.is_a?(Regexp) && !option.respond_to?(:call) + raise ArgumentError, "A regular expression or a proc or lambda must be supplied as :#{name}" end end end @@ -40,17 +55,26 @@ module ActiveModel # validates_format_of :email, :without => /NOSPAM/ # end # + # You can also provide a proc or lambda which will determine the regular expression that will be used to validate the attribute + # + # class Person < ActiveRecord::Base + # # Admin can have number as a first letter in their screen name + # validates_format_of :screen_name, :with => lambda{ |person| person.admin? ? /\A[a-z0-9][a-z0-9_\-]*\Z/i : /\A[a-z][a-z0-9_\-]*\Z/i } + # end + # # Note: use <tt>\A</tt> and <tt>\Z</tt> to match the start and end of the string, <tt>^</tt> and <tt>$</tt> match the start/end of a line. # - # You must pass either <tt>:with</tt> or <tt>:without</tt> as an option. In addition, both must be a regular expression, - # or else an exception will be raised. + # You must pass either <tt>:with</tt> or <tt>:without</tt> as an option. In addition, both must be a regular expression + # or a proc or lambda, or else an exception will be raised. # # Configuration options: # * <tt>:message</tt> - A custom error message (default is: "is invalid"). # * <tt>:allow_nil</tt> - If set to true, skips this validation if the attribute is +nil+ (default is +false+). # * <tt>:allow_blank</tt> - If set to true, skips this validation if the attribute is blank (default is +false+). # * <tt>:with</tt> - Regular expression that if the attribute matches will result in a successful validation. + # This can be provided as a proc or lambda returning regular expression which will be called at runtime. # * <tt>:without</tt> - Regular expression that if the attribute does not match will result in a successful validation. + # This can be provided as a proc or lambda returning regular expression which will be called at runtime. # * <tt>:on</tt> - Specifies when this validation is active. Runs in all # validation contexts by default (+nil+), other options are <tt>:create</tt> # and <tt>:update</tt>. diff --git a/activemodel/lib/active_model/validations/inclusion.rb b/activemodel/lib/active_model/validations/inclusion.rb index 92ac940f36..d32aebeb88 100644 --- a/activemodel/lib/active_model/validations/inclusion.rb +++ b/activemodel/lib/active_model/validations/inclusion.rb @@ -5,20 +5,30 @@ module ActiveModel # == Active Model Inclusion Validator module Validations class InclusionValidator < EachValidator + ERROR_MESSAGE = "An object with the method #include? or a proc or lambda is required, " << + "and must be supplied as the :in option of the configuration hash" + def check_validity! - raise ArgumentError, "An object with the method include? is required must be supplied as the " << - ":in option of the configuration hash" unless options[:in].respond_to?(:include?) + unless [:include?, :call].any?{ |method| options[:in].respond_to?(method) } + raise ArgumentError, ERROR_MESSAGE + end end def validate_each(record, attribute, value) - record.errors.add(attribute, :inclusion, options.except(:in).merge!(:value => value)) unless options[:in].send(include?, value) + delimiter = options[:in] + exclusions = delimiter.respond_to?(:call) ? delimiter.call(record) : delimiter + unless exclusions.send(inclusion_method(exclusions), value) + record.errors.add(attribute, :inclusion, options.except(:in).merge!(:value => value)) + end end + private + # In Ruby 1.9 <tt>Range#include?</tt> on non-numeric ranges checks all possible values in the # range for equality, so it may be slow for large ranges. The new <tt>Range#cover?</tt> # uses the previous logic of comparing a value with the range endpoints. - def include? - options[:in].is_a?(Range) ? :cover? : :include? + def inclusion_method(enumerable) + enumerable.is_a?(Range) ? :cover? : :include? end end @@ -29,11 +39,13 @@ module ActiveModel # validates_inclusion_of :gender, :in => %w( m f ) # validates_inclusion_of :age, :in => 0..99 # validates_inclusion_of :format, :in => %w( jpg gif png ), :message => "extension %{value} is not included in the list" + # validates_inclusion_of :states, :in => lambda{ |person| STATES[person.country] } # end # # Configuration options: - # * <tt>:in</tt> - An enumerable object of available items. - # If the enumerable is a range the test is performed with <tt>Range#cover?</tt> + # * <tt>:in</tt> - An enumerable object of available items. This can be + # supplied as a proc or lambda which returns an enumerable. If the enumerable + # is a range the test is performed with <tt>Range#cover?</tt> # (backported in Active Support for 1.8), otherwise with <tt>include?</tt>. # * <tt>:message</tt> - Specifies a custom error message (default is: "is not included in the list"). # * <tt>:allow_nil</tt> - If set to true, skips this validation if the attribute is +nil+ (default is +false+). diff --git a/activemodel/lib/active_model/validations/length.rb b/activemodel/lib/active_model/validations/length.rb index 7af6c83460..72735cfb89 100644 --- a/activemodel/lib/active_model/validations/length.rb +++ b/activemodel/lib/active_model/validations/length.rb @@ -43,7 +43,8 @@ module ActiveModel value ||= [] if key == :maximum - next if value && value.size.send(validity_check, check_value) + value_length = value.respond_to?(:length) ? value.length : value.to_s.length + next if value_length.send(validity_check, check_value) errors_options = options.except(*RESERVED_OPTIONS) errors_options[:count] = check_value diff --git a/activemodel/lib/active_model/validator.rb b/activemodel/lib/active_model/validator.rb index 1c6123eb09..5304743389 100644 --- a/activemodel/lib/active_model/validator.rb +++ b/activemodel/lib/active_model/validator.rb @@ -1,6 +1,7 @@ require 'active_support/core_ext/array/wrap' require "active_support/core_ext/module/anonymous" require 'active_support/core_ext/object/blank' +require 'active_support/core_ext/object/inclusion' module ActiveModel #:nodoc: @@ -67,7 +68,7 @@ module ActiveModel #:nodoc: # # class TitleValidator < ActiveModel::EachValidator # def validate_each(record, attribute, value) - # record.errors[attribute] << 'must be Mr. Mrs. or Dr.' unless ['Mr.', 'Mrs.', 'Dr.'].include?(value) + # record.errors[attribute] << 'must be Mr. Mrs. or Dr.' unless value.in?(['Mr.', 'Mrs.', 'Dr.']) # end # end # @@ -120,7 +121,7 @@ module ActiveModel #:nodoc: # Override this method in subclasses with validation logic, adding errors # to the records +errors+ array where necessary. def validate(record) - raise NotImplementedError + raise NotImplementedError, "Subclasses must implement a validate(record) method." end end @@ -156,7 +157,7 @@ module ActiveModel #:nodoc: # Override this method in subclasses with the validation logic, adding # errors to the records +errors+ array where necessary. def validate_each(record, attribute, value) - raise NotImplementedError + raise NotImplementedError, "Subclasses must implement a validate_each(record, attribute, value) method" end # Hook method that gets called by the initializer allowing verification |