From 8828b2ca674acfa028a3c1e086a1795d3bb893e1 Mon Sep 17 00:00:00 2001 From: Pratik Naik Date: Thu, 19 Mar 2009 23:28:59 +0000 Subject: Move all the Active Record validations to Active Model --- activemodel/lib/active_model.rb | 48 ++++++- .../lib/active_model/deprecated_error_methods.rb | 23 +-- activemodel/lib/active_model/errors.rb | 160 ++++++++++++++++----- activemodel/lib/active_model/validations.rb | 41 ++++-- .../lib/active_model/validations/acceptance.rb | 21 +-- .../lib/active_model/validations/associated.rb | 11 +- .../lib/active_model/validations/confirmation.rb | 10 +- .../lib/active_model/validations/exclusion.rb | 16 ++- activemodel/lib/active_model/validations/format.rb | 16 ++- .../lib/active_model/validations/inclusion.rb | 16 ++- activemodel/lib/active_model/validations/length.rb | 80 +++++------ .../lib/active_model/validations/numericality.rb | 23 +-- .../lib/active_model/validations/presence.rb | 32 ++--- .../lib/active_model/validations/uniqueness.rb | 125 +++++++++++----- 14 files changed, 408 insertions(+), 214 deletions(-) (limited to 'activemodel') diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index 4ed7b0889d..e777351cea 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -1,5 +1,43 @@ -require 'active_model/observing' -# disabled until they're tested -# require 'active_model/callbacks' -# require 'active_model/validations' -require 'active_model/base' \ No newline at end of file +#-- +# Copyright (c) 2004-2009 David Heinemeier Hansson +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +#++ + +begin + require 'active_support' +rescue LoadError + activesupport_path = "#{File.dirname(__FILE__)}/../../activesupport/lib" + if File.directory?(activesupport_path) + $:.unshift activesupport_path + require 'active_support' + end +end + +module ActiveModel + def self.load_all! + [Base] + end + + autoload :Base, 'active_model/base' + autoload :Validations, 'active_model/validations' + autoload :Errors, 'active_model/errors' + autoload :DeprecatedErrorMethods, 'active_model/deprecated_error_methods' +end diff --git a/activemodel/lib/active_model/deprecated_error_methods.rb b/activemodel/lib/active_model/deprecated_error_methods.rb index e0cbd9ba29..b0d7023046 100644 --- a/activemodel/lib/active_model/deprecated_error_methods.rb +++ b/activemodel/lib/active_model/deprecated_error_methods.rb @@ -1,37 +1,28 @@ module ActiveModel module DeprecatedErrorMethods def on(attribute) - ActiveSupport::Deprecation.warn "Errors#on have been deprecated, use Errors#[] instead" - self[attribute] + # ActiveSupport::Deprecation.warn "Errors#on have been deprecated, use Errors#[] instead" + errors = self[attribute] + errors.size < 2 ? errors.first : errors end def on_base - ActiveSupport::Deprecation.warn "Errors#on_base have been deprecated, use Errors#[:base] instead" + # ActiveSupport::Deprecation.warn "Errors#on_base have been deprecated, use Errors#[:base] instead" on(:base) end - def add(attribute, msg = Errors.default_error_messages[:invalid]) - ActiveSupport::Deprecation.warn "Errors#add(attribute, msg) has been deprecated, use Errors#[attribute] << msg instead" - self[attribute] << msg - end - def add_to_base(msg) - ActiveSupport::Deprecation.warn "Errors#add_to_base(msg) has been deprecated, use Errors#[:base] << msg instead" + # ActiveSupport::Deprecation.warn "Errors#add_to_base(msg) has been deprecated, use Errors#[:base] << msg instead" self[:base] << msg end def invalid?(attribute) - ActiveSupport::Deprecation.warn "Errors#invalid?(attribute) has been deprecated, use Errors#[attribute].any? instead" + # ActiveSupport::Deprecation.warn "Errors#invalid?(attribute) has been deprecated, use Errors#[attribute].any? instead" self[attribute].any? end - def full_messages - ActiveSupport::Deprecation.warn "Errors#full_messages has been deprecated, use Errors#to_a instead" - to_a - end - def each_full - ActiveSupport::Deprecation.warn "Errors#each_full has been deprecated, use Errors#to_a.each instead" + # ActiveSupport::Deprecation.warn "Errors#each_full has been deprecated, use Errors#to_a.each instead" to_a.each { |error| yield error } end end diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index bcf0810290..ad7ed723a1 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -1,40 +1,27 @@ module ActiveModel class Errors < Hash include DeprecatedErrorMethods - - @@default_error_messages = { - :inclusion => "is not included in the list", - :exclusion => "is reserved", - :invalid => "is invalid", - :confirmation => "doesn't match confirmation", - :accepted => "must be accepted", - :empty => "can't be empty", - :blank => "can't be blank", - :too_long => "is too long (maximum is %d characters)", - :too_short => "is too short (minimum is %d characters)", - :wrong_length => "is the wrong length (should be %d characters)", - :taken => "has already been taken", - :not_a_number => "is not a number", - :greater_than => "must be greater than %d", - :greater_than_or_equal_to => "must be greater than or equal to %d", - :equal_to => "must be equal to %d", - :less_than => "must be less than %d", - :less_than_or_equal_to => "must be less than or equal to %d", - :odd => "must be odd", - :even => "must be even" - } - - ## - # :singleton-method: - # Holds a hash with all the default error messages that can be replaced by your own copy or localizations. - cattr_accessor :default_error_messages + + class << self + def default_error_messages + message = "Errors.default_error_messages has been deprecated. Please use I18n.translate('activerecord.errors.messages')." + ActiveSupport::Deprecation.warn(message) + + I18n.translate 'activerecord.errors.messages' + end + end + + def initialize(base) + @base = base + super() + end alias_method :get, :[] alias_method :set, :[]= def [](attribute) if errors = get(attribute.to_sym) - errors.size == 1 ? errors.first : errors + errors else set(attribute.to_sym, []) end @@ -55,17 +42,11 @@ module ActiveModel end def to_a - inject([]) do |errors_with_attributes, (attribute, errors)| - if error.blank? - errors_with_attributes - else - if attr == :base - errors_with_attributes << error - else - errors_with_attributes << (attribute.to_s.humanize + " " + error) - end - end - end + full_messages + end + + def count + to_a.size end def to_xml(options={}) @@ -78,5 +59,106 @@ module ActiveModel to_a.each { |error| e.error(error) } end end + + # Adds an error message (+messsage+) to the +attribute+, which will be returned on a call to on(attribute) + # for the same attribute and ensure that this error object returns false when asked if empty?. More than one + # error can be added to the same +attribute+ in which case an array will be returned on a call to on(attribute). + # If no +messsage+ is supplied, :invalid is assumed. + # If +message+ is a Symbol, it will be translated, using the appropriate scope (see translate_error). + def add(attribute, message = nil, options = {}) + message ||= :invalid + message = generate_message(attribute, message, options) if message.is_a?(Symbol) + self[attribute] << message + end + + # Will add an error message to each of the attributes in +attributes+ that is empty. + def add_on_empty(attributes, custom_message = nil) + [attributes].flatten.each do |attribute| + value = @base.respond_to?(attribute.to_s) ? @base.send(attribute.to_s) : @base[attribute.to_s] + is_empty = value.respond_to?(:empty?) ? value.empty? : false + add(attribute, :empty, :default => custom_message) unless !value.nil? && !is_empty + end + end + + # Will add an error message to each of the attributes in +attributes+ that is blank (using Object#blank?). + def add_on_blank(attributes, custom_message = nil) + [attributes].flatten.each do |attribute| + value = @base.respond_to?(attribute.to_sym) ? @base.send(attribute.to_sym) : @base[attribute.to_sym] + add(attribute, :blank, :default => custom_message) if value.blank? + end + end + + # Returns all the full error messages in an array. + # + # class Company < ActiveRecord::Base + # validates_presence_of :name, :address, :email + # validates_length_of :name, :in => 5..30 + # end + # + # company = Company.create(:address => '123 First St.') + # company.errors.full_messages # => + # ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Address can't be blank"] + def full_messages(options = {}) + full_messages = [] + + each do |attribute, messages| + next if messages.empty? + + if attribute == :base + messages.each {|m| full_messages << m } + else + attr_name = @base.class.human_attribute_name(attribute.to_s) + prefix = attr_name + I18n.t('activerecord.errors.format.separator', :default => ' ') + messages.each do |m| + full_messages << "#{prefix}#{m}" + end + end + end + + full_messages + end + + # Translates an error message in it's default scope (activerecord.errrors.messages). + # Error messages are first looked up in models.MODEL.attributes.ATTRIBUTE.MESSAGE, if it's not there, + # it's looked up in models.MODEL.MESSAGE and if that is not there it returns the translation of the + # default message (e.g. activerecord.errors.messages.MESSAGE). The translated model name, + # translated attribute name and the value are available for interpolation. + # + # When using inheritence in your models, it will check all the inherited models too, but only if the model itself + # hasn't been found. Say you have class Admin < User; end and you wanted the translation for the :blank + # error +message+ for the title +attribute+, it looks for these translations: + # + #
    + #
  1. activerecord.errors.models.admin.attributes.title.blank
  2. + #
  3. activerecord.errors.models.admin.blank
  4. + #
  5. activerecord.errors.models.user.attributes.title.blank
  6. + #
  7. activerecord.errors.models.user.blank
  8. + #
  9. activerecord.errors.messages.blank
  10. + #
  11. any default you provided through the +options+ hash (in the activerecord.errors scope)
  12. + #
+ def generate_message(attribute, message = :invalid, options = {}) + message, options[:default] = options[:default], message if options[:default].is_a?(Symbol) + + defaults = @base.class.self_and_descendants_from_active_record.map do |klass| + [ :"models.#{klass.name.underscore}.attributes.#{attribute}.#{message}", + :"models.#{klass.name.underscore}.#{message}" ] + end + + defaults << options.delete(:default) + defaults = defaults.compact.flatten << :"messages.#{message}" + + key = defaults.shift + value = @base.respond_to?(attribute) ? @base.send(attribute) : nil + + options = { :default => defaults, + :model => @base.class.human_name, + :attribute => @base.class.human_attribute_name(attribute.to_s), + :value => value, + :scope => [:activerecord, :errors] + }.merge(options) + + I18n.translate(key, options) + end + end end \ No newline at end of file diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb index 460d2d82e5..3ea17381c9 100644 --- a/activemodel/lib/active_model/validations.rb +++ b/activemodel/lib/active_model/validations.rb @@ -38,7 +38,7 @@ module ActiveModel # end # # This usage applies to +validate_on_create+ and +validate_on_update as well+. - # + # Validates each attribute against a block. # # class Person < ActiveRecord::Base @@ -48,7 +48,7 @@ module ActiveModel # end # # Options: - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) + # * :on - Specifies when this validation is active (default is :save, other options :create, :update). # * :allow_nil - Skip validation if attribute is +nil+. # * :allow_blank - Skip validation if attribute is blank. # * :if - Specifies a method, proc or string to call to determine if the validation should @@ -83,7 +83,7 @@ module ActiveModel # Returns the Errors object that holds all information about attribute error messages. def errors - @errors ||= Errors.new + @errors ||= Errors.new(self) end # Runs all the specified validations and returns true if no errors were added otherwise false. @@ -92,35 +92,52 @@ module ActiveModel run_callbacks(:validate) - if responds_to?(:validate) - ActiveSupport::Deprecations.warn "Base#validate has been deprecated, please use Base.validate :method instead" + if respond_to?(:validate) + # ActiveSupport::Deprecation.warn "Base#validate has been deprecated, please use Base.validate :method instead" validate end if new_record? run_callbacks(:validate_on_create) - if responds_to?(:validate_on_create) - ActiveSupport::Deprecations.warn( - "Base#validate_on_create has been deprecated, please use Base.validate_on_create :method instead") + if respond_to?(:validate_on_create) + # ActiveSupport::Deprecation.warn "Base#validate_on_create has been deprecated, please use Base.validate_on_create :method instead" validate_on_create end else run_callbacks(:validate_on_update) - if responds_to?(:validate_on_update) - ActiveSupport::Deprecations.warn( - "Base#validate_on_update has been deprecated, please use Base.validate_on_update :method instead") + if respond_to?(:validate_on_update) + # ActiveSupport::Deprecation.warn "Base#validate_on_update has been deprecated, please use Base.validate_on_update :method instead" validate_on_update end end errors.empty? end + + # Performs the opposite of valid?. Returns true if errors were added, false otherwise. + def invalid? + !valid? + end + + protected + + # Overwrite this method for validation checks on all saves and use Errors.add(field, msg) for invalid attributes. + def validate + end + + # Overwrite this method for validation checks used only on creation. + def validate_on_create + end + + # Overwrite this method for validation checks used only on updates. + def validate_on_update + end end end Dir[File.dirname(__FILE__) + "/validations/*.rb"].sort.each do |path| filename = File.basename(path) require "active_model/validations/#{filename}" -end \ No newline at end of file +end diff --git a/activemodel/lib/active_model/validations/acceptance.rb b/activemodel/lib/active_model/validations/acceptance.rb index 9be7d51ffb..db1fd04bb5 100644 --- a/activemodel/lib/active_model/validations/acceptance.rb +++ b/activemodel/lib/active_model/validations/acceptance.rb @@ -8,16 +8,16 @@ module ActiveModel # validates_acceptance_of :eula, :message => "must be abided" # end # - # If the database column does not exist, the :terms_of_service attribute is entirely virtual. This check is - # performed only if :terms_of_service is not +nil+ and by default on save. + # If the database column does not exist, the +terms_of_service+ attribute is entirely virtual. This check is + # performed only if +terms_of_service+ is not +nil+ and by default on save. # # Configuration options: - # * :message - A custom error message (default is: "must be accepted") - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) - # * :allow_nil - Skip validation if attribute is +nil+. (default is +true+) + # * :message - A custom error message (default is: "must be accepted"). + # * :on - Specifies when this validation is active (default is :save, other options :create, :update). + # * :allow_nil - Skip validation if attribute is +nil+ (default is true). # * :accept - Specifies value that is considered accepted. The default value is a string "1", which # makes it easy to relate to an HTML checkbox. This should be set to +true+ if you are validating a database - # column, since the attribute is typecasted from "1" to +true+ before validation. + # column, since the attribute is typecast from "1" to +true+ before validation. # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. @@ -25,19 +25,22 @@ module ActiveModel # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. def validates_acceptance_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:accepted], :on => :save, :allow_nil => true, :accept => "1" } + configuration = { :on => :save, :allow_nil => true, :accept => "1" } configuration.update(attr_names.extract_options!) db_cols = begin column_names - rescue ActiveRecord::StatementInvalid + rescue Exception # To ignore both statement and connection errors [] end + names = attr_names.reject { |name| db_cols.include?(name.to_s) } attr_accessor(*names) validates_each(attr_names,configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless value == configuration[:accept] + unless value == configuration[:accept] + record.errors.add(attr_name, :accepted, :default => configuration[:message]) + end end end end diff --git a/activemodel/lib/active_model/validations/associated.rb b/activemodel/lib/active_model/validations/associated.rb index b2d78af580..ba4af5d66d 100644 --- a/activemodel/lib/active_model/validations/associated.rb +++ b/activemodel/lib/active_model/validations/associated.rb @@ -18,14 +18,14 @@ module ActiveModel # validates_associated :book # end # - # ...this would specify a circular dependency and cause infinite recursion. + # this would specify a circular dependency and cause infinite recursion. # # NOTE: This validation will not fail if the association hasn't been assigned. If you want to ensure that the association # is both present and guaranteed to be valid, you also need to use +validates_presence_of+. # # Configuration options: # * :message - A custom error message (default is: "is invalid") - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) + # * :on - Specifies when this validation is active (default is :save, other options :create, :update). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. @@ -33,12 +33,13 @@ module ActiveModel # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. def validates_associated(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless - (value.is_a?(Array) ? value : [value]).inject(true) { |v, r| (r.nil? || r.valid?) && v } + unless (value.is_a?(Array) ? value : [value]).collect { |r| r.nil? || r.valid? }.all? + record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) + end end end end diff --git a/activemodel/lib/active_model/validations/confirmation.rb b/activemodel/lib/active_model/validations/confirmation.rb index ba4a18adb7..5f18da58e3 100644 --- a/activemodel/lib/active_model/validations/confirmation.rb +++ b/activemodel/lib/active_model/validations/confirmation.rb @@ -21,8 +21,8 @@ module ActiveModel # validates_presence_of :password_confirmation, :if => :password_changed? # # Configuration options: - # * :message - A custom error message (default is: "doesn't match confirmation") - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) + # * :message - A custom error message (default is: "doesn't match confirmation"). + # * :on - Specifies when this validation is active (default is :save, other options :create, :update). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. @@ -30,13 +30,15 @@ module ActiveModel # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. def validates_confirmation_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:confirmation], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) attr_accessor(*(attr_names.map { |n| "#{n}_confirmation" })) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless record.send("#{attr_name}_confirmation").nil? or value == record.send("#{attr_name}_confirmation") + unless record.send("#{attr_name}_confirmation").nil? or value == record.send("#{attr_name}_confirmation") + record.errors.add(attr_name, :confirmation, :default => configuration[:message]) + end end end end diff --git a/activemodel/lib/active_model/validations/exclusion.rb b/activemodel/lib/active_model/validations/exclusion.rb index 1592000fc7..435ba5cab6 100644 --- a/activemodel/lib/active_model/validations/exclusion.rb +++ b/activemodel/lib/active_model/validations/exclusion.rb @@ -6,14 +6,14 @@ module ActiveModel # class Person < ActiveRecord::Base # 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 %s is not allowed" + # validates_exclusion_of :format, :in => %w( mov avi ), :message => "extension {{value}} is not allowed" # end # # Configuration options: - # * :in - An enumerable object of items that the value shouldn't be part of - # * :message - Specifies a custom error message (default is: "is reserved") - # * :allow_nil - If set to +true+, skips this validation if the attribute is +nil+ (default is: +false+) - # * :allow_blank - If set to +true+, skips this validation if the attribute is blank (default is: +false+) + # * :in - An enumerable object of items that the value shouldn't be part of. + # * :message - Specifies a custom error message (default is: "is reserved"). + # * :allow_nil - If set to true, skips this validation if the attribute is +nil+ (default is +false+). + # * :allow_blank - If set to true, skips this validation if the attribute is blank (default is +false+). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. @@ -21,7 +21,7 @@ module ActiveModel # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. def validates_exclusion_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:exclusion], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) enum = configuration[:in] || configuration[:within] @@ -29,7 +29,9 @@ module ActiveModel raise(ArgumentError, "An object with the method include? is required must be supplied as the :in option of the configuration hash") unless enum.respond_to?(:include?) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message] % value) if enum.include?(value) + if enum.include?(value) + record.errors.add(attr_name, :exclusion, :default => configuration[:message], :value => value) + end end end end diff --git a/activemodel/lib/active_model/validations/format.rb b/activemodel/lib/active_model/validations/format.rb index 1320ef646a..c71567b993 100644 --- a/activemodel/lib/active_model/validations/format.rb +++ b/activemodel/lib/active_model/validations/format.rb @@ -13,11 +13,11 @@ module ActiveModel # A regular expression must be provided or else an exception will be raised. # # Configuration options: - # * :message - A custom error message (default is: "is invalid") - # * :allow_nil - If set to +true+, skips this validation if the attribute is +nil+ (default is: +false+) - # * :allow_blank - If set to +true+, skips this validation if the attribute is blank (default is: +false+) - # * :with - The regular expression used to validate the format with (note: must be supplied!) - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) + # * :message - A custom error message (default is: "is invalid"). + # * :allow_nil - If set to true, skips this validation if the attribute is +nil+ (default is +false+). + # * :allow_blank - If set to true, skips this validation if the attribute is blank (default is +false+). + # * :with - The regular expression used to validate the format with (note: must be supplied!). + # * :on - Specifies when this validation is active (default is :save, other options :create, :update). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. @@ -25,13 +25,15 @@ module ActiveModel # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. def validates_format_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid], :on => :save, :with => nil } + configuration = { :on => :save, :with => nil } configuration.update(attr_names.extract_options!) raise(ArgumentError, "A regular expression must be supplied as the :with option of the configuration hash") unless configuration[:with].is_a?(Regexp) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless value.to_s =~ configuration[:with] + unless value.to_s =~ configuration[:with] + record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) + end end end end diff --git a/activemodel/lib/active_model/validations/inclusion.rb b/activemodel/lib/active_model/validations/inclusion.rb index f288810d90..0cb8e44770 100644 --- a/activemodel/lib/active_model/validations/inclusion.rb +++ b/activemodel/lib/active_model/validations/inclusion.rb @@ -6,14 +6,14 @@ module ActiveModel # class Person < ActiveRecord::Base # 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 %s is not included in the list" + # validates_inclusion_of :format, :in => %w( jpg gif png ), :message => "extension {{value}} is not included in the list" # end # # Configuration options: - # * :in - An enumerable object of available items - # * :message - Specifies a custom error message (default is: "is not included in the list") - # * :allow_nil - If set to +true+, skips this validation if the attribute is null (default is: +false+) - # * :allow_blank - If set to +true+, skips this validation if the attribute is blank (default is: +false+) + # * :in - An enumerable object of available items. + # * :message - Specifies a custom error message (default is: "is not included in the list"). + # * :allow_nil - If set to true, skips this validation if the attribute is +nil+ (default is +false+). + # * :allow_blank - If set to true, skips this validation if the attribute is blank (default is +false+). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. @@ -21,7 +21,7 @@ module ActiveModel # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. def validates_inclusion_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:inclusion], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) enum = configuration[:in] || configuration[:within] @@ -29,7 +29,9 @@ module ActiveModel raise(ArgumentError, "An object with the method include? is required must be supplied as the :in option of the configuration hash") unless enum.respond_to?(:include?) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message] % value) unless enum.include?(value) + unless enum.include?(value) + record.errors.add(attr_name, :inclusion, :default => configuration[:message], :value => value) + end end end end diff --git a/activemodel/lib/active_model/validations/length.rb b/activemodel/lib/active_model/validations/length.rb index 673ad33974..f6bb1f6d70 100644 --- a/activemodel/lib/active_model/validations/length.rb +++ b/activemodel/lib/active_model/validations/length.rb @@ -2,44 +2,46 @@ module ActiveModel module Validations module ClassMethods ALL_RANGE_OPTIONS = [ :is, :within, :in, :minimum, :maximum ].freeze - + # Validates that the specified attribute matches the length restrictions supplied. Only one option can be used at a time: # # class Person < ActiveRecord::Base - # validates_length_of :first_name, :maximum => 30 - # validates_length_of :last_name, :maximum => 30, :message => "less than %d if you don't mind" + # validates_length_of :first_name, :maximum=>30 + # validates_length_of :last_name, :maximum=>30, :message=>"less than {{count}} if you don't mind" # validates_length_of :fax, :in => 7..32, :allow_nil => true # validates_length_of :phone, :in => 7..32, :allow_blank => true # validates_length_of :user_name, :within => 6..20, :too_long => "pick a shorter name", :too_short => "pick a longer name" - # validates_length_of :fav_bra_size, :minimum => 1, :too_short => "please enter at least %d character" - # validates_length_of :smurf_leader, :is => 4, :message => "papa is spelled with %d characters... don't play me." + # validates_length_of :fav_bra_size, :minimum => 1, :too_short => "please enter at least {{count}} character" + # validates_length_of :smurf_leader, :is => 4, :message => "papa is spelled with {{count}} characters... don't play me." + # validates_length_of :essay, :minimum => 100, :too_short => "Your essay must be at least {{count}} words."), :tokenizer => lambda {|str| str.scan(/\w+/) } # end # # Configuration options: - # * :minimum - The minimum size of the attribute - # * :maximum - The maximum size of the attribute - # * :is - The exact size of the attribute - # * :within - A range specifying the minimum and maximum size of the attribute - # * :in - A synonym (or alias) for :within + # * :minimum - The minimum size of the attribute. + # * :maximum - The maximum size of the attribute. + # * :is - The exact size of the attribute. + # * :within - A range specifying the minimum and maximum size of the attribute. + # * :in - A synonym(or alias) for :within. # * :allow_nil - Attribute may be +nil+; skip validation. # * :allow_blank - Attribute may be blank; skip validation. - # * :too_long - The error message if the attribute goes over the maximum (default is: "is too long (maximum is %d characters)") - # * :too_short - The error message if the attribute goes under the minimum (default is: "is too short (min is %d characters)") - # * :wrong_length - The error message if using the :is method and the attribute is the wrong size (default is: "is the wrong length (should be %d characters)") - # * :message - The error message to use for a :minimum, :maximum, or :is violation. An alias of the appropriate :too_long/too_short/wrong_length message - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) + # * :too_long - The error message if the attribute goes over the maximum (default is: "is too long (maximum is {{count}} characters)"). + # * :too_short - The error message if the attribute goes under the minimum (default is: "is too short (min is {{count}} characters)"). + # * :wrong_length - The error message if using the :is method and the attribute is the wrong size (default is: "is the wrong length (should be {{count}} characters)"). + # * :message - The error message to use for a :minimum, :maximum, or :is violation. An alias of the appropriate too_long/too_short/wrong_length message. + # * :on - Specifies when this validation is active (default is :save, other options :create, :update). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. # * :unless - Specifies a method, proc or string to call to determine if the validation should # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. + # * :tokenizer - Specifies how to split up the attribute string. (e.g. :tokenizer => lambda {|str| str.scan(/\w+/)} to + # count words as in above example.) + # Defaults to lambda{ |value| value.split(//) } which counts individual characters. def validates_length_of(*attrs) # Merge given options with defaults. options = { - :too_long => ActiveRecord::Errors.default_error_messages[:too_long], - :too_short => ActiveRecord::Errors.default_error_messages[:too_short], - :wrong_length => ActiveRecord::Errors.default_error_messages[:wrong_length] + :tokenizer => lambda {|value| value.split(//)} }.merge(DEFAULT_VALIDATION_OPTIONS) options.update(attrs.extract_options!.symbolize_keys) @@ -57,35 +59,33 @@ module ActiveModel # Get range option and value. option = range_options.first option_value = options[range_options.first] + key = {:is => :wrong_length, :minimum => :too_short, :maximum => :too_long}[option] + custom_message = options[:message] || options[key] case option - when :within, :in - raise ArgumentError, ":#{option} must be a Range" unless option_value.is_a?(Range) - - too_short = options[:too_short] % option_value.begin - too_long = options[:too_long] % option_value.end + when :within, :in + raise ArgumentError, ":#{option} must be a Range" unless option_value.is_a?(Range) - validates_each(attrs, options) do |record, attr, value| - value = value.split(//) if value.kind_of?(String) - if value.nil? or value.size < option_value.begin - record.errors.add(attr, too_short) - elsif value.size > option_value.end - record.errors.add(attr, too_long) - end + validates_each(attrs, options) do |record, attr, value| + value = options[:tokenizer].call(value) if value.kind_of?(String) + if value.nil? or value.size < option_value.begin + record.errors.add(attr, :too_short, :default => custom_message || options[:too_short], :count => option_value.begin) + elsif value.size > option_value.end + record.errors.add(attr, :too_long, :default => custom_message || options[:too_long], :count => option_value.end) end - when :is, :minimum, :maximum - raise ArgumentError, ":#{option} must be a nonnegative Integer" unless option_value.is_a?(Integer) and option_value >= 0 - - # Declare different validations per option. - validity_checks = { :is => "==", :minimum => ">=", :maximum => "<=" } - message_options = { :is => :wrong_length, :minimum => :too_short, :maximum => :too_long } + end + when :is, :minimum, :maximum + raise ArgumentError, ":#{option} must be a nonnegative Integer" unless option_value.is_a?(Integer) and option_value >= 0 - message = (options[:message] || options[message_options[option]]) % option_value + # Declare different validations per option. + validity_checks = { :is => "==", :minimum => ">=", :maximum => "<=" } - validates_each(attrs, options) do |record, attr, value| - value = value.split(//) if value.kind_of?(String) - record.errors.add(attr, message) unless !value.nil? and value.size.method(validity_checks[option])[option_value] + validates_each(attrs, options) do |record, attr, value| + value = options[:tokenizer].call(value) if value.kind_of?(String) + unless !value.nil? and value.size.method(validity_checks[option])[option_value] + record.errors.add(attr, key, :default => custom_message, :count => option_value) end + end end end diff --git a/activemodel/lib/active_model/validations/numericality.rb b/activemodel/lib/active_model/validations/numericality.rb index 92ca5f4082..d8e3470304 100644 --- a/activemodel/lib/active_model/validations/numericality.rb +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -5,10 +5,9 @@ module ActiveModel :equal_to => '==', :less_than => '<', :less_than_or_equal_to => '<=', :odd => 'odd?', :even => 'even?' }.freeze - # Validates whether the value of the specified attribute is numeric by trying to convert it to - # a float with Kernel.Float (if integer is false) or applying it to the regular expression - # /\A[\+\-]?\d+\Z/ (if integer is true). + # a float with Kernel.Float (if only_integer is false) or applying it to the regular expression + # /\A[\+\-]?\d+\Z/ (if only_integer is set to true). # # class Person < ActiveRecord::Base # validates_numericality_of :value, :on => :create @@ -50,15 +49,15 @@ module ActiveModel if configuration[:only_integer] unless raw_value.to_s =~ /\A[+-]?\d+\Z/ - record.errors.add(attr_name, configuration[:message] || ActiveRecord::Errors.default_error_messages[:not_a_number]) + record.errors.add(attr_name, :not_a_number, :value => raw_value, :default => configuration[:message]) next end raw_value = raw_value.to_i else - begin - raw_value = Kernel.Float(raw_value.to_s) + begin + raw_value = Kernel.Float(raw_value) rescue ArgumentError, TypeError - record.errors.add(attr_name, configuration[:message] || ActiveRecord::Errors.default_error_messages[:not_a_number]) + record.errors.add(attr_name, :not_a_number, :value => raw_value, :default => configuration[:message]) next end end @@ -66,11 +65,13 @@ module ActiveModel numericality_options.each do |option| case option when :odd, :even - record.errors.add(attr_name, configuration[:message] || ActiveRecord::Errors.default_error_messages[option]) unless raw_value.to_i.method(ALL_NUMERICALITY_CHECKS[option])[] + unless raw_value.to_i.method(ALL_NUMERICALITY_CHECKS[option])[] + record.errors.add(attr_name, option, :value => raw_value, :default => configuration[:message]) + end else - message = configuration[:message] || ActiveRecord::Errors.default_error_messages[option] - message = message % configuration[option] if configuration[option] - record.errors.add(attr_name, message) unless raw_value.method(ALL_NUMERICALITY_CHECKS[option])[configuration[option]] + unless raw_value.method(ALL_NUMERICALITY_CHECKS[option])[configuration[option]] + record.errors.add(attr_name, option, :default => configuration[:message], :value => raw_value, :count => configuration[option]) + end end end end diff --git a/activemodel/lib/active_model/validations/presence.rb b/activemodel/lib/active_model/validations/presence.rb index 62e466901b..4fd60621cd 100644 --- a/activemodel/lib/active_model/validations/presence.rb +++ b/activemodel/lib/active_model/validations/presence.rb @@ -7,28 +7,26 @@ module ActiveModel # validates_presence_of :first_name # end # - # The +first_name+ attribute must be in the object and it cannot be blank. + # The first_name attribute must be in the object and it cannot be blank. # - # If you want to validate the presence of a boolean field (where the real values are +true+ and +false+), - # you will want to use + # If you want to validate the presence of a boolean field (where the real values are true and false), + # you will want to use validates_inclusion_of :field_name, :in => [true, false]. # - # validates_inclusion_of :field_name, :in => [true, false] - # - # This is due to the way Object#blank? handles boolean values: - # - # false.blank? # => true + # This is due to the way Object#blank? handles boolean values: false.blank? # => true. # # Configuration options: - # * :message - A custom error message (default is: "can't be blank") - # * :on - Specifies when this validation is active (default is :save, other options :create, :update) - # * :if - Specifies a method, proc or string to call to determine if the validation should - # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The - # method, proc or string should return or evaluate to a true or false value. - # * :unless - Specifies a method, proc or string to call to determine if the validation should - # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The - # method, proc or string should return or evaluate to a true or false value. + # * message - A custom error message (default is: "can't be blank"). + # * on - Specifies when this validation is active (default is :save, other options :create, + # :update). + # * if - Specifies a method, proc or string to call to determine if the validation should + # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). + # The method, proc or string should return or evaluate to a true or false value. + # * unless - Specifies a method, proc or string to call to determine if the validation should + # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). + # The method, proc or string should return or evaluate to a true or false value. + # def validates_presence_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:blank], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) # can't use validates_each here, because it cannot cope with nonexistent attributes, diff --git a/activemodel/lib/active_model/validations/uniqueness.rb b/activemodel/lib/active_model/validations/uniqueness.rb index 593d2cfd54..37beac863f 100644 --- a/activemodel/lib/active_model/validations/uniqueness.rb +++ b/activemodel/lib/active_model/validations/uniqueness.rb @@ -18,24 +18,84 @@ module ActiveModel # When the record is created, a check is performed to make sure that no record exists in the database with the given value for the specified # attribute (that maps to a column). When the record is updated, the same check is made but disregarding the record itself. # - # Because this check is performed outside the database there is still a chance that duplicate values - # will be inserted in two parallel transactions. To guarantee against this you should create a - # unique index on the field. See +add_index+ for more information. - # # Configuration options: - # * :message - Specifies a custom error message (default is: "has already been taken") + # * :message - Specifies a custom error message (default is: "has already been taken"). # * :scope - One or more columns by which to limit the scope of the uniqueness constraint. - # * :case_sensitive - Looks for an exact match. Ignored by non-text columns (+true+ by default). - # * :allow_nil - If set to +true+, skips this validation if the attribute is +nil+ (default is: +false+) - # * :allow_blank - If set to +true+, skips this validation if the attribute is blank (default is: +false+) + # * :case_sensitive - Looks for an exact match. Ignored by non-text columns (+true+ by default). + # * :allow_nil - If set to true, skips this validation if the attribute is +nil+ (default is +false+). + # * :allow_blank - If set to true, skips this validation if the attribute is blank (default is +false+). # * :if - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. :if => :allow_validation, or :if => Proc.new { |user| user.signup_step > 2 }). The # method, proc or string should return or evaluate to a true or false value. # * :unless - Specifies a method, proc or string to call to determine if the validation should # not occur (e.g. :unless => :skip_validation, or :unless => Proc.new { |user| user.signup_step <= 2 }). The # method, proc or string should return or evaluate to a true or false value. + # + # === Concurrency and integrity + # + # Using this validation method in conjunction with ActiveRecord::Base#save + # does not guarantee the absence of duplicate record insertions, because + # uniqueness checks on the application level are inherently prone to race + # conditions. For example, suppose that two users try to post a Comment at + # the same time, and a Comment's title must be unique. At the database-level, + # the actions performed by these users could be interleaved in the following manner: + # + # User 1 | User 2 + # ------------------------------------+-------------------------------------- + # # User 1 checks whether there's | + # # already a comment with the title | + # # 'My Post'. This is not the case. | + # SELECT * FROM comments | + # WHERE title = 'My Post' | + # | + # | # User 2 does the same thing and also + # | # infers that his title is unique. + # | SELECT * FROM comments + # | WHERE title = 'My Post' + # | + # # User 1 inserts his comment. | + # INSERT INTO comments | + # (title, content) VALUES | + # ('My Post', 'hi!') | + # | + # | # User 2 does the same thing. + # | INSERT INTO comments + # | (title, content) VALUES + # | ('My Post', 'hello!') + # | + # | # ^^^^^^ + # | # Boom! We now have a duplicate + # | # title! + # + # This could even happen if you use transactions with the 'serializable' + # isolation level. There are several ways to get around this problem: + # - By locking the database table before validating, and unlocking it after + # saving. However, table locking is very expensive, and thus not + # recommended. + # - By locking a lock file before validating, and unlocking it after saving. + # This does not work if you've scaled your Rails application across + # multiple web servers (because they cannot share lock files, or cannot + # do that efficiently), and thus not recommended. + # - Creating a unique index on the field, by using + # ActiveRecord::ConnectionAdapters::SchemaStatements#add_index. In the + # rare case that a race condition occurs, the database will guarantee + # the field's uniqueness. + # + # When the database catches such a duplicate insertion, + # ActiveRecord::Base#save will raise an ActiveRecord::StatementInvalid + # exception. You can either choose to let this error propagate (which + # will result in the default Rails exception page being shown), or you + # can catch it and restart the transaction (e.g. by telling the user + # that the title already exists, and asking him to re-enter the title). + # This technique is also known as optimistic concurrency control: + # http://en.wikipedia.org/wiki/Optimistic_concurrency_control + # + # Active Record currently provides no way to distinguish unique + # index constraint errors from other types of database errors, so you + # will have to parse the (database-specific) exception message to detect + # such a case. def validates_uniqueness_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:taken] } + configuration = { :case_sensitive => true } configuration.update(attr_names.extract_options!) validates_each(attr_names,configuration) do |record, attr_name, value| @@ -53,20 +113,31 @@ module ActiveModel # class (which has a database table to query from). finder_class = class_hierarchy.detect { |klass| !klass.abstract_class? } - if value.nil? || (configuration[:case_sensitive] || !finder_class.columns_hash[attr_name.to_s].text?) - condition_sql = "#{record.class.quoted_table_name}.#{attr_name} #{attribute_condition(value)}" + column = finder_class.columns_hash[attr_name.to_s] + + if value.nil? + comparison_operator = "IS ?" + elsif column.text? + comparison_operator = "#{connection.case_sensitive_equality_operator} ?" + value = column.limit ? value.to_s[0, column.limit] : value.to_s + else + comparison_operator = "= ?" + end + + sql_attribute = "#{record.class.quoted_table_name}.#{connection.quote_column_name(attr_name)}" + + if value.nil? || (configuration[:case_sensitive] || !column.text?) + condition_sql = "#{sql_attribute} #{comparison_operator}" condition_params = [value] else - # sqlite has case sensitive SELECT query, while MySQL/Postgresql don't. - # Hence, this is needed only for sqlite. - condition_sql = "LOWER(#{record.class.quoted_table_name}.#{attr_name}) #{attribute_condition(value)}" - condition_params = [value.downcase] + condition_sql = "LOWER(#{sql_attribute}) #{comparison_operator}" + condition_params = [value.mb_chars.downcase] end if scope = configuration[:scope] Array(scope).map do |scope_item| scope_value = record.send(scope_item) - condition_sql << " AND #{record.class.quoted_table_name}.#{scope_item} #{attribute_condition(scope_value)}" + condition_sql << " AND " << attribute_condition("#{record.class.quoted_table_name}.#{scope_item}", scope_value) condition_params << scope_value end end @@ -76,26 +147,10 @@ module ActiveModel condition_params << record.send(:id) end - results = finder_class.with_exclusive_scope do - connection.select_all( - construct_finder_sql( - :select => attr_name, - :from => finder_class.quoted_table_name, - :conditions => [condition_sql, *condition_params] - ) - ) - end - - unless results.length.zero? - found = true - - # As MySQL/Postgres don't have case sensitive SELECT queries, we try to find duplicate - # column in ruby when case sensitive option - if configuration[:case_sensitive] && finder_class.columns_hash[attr_name.to_s].text? - found = results.any? { |a| a[attr_name.to_s] == value } + finder_class.with_exclusive_scope do + if finder_class.exists?([condition_sql, *condition_params]) + record.errors.add(attr_name, :taken, :default => configuration[:message], :value => value) end - - record.errors.add(attr_name, configuration[:message]) if found end end end -- cgit v1.2.3