diff options
Diffstat (limited to 'activemodel')
18 files changed, 687 insertions, 131 deletions
diff --git a/activemodel/CHANGELOG b/activemodel/CHANGELOG new file mode 100644 index 0000000000..26500568ee --- /dev/null +++ b/activemodel/CHANGELOG @@ -0,0 +1,11 @@ +*Edge* + +* Add validates_format_of :without => /regexp/ option. #430 [Elliot Winkler, Peer Allan] + + Example : + + validates_format_of :subdomain, :without => /www|admin|mail/ + +* Introduce validates_with to encapsulate attribute validations in a class. #2630 [Jeff Dean] + +* Extracted from Active Record and Active Resource. diff --git a/activemodel/CHANGES b/activemodel/CHANGES index a9f9c27507..217a6d6bf7 100644 --- a/activemodel/CHANGES +++ b/activemodel/CHANGES @@ -9,4 +9,4 @@ Changes from extracting bits to ActiveModel klass.add_observer(self) klass.class_eval 'def after_find() end' unless klass.respond_to?(:after_find) - end
\ No newline at end of file + end diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index 9bb4cf8b54..5bb931be7f 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -29,12 +29,14 @@ module ActiveModel autoload :AttributeMethods, 'active_model/attribute_methods' autoload :Conversion, 'active_model/conversion' autoload :DeprecatedErrorMethods, 'active_model/deprecated_error_methods' + autoload :Dirty, 'active_model/dirty' autoload :Errors, 'active_model/errors' + autoload :Lint, 'active_model/lint' autoload :Name, 'active_model/naming' autoload :Naming, 'active_model/naming' autoload :Observer, 'active_model/observing' autoload :Observing, 'active_model/observing' - autoload :Serializer, 'active_model/serializer' + autoload :Serialization, 'active_model/serialization' autoload :StateMachine, 'active_model/state_machine' autoload :TestCase, 'active_model/test_case' autoload :Validations, 'active_model/validations' diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb index de80559036..1091ad3095 100644 --- a/activemodel/lib/active_model/attribute_methods.rb +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -133,18 +133,31 @@ module ActiveModel undefine_attribute_methods end + def alias_attribute(new_name, old_name) + attribute_method_matchers.each do |matcher| + module_eval <<-STR, __FILE__, __LINE__+1 + def #{matcher.method_name(new_name)}(*args) + send(:#{matcher.method_name(old_name)}, *args) + end + STR + end + end + def define_attribute_methods(attr_names) return if attribute_methods_generated? - attr_names.each do |name| - attribute_method_matchers.each do |method| - method_name = "#{method.prefix}#{name}#{method.suffix}" - unless instance_method_already_implemented?(method_name) - generate_method = "define_method_#{method.prefix}attribute#{method.suffix}" + attr_names.each do |attr_name| + attribute_method_matchers.each do |matcher| + unless instance_method_already_implemented?(matcher.method_name(attr_name)) + generate_method = "define_method_#{matcher.prefix}attribute#{matcher.suffix}" if respond_to?(generate_method) - send(generate_method, name) + send(generate_method, attr_name) else - generated_attribute_methods.module_eval("def #{method_name}(*args); send(:#{method.prefix}attribute#{method.suffix}, '#{name}', *args); end", __FILE__, __LINE__) + generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__+1 + def #{matcher.method_name(attr_name)}(*args) + send(:#{matcher.method_missing_target}, '#{attr_name}', *args) + end + STR end end end @@ -180,7 +193,7 @@ module ActiveModel class AttributeMethodMatcher attr_reader :prefix, :suffix - AttributeMethodMatch = Struct.new(:prefix, :base, :suffix) + AttributeMethodMatch = Struct.new(:target, :attr_name) def initialize(options = {}) options.symbolize_keys! @@ -190,11 +203,19 @@ module ActiveModel def match(method_name) if matchdata = @regex.match(method_name) - AttributeMethodMatch.new(matchdata[1], matchdata[2], matchdata[3]) + AttributeMethodMatch.new(method_missing_target, matchdata[2]) else nil end end + + def method_name(attr_name) + "#{prefix}#{attr_name}#{suffix}" + end + + def method_missing_target + :"#{prefix}attribute#{suffix}" + end end def attribute_method_matchers #:nodoc: @@ -214,7 +235,7 @@ module ActiveModel method_name = method_id.to_s if match = match_attribute_method?(method_name) guard_private_attribute_method!(method_name, args) - return __send__("#{match.prefix}attribute#{match.suffix}", match.base, *args, &block) + return __send__(match.target, match.attr_name, *args, &block) end super end @@ -246,7 +267,7 @@ module ActiveModel # The struct's attributes are prefix, base and suffix. def match_attribute_method?(method_name) self.class.send(:attribute_method_matchers).each do |method| - if (match = method.match(method_name)) && attribute_method?(match.base) + if (match = method.match(method_name)) && attribute_method?(match.attr_name) return match end end diff --git a/activemodel/lib/active_model/dirty.rb b/activemodel/lib/active_model/dirty.rb new file mode 100644 index 0000000000..735c61df74 --- /dev/null +++ b/activemodel/lib/active_model/dirty.rb @@ -0,0 +1,126 @@ +module ActiveModel + # Track unsaved attribute changes. + # + # A newly instantiated object is unchanged: + # person = Person.find_by_name('Uncle Bob') + # person.changed? # => false + # + # Change the name: + # person.name = 'Bob' + # person.changed? # => true + # person.name_changed? # => true + # person.name_was # => 'Uncle Bob' + # person.name_change # => ['Uncle Bob', 'Bob'] + # person.name = 'Bill' + # person.name_change # => ['Uncle Bob', 'Bill'] + # + # Save the changes: + # person.save + # person.changed? # => false + # person.name_changed? # => false + # + # Assigning the same value leaves the attribute unchanged: + # person.name = 'Bill' + # person.name_changed? # => false + # person.name_change # => nil + # + # Which attributes have changed? + # person.name = 'Bob' + # person.changed # => ['name'] + # person.changes # => { 'name' => ['Bill', 'Bob'] } + # + # Resetting an attribute returns it to its original state: + # person.reset_name! # => 'Bill' + # person.changed? # => false + # person.name_changed? # => false + # person.name # => 'Bill' + # + # Before modifying an attribute in-place: + # person.name_will_change! + # person.name << 'y' + # person.name_change # => ['Bill', 'Billy'] + module Dirty + extend ActiveSupport::Concern + include ActiveModel::AttributeMethods + + included do + attribute_method_suffix '_changed?', '_change', '_will_change!', '_was' + attribute_method_affix :prefix => 'reset_', :suffix => '!' + end + + # Do any attributes have unsaved changes? + # person.changed? # => false + # person.name = 'bob' + # person.changed? # => true + def changed? + !changed_attributes.empty? + end + + # List of attributes with unsaved changes. + # person.changed # => [] + # person.name = 'bob' + # person.changed # => ['name'] + def changed + changed_attributes.keys + end + + # Map of changed attrs => [original value, new value]. + # person.changes # => {} + # person.name = 'bob' + # person.changes # => { 'name' => ['bill', 'bob'] } + def changes + changed.inject({}) { |h, attr| h[attr] = attribute_change(attr); h } + end + + # Map of attributes that were changed when the model was saved. + # person.name # => 'bob' + # person.name = 'robert' + # person.save + # person.previous_changes # => {'name' => ['bob, 'robert']} + def previous_changes + previously_changed_attributes + end + + private + # Map of change <tt>attr => original value</tt>. + def changed_attributes + @changed_attributes ||= {} + end + + # Map of fields that were changed when the model was saved + def previously_changed_attributes + @previously_changed || {} + end + + # Handle <tt>*_changed?</tt> for +method_missing+. + def attribute_changed?(attr) + changed_attributes.include?(attr) + end + + # Handle <tt>*_change</tt> for +method_missing+. + def attribute_change(attr) + [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr) + end + + # Handle <tt>*_was</tt> for +method_missing+. + def attribute_was(attr) + attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr) + end + + # Handle <tt>*_will_change!</tt> for +method_missing+. + def attribute_will_change!(attr) + begin + value = __send__(attr) + value = value.duplicable? ? value.clone : value + rescue TypeError, NoMethodError + end + + changed_attributes[attr] = value + end + + # Handle <tt>reset_*!</tt> for +method_missing+. + def reset_attribute!(attr) + __send__("#{attr}=", changed_attributes[attr]) if attribute_changed?(attr) + end + end +end diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb new file mode 100644 index 0000000000..46af8ca9de --- /dev/null +++ b/activemodel/lib/active_model/lint.rb @@ -0,0 +1,96 @@ +require "test/unit" +require "test/unit/ui/console/testrunner" + +# You can test whether an object is compliant with the ActiveModel API by +# calling ActiveModel::Compliance.test(object). It will emit a Test::Unit +# output that tells you whether your object is fully compliant, or if not, +# which aspects of the API are not implemented. +# +# These tests do not attempt to determine the semantic correctness of the +# returned values. For instance, you could implement valid? to always +# return true, and the tests would pass. It is up to you to ensure that +# the values are semantically meaningful. +# +# Objects you pass in are expected to return a compliant object from a +# call to to_model. It is perfectly fine for to_model to return self. + +module ActiveModel + module Lint + def self.test(object, verbosity = 2, output = STDOUT) + test_class = Class.new(::Test::Unit::TestCase) do + include Test + + define_method(:setup) do + assert object.respond_to?(:to_model), "The object should respond_to :to_model" + @object = object.to_model + super + end + end + + ::Test::Unit::UI::Console::TestRunner.new(test_class, verbosity, output).start + end + + module Test + def assert_boolean(name, result) + assert result == true || result == false, "#{name} should be a boolean" + end + + # valid? + # ------ + # + # Returns a boolean that specifies whether the object is in a valid or invalid + # state. + def test_valid? + assert @object.respond_to?(:valid?), "The model should respond to valid?" + assert_boolean "valid?", @object.valid? + end + + # new_record? + # ----------- + # + # Returns a boolean that specifies whether the object has been persisted yet. + # This is used when calculating the URL for an object. If the object is + # not persisted, a form for that object, for instance, will be POSTed to the + # collection. If it is persisted, a form for the object will put PUTed to the + # URL for the object. + def test_new_record? + assert @object.respond_to?(:new_record?), "The model should respond to new_record?" + assert_boolean "new_record?", @object.new_record? + end + + def test_destroyed? + assert @object.respond_to?(:new_record?), "The model should respond to destroyed?" + assert_boolean "destroyed?", @object.destroyed? + end + + # errors + # ------ + # + # Returns an object that has :[] and :full_messages defined on it. See below + # for more details. + def setup + assert @object.respond_to?(:errors), "The model should respond to errors" + @errors = @object.errors + end + + # This module tests the #errors object + module Errors + # Returns an Array of Strings that are the errors for the attribute in + # question. If localization is used, the Strings should be localized + # for the current locale. If no error is present, this method should + # return an empty Array. + def test_errors_aref + assert @errors[:hello].is_a?(Array), "errors#[] should return an Array" + end + + # Returns an Array of all error messages for the object. Each message + # should contain information about the field, if applicable. + def test_errors_full_messages + assert @errors.full_messages.is_a?(Array), "errors#full_messages should return an Array" + end + end + + include Errors + end + end +end
\ No newline at end of file diff --git a/activemodel/lib/active_model/serialization.rb b/activemodel/lib/active_model/serialization.rb new file mode 100644 index 0000000000..4c0073f687 --- /dev/null +++ b/activemodel/lib/active_model/serialization.rb @@ -0,0 +1,30 @@ +require 'active_support/core_ext/hash/except' +require 'active_support/core_ext/hash/slice' + +module ActiveModel + module Serialization + def serializable_hash(options = nil) + options ||= {} + + options[:only] = Array.wrap(options[:only]).map { |n| n.to_s } + options[:except] = Array.wrap(options[:except]).map { |n| n.to_s } + + attribute_names = attributes.keys.sort + if options[:only].any? + attribute_names &= options[:only] + elsif options[:except].any? + attribute_names -= options[:except] + end + + method_names = Array.wrap(options[:methods]).inject([]) do |methods, name| + methods << name if respond_to?(name.to_s) + methods + end + + (attribute_names + method_names).inject({}) { |hash, name| + hash[name] = send(name) + hash + } + end + end +end diff --git a/activemodel/lib/active_model/serializer.rb b/activemodel/lib/active_model/serializer.rb deleted file mode 100644 index 5b603bdbd7..0000000000 --- a/activemodel/lib/active_model/serializer.rb +++ /dev/null @@ -1,60 +0,0 @@ -require 'active_support/core_ext/hash/except' -require 'active_support/core_ext/hash/slice' - -module ActiveModel - class Serializer - attr_reader :options - - def initialize(serializable, options = nil) - @serializable = serializable - @options = options ? options.dup : {} - - @options[:only] = Array.wrap(@options[:only]).map { |n| n.to_s } - @options[:except] = Array.wrap(@options[:except]).map { |n| n.to_s } - end - - def serialize - raise NotImplemented - end - - def to_s(&block) - serialize(&block) - end - - # To replicate the behavior in ActiveRecord#attributes, - # <tt>:except</tt> takes precedence over <tt>:only</tt>. If <tt>:only</tt> is not set - # for a N level model but is set for the N+1 level models, - # then because <tt>:except</tt> is set to a default value, the second - # level model can have both <tt>:except</tt> and <tt>:only</tt> set. So if - # <tt>:only</tt> is set, always delete <tt>:except</tt>. - def serializable_attribute_names - attribute_names = @serializable.attributes.keys.sort - - if options[:only].any? - attribute_names &= options[:only] - elsif options[:except].any? - attribute_names -= options[:except] - end - - attribute_names - end - - def serializable_method_names - Array.wrap(options[:methods]).inject([]) do |methods, name| - methods << name if @serializable.respond_to?(name.to_s) - methods - end - end - - def serializable_names - serializable_attribute_names + serializable_method_names - end - - def serializable_hash - serializable_names.inject({}) { |hash, name| - hash[name] = @serializable.send(name) - hash - } - end - end -end diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb index e94512fd64..ee6d48bfc6 100644 --- a/activemodel/lib/active_model/serializers/json.rb +++ b/activemodel/lib/active_model/serializers/json.rb @@ -5,6 +5,7 @@ module ActiveModel module Serializers module JSON extend ActiveSupport::Concern + include ActiveModel::Serialization included do extend ActiveModel::Naming @@ -12,19 +13,6 @@ module ActiveModel cattr_accessor :include_root_in_json, :instance_writer => false end - class Serializer < ActiveModel::Serializer - def serializable_hash - model = super - @serializable.include_root_in_json ? - { @serializable.class.model_name.element => model } : - model - end - - def serialize - ActiveSupport::JSON.encode(serializable_hash) - end - end - # Returns a JSON string representing the model. Some configuration is # available through +options+. # @@ -92,7 +80,9 @@ module ActiveModel # {"comments": [{"body": "Don't think too hard"}], # "title": "So I was thinking"}]} def encode_json(encoder) - Serializer.new(self, encoder.options).to_s + hash = serializable_hash(encoder.options) + hash = { self.class.model_name.element => hash } if include_root_in_json + ActiveSupport::JSON.encode(hash) end def as_json(options = nil) diff --git a/activemodel/lib/active_model/serializers/xml.rb b/activemodel/lib/active_model/serializers/xml.rb index 4508a39347..86149f1e5f 100644 --- a/activemodel/lib/active_model/serializers/xml.rb +++ b/activemodel/lib/active_model/serializers/xml.rb @@ -5,8 +5,9 @@ module ActiveModel module Serializers module Xml extend ActiveSupport::Concern + include ActiveModel::Serialization - class Serializer < ActiveModel::Serializer #:nodoc: + class Serializer #:nodoc: class Attribute #:nodoc: attr_reader :name, :value, :type @@ -74,32 +75,32 @@ module ActiveModel end end - def builder - @builder ||= begin - require 'builder' unless defined? ::Builder - options[:indent] ||= 2 - builder = options[:builder] ||= ::Builder::XmlMarkup.new(:indent => options[:indent]) + attr_reader :options - unless options[:skip_instruct] - builder.instruct! - options[:skip_instruct] = true - end + def initialize(serializable, options = nil) + @serializable = serializable + @options = options ? options.dup : {} - builder - end + @options[:only] = Array.wrap(@options[:only]).map { |n| n.to_s } + @options[:except] = Array.wrap(@options[:except]).map { |n| n.to_s } end - def root - root = (options[:root] || @serializable.class.model_name.singular).to_s - reformat_name(root) - end - - def dasherize? - !options.has_key?(:dasherize) || options[:dasherize] - end + # To replicate the behavior in ActiveRecord#attributes, + # <tt>:except</tt> takes precedence over <tt>:only</tt>. If <tt>:only</tt> is not set + # for a N level model but is set for the N+1 level models, + # then because <tt>:except</tt> is set to a default value, the second + # level model can have both <tt>:except</tt> and <tt>:only</tt> set. So if + # <tt>:only</tt> is set, always delete <tt>:except</tt>. + def serializable_attribute_names + attribute_names = @serializable.attributes.keys.sort + + if options[:only].any? + attribute_names &= options[:only] + elsif options[:except].any? + attribute_names -= options[:except] + end - def camelize? - options.has_key?(:camelize) && options[:camelize] + attribute_names end def serializable_attributes @@ -134,6 +135,34 @@ module ActiveModel end private + def builder + @builder ||= begin + require 'builder' unless defined? ::Builder + options[:indent] ||= 2 + builder = options[:builder] ||= ::Builder::XmlMarkup.new(:indent => options[:indent]) + + unless options[:skip_instruct] + builder.instruct! + options[:skip_instruct] = true + end + + builder + end + end + + def root + root = (options[:root] || @serializable.class.model_name.singular).to_s + reformat_name(root) + end + + def dasherize? + !options.has_key?(:dasherize) || options[:dasherize] + end + + def camelize? + options.has_key?(:camelize) && options[:camelize] + end + def reformat_name(name) name = name.camelize if camelize? dasherize? ? name.dasherize : name @@ -163,8 +192,7 @@ module ActiveModel end def to_xml(options = {}, &block) - serializer = Serializer.new(self, options) - block_given? ? serializer.to_s(&block) : serializer.to_s + Serializer.new(self, options).serialize(&block) end def from_xml(xml) diff --git a/activemodel/lib/active_model/validations/format.rb b/activemodel/lib/active_model/validations/format.rb index 6f3b668bf0..c670dafc7c 100644 --- a/activemodel/lib/active_model/validations/format.rb +++ b/activemodel/lib/active_model/validations/format.rb @@ -1,22 +1,30 @@ module ActiveModel module Validations module ClassMethods - # Validates whether the value of the specified attribute is of the correct form by matching it against the regular expression - # provided. + # Validates whether the value of the specified attribute is of the correct form, going by the regular expression provided. + # You can require that the attribute matches the regular expression: # # class Person < ActiveRecord::Base # validates_format_of :email, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/i, :on => :create # end # + # Alternatively, you can require that the specified attribute does _not_ match the regular expression: + # + # class Person < ActiveRecord::Base + # validates_format_of :email, :without => /NOSPAM/ + # 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. # - # A regular expression must be provided 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 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> - The regular expression used to validate the format with (note: must be supplied!). + # * <tt>:with</tt> - Regular expression that if the attribute matches will result in a successful validation. + # * <tt>:without</tt> - Regular expression that if the attribute does not match will result in a successful validation. # * <tt>:on</tt> - Specifies when this validation is active (default is <tt>:save</tt>, other options <tt>:create</tt>, <tt>:update</tt>). # * <tt>:if</tt> - Specifies a method, proc or string to call to determine if the validation should # occur (e.g. <tt>:if => :allow_validation</tt>, or <tt>:if => Proc.new { |user| user.signup_step > 2 }</tt>). The @@ -25,14 +33,27 @@ module ActiveModel # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_format_of(*attr_names) - configuration = { :with => nil } - configuration.update(attr_names.extract_options!) + configuration = attr_names.extract_options! + + unless configuration.include?(:with) ^ configuration.include?(:without) # ^ == xor, or "exclusive or" + raise ArgumentError, "Either :with or :without must be supplied (but not both)" + end - raise(ArgumentError, "A regular expression must be supplied as the :with option of the configuration hash") unless configuration[:with].is_a?(Regexp) + if configuration[:with] && !configuration[:with].is_a?(Regexp) + raise ArgumentError, "A regular expression must be supplied as the :with option of the configuration hash" + end - validates_each(attr_names, configuration) do |record, attr_name, value| - unless value.to_s =~ configuration[:with] - record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) + if configuration[:without] && !configuration[:without].is_a?(Regexp) + raise ArgumentError, "A regular expression must be supplied as the :without option of the configuration hash" + end + + if configuration[:with] + validates_each(attr_names, configuration) do |record, attr_name, value| + record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) if value.to_s !~ configuration[:with] + end + elsif configuration[:without] + validates_each(attr_names, configuration) do |record, attr_name, value| + record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) if value.to_s =~ configuration[:without] end end end diff --git a/activemodel/lib/active_model/validations/numericality.rb b/activemodel/lib/active_model/validations/numericality.rb index ada6e28594..32dbcd82d0 100644 --- a/activemodel/lib/active_model/validations/numericality.rb +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -31,6 +31,21 @@ module ActiveModel # * <tt>:unless</tt> - Specifies a method, proc or string to call to determine if the validation should # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. + # + # The following checks can also be supplied with a proc or a symbol which corresponds to a method: + # * <tt>:greater_than</tt> + # * <tt>:greater_than_or_equal_to</tt> + # * <tt>:equal_to</tt> + # * <tt>:less_than</tt> + # * <tt>:less_than_or_equal_to</tt> + # + # class Person < ActiveRecord::Base + # validates_numericality_of :width, :less_than => Proc.new { |person| person.height } + # validates_numericality_of :width, :greater_than => :minimum_weight + # end + # + # + def validates_numericality_of(*attr_names) configuration = { :only_integer => false, :allow_nil => false } configuration.update(attr_names.extract_options!) @@ -38,7 +53,8 @@ module ActiveModel numericality_options = ALL_NUMERICALITY_CHECKS.keys & configuration.keys (numericality_options - [ :odd, :even ]).each do |option| - raise ArgumentError, ":#{option} must be a number" unless configuration[option].is_a?(Numeric) + value = configuration[option] + raise ArgumentError, ":#{option} must be a number, a symbol or a proc" unless value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol) end validates_each(attr_names,configuration) do |record, attr_name, value| @@ -74,6 +90,9 @@ module ActiveModel record.errors.add(attr_name, option, :value => raw_value, :default => configuration[:message]) end else + configuration[option] = configuration[option].call(record) if configuration[option].is_a? Proc + configuration[option] = record.method(configuration[option]).call if configuration[option].is_a? Symbol + 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 diff --git a/activemodel/lib/active_model/validations/with.rb b/activemodel/lib/active_model/validations/with.rb new file mode 100644 index 0000000000..851cdfebf0 --- /dev/null +++ b/activemodel/lib/active_model/validations/with.rb @@ -0,0 +1,64 @@ +module ActiveModel + module Validations + module ClassMethods + + # Passes the record off to the class or classes specified and allows them to add errors based on more complex conditions. + # + # class Person < ActiveRecord::Base + # validates_with MyValidator + # end + # + # class MyValidator < ActiveRecord::Validator + # def validate + # if some_complex_logic + # record.errors[:base] << "This record is invalid" + # end + # end + # + # private + # def some_complex_logic + # # ... + # end + # end + # + # You may also pass it multiple classes, like so: + # + # class Person < ActiveRecord::Base + # validates_with MyValidator, MyOtherValidator, :on => :create + # end + # + # Configuration options: + # * <tt>on</tt> - Specifies when this validation is active (<tt>:create</tt> or <tt>:update</tt> + # * <tt>if</tt> - Specifies a method, proc or string to call to determine if the validation should + # occur (e.g. <tt>:if => :allow_validation</tt>, or <tt>:if => Proc.new { |user| user.signup_step > 2 }</tt>). + # The method, proc or string should return or evaluate to a true or false value. + # * <tt>unless</tt> - Specifies a method, proc or string to call to determine if the validation should + # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). + # The method, proc or string should return or evaluate to a true or false value. + # + # If you pass any additional configuration options, they will be passed to the class and available as <tt>options</tt>: + # + # class Person < ActiveRecord::Base + # validates_with MyValidator, :my_custom_key => "my custom value" + # end + # + # class MyValidator < ActiveRecord::Validator + # def validate + # options[:my_custom_key] # => "my custom value" + # end + # end + # + def validates_with(*args) + configuration = args.extract_options! + + send(validation_method(configuration[:on]), configuration) do |record| + args.each do |klass| + klass.new(record, configuration.except(:on, :if, :unless)).validate + end + end + end + end + end +end + + diff --git a/activemodel/test/cases/lint_test.rb b/activemodel/test/cases/lint_test.rb new file mode 100644 index 0000000000..165c353045 --- /dev/null +++ b/activemodel/test/cases/lint_test.rb @@ -0,0 +1,50 @@ +require "cases/helper" + +class TestLint < Test::Unit::TestCase + class CompliantObject + def to_model + self + end + + def valid?() true end + def new_record?() true end + def destroyed?() true end + + def errors + obj = Object.new + def obj.[](key) [] end + def obj.full_messages() [] end + obj + end + end + + def assert_output(object, failures, errors, *test_names) + ActiveModel::Lint.test(object, 3, output = StringIO.new) + regex = %r{#{failures} failures, #{errors} errors} + assert_match regex, output.string + + test_names.each do |test_name| + assert_match test_name, output.string + end + end + + def test_valid + assert_output(CompliantObject.new, 0, 0, /test_valid/) + end + + def test_new_record + assert_output(CompliantObject.new, 0, 0, /test_new_record?/) + end + + def test_destroyed + assert_output(CompliantObject.new, 0, 0, /test_destroyed/) + end + + def test_errors_aref + assert_output(CompliantObject.new, 0, 0, /test_errors_aref/) + end + + def test_errors_full_messages + assert_output(CompliantObject.new, 0, 0, /test_errors_aref/) + end +end
\ No newline at end of file diff --git a/activemodel/test/cases/tests_database.rb b/activemodel/test/cases/tests_database.rb index 0f4475fa2d..8dd00ea147 100644 --- a/activemodel/test/cases/tests_database.rb +++ b/activemodel/test/cases/tests_database.rb @@ -27,15 +27,10 @@ module ActiveModel def self.setup_connection defaults = { :database => ':memory:' } - begin - adapter = defined?(JRUBY_VERSION) ? 'jdbcsqlite3' : 'sqlite3' - options = defaults.merge :adapter => adapter, :timeout => 500 - ActiveRecord::Base.establish_connection(options) - rescue Exception - $stderr.puts 'SQLite 3 unavailable; trying SQLite 2.' - options = defaults.merge :adapter => 'sqlite' - ActiveRecord::Base.establish_connection(options) - end + + adapter = defined?(JRUBY_VERSION) ? 'jdbcsqlite3' : 'sqlite3' + options = defaults.merge :adapter => adapter, :timeout => 500 + ActiveRecord::Base.establish_connection(options) end end end diff --git a/activemodel/test/cases/validations/format_validation_test.rb b/activemodel/test/cases/validations/format_validation_test.rb index 2c06a9dd02..e19e4bf7b3 100644 --- a/activemodel/test/cases/validations/format_validation_test.rb +++ b/activemodel/test/cases/validations/format_validation_test.rb @@ -71,6 +71,35 @@ class PresenceValidationTest < ActiveModel::TestCase assert_equal ["can't be Invalid title"], t.errors[:title] end + def test_validate_format_with_not_option + Topic.validates_format_of(:title, :without => /foo/, :message => "should not contain foo") + t = Topic.new + + t.title = "foobar" + t.valid? + assert_equal ["should not contain foo"], t.errors[:title] + + t.title = "something else" + t.valid? + assert_equal [], t.errors[:title] + end + + def test_validate_format_of_without_any_regexp_should_raise_error + assert_raise(ArgumentError) { Topic.validates_format_of(:title) } + end + + def test_validates_format_of_with_both_regexps_should_raise_error + assert_raise(ArgumentError) { Topic.validates_format_of(:title, :with => /this/, :without => /that/) } + end + + def test_validates_format_of_when_with_isnt_a_regexp_should_raise_error + assert_raise(ArgumentError) { Topic.validates_format_of(:title, :with => "clearly not a regexp") } + end + + def test_validates_format_of_when_not_isnt_a_regexp_should_raise_error + assert_raise(ArgumentError) { Topic.validates_format_of(:title, :without => "clearly not a regexp") } + end + def test_validates_format_of_with_custom_error_using_quotes repair_validations(Developer) do Developer.validates_format_of :name, :with => /^(A-Z*)$/, :message=> "format 'single' and \"double\" quotes" diff --git a/activemodel/test/cases/validations/numericality_validation_test.rb b/activemodel/test/cases/validations/numericality_validation_test.rb index 0af6eb69ce..d3201966dc 100644 --- a/activemodel/test/cases/validations/numericality_validation_test.rb +++ b/activemodel/test/cases/validations/numericality_validation_test.rb @@ -106,6 +106,24 @@ class NumericalityValidationTest < ActiveModel::TestCase valid!([2]) end + def test_validates_numericality_with_proc + Topic.send(:define_method, :min_approved, lambda { 5 }) + Topic.validates_numericality_of :approved, :greater_than_or_equal_to => Proc.new {|topic| topic.min_approved } + + invalid!([3, 4]) + valid!([5, 6]) + Topic.send(:remove_method, :min_approved) + end + + def test_validates_numericality_with_symbol + Topic.send(:define_method, :max_approved, lambda { 5 }) + Topic.validates_numericality_of :approved, :less_than_or_equal_to => :max_approved + + invalid!([6]) + valid!([4, 5]) + Topic.send(:remove_method, :max_approved) + end + def test_validates_numericality_with_numeric_message Topic.validates_numericality_of :approved, :less_than => 4, :message => "smaller than {{count}}" topic = Topic.new("title" => "numeric test", "approved" => 10) diff --git a/activemodel/test/cases/validations/with_validation_test.rb b/activemodel/test/cases/validations/with_validation_test.rb new file mode 100644 index 0000000000..f55fdc5864 --- /dev/null +++ b/activemodel/test/cases/validations/with_validation_test.rb @@ -0,0 +1,116 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' + +class ValidatesWithTest < ActiveRecord::TestCase + include ActiveModel::ValidationsRepairHelper + + repair_validations(Topic) + + ERROR_MESSAGE = "Validation error from validator" + OTHER_ERROR_MESSAGE = "Validation error from other validator" + + class ValidatorThatAddsErrors < ActiveRecord::Validator + def validate() + record.errors[:base] << ERROR_MESSAGE + end + end + + class OtherValidatorThatAddsErrors < ActiveRecord::Validator + def validate() + record.errors[:base] << OTHER_ERROR_MESSAGE + end + end + + class ValidatorThatDoesNotAddErrors < ActiveRecord::Validator + def validate() + end + end + + class ValidatorThatValidatesOptions < ActiveRecord::Validator + def validate() + if options[:field] == :first_name + record.errors[:base] << ERROR_MESSAGE + end + end + end + + test "vaidation with class that adds errors" do + Topic.validates_with(ValidatorThatAddsErrors) + topic = Topic.new + assert !topic.valid?, "A class that adds errors causes the record to be invalid" + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "with a class that returns valid" do + Topic.validates_with(ValidatorThatDoesNotAddErrors) + topic = Topic.new + assert topic.valid?, "A class that does not add errors does not cause the record to be invalid" + end + + test "with a class that adds errors on update and a new record" do + Topic.validates_with(ValidatorThatAddsErrors, :on => :update) + topic = Topic.new + assert topic.valid?, "Validation doesn't run on create if 'on' is set to update" + end + + test "with a class that adds errors on create and a new record" do + Topic.validates_with(ValidatorThatAddsErrors, :on => :create) + topic = Topic.new + assert !topic.valid?, "Validation does run on create if 'on' is set to create" + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "with multiple classes" do + Topic.validates_with(ValidatorThatAddsErrors, OtherValidatorThatAddsErrors) + topic = Topic.new + assert !topic.valid? + assert topic.errors[:base].include?(ERROR_MESSAGE) + assert topic.errors[:base].include?(OTHER_ERROR_MESSAGE) + end + + test "with if statements that return false" do + Topic.validates_with(ValidatorThatAddsErrors, :if => "1 == 2") + topic = Topic.new + assert topic.valid? + end + + test "with if statements that return true" do + Topic.validates_with(ValidatorThatAddsErrors, :if => "1 == 1") + topic = Topic.new + assert !topic.valid? + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "with unless statements that return true" do + Topic.validates_with(ValidatorThatAddsErrors, :unless => "1 == 1") + topic = Topic.new + assert topic.valid? + end + + test "with unless statements that returns false" do + Topic.validates_with(ValidatorThatAddsErrors, :unless => "1 == 2") + topic = Topic.new + assert !topic.valid? + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "passes all non-standard configuration options to the validator class" do + topic = Topic.new + validator = mock() + validator.expects(:new).with(topic, {:foo => :bar}).returns(validator) + validator.expects(:validate) + + Topic.validates_with(validator, :if => "1 == 1", :foo => :bar) + assert topic.valid? + end + + test "validates_with with options" do + Topic.validates_with(ValidatorThatValidatesOptions, :field => :first_name) + topic = Topic.new + assert !topic.valid? + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + +end |