diff options
Diffstat (limited to 'activemodel')
91 files changed, 10611 insertions, 0 deletions
diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md new file mode 100644 index 0000000000..c14b0688c7 --- /dev/null +++ b/activemodel/CHANGELOG.md @@ -0,0 +1,58 @@ +* Passwords with spaces only allowed in `ActiveModel::SecurePassword`. + + Presence validation can be used to restore old behavior. + + *Yevhene Shemet* + +* Validate options passed to `ActiveModel::Validations.validate`. + + Preventing, in many cases, the simple mistake of using `validate` instead of `validates`. + + *Sonny Michaud* + +* Deprecate `reset_#{attribute}` in favor of `restore_#{attribute}`. + + These methods may cause confusion with the `reset_changes` that behaves differently + of them. + +* Deprecate `ActiveModel::Dirty#reset_changes` in favor of `#clear_changes_information`. + + This method name is causing confusion with the `reset_#{attribute}` + methods. While `reset_name` set the value of the name attribute for the + previous value `reset_changes` only discard the changes and previous + changes. + +* Added `restore_attributes` method to `ActiveModel::Dirty` API to restore all the + changed values to the previous data. + + *Igor G.* + +* Allow proc and symbol as values for `only_integer` of `NumericalityValidator` + + *Robin Mehner* + +* `has_secure_password` now verifies that the given password is less than 72 + characters if validations are enabled. + + Fixes #14591. + + *Akshay Vishnoi* + +* Remove deprecated `Validator#setup` without replacement. + + See #10716. + + *Kuldeep Aggarwal* + +* Add plural and singular form for length validator's default messages. + + *Abd ar-Rahman Hamid* + +* Introduce `validate` as an alias for `valid?`. + + This is more intuitive when you want to run validations but don't care about + the return value. + + *Henrik Nyh* + +Please check [4-1-stable](https://github.com/rails/rails/blob/4-1-stable/activemodel/CHANGELOG.md) for previous changes. diff --git a/activemodel/MIT-LICENSE b/activemodel/MIT-LICENSE new file mode 100644 index 0000000000..d58dd9ed9b --- /dev/null +++ b/activemodel/MIT-LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2004-2014 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. + diff --git a/activemodel/README.rdoc b/activemodel/README.rdoc new file mode 100644 index 0000000000..f6beff14e1 --- /dev/null +++ b/activemodel/README.rdoc @@ -0,0 +1,272 @@ += Active Model -- model interfaces for Rails + +Active Model provides a known set of interfaces for usage in model classes. +They allow for Action Pack helpers to interact with non-Active Record models, +for example. Active Model also helps with building custom ORMs for use outside of +the Rails framework. + +Prior to Rails 3.0, if a plugin or gem developer wanted to have an object +interact with Action Pack helpers, it was required to either copy chunks of +code from Rails, or monkey patch entire helpers to make them handle objects +that did not exactly conform to the Active Record interface. This would result +in code duplication and fragile applications that broke on upgrades. Active +Model solves this by defining an explicit API. You can read more about the +API in <tt>ActiveModel::Lint::Tests</tt>. + +Active Model provides a default module that implements the basic API required +to integrate with Action Pack out of the box: <tt>ActiveModel::Model</tt>. + + class Person + include ActiveModel::Model + + attr_accessor :name, :age + validates_presence_of :name + end + + person = Person.new(name: 'bob', age: '18') + person.name # => 'bob' + person.age # => '18' + person.valid? # => true + +It includes model name introspections, conversions, translations and +validations, resulting in a class suitable to be used with Action Pack. +See <tt>ActiveModel::Model</tt> for more examples. + +Active Model also provides the following functionality to have ORM-like +behavior out of the box: + +* Add attribute magic to objects + + class Person + include ActiveModel::AttributeMethods + + attribute_method_prefix 'clear_' + define_attribute_methods :name, :age + + attr_accessor :name, :age + + def clear_attribute(attr) + send("#{attr}=", nil) + end + end + + person = Person.new + person.clear_name + person.clear_age + + {Learn more}[link:classes/ActiveModel/AttributeMethods.html] + +* Callbacks for certain operations + + class Person + extend ActiveModel::Callbacks + define_model_callbacks :create + + def create + run_callbacks :create do + # Your create action methods here + end + end + end + + This generates +before_create+, +around_create+ and +after_create+ + class methods that wrap your create method. + + {Learn more}[link:classes/ActiveModel/Callbacks.html] + +* Tracking value changes + + class Person + include ActiveModel::Dirty + + define_attribute_methods :name + + def name + @name + end + + def name=(val) + name_will_change! unless val == @name + @name = val + end + + def save + # do persistence work + changes_applied + end + end + + person = Person.new + person.name # => nil + person.changed? # => false + person.name = 'bob' + person.changed? # => true + person.changed # => ['name'] + person.changes # => { 'name' => [nil, 'bob'] } + person.save + person.name = 'robert' + person.save + person.previous_changes # => {'name' => ['bob, 'robert']} + + {Learn more}[link:classes/ActiveModel/Dirty.html] + +* Adding +errors+ interface to objects + + Exposing error messages allows objects to interact with Action Pack + helpers seamlessly. + + class Person + + def initialize + @errors = ActiveModel::Errors.new(self) + end + + attr_accessor :name + attr_reader :errors + + def validate! + errors.add(:name, "cannot be nil") if name.nil? + end + + def self.human_attribute_name(attr, options = {}) + "Name" + end + end + + person = Person.new + person.name = nil + person.validate! + person.errors.full_messages + # => ["Name cannot be nil"] + + {Learn more}[link:classes/ActiveModel/Errors.html] + +* Model name introspection + + class NamedPerson + extend ActiveModel::Naming + end + + NamedPerson.model_name.name # => "NamedPerson" + NamedPerson.model_name.human # => "Named person" + + {Learn more}[link:classes/ActiveModel/Naming.html] + +* Making objects serializable + + ActiveModel::Serialization provides a standard interface for your object + to provide +to_json+ or +to_xml+ serialization. + + class SerialPerson + include ActiveModel::Serialization + + attr_accessor :name + + def attributes + {'name' => name} + end + end + + s = SerialPerson.new + s.serializable_hash # => {"name"=>nil} + + class SerialPerson + include ActiveModel::Serializers::JSON + end + + s = SerialPerson.new + s.to_json # => "{\"name\":null}" + + class SerialPerson + include ActiveModel::Serializers::Xml + end + + s = SerialPerson.new + s.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person... + + {Learn more}[link:classes/ActiveModel/Serialization.html] + +* Internationalization (i18n) support + + class Person + extend ActiveModel::Translation + end + + Person.human_attribute_name('my_attribute') + # => "My attribute" + + {Learn more}[link:classes/ActiveModel/Translation.html] + +* Validation support + + class Person + include ActiveModel::Validations + + attr_accessor :first_name, :last_name + + validates_each :first_name, :last_name do |record, attr, value| + record.errors.add attr, 'starts with z.' if value.to_s[0] == ?z + end + end + + person = Person.new + person.first_name = 'zoolander' + person.valid? # => false + + {Learn more}[link:classes/ActiveModel/Validations.html] + +* Custom validators + + class HasNameValidator < ActiveModel::Validator + def validate(record) + record.errors[:name] = "must exist" if record.name.blank? + end + end + + class ValidatorPerson + include ActiveModel::Validations + validates_with HasNameValidator + attr_accessor :name + end + + p = ValidatorPerson.new + p.valid? # => false + p.errors.full_messages # => ["Name must exist"] + p.name = "Bob" + p.valid? # => true + + {Learn more}[link:classes/ActiveModel/Validator.html] + + +== Download and installation + +The latest version of Active Model can be installed with RubyGems: + + % [sudo] gem install activemodel + +Source code can be downloaded as part of the Rails project on GitHub + +* https://github.com/rails/rails/tree/master/activemodel + + +== License + +Active Model is released under the MIT license: + +* http://www.opensource.org/licenses/MIT + + +== Support + +API documentation is at + +* http://api.rubyonrails.org + +Bug reports can be filed for the Ruby on Rails project here: + +* https://github.com/rails/rails/issues + +Feature requests should be discussed on the rails-core mailing list here: + +* https://groups.google.com/forum/?fromgroups#!forum/rubyonrails-core + diff --git a/activemodel/Rakefile b/activemodel/Rakefile new file mode 100644 index 0000000000..407dda2ec3 --- /dev/null +++ b/activemodel/Rakefile @@ -0,0 +1,35 @@ +dir = File.dirname(__FILE__) + +require 'rake/testtask' + +task :default => :test + +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = Dir.glob("#{dir}/test/cases/**/*_test.rb").sort + t.warning = true + t.verbose = true +end + +namespace :test do + task :isolated do + Dir.glob("#{dir}/test/**/*_test.rb").all? do |file| + sh(Gem.ruby, '-w', "-I#{dir}/lib", "-I#{dir}/test", file) + end or raise "Failures" + end +end + +require 'rubygems/package_task' + +spec = eval(File.read("#{dir}/activemodel.gemspec")) + +Gem::PackageTask.new(spec) do |p| + p.gem_spec = spec +end + +desc "Release to rubygems" +task :release => :package do + require 'rake/gemcutter' + Rake::Gemcutter::Tasks.new(spec).define + Rake::Task['gem:push'].invoke +end diff --git a/activemodel/activemodel.gemspec b/activemodel/activemodel.gemspec new file mode 100644 index 0000000000..36e565f692 --- /dev/null +++ b/activemodel/activemodel.gemspec @@ -0,0 +1,24 @@ +version = File.read(File.expand_path('../../RAILS_VERSION', __FILE__)).strip + +Gem::Specification.new do |s| + s.platform = Gem::Platform::RUBY + s.name = 'activemodel' + s.version = version + s.summary = 'A toolkit for building modeling frameworks (part of Rails).' + s.description = 'A toolkit for building modeling frameworks like Active Record. Rich support for attributes, callbacks, validations, serialization, internationalization, and testing.' + + s.required_ruby_version = '>= 1.9.3' + + s.license = 'MIT' + + s.author = 'David Heinemeier Hansson' + s.email = 'david@loudthinking.com' + s.homepage = 'http://www.rubyonrails.org' + + s.files = Dir['CHANGELOG.md', 'MIT-LICENSE', 'README.rdoc', 'lib/**/*'] + s.require_path = 'lib' + + s.add_dependency 'activesupport', version + + s.add_dependency 'builder', '~> 3.1' +end diff --git a/activemodel/examples/validations.rb b/activemodel/examples/validations.rb new file mode 100644 index 0000000000..b8e74acd5e --- /dev/null +++ b/activemodel/examples/validations.rb @@ -0,0 +1,30 @@ +require File.expand_path('../../../load_paths', __FILE__) +require 'active_model' + +class Person + include ActiveModel::Conversion + include ActiveModel::Validations + + validates :name, presence: true + + attr_accessor :name + + def initialize(attributes = {}) + @name = attributes[:name] + end + + def persist + @persisted = true + end + + def persisted? + @persisted + end +end + +person1 = Person.new +p person1.valid? # => false +p person1.errors.messages # => {:name=>["can't be blank"]} + +person2 = Person.new(name: 'matz') +p person2.valid? # => true diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb new file mode 100644 index 0000000000..feb3d9371d --- /dev/null +++ b/activemodel/lib/active_model.rb @@ -0,0 +1,71 @@ +#-- +# Copyright (c) 2004-2014 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. +#++ + +require 'active_support' +require 'active_support/rails' +require 'active_model/version' + +module ActiveModel + extend ActiveSupport::Autoload + + autoload :AttributeMethods + autoload :BlockValidator, 'active_model/validator' + autoload :Callbacks + autoload :Conversion + autoload :Dirty + autoload :EachValidator, 'active_model/validator' + autoload :ForbiddenAttributesProtection + autoload :Lint + autoload :Model + autoload :Name, 'active_model/naming' + autoload :Naming + autoload :SecurePassword + autoload :Serialization + autoload :TestCase + autoload :Translation + autoload :Validations + autoload :Validator + + eager_autoload do + autoload :Errors + autoload :StrictValidationFailed, 'active_model/errors' + end + + module Serializers + extend ActiveSupport::Autoload + + eager_autoload do + autoload :JSON + autoload :Xml + end + end + + def self.eager_load! + super + ActiveModel::Serializers.eager_load! + end +end + +ActiveSupport.on_load(:i18n) do + I18n.load_path << File.dirname(__FILE__) + '/active_model/locale/en.yml' +end diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb new file mode 100644 index 0000000000..ea07c5c039 --- /dev/null +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -0,0 +1,480 @@ +require 'thread_safe' +require 'mutex_m' + +module ActiveModel + # Raised when an attribute is not defined. + # + # class User < ActiveRecord::Base + # has_many :pets + # end + # + # user = User.first + # user.pets.select(:id).first.user_id + # # => ActiveModel::MissingAttributeError: missing attribute: user_id + class MissingAttributeError < NoMethodError + end + + # == Active \Model \Attribute \Methods + # + # Provides a way to add prefixes and suffixes to your methods as + # well as handling the creation of <tt>ActiveRecord::Base</tt>-like + # class methods such as +table_name+. + # + # The requirements to implement <tt>ActiveModel::AttributeMethods</tt> are to: + # + # * <tt>include ActiveModel::AttributeMethods</tt> in your class. + # * Call each of its method you want to add, such as +attribute_method_suffix+ + # or +attribute_method_prefix+. + # * Call +define_attribute_methods+ after the other methods are called. + # * Define the various generic +_attribute+ methods that you have declared. + # * Define an +attributes+ method which returns a hash with each + # attribute name in your model as hash key and the attribute value as hash value. + # Hash keys must be strings. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::AttributeMethods + # + # attribute_method_affix prefix: 'reset_', suffix: '_to_default!' + # attribute_method_suffix '_contrived?' + # attribute_method_prefix 'clear_' + # define_attribute_methods :name + # + # attr_accessor :name + # + # def attributes + # { 'name' => @name } + # end + # + # private + # + # def attribute_contrived?(attr) + # true + # end + # + # def clear_attribute(attr) + # send("#{attr}=", nil) + # end + # + # def reset_attribute_to_default!(attr) + # send("#{attr}=", 'Default Name') + # end + # end + module AttributeMethods + extend ActiveSupport::Concern + + NAME_COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?=]?\z/ + CALL_COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?]?\z/ + + included do + class_attribute :attribute_aliases, :attribute_method_matchers, instance_writer: false + self.attribute_aliases = {} + self.attribute_method_matchers = [ClassMethods::AttributeMethodMatcher.new] + end + + module ClassMethods + # Declares a method available for all attributes with the given prefix. + # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method. + # + # #{prefix}#{attr}(*args, &block) + # + # to + # + # #{prefix}attribute(#{attr}, *args, &block) + # + # An instance method <tt>#{prefix}attribute</tt> must exist and accept + # at least the +attr+ argument. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_prefix 'clear_' + # define_attribute_methods :name + # + # private + # + # def clear_attribute(attr) + # send("#{attr}=", nil) + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.clear_name + # person.name # => nil + def attribute_method_prefix(*prefixes) + self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix } + undefine_attribute_methods + end + + # Declares a method available for all attributes with the given suffix. + # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method. + # + # #{attr}#{suffix}(*args, &block) + # + # to + # + # attribute#{suffix}(#{attr}, *args, &block) + # + # An <tt>attribute#{suffix}</tt> instance method must exist and accept at + # least the +attr+ argument. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # define_attribute_methods :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.name_short? # => true + def attribute_method_suffix(*suffixes) + self.attribute_method_matchers += suffixes.map! { |suffix| AttributeMethodMatcher.new suffix: suffix } + undefine_attribute_methods + end + + # Declares a method available for all attributes with the given prefix + # and suffix. Uses +method_missing+ and <tt>respond_to?</tt> to rewrite + # the method. + # + # #{prefix}#{attr}#{suffix}(*args, &block) + # + # to + # + # #{prefix}attribute#{suffix}(#{attr}, *args, &block) + # + # An <tt>#{prefix}attribute#{suffix}</tt> instance method must exist and + # accept at least the +attr+ argument. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_affix prefix: 'reset_', suffix: '_to_default!' + # define_attribute_methods :name + # + # private + # + # def reset_attribute_to_default!(attr) + # send("#{attr}=", 'Default Name') + # end + # end + # + # person = Person.new + # person.name # => 'Gem' + # person.reset_name_to_default! + # person.name # => 'Default Name' + def attribute_method_affix(*affixes) + self.attribute_method_matchers += affixes.map! { |affix| AttributeMethodMatcher.new prefix: affix[:prefix], suffix: affix[:suffix] } + undefine_attribute_methods + end + + # Allows you to make aliases for attributes. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # define_attribute_methods :name + # + # alias_attribute :nickname, :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.nickname # => "Bob" + # person.name_short? # => true + # person.nickname_short? # => true + def alias_attribute(new_name, old_name) + self.attribute_aliases = attribute_aliases.merge(new_name.to_s => old_name.to_s) + attribute_method_matchers.each do |matcher| + matcher_new = matcher.method_name(new_name).to_s + matcher_old = matcher.method_name(old_name).to_s + define_proxy_call false, self, matcher_new, matcher_old + end + end + + # Is +new_name+ an alias? + def attribute_alias?(new_name) + attribute_aliases.key? new_name.to_s + end + + # Returns the original name for the alias +name+ + def attribute_alias(name) + attribute_aliases[name.to_s] + end + + # Declares the attributes that should be prefixed and suffixed by + # ActiveModel::AttributeMethods. + # + # To use, pass attribute names (as strings or symbols), be sure to declare + # +define_attribute_methods+ after you define any prefix, suffix or affix + # methods, or they will not hook in. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name, :age, :address + # attribute_method_prefix 'clear_' + # + # # Call to define_attribute_methods must appear after the + # # attribute_method_prefix, attribute_method_suffix or + # # attribute_method_affix declares. + # define_attribute_methods :name, :age, :address + # + # private + # + # def clear_attribute(attr) + # send("#{attr}=", nil) + # end + # end + def define_attribute_methods(*attr_names) + attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) } + end + + # Declares an attribute that should be prefixed and suffixed by + # ActiveModel::AttributeMethods. + # + # To use, pass an attribute name (as string or symbol), be sure to declare + # +define_attribute_method+ after you define any prefix, suffix or affix + # method, or they will not hook in. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # + # # Call to define_attribute_method must appear after the + # # attribute_method_prefix, attribute_method_suffix or + # # attribute_method_affix declares. + # define_attribute_method :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.name_short? # => true + def define_attribute_method(attr_name) + attribute_method_matchers.each do |matcher| + method_name = matcher.method_name(attr_name) + + unless instance_method_already_implemented?(method_name) + generate_method = "define_method_#{matcher.method_missing_target}" + + if respond_to?(generate_method, true) + send(generate_method, attr_name) + else + define_proxy_call true, generated_attribute_methods, method_name, matcher.method_missing_target, attr_name.to_s + end + end + end + attribute_method_matchers_cache.clear + end + + # Removes all the previously dynamically defined methods from the class. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # define_attribute_method :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name_short? # => true + # + # Person.undefine_attribute_methods + # + # person.name_short? # => NoMethodError + def undefine_attribute_methods + generated_attribute_methods.module_eval do + instance_methods.each { |m| undef_method(m) } + end + attribute_method_matchers_cache.clear + end + + def generated_attribute_methods #:nodoc: + @generated_attribute_methods ||= Module.new { + extend Mutex_m + }.tap { |mod| include mod } + end + + protected + def instance_method_already_implemented?(method_name) #:nodoc: + generated_attribute_methods.method_defined?(method_name) + end + + private + # The methods +method_missing+ and +respond_to?+ of this module are + # invoked often in a typical rails, both of which invoke the method + # +match_attribute_method?+. The latter method iterates through an + # array doing regular expression matches, which results in a lot of + # object creations. Most of the time it returns a +nil+ match. As the + # match result is always the same given a +method_name+, this cache is + # used to alleviate the GC, which ultimately also speeds up the app + # significantly (in our case our test suite finishes 10% faster with + # this cache). + def attribute_method_matchers_cache #:nodoc: + @attribute_method_matchers_cache ||= ThreadSafe::Cache.new(initial_capacity: 4) + end + + def attribute_method_matcher(method_name) #:nodoc: + attribute_method_matchers_cache.compute_if_absent(method_name) do + # Must try to match prefixes/suffixes first, or else the matcher with no prefix/suffix + # will match every time. + matchers = attribute_method_matchers.partition(&:plain?).reverse.flatten(1) + match = nil + matchers.detect { |method| match = method.match(method_name) } + match + end + end + + # Define a method `name` in `mod` that dispatches to `send` + # using the given `extra` args. This fallbacks `define_method` + # and `send` if the given names cannot be compiled. + def define_proxy_call(include_private, mod, name, send, *extra) #:nodoc: + defn = if name =~ NAME_COMPILABLE_REGEXP + "def #{name}(*args)" + else + "define_method(:'#{name}') do |*args|" + end + + extra = (extra.map!(&:inspect) << "*args").join(", ") + + target = if send =~ CALL_COMPILABLE_REGEXP + "#{"self." unless include_private}#{send}(#{extra})" + else + "send(:'#{send}', #{extra})" + end + + mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + #{defn} + #{target} + end + RUBY + end + + class AttributeMethodMatcher #:nodoc: + attr_reader :prefix, :suffix, :method_missing_target + + AttributeMethodMatch = Struct.new(:target, :attr_name, :method_name) + + def initialize(options = {}) + @prefix, @suffix = options.fetch(:prefix, ''), options.fetch(:suffix, '') + @regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/ + @method_missing_target = "#{@prefix}attribute#{@suffix}" + @method_name = "#{prefix}%s#{suffix}" + end + + def match(method_name) + if @regex =~ method_name + AttributeMethodMatch.new(method_missing_target, $1, method_name) + end + end + + def method_name(attr_name) + @method_name % attr_name + end + + def plain? + prefix.empty? && suffix.empty? + end + end + end + + # Allows access to the object attributes, which are held in the hash + # returned by <tt>attributes</tt>, as though they were first-class + # methods. So a +Person+ class with a +name+ attribute can for example use + # <tt>Person#name</tt> and <tt>Person#name=</tt> and never directly use + # the attributes hash -- except for multiple assigns with + # <tt>ActiveRecord::Base#attributes=</tt>. + # + # It's also possible to instantiate related objects, so a <tt>Client</tt> + # class belonging to the +clients+ table with a +master_id+ foreign key + # can instantiate master through <tt>Client#master</tt>. + def method_missing(method, *args, &block) + if respond_to_without_attributes?(method, true) + super + else + match = match_attribute_method?(method.to_s) + match ? attribute_missing(match, *args, &block) : super + end + end + + # +attribute_missing+ is like +method_missing+, but for attributes. When + # +method_missing+ is called we check to see if there is a matching + # attribute method. If so, we tell +attribute_missing+ to dispatch the + # attribute. This method can be overloaded to customize the behavior. + def attribute_missing(match, *args, &block) + __send__(match.target, match.attr_name, *args, &block) + end + + # A +Person+ instance with a +name+ attribute can ask + # <tt>person.respond_to?(:name)</tt>, <tt>person.respond_to?(:name=)</tt>, + # and <tt>person.respond_to?(:name?)</tt> which will all return +true+. + alias :respond_to_without_attributes? :respond_to? + def respond_to?(method, include_private_methods = false) + if super + true + elsif !include_private_methods && super(method, true) + # If we're here then we haven't found among non-private methods + # but found among all methods. Which means that the given method is private. + false + else + !match_attribute_method?(method.to_s).nil? + end + end + + protected + def attribute_method?(attr_name) #:nodoc: + respond_to_without_attributes?(:attributes) && attributes.include?(attr_name) + end + + private + # Returns a struct representing the matching attribute method. + # The struct's attributes are prefix, base and suffix. + def match_attribute_method?(method_name) + match = self.class.send(:attribute_method_matcher, method_name) + match if match && attribute_method?(match.attr_name) + end + + def missing_attribute(attr_name, stack) + raise ActiveModel::MissingAttributeError, "missing attribute: #{attr_name}", stack + end + end +end diff --git a/activemodel/lib/active_model/callbacks.rb b/activemodel/lib/active_model/callbacks.rb new file mode 100644 index 0000000000..b27a39b787 --- /dev/null +++ b/activemodel/lib/active_model/callbacks.rb @@ -0,0 +1,146 @@ +require 'active_support/core_ext/array/extract_options' + +module ActiveModel + # == Active \Model \Callbacks + # + # Provides an interface for any class to have Active Record like callbacks. + # + # Like the Active Record methods, the callback chain is aborted as soon as + # one of the methods in the chain returns +false+. + # + # First, extend ActiveModel::Callbacks from the class you are creating: + # + # class MyModel + # extend ActiveModel::Callbacks + # end + # + # Then define a list of methods that you want callbacks attached to: + # + # define_model_callbacks :create, :update + # + # This will provide all three standard callbacks (before, around and after) + # for both the <tt>:create</tt> and <tt>:update</tt> methods. To implement, + # you need to wrap the methods you want callbacks on in a block so that the + # callbacks get a chance to fire: + # + # def create + # run_callbacks :create do + # # Your create action methods here + # end + # end + # + # Then in your class, you can use the +before_create+, +after_create+ and + # +around_create+ methods, just as you would in an Active Record model. + # + # before_create :action_before_create + # + # def action_before_create + # # Your code here + # end + # + # When defining an around callback remember to yield to the block, otherwise + # it won't be executed: + # + # around_create :log_status + # + # def log_status + # puts 'going to call the block...' + # yield + # puts 'block successfully called.' + # end + # + # You can choose not to have all three callbacks by passing a hash to the + # +define_model_callbacks+ method. + # + # define_model_callbacks :create, only: [:after, :before] + # + # Would only create the +after_create+ and +before_create+ callback methods in + # your class. + module Callbacks + def self.extended(base) #:nodoc: + base.class_eval do + include ActiveSupport::Callbacks + end + end + + # define_model_callbacks accepts the same options +define_callbacks+ does, + # in case you want to overwrite a default. Besides that, it also accepts an + # <tt>:only</tt> option, where you can choose if you want all types (before, + # around or after) or just some. + # + # define_model_callbacks :initializer, only: :after + # + # Note, the <tt>only: <type></tt> hash will apply to all callbacks defined + # on that method call. To get around this you can call the define_model_callbacks + # method as many times as you need. + # + # define_model_callbacks :create, only: :after + # define_model_callbacks :update, only: :before + # define_model_callbacks :destroy, only: :around + # + # Would create +after_create+, +before_update+ and +around_destroy+ methods + # only. + # + # You can pass in a class to before_<type>, after_<type> and around_<type>, + # in which case the callback will call that class's <action>_<type> method + # passing the object that the callback is being called on. + # + # class MyModel + # extend ActiveModel::Callbacks + # define_model_callbacks :create + # + # before_create AnotherClass + # end + # + # class AnotherClass + # def self.before_create( obj ) + # # obj is the MyModel instance that the callback is being called on + # end + # end + def define_model_callbacks(*callbacks) + options = callbacks.extract_options! + options = { + terminator: ->(_,result) { result == false }, + skip_after_callbacks_if_terminated: true, + scope: [:kind, :name], + only: [:before, :around, :after] + }.merge!(options) + + types = Array(options.delete(:only)) + + callbacks.each do |callback| + define_callbacks(callback, options) + + types.each do |type| + send("_define_#{type}_model_callback", self, callback) + end + end + end + + private + + def _define_before_model_callback(klass, callback) #:nodoc: + klass.define_singleton_method("before_#{callback}") do |*args, &block| + set_callback(:"#{callback}", :before, *args, &block) + end + end + + def _define_around_model_callback(klass, callback) #:nodoc: + klass.define_singleton_method("around_#{callback}") do |*args, &block| + set_callback(:"#{callback}", :around, *args, &block) + end + end + + def _define_after_model_callback(klass, callback) #:nodoc: + klass.define_singleton_method("after_#{callback}") do |*args, &block| + options = args.extract_options! + options[:prepend] = true + conditional = ActiveSupport::Callbacks::Conditionals::Value.new { |v| + v != false + } + options[:if] = Array(options[:if]) << conditional + set_callback(:"#{callback}", :after, *(args << options), &block) + end + end + end +end diff --git a/activemodel/lib/active_model/conversion.rb b/activemodel/lib/active_model/conversion.rb new file mode 100644 index 0000000000..9c9b6f4a77 --- /dev/null +++ b/activemodel/lib/active_model/conversion.rb @@ -0,0 +1,100 @@ +module ActiveModel + # == Active \Model \Conversion + # + # Handles default conversions: to_model, to_key, to_param, and to_partial_path. + # + # Let's take for example this non-persisted object. + # + # class ContactMessage + # include ActiveModel::Conversion + # + # # ContactMessage are never persisted in the DB + # def persisted? + # false + # end + # end + # + # cm = ContactMessage.new + # cm.to_model == cm # => true + # cm.to_key # => nil + # cm.to_param # => nil + # cm.to_partial_path # => "contact_messages/contact_message" + module Conversion + extend ActiveSupport::Concern + + # If your object is already designed to implement all of the Active Model + # you can use the default <tt>:to_model</tt> implementation, which simply + # returns +self+. + # + # class Person + # include ActiveModel::Conversion + # end + # + # person = Person.new + # person.to_model == person # => true + # + # If your model does not act like an Active Model object, then you should + # define <tt>:to_model</tt> yourself returning a proxy object that wraps + # your object with Active Model compliant methods. + def to_model + self + end + + # Returns an Array of all key attributes if any is set, regardless if + # the object is persisted or not. Returns +nil+ if there are no key attributes. + # + # class Person + # include ActiveModel::Conversion + # attr_accessor :id + # end + # + # person = Person.create(id: 1) + # person.to_key # => [1] + def to_key + key = respond_to?(:id) && id + key ? [key] : nil + end + + # Returns a +string+ representing the object's key suitable for use in URLs, + # or +nil+ if <tt>persisted?</tt> is +false+. + # + # class Person + # include ActiveModel::Conversion + # attr_accessor :id + # def persisted? + # true + # end + # end + # + # person = Person.create(id: 1) + # person.to_param # => "1" + def to_param + (persisted? && key = to_key) ? key.join('-') : nil + end + + # Returns a +string+ identifying the path associated with the object. + # ActionPack uses this to find a suitable partial to represent the object. + # + # class Person + # include ActiveModel::Conversion + # end + # + # person = Person.new + # person.to_partial_path # => "people/person" + def to_partial_path + self.class._to_partial_path + end + + module ClassMethods #:nodoc: + # Provide a class level cache for #to_partial_path. This is an + # internal method and should not be accessed directly. + def _to_partial_path #:nodoc: + @_to_partial_path ||= begin + element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name)) + collection = ActiveSupport::Inflector.tableize(name) + "#{collection}/#{element}".freeze + end + end + end + end +end diff --git a/activemodel/lib/active_model/dirty.rb b/activemodel/lib/active_model/dirty.rb new file mode 100644 index 0000000000..d11243c4c0 --- /dev/null +++ b/activemodel/lib/active_model/dirty.rb @@ -0,0 +1,240 @@ +require 'active_support/hash_with_indifferent_access' +require 'active_support/core_ext/object/duplicable' + +module ActiveModel + # == Active \Model \Dirty + # + # Provides a way to track changes in your object in the same way as + # Active Record does. + # + # 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 + # track. + # * Call <tt>attr_name_will_change!</tt> before each change to the tracked + # attribute. + # * Call <tt>changes_applied</tt> after the changes are persisted. + # * Call <tt>clear_changes_information</tt> when you want to reset the changes + # information. + # * Call <tt>restore_attributes</tt> when you want to restore previous data. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::Dirty + # + # define_attribute_methods :name + # + # def name + # @name + # end + # + # def name=(val) + # name_will_change! unless val == @name + # @name = val + # end + # + # def save + # # do persistence work + # + # changes_applied + # end + # + # def reload! + # # get the values from the persistence layer + # + # clear_changes_information + # end + # + # def rollback! + # restore_attributes + # end + # end + # + # 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_changed?(from: "Uncle Bob", to: "Bob") # => 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 + # + # Reset the changes: + # + # person.previous_changes # => {"name" => ["Uncle Bob", "Bill"]} + # person.reload! + # person.previous_changes # => {} + # + # Rollback the changes: + # + # person.name = "Uncle Bob" + # person.rollback! + # person.name # => "Bill" + # 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"]} + # + # If an attribute is modified in-place then make use of + # +[attribute_name]_will_change!+ to mark that the attribute is changing. + # Otherwise Active Model can't track changes to in-place attributes. Note + # that Active Record can detect in-place modifications automatically. You do + # not need to call +[attribute_name]_will_change!+ on Active Record models. + # + # person.name_will_change! + # person.name_change # => ["Bill", "Bill"] + # 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: '!' + attribute_method_affix prefix: 'restore_', suffix: '!' + end + + # Returns +true+ if any attribute have unsaved changes, +false+ otherwise. + # + # person.changed? # => false + # person.name = 'bob' + # person.changed? # => true + def changed? + changed_attributes.present? + end + + # Returns an array with the name of the attributes with unsaved changes. + # + # person.changed # => [] + # person.name = 'bob' + # person.changed # => ["name"] + def changed + changed_attributes.keys + end + + # Returns a hash of changed attributes indicating their original + # and new values like <tt>attr => [original value, new value]</tt>. + # + # person.changes # => {} + # person.name = 'bob' + # person.changes # => { "name" => ["bill", "bob"] } + def changes + ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }] + end + + # Returns a hash of attributes that were changed before the model was saved. + # + # person.name # => "bob" + # person.name = 'robert' + # person.save + # person.previous_changes # => {"name" => ["bob", "robert"]} + def previous_changes + @previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new + end + + # Returns a hash of the attributes with unsaved changes indicating their original + # values like <tt>attr => original value</tt>. + # + # person.name # => "bob" + # person.name = 'robert' + # person.changed_attributes # => {"name" => "bob"} + def changed_attributes + @changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new + end + + # Handle <tt>*_changed?</tt> for +method_missing+. + def attribute_changed?(attr, options = {}) #:nodoc: + result = changed_attributes.include?(attr) + result &&= options[:to] == __send__(attr) if options.key?(:to) + result &&= options[:from] == changed_attributes[attr] if options.key?(:from) + result + end + + # Handle <tt>*_was</tt> for +method_missing+. + def attribute_was(attr) # :nodoc: + attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr) + end + + # Restore all previous data of the provided attributes. + def restore_attributes(attributes = changed) + attributes.each { |attr| restore_attribute! attr } + end + + private + + # Removes current changes and makes them accessible through +previous_changes+. + def changes_applied # :doc: + @previously_changed = changes + @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new + end + + # Clear all dirty data: current changes and previous changes. + def clear_changes_information # :doc: + @previously_changed = ActiveSupport::HashWithIndifferentAccess.new + @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new + end + + def reset_changes + ActiveSupport::Deprecation.warn "#reset_changes is deprecated and will be removed on Rails 5. Please use #clear_changes_information instead." + clear_changes_information + end + + # Handle <tt>*_change</tt> for +method_missing+. + def attribute_change(attr) + [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr) + end + + # Handle <tt>*_will_change!</tt> for +method_missing+. + def attribute_will_change!(attr) + return if attribute_changed?(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) + ActiveSupport::Deprecation.warn "#reset_#{attr}! is deprecated and will be removed on Rails 5. Please use #restore_#{attr}! instead." + + restore_attribute!(attr) + end + + # Handle <tt>restore_*!</tt> for +method_missing+. + def restore_attribute!(attr) + if attribute_changed?(attr) + __send__("#{attr}=", changed_attributes[attr]) + changed_attributes.delete(attr) + end + end + end +end diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb new file mode 100644 index 0000000000..1d025beeef --- /dev/null +++ b/activemodel/lib/active_model/errors.rb @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- + +require 'active_support/core_ext/array/conversions' +require 'active_support/core_ext/string/inflections' + +module ActiveModel + # == Active \Model \Errors + # + # Provides a modified +Hash+ that you can include in your object + # for handling error messages and interacting with Action View helpers. + # + # A minimal implementation could be: + # + # class Person + # # Required dependency for ActiveModel::Errors + # extend ActiveModel::Naming + # + # def initialize + # @errors = ActiveModel::Errors.new(self) + # end + # + # attr_accessor :name + # attr_reader :errors + # + # def validate! + # errors.add(:name, "cannot be nil") if name.nil? + # end + # + # # The following methods are needed to be minimally implemented + # + # def read_attribute_for_validation(attr) + # send(attr) + # end + # + # def Person.human_attribute_name(attr, options = {}) + # attr + # end + # + # def Person.lookup_ancestors + # [self] + # end + # end + # + # The last three methods are required in your object for Errors to be + # able to generate error messages correctly and also handle multiple + # languages. Of course, if you extend your object with ActiveModel::Translation + # you will not need to implement the last two. Likewise, using + # ActiveModel::Validations will handle the validation related methods + # for you. + # + # The above allows you to do: + # + # person = Person.new + # person.validate! # => ["cannot be nil"] + # person.errors.full_messages # => ["name cannot be nil"] + # # etc.. + class Errors + include Enumerable + + CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict] + + attr_reader :messages + + # Pass in the instance of the object that is using the errors object. + # + # class Person + # def initialize + # @errors = ActiveModel::Errors.new(self) + # end + # end + def initialize(base) + @base = base + @messages = {} + end + + def initialize_dup(other) # :nodoc: + @messages = other.messages.dup + super + end + + # Clear the error messages. + # + # person.errors.full_messages # => ["name cannot be nil"] + # person.errors.clear + # person.errors.full_messages # => [] + def clear + messages.clear + end + + # Returns +true+ if the error messages include an error for the given key + # +attribute+, +false+ otherwise. + # + # person.errors.messages # => {:name=>["cannot be nil"]} + # person.errors.include?(:name) # => true + # person.errors.include?(:age) # => false + def include?(attribute) + messages[attribute].present? + end + # aliases include? + alias :has_key? :include? + + # Get messages for +key+. + # + # person.errors.messages # => {:name=>["cannot be nil"]} + # person.errors.get(:name) # => ["cannot be nil"] + # person.errors.get(:age) # => nil + def get(key) + messages[key] + end + + # Set messages for +key+ to +value+. + # + # person.errors.get(:name) # => ["cannot be nil"] + # person.errors.set(:name, ["can't be nil"]) + # person.errors.get(:name) # => ["can't be nil"] + def set(key, value) + messages[key] = value + end + + # Delete messages for +key+. Returns the deleted messages. + # + # person.errors.get(:name) # => ["cannot be nil"] + # person.errors.delete(:name) # => ["cannot be nil"] + # person.errors.get(:name) # => nil + def delete(key) + messages.delete(key) + end + + # When passed a symbol or a name of a method, returns an array of errors + # for the method. + # + # person.errors[:name] # => ["cannot be nil"] + # person.errors['name'] # => ["cannot be nil"] + def [](attribute) + get(attribute.to_sym) || set(attribute.to_sym, []) + end + + # Adds to the supplied attribute the supplied error message. + # + # person.errors[:name] = "must be set" + # person.errors[:name] # => ['must be set'] + def []=(attribute, error) + self[attribute] << error + end + + # Iterates through each error key, value pair in the error messages hash. + # Yields the attribute and the error for that attribute. If the attribute + # has more than one error message, yields once for each error message. + # + # person.errors.add(:name, "can't be blank") + # person.errors.each do |attribute, error| + # # Will yield :name and "can't be blank" + # end + # + # person.errors.add(:name, "must be specified") + # person.errors.each do |attribute, error| + # # Will yield :name and "can't be blank" + # # then yield :name and "must be specified" + # end + def each + messages.each_key do |attribute| + self[attribute].each { |error| yield attribute, error } + end + end + + # Returns the number of error messages. + # + # person.errors.add(:name, "can't be blank") + # person.errors.size # => 1 + # person.errors.add(:name, "must be specified") + # person.errors.size # => 2 + def size + values.flatten.size + end + + # Returns all message values. + # + # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} + # person.errors.values # => [["cannot be nil", "must be specified"]] + def values + messages.values + end + + # Returns all message keys. + # + # person.errors.messages # => {:name=>["cannot be nil", "must be specified"]} + # person.errors.keys # => [:name] + def keys + messages.keys + end + + # Returns an array of error messages, with the attribute name included. + # + # person.errors.add(:name, "can't be blank") + # person.errors.add(:name, "must be specified") + # person.errors.to_a # => ["name can't be blank", "name must be specified"] + def to_a + full_messages + end + + # Returns the number of error messages. + # + # person.errors.add(:name, "can't be blank") + # person.errors.count # => 1 + # person.errors.add(:name, "must be specified") + # person.errors.count # => 2 + def count + to_a.size + end + + # Returns +true+ if no errors are found, +false+ otherwise. + # If the error message is a string it can be empty. + # + # person.errors.full_messages # => ["name cannot be nil"] + # person.errors.empty? # => false + def empty? + all? { |k, v| v && v.empty? && !v.is_a?(String) } + end + # aliases empty? + alias_method :blank?, :empty? + + # Returns an xml formatted representation of the Errors hash. + # + # person.errors.add(:name, "can't be blank") + # person.errors.add(:name, "must be specified") + # person.errors.to_xml + # # => + # # <?xml version=\"1.0\" encoding=\"UTF-8\"?> + # # <errors> + # # <error>name can't be blank</error> + # # <error>name must be specified</error> + # # </errors> + def to_xml(options={}) + to_a.to_xml({ root: "errors", skip_types: true }.merge!(options)) + end + + # Returns a Hash that can be used as the JSON representation for this + # object. You can pass the <tt>:full_messages</tt> option. This determines + # if the json object should contain full messages or not (false by default). + # + # person.errors.as_json # => {:name=>["cannot be nil"]} + # person.errors.as_json(full_messages: true) # => {:name=>["name cannot be nil"]} + def as_json(options=nil) + to_hash(options && options[:full_messages]) + end + + # Returns a Hash of attributes with their error messages. If +full_messages+ + # is +true+, it will contain full messages (see +full_message+). + # + # person.errors.to_hash # => {:name=>["cannot be nil"]} + # person.errors.to_hash(true) # => {:name=>["name cannot be nil"]} + def to_hash(full_messages = false) + if full_messages + messages = {} + self.messages.each do |attribute, array| + messages[attribute] = array.map { |message| full_message(attribute, message) } + end + messages + else + self.messages.dup + end + end + + # Adds +message+ to the error messages on +attribute+. More than one error + # can be added to the same +attribute+. If no +message+ is supplied, + # <tt>:invalid</tt> is assumed. + # + # person.errors.add(:name) + # # => ["is invalid"] + # person.errors.add(:name, 'must be implemented') + # # => ["is invalid", "must be implemented"] + # + # person.errors.messages + # # => {:name=>["must be implemented", "is invalid"]} + # + # If +message+ is a symbol, it will be translated using the appropriate + # scope (see +generate_message+). + # + # If +message+ is a proc, it will be called, allowing for things like + # <tt>Time.now</tt> to be used within an error. + # + # If the <tt>:strict</tt> option is set to +true+, it will raise + # ActiveModel::StrictValidationFailed instead of adding the error. + # <tt>:strict</tt> option can also be set to any other exception. + # + # person.errors.add(:name, nil, strict: true) + # # => ActiveModel::StrictValidationFailed: name is invalid + # person.errors.add(:name, nil, strict: NameIsInvalid) + # # => NameIsInvalid: name is invalid + # + # person.errors.messages # => {} + # + # +attribute+ should be set to <tt>:base</tt> if the error is not + # directly associated with a single attribute. + # + # person.errors.add(:base, "either name or email must be present") + # person.errors.messages + # # => {:base=>["either name or email must be present"]} + def add(attribute, message = :invalid, options = {}) + message = normalize_message(attribute, message, options) + if exception = options[:strict] + exception = ActiveModel::StrictValidationFailed if exception == true + raise exception, full_message(attribute, message) + end + + self[attribute] << message + end + + # Will add an error message to each of the attributes in +attributes+ + # that is empty. + # + # person.errors.add_on_empty(:name) + # person.errors.messages + # # => {:name=>["can't be empty"]} + def add_on_empty(attributes, options = {}) + Array(attributes).each do |attribute| + value = @base.send(:read_attribute_for_validation, attribute) + is_empty = value.respond_to?(:empty?) ? value.empty? : false + add(attribute, :empty, options) if value.nil? || is_empty + end + end + + # Will add an error message to each of the attributes in +attributes+ that + # is blank (using Object#blank?). + # + # person.errors.add_on_blank(:name) + # person.errors.messages + # # => {:name=>["can't be blank"]} + def add_on_blank(attributes, options = {}) + Array(attributes).each do |attribute| + value = @base.send(:read_attribute_for_validation, attribute) + add(attribute, :blank, options) if value.blank? + end + end + + # Returns +true+ if an error on the attribute with the given message is + # present, +false+ otherwise. +message+ is treated the same as for +add+. + # + # person.errors.add :name, :blank + # person.errors.added? :name, :blank # => true + def added?(attribute, message = :invalid, options = {}) + message = normalize_message(attribute, message, options) + self[attribute].include? message + end + + # Returns all the full error messages in an array. + # + # class Person + # validates_presence_of :name, :address, :email + # validates_length_of :name, in: 5..30 + # end + # + # person = Person.create(address: '123 First St.') + # person.errors.full_messages + # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Email can't be blank"] + def full_messages + map { |attribute, message| full_message(attribute, message) } + end + + # Returns all the full error messages for a given attribute in an array. + # + # class Person + # validates_presence_of :name, :email + # validates_length_of :name, in: 5..30 + # end + # + # person = Person.create() + # person.errors.full_messages_for(:name) + # # => ["Name is too short (minimum is 5 characters)", "Name can't be blank"] + def full_messages_for(attribute) + (get(attribute) || []).map { |message| full_message(attribute, message) } + end + + # Returns a full message for a given attribute. + # + # person.errors.full_message(:name, 'is invalid') # => "Name is invalid" + def full_message(attribute, message) + return message if attribute == :base + attr_name = attribute.to_s.tr('.', '_').humanize + attr_name = @base.class.human_attribute_name(attribute, default: attr_name) + I18n.t(:"errors.format", { + default: "%{attribute} %{message}", + attribute: attr_name, + message: message + }) + end + + # Translates an error message in its default scope + # (<tt>activemodel.errors.messages</tt>). + # + # Error messages are first looked up in <tt>models.MODEL.attributes.ATTRIBUTE.MESSAGE</tt>, + # if it's not there, it's looked up in <tt>models.MODEL.MESSAGE</tt> and if + # that is not there also, it returns the translation of the default message + # (e.g. <tt>activemodel.errors.messages.MESSAGE</tt>). The translated model + # name, translated attribute name and the value are available for + # interpolation. + # + # 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, + # it looks for these translations: + # + # * <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) + + if @base.class.respond_to?(:i18n_scope) + defaults = @base.class.lookup_ancestors.map do |klass| + [ :"#{@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 + else + defaults = [] + end + + defaults << options.delete(:message) + defaults << :"#{@base.class.i18n_scope}.errors.messages.#{type}" if @base.class.respond_to?(:i18n_scope) + defaults << :"errors.attributes.#{attribute}.#{type}" + defaults << :"errors.messages.#{type}" + + defaults.compact! + defaults.flatten! + + key = defaults.shift + value = (attribute != :base ? @base.send(:read_attribute_for_validation, attribute) : nil) + + options = { + default: defaults, + model: @base.class.model_name.human, + attribute: @base.class.human_attribute_name(attribute), + value: value + }.merge!(options) + + I18n.translate(key, options) + end + + private + def normalize_message(attribute, message, options) + case message + when Symbol + generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS)) + when Proc + message.call + else + message + end + end + end + + # Raised when a validation cannot be corrected by end users and are considered + # exceptional. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # + # validates_presence_of :name, strict: true + # end + # + # person = Person.new + # person.name = nil + # person.valid? + # # => ActiveModel::StrictValidationFailed: Name can't be blank + class StrictValidationFailed < StandardError + end +end diff --git a/activemodel/lib/active_model/forbidden_attributes_protection.rb b/activemodel/lib/active_model/forbidden_attributes_protection.rb new file mode 100644 index 0000000000..7468f95548 --- /dev/null +++ b/activemodel/lib/active_model/forbidden_attributes_protection.rb @@ -0,0 +1,27 @@ +module ActiveModel + # Raised when forbidden attributes are used for mass assignment. + # + # class Person < ActiveRecord::Base + # end + # + # params = ActionController::Parameters.new(name: 'Bob') + # Person.new(params) + # # => ActiveModel::ForbiddenAttributesError + # + # params.permit! + # Person.new(params) + # # => #<Person id: nil, name: "Bob"> + class ForbiddenAttributesError < StandardError + end + + module ForbiddenAttributesProtection # :nodoc: + protected + def sanitize_for_mass_assignment(attributes) + if attributes.respond_to?(:permitted?) && !attributes.permitted? + raise ActiveModel::ForbiddenAttributesError + else + attributes + end + end + end +end diff --git a/activemodel/lib/active_model/gem_version.rb b/activemodel/lib/active_model/gem_version.rb new file mode 100644 index 0000000000..964b24398d --- /dev/null +++ b/activemodel/lib/active_model/gem_version.rb @@ -0,0 +1,15 @@ +module ActiveModel + # Returns the version of the currently loaded ActiveModel as a <tt>Gem::Version</tt> + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 4 + MINOR = 2 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/activemodel/lib/active_model/lint.rb b/activemodel/lib/active_model/lint.rb new file mode 100644 index 0000000000..c6bc18b008 --- /dev/null +++ b/activemodel/lib/active_model/lint.rb @@ -0,0 +1,110 @@ +module ActiveModel + module Lint + # == Active \Model \Lint \Tests + # + # You can test whether an object is compliant with the Active \Model API by + # including <tt>ActiveModel::Lint::Tests</tt> in your TestCase. It will + # include tests that tell you whether your object is fully compliant, + # or if not, which aspects of the API are not implemented. + # + # Note an object is not required to implement all APIs in order to work + # with Action Pack. This module only intends to provide guidance in case + # you want all features out of the box. + # + # These tests do not attempt to determine the semantic correctness of the + # returned values. For instance, you could implement <tt>valid?</tt> 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 <tt>to_model</tt>. It is perfectly fine for <tt>to_model</tt> to return + # +self+. + module Tests + + # == Responds to <tt>to_key</tt> + # + # Returns an Enumerable of all (primary) key attributes + # or nil if <tt>model.persisted?</tt> is false. This is used by + # <tt>dom_id</tt> to generate unique ids for the object. + 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?, "to_key should return nil when `persisted?` returns false" + end + + # == Responds to <tt>to_param</tt> + # + # Returns a string representing the object's key suitable for use in URLs + # or +nil+ if <tt>model.persisted?</tt> is +false+. + # + # Implementers can decide to either raise an exception or provide a + # default in case the record uses a composite primary key. There are no + # tests for this behavior in lint because it doesn't make sense to force + # any of the possible implementation strategies on the implementer. + # However, if the resource is not persisted?, then <tt>to_param</tt> + # should always return +nil+. + def test_to_param + 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?, "to_param should return nil when `persisted?` returns false" + end + + # == Responds to <tt>to_partial_path</tt> + # + # Returns a string giving a relative path. This is used for looking up + # partials. For example, a BlogPost model might return "blog_posts/blog_post" + def test_to_partial_path + assert model.respond_to?(:to_partial_path), "The model should respond to to_partial_path" + assert_kind_of String, model.to_partial_path + end + + # == Responds to <tt>persisted?</tt> + # + # 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 route to + # the create action. If it is persisted, a form for the object will routes + # to the update action. + def test_persisted? + assert model.respond_to?(:persisted?), "The model should respond to persisted?" + assert_boolean model.persisted?, "persisted?" + end + + # == \Naming + # + # Model.model_name must return a string with some convenience methods: + # <tt>:human</tt>, <tt>:singular</tt> and <tt>:plural</tt>. Check + # ActiveModel::Naming for more information. + def test_model_naming + assert model.class.respond_to?(:model_name), "The model should respond to model_name" + model_name = model.class.model_name + assert model_name.respond_to?(:to_str) + assert model_name.human.respond_to?(:to_str) + assert model_name.singular.respond_to?(:to_str) + assert model_name.plural.respond_to?(:to_str) + end + + # == \Errors Testing + # + # Returns an object that implements [](attribute) defined which 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 model.respond_to?(:errors), "The model should respond to errors" + assert model.errors[:hello].is_a?(Array), "errors#[] should return an Array" + end + + private + def model + assert @model.respond_to?(:to_model), "The object should respond to to_model" + @model.to_model + end + + def assert_boolean(result, name) + assert result == true || result == false, "#{name} should be a boolean" + end + end + end +end diff --git a/activemodel/lib/active_model/locale/en.yml b/activemodel/lib/active_model/locale/en.yml new file mode 100644 index 0000000000..bf07945fe1 --- /dev/null +++ b/activemodel/lib/active_model/locale/en.yml @@ -0,0 +1,35 @@ +en: + errors: + # The default format to use in full error messages. + format: "%{attribute} %{message}" + + # The values :model, :attribute and :value are always available for interpolation + # The value :count is available when applicable. Can be used for pluralization. + messages: + inclusion: "is not included in the list" + exclusion: "is reserved" + invalid: "is invalid" + confirmation: "doesn't match %{attribute}" + accepted: "must be accepted" + empty: "can't be empty" + blank: "can't be blank" + present: "must be blank" + too_long: + one: "is too long (maximum is 1 character)" + other: "is too long (maximum is %{count} characters)" + too_short: + one: "is too short (minimum is 1 character)" + other: "is too short (minimum is %{count} characters)" + wrong_length: + one: "is the wrong length (should be 1 character)" + other: "is the wrong length (should be %{count} characters)" + not_a_number: "is not a number" + not_an_integer: "must be an integer" + greater_than: "must be greater than %{count}" + greater_than_or_equal_to: "must be greater than or equal to %{count}" + equal_to: "must be equal to %{count}" + less_than: "must be less than %{count}" + less_than_or_equal_to: "must be less than or equal to %{count}" + other_than: "must be other than %{count}" + odd: "must be odd" + even: "must be even" diff --git a/activemodel/lib/active_model/model.rb b/activemodel/lib/active_model/model.rb new file mode 100644 index 0000000000..640024eaa1 --- /dev/null +++ b/activemodel/lib/active_model/model.rb @@ -0,0 +1,99 @@ +module ActiveModel + + # == Active \Model \Basic \Model + # + # Includes the required interface for an object to interact with + # <tt>ActionPack</tt>, using different <tt>ActiveModel</tt> modules. + # It includes model name introspections, conversions, translations and + # validations. Besides that, it allows you to initialize the object with a + # hash of attributes, pretty much like <tt>ActiveRecord</tt> does. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::Model + # attr_accessor :name, :age + # end + # + # person = Person.new(name: 'bob', age: '18') + # person.name # => "bob" + # person.age # => "18" + # + # Note that, by default, <tt>ActiveModel::Model</tt> implements <tt>persisted?</tt> + # to return +false+, which is the most common case. You may want to override + # it in your class to simulate a different scenario: + # + # class Person + # include ActiveModel::Model + # attr_accessor :id, :name + # + # def persisted? + # self.id == 1 + # end + # end + # + # person = Person.new(id: 1, name: 'bob') + # person.persisted? # => true + # + # Also, if for some reason you need to run code on <tt>initialize</tt>, make + # sure you call +super+ if you want the attributes hash initialization to + # happen. + # + # class Person + # include ActiveModel::Model + # attr_accessor :id, :name, :omg + # + # def initialize(attributes={}) + # super + # @omg ||= true + # end + # end + # + # person = Person.new(id: 1, name: 'bob') + # person.omg # => true + # + # For more detailed information on other functionalities available, please + # refer to the specific modules included in <tt>ActiveModel::Model</tt> + # (see below). + module Model + def self.included(base) #:nodoc: + base.class_eval do + extend ActiveModel::Naming + extend ActiveModel::Translation + include ActiveModel::Validations + include ActiveModel::Conversion + end + end + + # Initializes a new model with the given +params+. + # + # class Person + # include ActiveModel::Model + # attr_accessor :name, :age + # end + # + # person = Person.new(name: 'bob', age: '18') + # person.name # => "bob" + # person.age # => "18" + def initialize(params={}) + params.each do |attr, value| + self.public_send("#{attr}=", value) + end if params + + super() + end + + # Indicates if the model is persisted. Default is +false+. + # + # class Person + # include ActiveModel::Model + # attr_accessor :id, :name + # end + # + # person = Person.new(id: 1, name: 'bob') + # person.persisted? # => false + def persisted? + false + end + end +end diff --git a/activemodel/lib/active_model/naming.rb b/activemodel/lib/active_model/naming.rb new file mode 100644 index 0000000000..241e88deeb --- /dev/null +++ b/activemodel/lib/active_model/naming.rb @@ -0,0 +1,317 @@ +require 'active_support/core_ext/hash/except' +require 'active_support/core_ext/module/introspection' + +module ActiveModel + class Name + include Comparable + + attr_reader :singular, :plural, :element, :collection, + :singular_route_key, :route_key, :param_key, :i18n_key, + :name + + alias_method :cache_key, :collection + + ## + # :method: == + # + # :call-seq: + # ==(other) + # + # Equivalent to <tt>String#==</tt>. Returns +true+ if the class name and + # +other+ are equal, otherwise +false+. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name == 'BlogPost' # => true + # BlogPost.model_name == 'Blog Post' # => false + + ## + # :method: === + # + # :call-seq: + # ===(other) + # + # Equivalent to <tt>#==</tt>. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name === 'BlogPost' # => true + # BlogPost.model_name === 'Blog Post' # => false + + ## + # :method: <=> + # + # :call-seq: + # ==(other) + # + # Equivalent to <tt>String#<=></tt>. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name <=> 'BlogPost' # => 0 + # BlogPost.model_name <=> 'Blog' # => 1 + # BlogPost.model_name <=> 'BlogPosts' # => -1 + + ## + # :method: =~ + # + # :call-seq: + # =~(regexp) + # + # Equivalent to <tt>String#=~</tt>. Match the class name against the given + # regexp. Returns the position where the match starts or +nil+ if there is + # no match. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name =~ /Post/ # => 4 + # BlogPost.model_name =~ /\d/ # => nil + + ## + # :method: !~ + # + # :call-seq: + # !~(regexp) + # + # Equivalent to <tt>String#!~</tt>. Match the class name against the given + # regexp. Returns +true+ if there is no match, otherwise +false+. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name !~ /Post/ # => false + # BlogPost.model_name !~ /\d/ # => true + + ## + # :method: eql? + # + # :call-seq: + # eql?(other) + # + # Equivalent to <tt>String#eql?</tt>. Returns +true+ if the class name and + # +other+ have the same length and content, otherwise +false+. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name.eql?('BlogPost') # => true + # BlogPost.model_name.eql?('Blog Post') # => false + + ## + # :method: to_s + # + # :call-seq: + # to_s() + # + # Returns the class name. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name.to_s # => "BlogPost" + + ## + # :method: to_str + # + # :call-seq: + # to_str() + # + # Equivalent to +to_s+. + delegate :==, :===, :<=>, :=~, :"!~", :eql?, :to_s, + :to_str, to: :name + + # Returns a new ActiveModel::Name instance. By default, the +namespace+ + # and +name+ option will take the namespace and name of the given class + # respectively. + # + # module Foo + # class Bar + # end + # end + # + # ActiveModel::Name.new(Foo::Bar).to_s + # # => "Foo::Bar" + def initialize(klass, namespace = nil, name = nil) + @name = name || klass.name + + raise ArgumentError, "Class name cannot be blank. You need to supply a name argument when anonymous class given" if @name.blank? + + @unnamespaced = @name.sub(/^#{namespace.name}::/, '') if namespace + @klass = klass + @singular = _singularize(@name) + @plural = ActiveSupport::Inflector.pluralize(@singular) + @element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(@name)) + @human = ActiveSupport::Inflector.humanize(@element) + @collection = ActiveSupport::Inflector.tableize(@name) + @param_key = (namespace ? _singularize(@unnamespaced) : @singular) + @i18n_key = @name.underscore.to_sym + + @route_key = (namespace ? ActiveSupport::Inflector.pluralize(@param_key) : @plural.dup) + @singular_route_key = ActiveSupport::Inflector.singularize(@route_key) + @route_key << "_index" if @plural == @singular + end + + # Transform the model name into a more humane format, using I18n. By default, + # it will underscore then humanize the class name. + # + # class BlogPost + # extend ActiveModel::Naming + # end + # + # BlogPost.model_name.human # => "Blog post" + # + # Specify +options+ with additional translating options. + def human(options={}) + return @human unless @klass.respond_to?(:lookup_ancestors) && + @klass.respond_to?(:i18n_scope) + + defaults = @klass.lookup_ancestors.map do |klass| + klass.model_name.i18n_key + end + + defaults << options[:default] if options[:default] + defaults << @human + + options = { scope: [@klass.i18n_scope, :models], count: 1, default: defaults }.merge!(options.except(:default)) + I18n.translate(defaults.shift, options) + end + + private + + def _singularize(string, replacement='_') + ActiveSupport::Inflector.underscore(string).tr('/', replacement) + end + end + + # == Active \Model \Naming + # + # Creates a +model_name+ method on your object. + # + # To implement, just extend ActiveModel::Naming in your object: + # + # class BookCover + # extend ActiveModel::Naming + # end + # + # BookCover.model_name.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. + module Naming + def self.extended(base) #:nodoc: + base.remove_possible_method :model_name + base.delegate :model_name, to: :class + end + + # Returns an ActiveModel::Name object for module. It can be + # used to retrieve all kinds of naming-related information + # (See ActiveModel::Name for more information). + # + # class Person + # include ActiveModel::Model + # end + # + # Person.model_name.name # => "Person" + # Person.model_name.class # => ActiveModel::Name + # Person.model_name.singular # => "person" + # Person.model_name.plural # => "people" + def model_name + @_model_name ||= begin + namespace = self.parents.detect do |n| + n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming? + end + ActiveModel::Name.new(self, namespace) + end + end + + # Returns the plural class name of a record or class. + # + # ActiveModel::Naming.plural(post) # => "posts" + # ActiveModel::Naming.plural(Highrise::Person) # => "highrise_people" + def self.plural(record_or_class) + model_name_from_record_or_class(record_or_class).plural + end + + # Returns the singular class name of a record or class. + # + # ActiveModel::Naming.singular(post) # => "post" + # ActiveModel::Naming.singular(Highrise::Person) # => "highrise_person" + def self.singular(record_or_class) + model_name_from_record_or_class(record_or_class).singular + end + + # Identifies whether the class name of a record or class is uncountable. + # + # ActiveModel::Naming.uncountable?(Sheep) # => true + # ActiveModel::Naming.uncountable?(Post) # => false + def self.uncountable?(record_or_class) + plural(record_or_class) == singular(record_or_class) + end + + # Returns string to use while generating route names. It differs for + # namespaced models regarding whether it's inside isolated engine. + # + # # For isolated engine: + # ActiveModel::Naming.singular_route_key(Blog::Post) # => "post" + # + # # For shared engine: + # ActiveModel::Naming.singular_route_key(Blog::Post) # => "blog_post" + def self.singular_route_key(record_or_class) + model_name_from_record_or_class(record_or_class).singular_route_key + end + + # Returns string to use while generating route names. It differs for + # namespaced models regarding whether it's inside isolated engine. + # + # # For isolated engine: + # ActiveModel::Naming.route_key(Blog::Post) # => "posts" + # + # # For shared engine: + # ActiveModel::Naming.route_key(Blog::Post) # => "blog_posts" + # + # The route key also considers if the noun is uncountable and, in + # such cases, automatically appends _index. + def self.route_key(record_or_class) + model_name_from_record_or_class(record_or_class).route_key + end + + # Returns string to use for params names. It differs for + # namespaced models regarding whether it's inside isolated engine. + # + # # For isolated engine: + # ActiveModel::Naming.param_key(Blog::Post) # => "post" + # + # # For shared engine: + # ActiveModel::Naming.param_key(Blog::Post) # => "blog_post" + def self.param_key(record_or_class) + model_name_from_record_or_class(record_or_class).param_key + end + + def self.model_name_from_record_or_class(record_or_class) #:nodoc: + if record_or_class.respond_to?(:model_name) + record_or_class.model_name + elsif record_or_class.respond_to?(:to_model) + record_or_class.to_model.class.model_name + else + record_or_class.class.model_name + end + end + private_class_method :model_name_from_record_or_class + end +end diff --git a/activemodel/lib/active_model/railtie.rb b/activemodel/lib/active_model/railtie.rb new file mode 100644 index 0000000000..1671eb7bd4 --- /dev/null +++ b/activemodel/lib/active_model/railtie.rb @@ -0,0 +1,12 @@ +require "active_model" +require "rails" + +module ActiveModel + class Railtie < Rails::Railtie # :nodoc: + config.eager_load_namespaces << ActiveModel + + initializer "active_model.secure_password" do + ActiveModel::SecurePassword.min_cost = Rails.env.test? + end + end +end diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb new file mode 100644 index 0000000000..f6ad35769f --- /dev/null +++ b/activemodel/lib/active_model/secure_password.rb @@ -0,0 +1,134 @@ +module ActiveModel + module SecurePassword + extend ActiveSupport::Concern + + # BCrypt hash function can handle maximum 72 characters, and if we pass + # password of length more than 72 characters it ignores extra characters. + # Hence need to put a restriction on password length. + MAX_PASSWORD_LENGTH_ALLOWED = 72 + + class << self + attr_accessor :min_cost # :nodoc: + end + self.min_cost = false + + module ClassMethods + # Adds methods to set and authenticate against a BCrypt password. + # This mechanism requires you to have a +password_digest+ attribute. + # + # The following validations are added automatically: + # * Password must be present on creation + # * Password length should be less than or equal to 72 characters + # * Confirmation of password (using a +password_confirmation+ attribute) + # + # If password confirmation validation is not needed, simply leave out the + # value for +password_confirmation+ (i.e. don't provide a form field for + # it). When this attribute has a +nil+ value, the validation will not be + # triggered. + # + # For further customizability, it is possible to supress the default + # validations by passing <tt>validations: false</tt> as an argument. + # + # Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password: + # + # gem 'bcrypt', '~> 3.1.7' + # + # Example using Active Record (which automatically includes ActiveModel::SecurePassword): + # + # # Schema: User(name:string, password_digest:string) + # class User < ActiveRecord::Base + # has_secure_password + # end + # + # user = User.new(name: 'david', password: '', password_confirmation: 'nomatch') + # user.save # => false, password required + # user.password = 'mUc3m00RsqyRe' + # user.save # => false, confirmation doesn't match + # user.password_confirmation = 'mUc3m00RsqyRe' + # user.save # => true + # user.authenticate('notright') # => false + # user.authenticate('mUc3m00RsqyRe') # => user + # User.find_by(name: 'david').try(:authenticate, 'notright') # => false + # User.find_by(name: 'david').try(:authenticate, 'mUc3m00RsqyRe') # => user + def has_secure_password(options = {}) + # Load bcrypt gem only when has_secure_password is used. + # This is to avoid ActiveModel (and by extension the entire framework) + # being dependent on a binary library. + begin + require 'bcrypt' + rescue LoadError + $stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install" + raise + end + + include InstanceMethodsOnActivation + + if options.fetch(:validations, true) + include ActiveModel::Validations + + # This ensures the model has a password by checking whether the password_digest + # is present, so that this works with both new and existing records. However, + # when there is an error, the message is added to the password attribute instead + # so that the error message will make sense to the end-user. + validate do |record| + record.errors.add(:password, :blank) unless record.password_digest.present? + end + + validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED + validates_confirmation_of :password, if: ->{ password.present? } + end + + # This code is necessary as long as the protected_attributes gem is supported. + if respond_to?(:attributes_protected_by_default) + def self.attributes_protected_by_default #:nodoc: + super + ['password_digest'] + end + end + end + end + + module InstanceMethodsOnActivation + # Returns +self+ if the password is correct, otherwise +false+. + # + # class User < ActiveRecord::Base + # has_secure_password validations: false + # end + # + # user = User.new(name: 'david', password: 'mUc3m00RsqyRe') + # user.save + # user.authenticate('notright') # => false + # user.authenticate('mUc3m00RsqyRe') # => user + def authenticate(unencrypted_password) + BCrypt::Password.new(password_digest) == unencrypted_password && self + end + + attr_reader :password + + # Encrypts the password into the +password_digest+ attribute, only if the + # new password is not empty. + # + # class User < ActiveRecord::Base + # has_secure_password validations: false + # end + # + # user = User.new + # user.password = nil + # user.password_digest # => nil + # user.password = 'mUc3m00RsqyRe' + # user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4." + def password=(unencrypted_password) + if unencrypted_password.nil? + self.password_digest = nil + elsif !unencrypted_password.empty? + @password = unencrypted_password + cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost + self.password_digest = BCrypt::Password.create(unencrypted_password, cost: cost) + end + end + + def password_confirmation=(unencrypted_password) + @password_confirmation = unencrypted_password + end + end + end +end diff --git a/activemodel/lib/active_model/serialization.rb b/activemodel/lib/active_model/serialization.rb new file mode 100644 index 0000000000..976f50b13e --- /dev/null +++ b/activemodel/lib/active_model/serialization.rb @@ -0,0 +1,163 @@ +require 'active_support/core_ext/hash/except' +require 'active_support/core_ext/hash/slice' + +module ActiveModel + # == Active \Model \Serialization + # + # Provides a basic serialization to a serializable_hash for your objects. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::Serialization + # + # attr_accessor :name + # + # def attributes + # {'name' => nil} + # end + # end + # + # Which would provide you with: + # + # person = Person.new + # person.serializable_hash # => {"name"=>nil} + # person.name = "Bob" + # person.serializable_hash # => {"name"=>"Bob"} + # + # An +attributes+ hash must be defined and should contain any attributes you + # need to be serialized. Attributes must be strings, not symbols. + # When called, serializable hash will use instance methods that match the name + # of the attributes hash's keys. In order to override this behavior, take a look + # at the private method +read_attribute_for_serialization+. + # + # Most of the time though, either the JSON or XML serializations are needed. + # Both of these modules automatically include the + # <tt>ActiveModel::Serialization</tt> module, so there is no need to + # explicitly include it. + # + # A minimal implementation including XML and JSON would be: + # + # class Person + # include ActiveModel::Serializers::JSON + # include ActiveModel::Serializers::Xml + # + # attr_accessor :name + # + # def attributes + # {'name' => nil} + # end + # end + # + # Which would provide you with: + # + # person = Person.new + # person.serializable_hash # => {"name"=>nil} + # person.as_json # => {"name"=>nil} + # person.to_json # => "{\"name\":null}" + # person.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person... + # + # person.name = "Bob" + # person.serializable_hash # => {"name"=>"Bob"} + # person.as_json # => {"name"=>"Bob"} + # person.to_json # => "{\"name\":\"Bob\"}" + # person.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<serial-person... + # + # Valid options are <tt>:only</tt>, <tt>:except</tt>, <tt>:methods</tt> and + # <tt>:include</tt>. The following are all valid examples: + # + # person.serializable_hash(only: 'name') + # person.serializable_hash(include: :address) + # person.serializable_hash(include: { address: { only: 'city' }}) + module Serialization + # Returns a serialized hash of your object. + # + # class Person + # include ActiveModel::Serialization + # + # attr_accessor :name, :age + # + # def attributes + # {'name' => nil, 'age' => nil} + # end + # + # def capitalized_name + # name.capitalize + # end + # end + # + # person = Person.new + # person.name = 'bob' + # person.age = 22 + # person.serializable_hash # => {"name"=>"bob", "age"=>22} + # person.serializable_hash(only: :name) # => {"name"=>"bob"} + # person.serializable_hash(except: :name) # => {"age"=>22} + # person.serializable_hash(methods: :capitalized_name) + # # => {"name"=>"bob", "age"=>22, "capitalized_name"=>"Bob"} + def serializable_hash(options = nil) + options ||= {} + + attribute_names = attributes.keys + if only = options[:only] + attribute_names &= Array(only).map(&:to_s) + elsif except = options[:except] + attribute_names -= Array(except).map(&:to_s) + end + + hash = {} + attribute_names.each { |n| hash[n] = read_attribute_for_serialization(n) } + + Array(options[:methods]).each { |m| hash[m.to_s] = send(m) if respond_to?(m) } + + serializable_add_includes(options) do |association, records, opts| + hash[association.to_s] = if records.respond_to?(:to_ary) + records.to_ary.map { |a| a.serializable_hash(opts) } + else + records.serializable_hash(opts) + end + end + + hash + end + + private + + # Hook method defining how an attribute value should be retrieved for + # serialization. By default this is assumed to be an instance named after + # the attribute. Override this method in subclasses should you need to + # retrieve the value for a given attribute differently: + # + # class MyClass + # include ActiveModel::Serialization + # + # def initialize(data = {}) + # @data = data + # end + # + # def read_attribute_for_serialization(key) + # @data[key] + # end + # end + alias :read_attribute_for_serialization :send + + # Add associations specified via the <tt>:include</tt> option. + # + # Expects a block that takes as arguments: + # +association+ - name of the association + # +records+ - the association record(s) to be serialized + # +opts+ - options for the association records + def serializable_add_includes(options = {}) #:nodoc: + return unless includes = options[:include] + + unless includes.is_a?(Hash) + includes = Hash[Array(includes).map { |n| n.is_a?(Hash) ? n.to_a.first : [n, {}] }] + end + + includes.each do |association, opts| + if records = send(association) + yield association, records, opts + end + end + end + end +end diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb new file mode 100644 index 0000000000..c58e73f6a7 --- /dev/null +++ b/activemodel/lib/active_model/serializers/json.rb @@ -0,0 +1,145 @@ +require 'active_support/json' + +module ActiveModel + module Serializers + # == Active \Model \JSON \Serializer + module JSON + extend ActiveSupport::Concern + include ActiveModel::Serialization + + included do + extend ActiveModel::Naming + + class_attribute :include_root_in_json + self.include_root_in_json = false + end + + # Returns a hash representing the model. Some configuration can be + # passed through +options+. + # + # The option <tt>include_root_in_json</tt> controls the top-level behavior + # of +as_json+. If +true+, +as_json+ will emit a single root node named + # after the object's type. The default value for <tt>include_root_in_json</tt> + # option is +false+. + # + # user = User.find(1) + # user.as_json + # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true} + # + # ActiveRecord::Base.include_root_in_json = true + # + # user.as_json + # # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true } } + # + # This behavior can also be achieved by setting the <tt>:root</tt> option + # to +true+ as in: + # + # user = User.find(1) + # user.as_json(root: true) + # # => { "user" => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true } } + # + # Without any +options+, the returned Hash will include all the model's + # attributes. + # + # user = User.find(1) + # user.as_json + # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true} + # + # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit + # the attributes included, and work similar to the +attributes+ method. + # + # user.as_json(only: [:id, :name]) + # # => { "id" => 1, "name" => "Konata Izumi" } + # + # user.as_json(except: [:id, :created_at, :age]) + # # => { "name" => "Konata Izumi", "awesome" => true } + # + # To include the result of some method calls on the model use <tt>:methods</tt>: + # + # user.as_json(methods: :permalink) + # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true, + # # "permalink" => "1-konata-izumi" } + # + # To include associations use <tt>:include</tt>: + # + # user.as_json(include: :posts) + # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true, + # # "posts" => [ { "id" => 1, "author_id" => 1, "title" => "Welcome to the weblog" }, + # # { "id" => 2, "author_id" => 1, "title" => "So I was thinking" } ] } + # + # Second level and higher order associations work as well: + # + # user.as_json(include: { posts: { + # include: { comments: { + # only: :body } }, + # only: :title } }) + # # => { "id" => 1, "name" => "Konata Izumi", "age" => 16, + # # "created_at" => "2006/08/01", "awesome" => true, + # # "posts" => [ { "comments" => [ { "body" => "1st post!" }, { "body" => "Second!" } ], + # # "title" => "Welcome to the weblog" }, + # # { "comments" => [ { "body" => "Don't think too hard" } ], + # # "title" => "So I was thinking" } ] } + def as_json(options = nil) + root = if options && options.key?(:root) + options[:root] + else + include_root_in_json + end + + if root + root = self.class.model_name.element if root == true + { root => serializable_hash(options) } + else + serializable_hash(options) + end + end + + # Sets the model +attributes+ from a JSON string. Returns +self+. + # + # class Person + # include ActiveModel::Serializers::JSON + # + # attr_accessor :name, :age, :awesome + # + # def attributes=(hash) + # hash.each do |key, value| + # send("#{key}=", value) + # end + # end + # + # def attributes + # instance_values + # end + # end + # + # json = { name: 'bob', age: 22, awesome:true }.to_json + # person = Person.new + # person.from_json(json) # => #<Person:0x007fec5e7a0088 @age=22, @awesome=true, @name="bob"> + # person.name # => "bob" + # person.age # => 22 + # person.awesome # => true + # + # The default value for +include_root+ is +false+. You can change it to + # +true+ if the given JSON string includes a single root node. + # + # json = { person: { name: 'bob', age: 22, awesome:true } }.to_json + # person = Person.new + # person.from_json(json) # => #<Person:0x007fec5e7a0088 @age=22, @awesome=true, @name="bob"> + # person.name # => "bob" + # person.age # => 22 + # person.awesome # => true + def from_json(json, include_root=include_root_in_json) + hash = ActiveSupport::JSON.decode(json) + hash = hash.values.first if include_root + self.attributes = hash + self + end + end + end +end diff --git a/activemodel/lib/active_model/serializers/xml.rb b/activemodel/lib/active_model/serializers/xml.rb new file mode 100644 index 0000000000..7f99536dbb --- /dev/null +++ b/activemodel/lib/active_model/serializers/xml.rb @@ -0,0 +1,238 @@ +require 'active_support/core_ext/module/attribute_accessors' +require 'active_support/core_ext/array/conversions' +require 'active_support/core_ext/hash/conversions' +require 'active_support/core_ext/hash/slice' +require 'active_support/core_ext/time/acts_like' + +module ActiveModel + module Serializers + # == Active Model XML Serializer + module Xml + extend ActiveSupport::Concern + include ActiveModel::Serialization + + included do + extend ActiveModel::Naming + end + + class Serializer #:nodoc: + class Attribute #:nodoc: + attr_reader :name, :value, :type + + def initialize(name, serializable, value) + @name, @serializable = name, serializable + + if value.acts_like?(:time) && value.respond_to?(:in_time_zone) + value = value.in_time_zone + end + + @value = value + @type = compute_type + end + + def decorations + decorations = {} + decorations[:encoding] = 'base64' if type == :binary + decorations[:type] = (type == :string) ? nil : type + decorations[:nil] = true if value.nil? + decorations + end + + protected + + def compute_type + return if value.nil? + type = ActiveSupport::XmlMini::TYPE_NAMES[value.class.name] + type ||= :string if value.respond_to?(:to_str) + type ||= :yaml + type + end + end + + class MethodAttribute < Attribute #:nodoc: + end + + attr_reader :options + + def initialize(serializable, options = nil) + @serializable = serializable + @options = options ? options.dup : {} + end + + def serializable_hash + @serializable.serializable_hash(@options.except(:include)) + end + + def serializable_collection + methods = Array(options[:methods]).map(&:to_s) + serializable_hash.map do |name, value| + name = name.to_s + if methods.include?(name) + self.class::MethodAttribute.new(name, @serializable, value) + else + self.class::Attribute.new(name, @serializable, value) + end + end + end + + def serialize + require 'builder' unless defined? ::Builder + + options[:indent] ||= 2 + options[:builder] ||= ::Builder::XmlMarkup.new(indent: options[:indent]) + + @builder = options[:builder] + @builder.instruct! unless options[:skip_instruct] + + root = (options[:root] || @serializable.class.model_name.element).to_s + root = ActiveSupport::XmlMini.rename_key(root, options) + + args = [root] + args << { xmlns: options[:namespace] } if options[:namespace] + args << { type: options[:type] } if options[:type] && !options[:skip_types] + + @builder.tag!(*args) do + add_attributes_and_methods + add_includes + add_extra_behavior + add_procs + yield @builder if block_given? + end + end + + private + + def add_extra_behavior + end + + def add_attributes_and_methods + serializable_collection.each do |attribute| + key = ActiveSupport::XmlMini.rename_key(attribute.name, options) + ActiveSupport::XmlMini.to_tag(key, attribute.value, + options.merge(attribute.decorations)) + end + end + + def add_includes + @serializable.send(:serializable_add_includes, options) do |association, records, opts| + add_associations(association, records, opts) + end + end + + # TODO: This can likely be cleaned up to simple use ActiveSupport::XmlMini.to_tag as well. + def add_associations(association, records, opts) + merged_options = opts.merge(options.slice(:builder, :indent)) + merged_options[:skip_instruct] = true + + [:skip_types, :dasherize, :camelize].each do |key| + merged_options[key] = options[key] if merged_options[key].nil? && !options[key].nil? + end + + if records.respond_to?(:to_ary) + records = records.to_ary + + tag = ActiveSupport::XmlMini.rename_key(association.to_s, options) + type = options[:skip_types] ? { } : { type: "array" } + association_name = association.to_s.singularize + merged_options[:root] = association_name + + if records.empty? + @builder.tag!(tag, type) + else + @builder.tag!(tag, type) do + records.each do |record| + if options[:skip_types] + record_type = {} + else + record_class = (record.class.to_s.underscore == association_name) ? nil : record.class.name + record_type = { type: record_class } + end + + record.to_xml merged_options.merge(record_type) + end + end + end + else + merged_options[:root] = association.to_s + + unless records.class.to_s.underscore == association.to_s + merged_options[:type] = records.class.name + end + + records.to_xml merged_options + end + end + + def add_procs + if procs = options.delete(:procs) + Array(procs).each do |proc| + if proc.arity == 1 + proc.call(options) + else + proc.call(options, @serializable) + end + end + end + end + end + + # Returns XML representing the model. Configuration can be + # passed through +options+. + # + # Without any +options+, the returned XML string will include all the + # model's attributes. + # + # user = User.find(1) + # user.to_xml + # + # <?xml version="1.0" encoding="UTF-8"?> + # <user> + # <id type="integer">1</id> + # <name>David</name> + # <age type="integer">16</age> + # <created-at type="dateTime">2011-01-30T22:29:23Z</created-at> + # </user> + # + # The <tt>:only</tt> and <tt>:except</tt> options can be used to limit the + # attributes included, and work similar to the +attributes+ method. + # + # To include the result of some method calls on the model use <tt>:methods</tt>. + # + # To include associations use <tt>:include</tt>. + # + # For further documentation, see <tt>ActiveRecord::Serialization#to_xml</tt> + def to_xml(options = {}, &block) + Serializer.new(self, options).serialize(&block) + end + + # Sets the model +attributes+ from an XML string. Returns +self+. + # + # class Person + # include ActiveModel::Serializers::Xml + # + # attr_accessor :name, :age, :awesome + # + # def attributes=(hash) + # hash.each do |key, value| + # instance_variable_set("@#{key}", value) + # end + # end + # + # def attributes + # instance_values + # end + # end + # + # xml = { name: 'bob', age: 22, awesome:true }.to_xml + # person = Person.new + # person.from_xml(xml) # => #<Person:0x007fec5e3b3c40 @age=22, @awesome=true, @name="bob"> + # person.name # => "bob" + # person.age # => 22 + # person.awesome # => true + def from_xml(xml) + self.attributes = Hash.from_xml(xml).values.first + self + end + end + end +end diff --git a/activemodel/lib/active_model/test_case.rb b/activemodel/lib/active_model/test_case.rb new file mode 100644 index 0000000000..5004855d56 --- /dev/null +++ b/activemodel/lib/active_model/test_case.rb @@ -0,0 +1,4 @@ +module ActiveModel #:nodoc: + class TestCase < ActiveSupport::TestCase #:nodoc: + end +end diff --git a/activemodel/lib/active_model/translation.rb b/activemodel/lib/active_model/translation.rb new file mode 100644 index 0000000000..8470915abb --- /dev/null +++ b/activemodel/lib/active_model/translation.rb @@ -0,0 +1,69 @@ +module ActiveModel + + # == Active \Model \Translation + # + # Provides integration between your object and the Rails internationalization + # (i18n) framework. + # + # A minimal implementation could be: + # + # class TranslatedPerson + # extend ActiveModel::Translation + # end + # + # TranslatedPerson.human_attribute_name('my_attribute') + # # => "My attribute" + # + # This also provides the required class methods for hooking into the + # Rails internationalization API, including being able to define a + # class based +i18n_scope+ and +lookup_ancestors+ to find translations in + # parent classes. + module Translation + include ActiveModel::Naming + + # Returns the +i18n_scope+ for the class. Overwrite if you want custom lookup. + def i18n_scope + :activemodel + end + + # When localizing a string, it goes through the lookup returned by this + # method, which is used in ActiveModel::Name#human, + # ActiveModel::Errors#full_messages and + # ActiveModel::Translation#human_attribute_name. + def lookup_ancestors + self.ancestors.select { |x| x.respond_to?(:model_name) } + end + + # Transforms attribute names into a more human format, such as "First name" + # instead of "first_name". + # + # Person.human_attribute_name("first_name") # => "First name" + # + # Specify +options+ with additional translating options. + def human_attribute_name(attribute, options = {}) + options = { count: 1 }.merge!(options) + parts = attribute.to_s.split(".") + attribute = parts.pop + namespace = parts.join("/") unless parts.empty? + attributes_scope = "#{self.i18n_scope}.attributes" + + if namespace + defaults = lookup_ancestors.map do |klass| + :"#{attributes_scope}.#{klass.model_name.i18n_key}/#{namespace}.#{attribute}" + end + defaults << :"#{attributes_scope}.#{namespace}.#{attribute}" + else + defaults = lookup_ancestors.map do |klass| + :"#{attributes_scope}.#{klass.model_name.i18n_key}.#{attribute}" + end + end + + defaults << :"attributes.#{attribute}" + defaults << options.delete(:default) if options[:default] + defaults << attribute.humanize + + options[:default] = defaults + I18n.translate(defaults.shift, options) + end + end +end diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb new file mode 100644 index 0000000000..f67a3be5c1 --- /dev/null +++ b/activemodel/lib/active_model/validations.rb @@ -0,0 +1,392 @@ +require 'active_support/core_ext/array/extract_options' +require 'active_support/core_ext/hash/keys' +require 'active_support/core_ext/hash/except' + +module ActiveModel + + # == Active \Model \Validations + # + # Provides a full validation framework to your objects. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :first_name, :last_name + # + # validates_each :first_name, :last_name do |record, attr, value| + # record.errors.add attr, 'starts with z.' if value.to_s[0] == ?z + # end + # end + # + # Which provides you with the full standard validation stack that you + # know from Active Record: + # + # person = Person.new + # person.valid? # => true + # person.invalid? # => false + # + # person.first_name = 'zoolander' + # person.valid? # => false + # person.invalid? # => true + # person.errors.messages # => {first_name:["starts with z."]} + # + # Note that <tt>ActiveModel::Validations</tt> automatically adds an +errors+ + # method to your instances initialized with a new <tt>ActiveModel::Errors</tt> + # object, so there is no need for you to do this manually. + module Validations + extend ActiveSupport::Concern + + included do + extend ActiveModel::Callbacks + extend ActiveModel::Translation + + extend HelperMethods + include HelperMethods + + attr_accessor :validation_context + define_callbacks :validate, scope: :name + + class_attribute :_validators + self._validators = Hash.new { |h,k| h[k] = [] } + end + + module ClassMethods + # Validates each attribute against a block. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :first_name, :last_name + # + # validates_each :first_name, :last_name, allow_blank: true do |record, attr, value| + # record.errors.add attr, 'starts with z.' if value.to_s[0] == ?z + # end + # end + # + # Options: + # * <tt>:on</tt> - Specifies the contexts where this validation is active. + # You can pass a symbol or an array of symbols. + # (e.g. <tt>on: :create</tt> or <tt>on: :custom_validation_context</tt> or + # <tt>on: [:create, :custom_validation_context]</tt>) + # * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+. + # * <tt>:allow_blank</tt> - Skip validation if attribute is blank. + # * <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. + def validates_each(*attr_names, &block) + validates_with BlockValidator, _merge_attributes(attr_names), &block + end + + # Adds a validation method or block to the class. This is useful when + # overriding the +validate+ instance method becomes too unwieldy and + # you're looking for more descriptive declaration of your validations. + # + # This can be done with a symbol pointing to a method: + # + # class Comment + # include ActiveModel::Validations + # + # validate :must_be_friends + # + # def must_be_friends + # errors.add(:base, 'Must be friends to leave a comment') unless commenter.friend_of?(commentee) + # end + # end + # + # With a block which is passed with the current record to be validated: + # + # class Comment + # include ActiveModel::Validations + # + # validate do |comment| + # comment.must_be_friends + # end + # + # def must_be_friends + # errors.add(:base, 'Must be friends to leave a comment') unless commenter.friend_of?(commentee) + # end + # end + # + # Or with a block where self points to the current record to be validated: + # + # class Comment + # include ActiveModel::Validations + # + # validate do + # errors.add(:base, 'Must be friends to leave a comment') unless commenter.friend_of?(commentee) + # end + # end + # + # Options: + # * <tt>:on</tt> - Specifies the contexts where this validation is active. + # You can pass a symbol or an array of symbols. + # (e.g. <tt>on: :create</tt> or <tt>on: :custom_validation_context</tt> or + # <tt>on: [:create, :custom_validation_context]</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. + def validate(*args, &block) + options = args.extract_options! + + if args.all? { |arg| arg.is_a?(Symbol) } + options.assert_valid_keys([:on, :if, :unless]) + end + + if options.key?(:on) + options = options.dup + options[:if] = Array(options[:if]) + options[:if].unshift lambda { |o| + Array(options[:on]).include?(o.validation_context) + } + end + + args << options + set_callback(:validate, *args, &block) + end + + # List all validators that are being used to validate the model using + # +validates_with+ method. + # + # class Person + # include ActiveModel::Validations + # + # validates_with MyValidator + # validates_with OtherValidator, on: :create + # validates_with StrictValidator, strict: true + # end + # + # Person.validators + # # => [ + # # #<MyValidator:0x007fbff403e808 @options={}>, + # # #<OtherValidator:0x007fbff403d930 @options={on: :create}>, + # # #<StrictValidator:0x007fbff3204a30 @options={strict:true}> + # # ] + def validators + _validators.values.flatten.uniq + end + + # Clears all of the validators and validations. + # + # Note that this will clear anything that is being used to validate + # the model for both the +validates_with+ and +validate+ methods. + # It clears the validators that are created with an invocation of + # +validates_with+ and the callbacks that are set by an invocation + # of +validate+. + # + # class Person + # include ActiveModel::Validations + # + # validates_with MyValidator + # validates_with OtherValidator, on: :create + # validates_with StrictValidator, strict: true + # validate :cannot_be_robot + # + # def cannot_be_robot + # errors.add(:base, 'A person cannot be a robot') if person_is_robot + # end + # end + # + # Person.validators + # # => [ + # # #<MyValidator:0x007fbff403e808 @options={}>, + # # #<OtherValidator:0x007fbff403d930 @options={on: :create}>, + # # #<StrictValidator:0x007fbff3204a30 @options={strict:true}> + # # ] + # + # If one runs <tt>Person.clear_validators!</tt> and then checks to see what + # validators this class has, you would obtain: + # + # Person.validators # => [] + # + # Also, the callback set by <tt>validate :cannot_be_robot</tt> will be erased + # so that: + # + # Person._validate_callbacks.empty? # => true + # + def clear_validators! + reset_callbacks(:validate) + _validators.clear + end + + # List all validators that are being used to validate a specific attribute. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name , :age + # + # validates_presence_of :name + # validates_inclusion_of :age, in: 0..99 + # end + # + # Person.validators_on(:name) + # # => [ + # # #<ActiveModel::Validations::PresenceValidator:0x007fe604914e60 @attributes=[:name], @options={}>, + # # ] + def validators_on(*attributes) + attributes.flat_map do |attribute| + _validators[attribute.to_sym] + end + end + + # Returns +true+ if +attribute+ is an attribute method, +false+ otherwise. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # end + # + # User.attribute_method?(:name) # => true + # User.attribute_method?(:age) # => false + def attribute_method?(attribute) + method_defined?(attribute) + end + + # Copy validators on inheritance. + def inherited(base) #:nodoc: + dup = _validators.dup + base._validators = dup.each { |k, v| dup[k] = v.dup } + super + end + end + + # Clean the +Errors+ object if instance is duped. + def initialize_dup(other) #:nodoc: + @errors = nil + super + end + + # Returns the +Errors+ object that holds all information about attribute + # error messages. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # validates_presence_of :name + # end + # + # person = Person.new + # person.valid? # => false + # person.errors # => #<ActiveModel::Errors:0x007fe603816640 @messages={name:["can't be blank"]}> + def errors + @errors ||= Errors.new(self) + end + + # Runs all the specified validations and returns +true+ if no errors were + # added otherwise +false+. + # + # Aliased as validate. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # validates_presence_of :name + # end + # + # person = Person.new + # person.name = '' + # person.valid? # => false + # person.name = 'david' + # person.valid? # => true + # + # Context can optionally be supplied to define which callbacks to test + # against (the context is defined on the validations using <tt>:on</tt>). + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # validates_presence_of :name, on: :new + # end + # + # person = Person.new + # person.valid? # => true + # person.valid?(:new) # => false + def valid?(context = nil) + current_context, self.validation_context = validation_context, context + errors.clear + run_validations! + ensure + self.validation_context = current_context + end + + alias_method :validate, :valid? + + # Performs the opposite of <tt>valid?</tt>. Returns +true+ if errors were + # added, +false+ otherwise. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # validates_presence_of :name + # end + # + # person = Person.new + # person.name = '' + # person.invalid? # => true + # person.name = 'david' + # person.invalid? # => false + # + # Context can optionally be supplied to define which callbacks to test + # against (the context is defined on the validations using <tt>:on</tt>). + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # validates_presence_of :name, on: :new + # end + # + # person = Person.new + # person.invalid? # => false + # person.invalid?(:new) # => true + def invalid?(context = nil) + !valid?(context) + end + + # Hook method defining how an attribute value should be retrieved. By default + # this is assumed to be an instance named after the attribute. Override this + # method in subclasses should you need to retrieve the value for a given + # attribute differently: + # + # class MyClass + # include ActiveModel::Validations + # + # def initialize(data = {}) + # @data = data + # end + # + # def read_attribute_for_validation(key) + # @data[key] + # end + # end + alias :read_attribute_for_validation :send + + protected + + def run_validations! #:nodoc: + run_callbacks :validate + errors.empty? + end + end +end + +Dir[File.dirname(__FILE__) + "/validations/*.rb"].each { |file| require file } diff --git a/activemodel/lib/active_model/validations/absence.rb b/activemodel/lib/active_model/validations/absence.rb new file mode 100644 index 0000000000..9b5416fb1d --- /dev/null +++ b/activemodel/lib/active_model/validations/absence.rb @@ -0,0 +1,31 @@ +module ActiveModel + module Validations + # == Active Model Absence Validator + class AbsenceValidator < EachValidator #:nodoc: + def validate_each(record, attr_name, value) + record.errors.add(attr_name, :present, options) if value.present? + end + end + + module HelperMethods + # Validates that the specified attributes are blank (as defined by + # Object#blank?). Happens by default on save. + # + # class Person < ActiveRecord::Base + # validates_absence_of :first_name + # end + # + # The first_name attribute must be in the object and it must be blank. + # + # Configuration options: + # * <tt>:message</tt> - A custom error message (default is: "must be blank"). + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_absence_of(*attr_names) + validates_with AbsenceValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/acceptance.rb b/activemodel/lib/active_model/validations/acceptance.rb new file mode 100644 index 0000000000..ac5e79859b --- /dev/null +++ b/activemodel/lib/active_model/validations/acceptance.rb @@ -0,0 +1,55 @@ +module ActiveModel + + module Validations + class AcceptanceValidator < EachValidator # :nodoc: + def initialize(options) + super({ allow_nil: true, accept: "1" }.merge!(options)) + setup!(options[:class]) + end + + def validate_each(record, attribute, value) + unless value == options[:accept] + record.errors.add(attribute, :accepted, options.except(:accept, :allow_nil)) + end + end + + private + def setup!(klass) + attr_readers = attributes.reject { |name| klass.attribute_method?(name) } + attr_writers = attributes.reject { |name| klass.attribute_method?("#{name}=") } + klass.send(:attr_reader, *attr_readers) + klass.send(:attr_writer, *attr_writers) + end + end + + module HelperMethods + # Encapsulates the pattern of wanting to validate the acceptance of a + # terms of service check box (or similar agreement). + # + # class Person < ActiveRecord::Base + # validates_acceptance_of :terms_of_service + # 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. + # + # Configuration options: + # * <tt>:message</tt> - A custom error message (default is: "must be + # accepted"). + # * <tt>:accept</tt> - 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 typecast from "1" to +true+ + # before validation. + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information. + def validates_acceptance_of(*attr_names) + validates_with AcceptanceValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/callbacks.rb b/activemodel/lib/active_model/validations/callbacks.rb new file mode 100644 index 0000000000..edfffdd3ce --- /dev/null +++ b/activemodel/lib/active_model/validations/callbacks.rb @@ -0,0 +1,115 @@ +module ActiveModel + module Validations + # == Active \Model \Validation \Callbacks + # + # Provides an interface for any class to have +before_validation+ and + # +after_validation+ callbacks. + # + # First, include ActiveModel::Validations::Callbacks from the class you are + # creating: + # + # class MyModel + # include ActiveModel::Validations::Callbacks + # + # before_validation :do_stuff_before_validation + # after_validation :do_stuff_after_validation + # end + # + # Like other <tt>before_*</tt> callbacks if +before_validation+ returns + # +false+ then <tt>valid?</tt> will not be called. + module Callbacks + extend ActiveSupport::Concern + + included do + include ActiveSupport::Callbacks + define_callbacks :validation, + terminator: ->(_,result) { result == false }, + skip_after_callbacks_if_terminated: true, + scope: [:kind, :name] + end + + module ClassMethods + # Defines a callback that will get called right before validation + # happens. + # + # class Person + # include ActiveModel::Validations + # include ActiveModel::Validations::Callbacks + # + # attr_accessor :name + # + # validates_length_of :name, maximum: 6 + # + # before_validation :remove_whitespaces + # + # private + # + # def remove_whitespaces + # name.strip! + # end + # end + # + # person = Person.new + # person.name = ' bob ' + # person.valid? # => true + # person.name # => "bob" + def before_validation(*args, &block) + options = args.last + if options.is_a?(Hash) && options[:on] + options[:if] = Array(options[:if]) + options[:on] = Array(options[:on]) + options[:if].unshift lambda { |o| + options[:on].include? o.validation_context + } + end + set_callback(:validation, :before, *args, &block) + end + + # Defines a callback that will get called right after validation + # happens. + # + # class Person + # include ActiveModel::Validations + # include ActiveModel::Validations::Callbacks + # + # attr_accessor :name, :status + # + # validates_presence_of :name + # + # after_validation :set_status + # + # private + # + # def set_status + # self.status = errors.empty? + # end + # end + # + # person = Person.new + # person.name = '' + # person.valid? # => false + # person.status # => false + # person.name = 'bob' + # person.valid? # => true + # person.status # => true + def after_validation(*args, &block) + options = args.extract_options! + options[:prepend] = true + options[:if] = Array(options[:if]) + if options[:on] + options[:on] = Array(options[:on]) + options[:if].unshift("#{options[:on]}.include? self.validation_context") + end + set_callback(:validation, :after, *(args << options), &block) + end + end + + protected + + # Overwrite run validations to include callbacks. + def run_validations! #:nodoc: + run_callbacks(:validation) { super } + end + end + end +end diff --git a/activemodel/lib/active_model/validations/clusivity.rb b/activemodel/lib/active_model/validations/clusivity.rb new file mode 100644 index 0000000000..bad9e4f9a9 --- /dev/null +++ b/activemodel/lib/active_model/validations/clusivity.rb @@ -0,0 +1,51 @@ +require 'active_support/core_ext/range' + +module ActiveModel + module Validations + module Clusivity #:nodoc: + ERROR_MESSAGE = "An object with the method #include? or a proc, lambda or symbol is required, " \ + "and must be supplied as the :in (or :within) option of the configuration hash" + + def check_validity! + unless delimiter.respond_to?(:include?) || delimiter.respond_to?(:call) || delimiter.respond_to?(:to_sym) + raise ArgumentError, ERROR_MESSAGE + end + end + + private + + def include?(record, value) + members = if delimiter.respond_to?(:call) + delimiter.call(record) + elsif delimiter.respond_to?(:to_sym) + record.send(delimiter) + else + delimiter + end + + members.send(inclusion_method(members), value) + end + + def delimiter + @delimiter ||= options[:in] || options[:within] + end + + # In Ruby 1.9 <tt>Range#include?</tt> on non-number-or-time-ish ranges checks all + # possible values in the range for equality, which is slower but more accurate. + # <tt>Range#cover?</tt> uses the previous logic of comparing a value with the range + # endpoints, which is fast but is only accurate on Numeric, Time, or DateTime ranges. + def inclusion_method(enumerable) + if enumerable.is_a? Range + case enumerable.first + when Numeric, Time, DateTime + :cover? + else + :include? + end + else + :include? + end + end + end + end +end diff --git a/activemodel/lib/active_model/validations/confirmation.rb b/activemodel/lib/active_model/validations/confirmation.rb new file mode 100644 index 0000000000..a51523912f --- /dev/null +++ b/activemodel/lib/active_model/validations/confirmation.rb @@ -0,0 +1,67 @@ +module ActiveModel + + module Validations + class ConfirmationValidator < EachValidator # :nodoc: + def initialize(options) + super + setup!(options[:class]) + end + + def validate_each(record, attribute, value) + if (confirmed = record.send("#{attribute}_confirmation")) && (value != confirmed) + human_attribute_name = record.class.human_attribute_name(attribute) + record.errors.add(:"#{attribute}_confirmation", :confirmation, options.merge(attribute: human_attribute_name)) + end + end + + private + def setup!(klass) + klass.send(:attr_reader, *attributes.map do |attribute| + :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation") + end.compact) + + klass.send(:attr_writer, *attributes.map do |attribute| + :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=") + end.compact) + end + end + + module HelperMethods + # Encapsulates the pattern of wanting to validate a password or email + # address field with a confirmation. + # + # Model: + # class Person < ActiveRecord::Base + # validates_confirmation_of :user_name, :password + # validates_confirmation_of :email_address, + # message: 'should match confirmation' + # end + # + # View: + # <%= password_field "person", "password" %> + # <%= password_field "person", "password_confirmation" %> + # + # The added +password_confirmation+ attribute is virtual; it exists only + # as an in-memory attribute for validating the password. To achieve this, + # the validation adds accessors to the model for the confirmation + # attribute. + # + # NOTE: This check is performed only if +password_confirmation+ is not + # +nil+. To require confirmation, make sure to add a presence check for + # the confirmation attribute: + # + # validates_presence_of :password_confirmation, if: :password_changed? + # + # Configuration options: + # * <tt>:message</tt> - A custom error message (default is: "doesn't match + # confirmation"). + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_confirmation_of(*attr_names) + validates_with ConfirmationValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/exclusion.rb b/activemodel/lib/active_model/validations/exclusion.rb new file mode 100644 index 0000000000..f342d27275 --- /dev/null +++ b/activemodel/lib/active_model/validations/exclusion.rb @@ -0,0 +1,46 @@ +require "active_model/validations/clusivity" + +module ActiveModel + + module Validations + class ExclusionValidator < EachValidator # :nodoc: + include Clusivity + + def validate_each(record, attribute, value) + if include?(record, value) + record.errors.add(attribute, :exclusion, options.except(:in, :within).merge!(value: value)) + end + end + end + + module HelperMethods + # Validates that the value of the specified attribute is not in a + # particular enumerable object. + # + # 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 %{value} is not allowed" + # validates_exclusion_of :password, in: ->(person) { [person.username, person.first_name] }, + # message: 'should not be the same as your username or first name' + # validates_exclusion_of :karma, in: :reserved_karmas + # 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, lambda or symbol which returns an + # enumerable. If the enumerable is a range the test is performed with + # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt> + # <tt>Range#cover?</tt>, otherwise with <tt>include?</tt>. + # * <tt>:message</tt> - Specifies a custom error message (default is: "is + # reserved"). + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_exclusion_of(*attr_names) + validates_with ExclusionValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/format.rb b/activemodel/lib/active_model/validations/format.rb new file mode 100644 index 0000000000..ff3e95da34 --- /dev/null +++ b/activemodel/lib/active_model/validations/format.rb @@ -0,0 +1,113 @@ +module ActiveModel + + module Validations + class FormatValidator < EachValidator # :nodoc: + def validate_each(record, attribute, 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 + + def check_validity! + unless options.include?(:with) ^ options.include?(:without) # ^ == xor, or "exclusive or" + raise ArgumentError, "Either :with or :without must be supplied (but not both)" + end + + check_options_validity :with + check_options_validity :without + end + + private + + 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(name) + if option = options[name] + if option.is_a?(Regexp) + if options[:multiline] != true && regexp_using_multiline_anchors?(option) + raise ArgumentError, "The provided regular expression is using multiline anchors (^ or $), " \ + "which may present a security risk. Did you mean to use \\A and \\z, or forgot to add the " \ + ":multiline => true option?" + end + elsif !option.respond_to?(:call) + raise ArgumentError, "A regular expression or a proc or lambda must be supplied as :#{name}" + end + end + end + + def regexp_using_multiline_anchors?(regexp) + source = regexp.source + source.start_with?("^") || (source.end_with?("$") && !source.end_with?("\\$")) + end + end + + module HelperMethods + # 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 + # + # 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: ->(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. + # + # Due to frequent misuse of <tt>^</tt> and <tt>$</tt>, you need to pass + # the <tt>multiline: true</tt> option in case you use any of these two + # anchors in the provided regular expression. In most cases, you should be + # using <tt>\A</tt> and <tt>\z</tt>. + # + # 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>: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>:multiline</tt> - Set to true if your regular expression contains + # anchors that match the beginning or end of lines as opposed to the + # beginning or end of the string. These anchors are <tt>^</tt> and <tt>$</tt>. + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_format_of(*attr_names) + validates_with FormatValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/inclusion.rb b/activemodel/lib/active_model/validations/inclusion.rb new file mode 100644 index 0000000000..c84025f083 --- /dev/null +++ b/activemodel/lib/active_model/validations/inclusion.rb @@ -0,0 +1,46 @@ +require "active_model/validations/clusivity" + +module ActiveModel + + module Validations + class InclusionValidator < EachValidator # :nodoc: + include Clusivity + + def validate_each(record, attribute, value) + unless include?(record, value) + record.errors.add(attribute, :inclusion, options.except(:in, :within).merge!(value: value)) + end + end + end + + module HelperMethods + # Validates whether the value of the specified attribute is available in a + # particular enumerable object. + # + # 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 %{value} is not included in the list" + # validates_inclusion_of :states, in: ->(person) { STATES[person.country] } + # validates_inclusion_of :karma, in: :available_karmas + # end + # + # Configuration options: + # * <tt>:in</tt> - An enumerable object of available items. This can be + # supplied as a proc, lambda or symbol which returns an enumerable. If the + # enumerable is a numerical range the test is performed with <tt>Range#cover?</tt>, + # otherwise with <tt>include?</tt>. When using a proc or lambda the instance + # under validation is passed as an argument. + # * <tt>:within</tt> - A synonym(or alias) for <tt>:in</tt> + # * <tt>:message</tt> - Specifies a custom error message (default is: "is + # not included in the list"). + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_inclusion_of(*attr_names) + validates_with InclusionValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/length.rb b/activemodel/lib/active_model/validations/length.rb new file mode 100644 index 0000000000..a96b30cadd --- /dev/null +++ b/activemodel/lib/active_model/validations/length.rb @@ -0,0 +1,126 @@ +module ActiveModel + + # == Active \Model Length Validator + module Validations + class LengthValidator < EachValidator # :nodoc: + MESSAGES = { is: :wrong_length, minimum: :too_short, maximum: :too_long }.freeze + CHECKS = { is: :==, minimum: :>=, maximum: :<= }.freeze + + RESERVED_OPTIONS = [:minimum, :maximum, :within, :is, :tokenizer, :too_short, :too_long] + + def initialize(options) + if range = (options.delete(:in) || options.delete(:within)) + raise ArgumentError, ":in and :within must be a Range" unless range.is_a?(Range) + options[:minimum], options[:maximum] = range.min, range.max + end + + if options[:allow_blank] == false && options[:minimum].nil? && options[:is].nil? + options[:minimum] = 1 + end + + super + end + + def check_validity! + keys = CHECKS.keys & options.keys + + if keys.empty? + raise ArgumentError, 'Range unspecified. Specify the :in, :within, :maximum, :minimum, or :is option.' + end + + keys.each do |key| + value = options[key] + + unless (value.is_a?(Integer) && value >= 0) || value == Float::INFINITY + raise ArgumentError, ":#{key} must be a nonnegative Integer or Infinity" + end + end + end + + def validate_each(record, attribute, value) + value = tokenize(value) + value_length = value.respond_to?(:length) ? value.length : value.to_s.length + errors_options = options.except(*RESERVED_OPTIONS) + + CHECKS.each do |key, validity_check| + next unless check_value = options[key] + + if !value.nil? || skip_nil_check?(key) + next if value_length.send(validity_check, check_value) + end + + errors_options[:count] = check_value + + default_message = options[MESSAGES[key]] + errors_options[:message] ||= default_message if default_message + + record.errors.add(attribute, MESSAGES[key], errors_options) + end + end + + private + + def tokenize(value) + if options[:tokenizer] && value.kind_of?(String) + options[:tokenizer].call(value) + end || value + end + + def skip_nil_check?(key) + key == :maximum && options[:allow_nil].nil? && options[:allow_blank].nil? + end + end + + module HelperMethods + + # 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 30 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 :zip_code, minimum: 5, too_short: 'please enter at least 5 characters' + # validates_length_of :smurf_leader, is: 4, message: "papa is spelled with 4 characters... don't play me." + # validates_length_of :essay, minimum: 100, too_short: 'Your essay must be at least 100 words.', + # tokenizer: ->(str) { str.scan(/\w+/) } + # end + # + # Configuration options: + # * <tt>:minimum</tt> - The minimum size of the attribute. + # * <tt>:maximum</tt> - The maximum size of the attribute. Allows +nil+ by + # default if not used with :minimum. + # * <tt>:is</tt> - The exact size of the attribute. + # * <tt>:within</tt> - A range specifying the minimum and maximum size of + # the attribute. + # * <tt>:in</tt> - A synonym (or alias) for <tt>:within</tt>. + # * <tt>:allow_nil</tt> - Attribute may be +nil+; skip validation. + # * <tt>:allow_blank</tt> - Attribute may be blank; skip validation. + # * <tt>:too_long</tt> - The error message if the attribute goes over the + # maximum (default is: "is too long (maximum is %{count} characters)"). + # * <tt>:too_short</tt> - The error message if the attribute goes under the + # minimum (default is: "is too short (min is %{count} characters)"). + # * <tt>:wrong_length</tt> - The error message if using the <tt>:is</tt> + # method and the attribute is the wrong size (default is: "is the wrong + # length (should be %{count} characters)"). + # * <tt>:message</tt> - The error message to use for a <tt>:minimum</tt>, + # <tt>:maximum</tt>, or <tt>:is</tt> violation. An alias of the appropriate + # <tt>too_long</tt>/<tt>too_short</tt>/<tt>wrong_length</tt> message. + # * <tt>:tokenizer</tt> - Specifies how to split up the attribute string. + # (e.g. <tt>tokenizer: ->(str) { str.scan(/\w+/) }</tt> to count words + # as in above example). Defaults to <tt>->(value) { value.split(//) }</tt> + # which counts individual characters. + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+ and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_length_of(*attr_names) + validates_with LengthValidator, _merge_attributes(attr_names) + end + + alias_method :validates_size_of, :validates_length_of + end + end +end diff --git a/activemodel/lib/active_model/validations/numericality.rb b/activemodel/lib/active_model/validations/numericality.rb new file mode 100644 index 0000000000..5bd162433d --- /dev/null +++ b/activemodel/lib/active_model/validations/numericality.rb @@ -0,0 +1,148 @@ +module ActiveModel + + module Validations + class NumericalityValidator < EachValidator # :nodoc: + CHECKS = { greater_than: :>, greater_than_or_equal_to: :>=, + equal_to: :==, less_than: :<, less_than_or_equal_to: :<=, + odd: :odd?, even: :even?, other_than: :!= }.freeze + + RESERVED_OPTIONS = CHECKS.keys + [:only_integer] + + def check_validity! + keys = CHECKS.keys - [:odd, :even] + options.slice(*keys).each do |option, value| + unless value.is_a?(Numeric) || value.is_a?(Proc) || value.is_a?(Symbol) + raise ArgumentError, ":#{option} must be a number, a symbol or a proc" + end + end + end + + def validate_each(record, attr_name, value) + before_type_cast = :"#{attr_name}_before_type_cast" + + raw_value = record.send(before_type_cast) if record.respond_to?(before_type_cast) + raw_value ||= value + + return if options[:allow_nil] && raw_value.nil? + + unless value = parse_raw_value_as_a_number(raw_value) + record.errors.add(attr_name, :not_a_number, filtered_options(raw_value)) + return + end + + if allow_only_integer?(record) + unless value = parse_raw_value_as_an_integer(raw_value) + record.errors.add(attr_name, :not_an_integer, filtered_options(raw_value)) + return + end + end + + options.slice(*CHECKS.keys).each do |option, option_value| + case option + when :odd, :even + unless value.to_i.send(CHECKS[option]) + record.errors.add(attr_name, option, filtered_options(value)) + end + else + case option_value + when Proc + option_value = option_value.call(record) + when Symbol + option_value = record.send(option_value) + end + + unless value.send(CHECKS[option], option_value) + record.errors.add(attr_name, option, filtered_options(value).merge!(count: option_value)) + end + end + end + end + + protected + + def parse_raw_value_as_a_number(raw_value) + Kernel.Float(raw_value) if raw_value !~ /\A0[xX]/ + rescue ArgumentError, TypeError + nil + end + + def parse_raw_value_as_an_integer(raw_value) + raw_value.to_i if raw_value.to_s =~ /\A[+-]?\d+\Z/ + end + + def filtered_options(value) + filtered = options.except(*RESERVED_OPTIONS) + filtered[:value] = value + filtered + end + + def allow_only_integer?(record) + case options[:only_integer] + when Symbol + record.send(options[:only_integer]) + when Proc + options[:only_integer].call(record) + else + options[:only_integer] + end + end + end + + module HelperMethods + # Validates whether the value of the specified attribute is numeric by + # trying to convert it to a float with Kernel.Float (if <tt>only_integer</tt> + # is +false+) or applying it to the regular expression <tt>/\A[\+\-]?\d+\Z/</tt> + # (if <tt>only_integer</tt> is set to +true+). + # + # class Person < ActiveRecord::Base + # validates_numericality_of :value, on: :create + # end + # + # Configuration options: + # * <tt>:message</tt> - A custom error message (default is: "is not a number"). + # * <tt>:only_integer</tt> - Specifies whether the value has to be an + # integer, e.g. an integral value (default is +false+). + # * <tt>:allow_nil</tt> - Skip validation if attribute is +nil+ (default is + # +false+). Notice that for fixnum and float columns empty strings are + # converted to +nil+. + # * <tt>:greater_than</tt> - Specifies the value must be greater than the + # supplied value. + # * <tt>:greater_than_or_equal_to</tt> - Specifies the value must be + # greater than or equal the supplied value. + # * <tt>:equal_to</tt> - Specifies the value must be equal to the supplied + # value. + # * <tt>:less_than</tt> - Specifies the value must be less than the + # supplied value. + # * <tt>:less_than_or_equal_to</tt> - Specifies the value must be less + # than or equal the supplied value. + # * <tt>:other_than</tt> - Specifies the value must be other than the + # supplied value. + # * <tt>:odd</tt> - Specifies the value must be an odd number. + # * <tt>:even</tt> - Specifies the value must be an even number. + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+ . + # See <tt>ActiveModel::Validation#validates</tt> for more information + # + # 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> + # * <tt>:only_integer</tt> + # + # For example: + # + # class Person < ActiveRecord::Base + # validates_numericality_of :width, less_than: ->(person) { person.height } + # validates_numericality_of :width, greater_than: :minimum_weight + # end + def validates_numericality_of(*attr_names) + validates_with NumericalityValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/presence.rb b/activemodel/lib/active_model/validations/presence.rb new file mode 100644 index 0000000000..5d593274eb --- /dev/null +++ b/activemodel/lib/active_model/validations/presence.rb @@ -0,0 +1,39 @@ + +module ActiveModel + + module Validations + class PresenceValidator < EachValidator # :nodoc: + def validate_each(record, attr_name, value) + record.errors.add(attr_name, :blank, options) if value.blank? + end + end + + module HelperMethods + # Validates that the specified attributes are not blank (as defined by + # Object#blank?). Happens by default on save. + # + # class Person < ActiveRecord::Base + # validates_presence_of :first_name + # end + # + # 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 + # <tt>validates_inclusion_of :field_name, in: [true, false]</tt>. + # + # This is due to the way Object#blank? handles boolean values: + # <tt>false.blank? # => true</tt>. + # + # Configuration options: + # * <tt>:message</tt> - A custom error message (default is: "can't be blank"). + # + # There is also a list of default options supported by every validator: + # +:if+, +:unless+, +:on+, +:allow_nil+, +:allow_blank+, and +:strict+. + # See <tt>ActiveModel::Validation#validates</tt> for more information + def validates_presence_of(*attr_names) + validates_with PresenceValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/lib/active_model/validations/validates.rb b/activemodel/lib/active_model/validations/validates.rb new file mode 100644 index 0000000000..ae8d377fdf --- /dev/null +++ b/activemodel/lib/active_model/validations/validates.rb @@ -0,0 +1,171 @@ +require 'active_support/core_ext/hash/slice' + +module ActiveModel + module Validations + module ClassMethods + # This method is a shortcut to all default validators and any custom + # validator classes ending in 'Validator'. Note that Rails default + # validators can be overridden inside specific classes by creating + # custom validator classes in their place such as PresenceValidator. + # + # Examples of using the default rails validators: + # + # validates :terms, acceptance: true + # validates :password, confirmation: true + # validates :username, exclusion: { in: %w(admin superuser) } + # validates :email, format: { with: /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, on: :create } + # validates :age, inclusion: { in: 0..9 } + # validates :first_name, length: { maximum: 30 } + # validates :age, numericality: true + # validates :username, presence: true + # validates :username, uniqueness: true + # + # The power of the +validates+ method comes when using custom validators + # and default validators in one call for a given attribute. + # + # class EmailValidator < ActiveModel::EachValidator + # def validate_each(record, attribute, value) + # record.errors.add attribute, (options[:message] || "is not an email") unless + # value =~ /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i + # end + # end + # + # class Person + # include ActiveModel::Validations + # attr_accessor :name, :email + # + # validates :name, presence: true, uniqueness: true, length: { maximum: 100 } + # validates :email, presence: true, email: true + # end + # + # Validator classes may also exist within the class being validated + # allowing custom modules of validators to be included as needed. + # + # class Film + # include ActiveModel::Validations + # + # class TitleValidator < ActiveModel::EachValidator + # def validate_each(record, attribute, value) + # record.errors.add attribute, "must start with 'the'" unless value =~ /\Athe/i + # end + # end + # + # validates :name, title: true + # end + # + # Additionally validator classes may be in another namespace and still + # used within any class. + # + # validates :name, :'film/title' => true + # + # The validators hash can also handle regular expressions, ranges, arrays + # and strings in shortcut form. + # + # validates :email, format: /@/ + # validates :gender, inclusion: %w(male female) + # validates :password, length: 6..20 + # + # When using shortcut form, ranges and arrays are passed to your + # validator's initializer as <tt>options[:in]</tt> while other types + # including regular expressions and strings are passed as <tt>options[:with]</tt>. + # + # There is also a list of options that could be used along with validators: + # + # * <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>. + # * <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. + # * <tt>:allow_nil</tt> - Skip validation if the attribute is +nil+. + # * <tt>:allow_blank</tt> - Skip validation if the attribute is blank. + # * <tt>:strict</tt> - If the <tt>:strict</tt> option is set to true + # will raise ActiveModel::StrictValidationFailed instead of adding the error. + # <tt>:strict</tt> option can also be set to any other exception. + # + # Example: + # + # validates :password, presence: true, confirmation: true, if: :password_required? + # validates :token, uniqueness: true, strict: TokenGenerationException + # + # + # Finally, the options +:if+, +:unless+, +:on+, +:allow_blank+, +:allow_nil+, +:strict+ + # and +:message+ can be given to one specific validator, as a hash: + # + # validates :password, presence: { if: :password_required?, message: 'is forgotten.' }, confirmation: true + def validates(*attributes) + defaults = attributes.extract_options!.dup + validations = defaults.slice!(*_validates_default_keys) + + raise ArgumentError, "You need to supply at least one attribute" if attributes.empty? + raise ArgumentError, "You need to supply at least one validation" if validations.empty? + + defaults[:attributes] = attributes + + validations.each do |key, options| + next unless options + key = "#{key.to_s.camelize}Validator" + + begin + validator = key.include?('::') ? key.constantize : const_get(key) + rescue NameError + raise ArgumentError, "Unknown validator: '#{key}'" + end + + validates_with(validator, defaults.merge(_parse_validates_options(options))) + end + end + + # This method is used to define validations that cannot be corrected by end + # users and are considered exceptional. So each validator defined with bang + # or <tt>:strict</tt> option set to <tt>true</tt> will always raise + # <tt>ActiveModel::StrictValidationFailed</tt> instead of adding error + # when validation fails. See <tt>validates</tt> for more information about + # the validation itself. + # + # class Person + # include ActiveModel::Validations + # + # attr_accessor :name + # validates! :name, presence: true + # end + # + # person = Person.new + # person.name = '' + # person.valid? + # # => ActiveModel::StrictValidationFailed: Name can't be blank + def validates!(*attributes) + options = attributes.extract_options! + options[:strict] = true + validates(*(attributes << options)) + end + + protected + + # When creating custom validators, it might be useful to be able to specify + # additional default keys. This can be done by overwriting this method. + def _validates_default_keys # :nodoc: + [:if, :unless, :on, :allow_blank, :allow_nil , :strict] + end + + def _parse_validates_options(options) # :nodoc: + case options + when TrueClass + {} + when Hash + options + when Range, Array + { in: options } + else + { with: options } + end + end + end + end +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..ff41572105 --- /dev/null +++ b/activemodel/lib/active_model/validations/with.rb @@ -0,0 +1,150 @@ +module ActiveModel + module Validations + module HelperMethods + private + def _merge_attributes(attr_names) + options = attr_names.extract_options!.symbolize_keys + attr_names.flatten! + options[:attributes] = attr_names + options + end + end + + class WithValidator < EachValidator # :nodoc: + def validate_each(record, attr, val) + method_name = options[:with] + + if record.method(method_name).arity == 0 + record.send method_name + else + record.send method_name, attr + end + end + end + + 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 + # include ActiveModel::Validations + # validates_with MyValidator + # end + # + # class MyValidator < ActiveModel::Validator + # def validate(record) + # if some_complex_logic + # record.errors.add :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 + # include ActiveModel::Validations + # 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. + # * <tt>:strict</tt> - Specifies whether validation should be strict. + # See <tt>ActiveModel::Validation#validates!</tt> for more information. + # + # If you pass any additional configuration options, they will be passed + # to the class and available as +options+: + # + # class Person + # include ActiveModel::Validations + # validates_with MyValidator, my_custom_key: 'my custom value' + # end + # + # class MyValidator < ActiveModel::Validator + # def validate(record) + # options[:my_custom_key] # => "my custom value" + # end + # end + def validates_with(*args, &block) + options = args.extract_options! + options[:class] = self + + args.each do |klass| + validator = klass.new(options, &block) + + if validator.respond_to?(:attributes) && !validator.attributes.empty? + validator.attributes.each do |attribute| + _validators[attribute.to_sym] << validator + end + else + _validators[nil] << validator + end + + validate(validator, options) + end + end + end + + # Passes the record off to the class or classes specified and allows them + # to add errors based on more complex conditions. + # + # class Person + # include ActiveModel::Validations + # + # validate :instance_validations + # + # def instance_validations + # validates_with MyValidator + # end + # end + # + # Please consult the class method documentation for more information on + # creating your own validator. + # + # You may also pass it multiple classes, like so: + # + # class Person + # include ActiveModel::Validations + # + # validate :instance_validations, on: :create + # + # def instance_validations + # validates_with MyValidator, MyOtherValidator + # end + # end + # + # Standard configuration options (<tt>:on</tt>, <tt>:if</tt> and + # <tt>:unless</tt>), which are available on the class version of + # +validates_with+, should instead be placed on the +validates+ method + # as these are applied and tested in the callback. + # + # If you pass any additional configuration options, they will be passed + # to the class and available as +options+, please refer to the + # class version of this method for more information. + def validates_with(*args, &block) + options = args.extract_options! + options[:class] = self.class + + args.each do |klass| + validator = klass.new(options, &block) + validator.validate(self) + end + end + end +end diff --git a/activemodel/lib/active_model/validator.rb b/activemodel/lib/active_model/validator.rb new file mode 100644 index 0000000000..0116de68ab --- /dev/null +++ b/activemodel/lib/active_model/validator.rb @@ -0,0 +1,182 @@ +require "active_support/core_ext/module/anonymous" + +module ActiveModel + + # == Active \Model \Validator + # + # A simple base class that can be used along with + # ActiveModel::Validations::ClassMethods.validates_with + # + # class Person + # include ActiveModel::Validations + # validates_with MyValidator + # end + # + # class MyValidator < ActiveModel::Validator + # def validate(record) + # if some_complex_logic + # record.errors[:base] = "This record is invalid" + # end + # end + # + # private + # def some_complex_logic + # # ... + # end + # end + # + # Any class that inherits from ActiveModel::Validator must implement a method + # called +validate+ which accepts a +record+. + # + # class Person + # include ActiveModel::Validations + # validates_with MyValidator + # end + # + # class MyValidator < ActiveModel::Validator + # def validate(record) + # record # => The person instance being validated + # options # => Any non-standard options passed to validates_with + # end + # end + # + # To cause a validation error, you must add to the +record+'s errors directly + # from within the validators message. + # + # class MyValidator < ActiveModel::Validator + # def validate(record) + # record.errors.add :base, "This is some custom error message" + # record.errors.add :first_name, "This is some complex validation" + # # etc... + # end + # end + # + # To add behavior to the initialize method, use the following signature: + # + # class MyValidator < ActiveModel::Validator + # def initialize(options) + # super + # @my_custom_field = options[:field_name] || :first_name + # end + # end + # + # Note that the validator is initialized only once for the whole application + # life cycle, and not on each validation run. + # + # The easiest way to add custom validators for validating individual attributes + # is with the convenient <tt>ActiveModel::EachValidator</tt>. + # + # class TitleValidator < ActiveModel::EachValidator + # def validate_each(record, attribute, value) + # record.errors.add attribute, 'must be Mr., Mrs., or Dr.' unless %w(Mr. Mrs. Dr.).include?(value) + # end + # end + # + # This can now be used in combination with the +validates+ method + # (see <tt>ActiveModel::Validations::ClassMethods.validates</tt> for more on this). + # + # class Person + # include ActiveModel::Validations + # attr_accessor :title + # + # validates :title, presence: true, title: true + # end + # + # It can be useful to access the class that is using that validator when there are prerequisites such + # as an +attr_accessor+ being present. This class is accessible via +options[:class]+ in the constructor. + # To setup your validator override the constructor. + # + # class MyValidator < ActiveModel::Validator + # def initialize(options={}) + # super + # options[:class].send :attr_accessor, :custom_attribute + # end + # end + class Validator + attr_reader :options + + # Returns the kind of the validator. + # + # PresenceValidator.kind # => :presence + # UniquenessValidator.kind # => :uniqueness + def self.kind + @kind ||= name.split('::').last.underscore.sub(/_validator$/, '').to_sym unless anonymous? + end + + # Accepts options that will be made available through the +options+ reader. + def initialize(options = {}) + @options = options.except(:class).freeze + end + + # Returns the kind for this validator. + # + # PresenceValidator.new.kind # => :presence + # UniquenessValidator.new.kind # => :uniqueness + def kind + self.class.kind + end + + # Override this method in subclasses with validation logic, adding errors + # to the records +errors+ array where necessary. + def validate(record) + raise NotImplementedError, "Subclasses must implement a validate(record) method." + end + end + + # +EachValidator+ is a validator which iterates through the attributes given + # in the options hash invoking the <tt>validate_each</tt> method passing in the + # record, attribute and value. + # + # All Active Model validations are built on top of this validator. + class EachValidator < Validator #:nodoc: + attr_reader :attributes + + # Returns a new validator instance. All options will be available via the + # +options+ reader, however the <tt>:attributes</tt> option will be removed + # and instead be made available through the +attributes+ reader. + def initialize(options) + @attributes = Array(options.delete(:attributes)) + raise ArgumentError, ":attributes cannot be blank" if @attributes.empty? + super + check_validity! + end + + # Performs validation on the supplied record. By default this will call + # +validates_each+ to determine validity therefore subclasses should + # override +validates_each+ with validation logic. + def validate(record) + attributes.each do |attribute| + value = record.read_attribute_for_validation(attribute) + next if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank]) + validate_each(record, attribute, value) + end + end + + # 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, "Subclasses must implement a validate_each(record, attribute, value) method" + end + + # Hook method that gets called by the initializer allowing verification + # that the arguments supplied are valid. You could for example raise an + # +ArgumentError+ when invalid options are supplied. + def check_validity! + end + end + + # +BlockValidator+ is a special +EachValidator+ which receives a block on initialization + # and call this block for each attribute being validated. +validates_each+ uses this validator. + class BlockValidator < EachValidator #:nodoc: + def initialize(options, &block) + @block = block + super + end + + private + + def validate_each(record, attribute, value) + @block.call(record, attribute, value) + end + end +end diff --git a/activemodel/lib/active_model/version.rb b/activemodel/lib/active_model/version.rb new file mode 100644 index 0000000000..b1f9082ea7 --- /dev/null +++ b/activemodel/lib/active_model/version.rb @@ -0,0 +1,8 @@ +require_relative 'gem_version' + +module ActiveModel + # Returns the version of the currently loaded ActiveModel as a <tt>Gem::Version</tt> + def self.version + gem_version + end +end diff --git a/activemodel/test/cases/attribute_methods_test.rb b/activemodel/test/cases/attribute_methods_test.rb new file mode 100644 index 0000000000..e81b7ac424 --- /dev/null +++ b/activemodel/test/cases/attribute_methods_test.rb @@ -0,0 +1,281 @@ +require 'cases/helper' + +class ModelWithAttributes + include ActiveModel::AttributeMethods + + class << self + define_method(:bar) do + 'original bar' + end + end + + def attributes + { foo: 'value of foo', baz: 'value of baz' } + end + +private + def attribute(name) + attributes[name.to_sym] + end +end + +class ModelWithAttributes2 + include ActiveModel::AttributeMethods + + attr_accessor :attributes + + attribute_method_suffix '_test' + +private + def attribute(name) + attributes[name.to_s] + end + + alias attribute_test attribute + + def private_method + "<3 <3" + end + +protected + + def protected_method + "O_o O_o" + end +end + +class ModelWithAttributesWithSpaces + include ActiveModel::AttributeMethods + + def attributes + { :'foo bar' => 'value of foo bar'} + end + +private + def attribute(name) + attributes[name.to_sym] + end +end + +class ModelWithWeirdNamesAttributes + include ActiveModel::AttributeMethods + + class << self + define_method(:'c?d') do + 'original c?d' + end + end + + def attributes + { :'a?b' => 'value of a?b' } + end + +private + def attribute(name) + attributes[name.to_sym] + end +end + +class ModelWithRubyKeywordNamedAttributes + include ActiveModel::AttributeMethods + + def attributes + { begin: 'value of begin', end: 'value of end' } + end + +private + def attribute(name) + attributes[name.to_sym] + end +end + +class ModelWithoutAttributesMethod + include ActiveModel::AttributeMethods +end + +class AttributeMethodsTest < ActiveModel::TestCase + test 'method missing works correctly even if attributes method is not defined' do + assert_raises(NoMethodError) { ModelWithoutAttributesMethod.new.foo } + end + + test 'unrelated classes should not share attribute method matchers' do + assert_not_equal ModelWithAttributes.send(:attribute_method_matchers), + ModelWithAttributes2.send(:attribute_method_matchers) + end + + test '#define_attribute_method generates attribute method' do + begin + ModelWithAttributes.define_attribute_method(:foo) + + assert_respond_to ModelWithAttributes.new, :foo + assert_equal "value of foo", ModelWithAttributes.new.foo + ensure + ModelWithAttributes.undefine_attribute_methods + end + end + + test '#define_attribute_method does not generate attribute method if already defined in attribute module' do + klass = Class.new(ModelWithAttributes) + klass.generated_attribute_methods.module_eval do + def foo + '<3' + end + end + klass.define_attribute_method(:foo) + + assert_equal '<3', klass.new.foo + end + + test '#define_attribute_method generates a method that is already defined on the host' do + klass = Class.new(ModelWithAttributes) do + def foo + super + end + end + klass.define_attribute_method(:foo) + + assert_equal 'value of foo', klass.new.foo + end + + test '#define_attribute_method generates attribute method with invalid identifier characters' do + begin + ModelWithWeirdNamesAttributes.define_attribute_method(:'a?b') + + assert_respond_to ModelWithWeirdNamesAttributes.new, :'a?b' + assert_equal "value of a?b", ModelWithWeirdNamesAttributes.new.send('a?b') + ensure + ModelWithWeirdNamesAttributes.undefine_attribute_methods + end + end + + test '#define_attribute_methods works passing multiple arguments' do + begin + ModelWithAttributes.define_attribute_methods(:foo, :baz) + + assert_equal "value of foo", ModelWithAttributes.new.foo + assert_equal "value of baz", ModelWithAttributes.new.baz + ensure + ModelWithAttributes.undefine_attribute_methods + end + end + + test '#define_attribute_methods generates attribute methods' do + begin + ModelWithAttributes.define_attribute_methods(:foo) + + assert_respond_to ModelWithAttributes.new, :foo + assert_equal "value of foo", ModelWithAttributes.new.foo + ensure + ModelWithAttributes.undefine_attribute_methods + end + end + + test '#alias_attribute generates attribute_aliases lookup hash' do + klass = Class.new(ModelWithAttributes) do + define_attribute_methods :foo + alias_attribute :bar, :foo + end + + assert_equal({ "bar" => "foo" }, klass.attribute_aliases) + end + + test '#define_attribute_methods generates attribute methods with spaces in their names' do + begin + ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar') + + assert_respond_to ModelWithAttributesWithSpaces.new, :'foo bar' + assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.send(:'foo bar') + ensure + ModelWithAttributesWithSpaces.undefine_attribute_methods + end + end + + test '#alias_attribute works with attributes with spaces in their names' do + begin + ModelWithAttributesWithSpaces.define_attribute_methods(:'foo bar') + ModelWithAttributesWithSpaces.alias_attribute(:'foo_bar', :'foo bar') + + assert_equal "value of foo bar", ModelWithAttributesWithSpaces.new.foo_bar + ensure + ModelWithAttributesWithSpaces.undefine_attribute_methods + end + end + + test '#alias_attribute works with attributes named as a ruby keyword' do + begin + ModelWithRubyKeywordNamedAttributes.define_attribute_methods([:begin, :end]) + ModelWithRubyKeywordNamedAttributes.alias_attribute(:from, :begin) + ModelWithRubyKeywordNamedAttributes.alias_attribute(:to, :end) + + assert_equal "value of begin", ModelWithRubyKeywordNamedAttributes.new.from + assert_equal "value of end", ModelWithRubyKeywordNamedAttributes.new.to + ensure + ModelWithRubyKeywordNamedAttributes.undefine_attribute_methods + end + end + + test '#undefine_attribute_methods removes attribute methods' do + ModelWithAttributes.define_attribute_methods(:foo) + ModelWithAttributes.undefine_attribute_methods + + assert !ModelWithAttributes.new.respond_to?(:foo) + assert_raises(NoMethodError) { ModelWithAttributes.new.foo } + end + + test 'accessing a suffixed attribute' do + m = ModelWithAttributes2.new + m.attributes = { 'foo' => 'bar' } + + assert_equal 'bar', m.foo + assert_equal 'bar', m.foo_test + end + + test 'should not interfere with method_missing if the attr has a private/protected method' do + m = ModelWithAttributes2.new + m.attributes = { 'private_method' => '<3', 'protected_method' => 'O_o' } + + # dispatches to the *method*, not the attribute + assert_equal '<3 <3', m.send(:private_method) + assert_equal 'O_o O_o', m.send(:protected_method) + + # sees that a method is already defined, so doesn't intervene + assert_raises(NoMethodError) { m.private_method } + assert_raises(NoMethodError) { m.protected_method } + end + + class ClassWithProtected + protected + def protected_method + end + end + + test 'should not interfere with respond_to? if the attribute has a private/protected method' do + m = ModelWithAttributes2.new + m.attributes = { 'private_method' => '<3', 'protected_method' => 'O_o' } + + assert !m.respond_to?(:private_method) + assert m.respond_to?(:private_method, true) + + c = ClassWithProtected.new + + # This is messed up, but it's how Ruby works at the moment. Apparently it will be changed + # in the future. + assert_equal c.respond_to?(:protected_method), m.respond_to?(:protected_method) + assert m.respond_to?(:protected_method, true) + end + + test 'should use attribute_missing to dispatch a missing attribute' do + m = ModelWithAttributes2.new + m.attributes = { 'foo' => 'bar' } + + def m.attribute_missing(match, *args, &block) + match + end + + match = m.foo_test + + assert_equal 'foo', match.attr_name + assert_equal 'attribute_test', match.target + assert_equal 'foo_test', match.method_name + end +end diff --git a/activemodel/test/cases/callbacks_test.rb b/activemodel/test/cases/callbacks_test.rb new file mode 100644 index 0000000000..5fede098d1 --- /dev/null +++ b/activemodel/test/cases/callbacks_test.rb @@ -0,0 +1,114 @@ +require "cases/helper" + +class CallbacksTest < ActiveModel::TestCase + + class CallbackValidator + def around_create(model) + model.callbacks << :before_around_create + yield + model.callbacks << :after_around_create + end + end + + class ModelCallbacks + attr_reader :callbacks + extend ActiveModel::Callbacks + + define_model_callbacks :create + define_model_callbacks :initialize, only: :after + define_model_callbacks :multiple, only: [:before, :around] + define_model_callbacks :empty, only: [] + + before_create :before_create + around_create CallbackValidator.new + + after_create do |model| + model.callbacks << :after_create + end + + after_create "@callbacks << :final_callback" + + def initialize(valid=true) + @callbacks, @valid = [], valid + end + + def before_create + @callbacks << :before_create + end + + def create + run_callbacks :create do + @callbacks << :create + @valid + end + end + end + + test "complete callback chain" do + model = ModelCallbacks.new + model.create + assert_equal model.callbacks, [ :before_create, :before_around_create, :create, + :after_around_create, :after_create, :final_callback] + end + + test "after callbacks are always appended" do + model = ModelCallbacks.new + model.create + assert_equal model.callbacks.last, :final_callback + end + + test "after callbacks are not executed if the block returns false" do + model = ModelCallbacks.new(false) + model.create + assert_equal model.callbacks, [ :before_create, :before_around_create, + :create, :after_around_create] + end + + test "only selects which types of callbacks should be created" do + assert !ModelCallbacks.respond_to?(:before_initialize) + assert !ModelCallbacks.respond_to?(:around_initialize) + assert_respond_to ModelCallbacks, :after_initialize + end + + test "only selects which types of callbacks should be created from an array list" do + assert_respond_to ModelCallbacks, :before_multiple + assert_respond_to ModelCallbacks, :around_multiple + assert !ModelCallbacks.respond_to?(:after_multiple) + end + + test "no callbacks should be created" do + assert !ModelCallbacks.respond_to?(:before_empty) + assert !ModelCallbacks.respond_to?(:around_empty) + assert !ModelCallbacks.respond_to?(:after_empty) + end + + class Violin + attr_reader :history + def initialize + @history = [] + end + extend ActiveModel::Callbacks + define_model_callbacks :create + def callback1; self.history << 'callback1'; end + def callback2; self.history << 'callback2'; end + def create + run_callbacks(:create) {} + self + end + end + class Violin1 < Violin + after_create :callback1, :callback2 + end + class Violin2 < Violin + after_create :callback1 + after_create :callback2 + end + + test "after_create callbacks with both callbacks declared in one line" do + assert_equal ["callback1", "callback2"], Violin1.new.create.history + end + test "after_create callbacks with both callbacks declared in different lines" do + assert_equal ["callback1", "callback2"], Violin2.new.create.history + end + +end diff --git a/activemodel/test/cases/conversion_test.rb b/activemodel/test/cases/conversion_test.rb new file mode 100644 index 0000000000..800cad6d9a --- /dev/null +++ b/activemodel/test/cases/conversion_test.rb @@ -0,0 +1,50 @@ +require 'cases/helper' +require 'models/contact' +require 'models/helicopter' + +class ConversionTest < ActiveModel::TestCase + test "to_model default implementation returns self" do + contact = Contact.new + assert_equal contact, contact.to_model + end + + test "to_key default implementation returns nil for new records" do + assert_nil Contact.new.to_key + end + + test "to_key default implementation returns the id in an array for persisted records" do + assert_equal [1], Contact.new(id: 1).to_key + end + + test "to_param default implementation returns nil for new records" do + assert_nil Contact.new.to_param + end + + test "to_param default implementation returns a string of ids for persisted records" do + assert_equal "1", Contact.new(id: 1).to_param + end + + test "to_param returns the string joined by '-'" do + assert_equal "abc-xyz", Contact.new(id: ["abc", "xyz"]).to_param + end + + test "to_param returns nil if to_key is nil" do + klass = Class.new(Contact) do + def persisted? + true + end + end + + assert_nil klass.new.to_param + end + + test "to_partial_path default implementation returns a string giving a relative path" do + assert_equal "contacts/contact", Contact.new.to_partial_path + assert_equal "helicopters/helicopter", Helicopter.new.to_partial_path, + "ActiveModel::Conversion#to_partial_path caching should be class-specific" + end + + test "to_partial_path handles namespaced models" do + assert_equal "helicopter/comanches/comanche", Helicopter::Comanche.new.to_partial_path + end +end diff --git a/activemodel/test/cases/dirty_test.rb b/activemodel/test/cases/dirty_test.rb new file mode 100644 index 0000000000..db2cd885e2 --- /dev/null +++ b/activemodel/test/cases/dirty_test.rb @@ -0,0 +1,228 @@ +require "cases/helper" + +class DirtyTest < ActiveModel::TestCase + class DirtyModel + include ActiveModel::Dirty + define_attribute_methods :name, :color, :size + + def initialize + @name = nil + @color = nil + @size = nil + end + + def name + @name + end + + def name=(val) + name_will_change! + @name = val + end + + def color + @color + end + + def color=(val) + color_will_change! unless val == @color + @color = val + end + + def size + @size + end + + def size=(val) + attribute_will_change!(:size) unless val == @size + @size = val + end + + def save + changes_applied + end + + def reload + clear_changes_information + end + + def deprecated_reload + reset_changes + end + end + + setup do + @model = DirtyModel.new + end + + test "setting attribute will result in change" do + assert !@model.changed? + assert !@model.name_changed? + @model.name = "Ringo" + assert @model.changed? + assert @model.name_changed? + end + + test "list of changed attribute keys" do + assert_equal [], @model.changed + @model.name = "Paul" + assert_equal ['name'], @model.changed + end + + test "changes to attribute values" do + assert !@model.changes['name'] + @model.name = "John" + assert_equal [nil, "John"], @model.changes['name'] + end + + test "checking if an attribute has changed to a particular value" do + @model.name = "Ringo" + assert @model.name_changed?(from: nil, to: "Ringo") + assert_not @model.name_changed?(from: "Pete", to: "Ringo") + assert @model.name_changed?(to: "Ringo") + assert_not @model.name_changed?(to: "Pete") + assert @model.name_changed?(from: nil) + assert_not @model.name_changed?(from: "Pete") + end + + test "changes accessible through both strings and symbols" do + @model.name = "David" + assert_not_nil @model.changes[:name] + assert_not_nil @model.changes['name'] + end + + test "be consistent with symbols arguments after the changes are applied" do + @model.name = "David" + assert @model.attribute_changed?(:name) + @model.save + @model.name = 'Rafael' + assert @model.attribute_changed?(:name) + end + + test "attribute mutation" do + @model.instance_variable_set("@name", "Yam") + assert !@model.name_changed? + @model.name.replace("Hadad") + assert !@model.name_changed? + @model.name_will_change! + @model.name.replace("Baal") + assert @model.name_changed? + end + + test "resetting attribute" do + @model.name = "Bob" + @model.restore_name! + assert_nil @model.name + assert !@model.name_changed? + end + + test "setting color to same value should not result in change being recorded" do + @model.color = "red" + assert @model.color_changed? + @model.save + assert !@model.color_changed? + assert !@model.changed? + @model.color = "red" + assert !@model.color_changed? + assert !@model.changed? + end + + test "saving should reset model's changed status" do + @model.name = "Alf" + assert @model.changed? + @model.save + assert !@model.changed? + assert !@model.name_changed? + end + + test "saving should preserve previous changes" do + @model.name = "Jericho Cane" + @model.save + assert_equal [nil, "Jericho Cane"], @model.previous_changes['name'] + end + + test "previous value is preserved when changed after save" do + assert_equal({}, @model.changed_attributes) + @model.name = "Paul" + assert_equal({ "name" => nil }, @model.changed_attributes) + + @model.save + + @model.name = "John" + assert_equal({ "name" => "Paul" }, @model.changed_attributes) + end + + test "changing the same attribute multiple times retains the correct original value" do + @model.name = "Otto" + @model.save + @model.name = "DudeFella ManGuy" + @model.name = "Mr. Manfredgensonton" + assert_equal ["Otto", "Mr. Manfredgensonton"], @model.name_change + assert_equal @model.name_was, "Otto" + end + + test "using attribute_will_change! with a symbol" do + @model.size = 1 + assert @model.size_changed? + end + + test "reload should reset all changes" do + @model.name = 'Dmitry' + @model.name_changed? + @model.save + @model.name = 'Bob' + + assert_equal [nil, 'Dmitry'], @model.previous_changes['name'] + assert_equal 'Dmitry', @model.changed_attributes['name'] + + @model.reload + + assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.previous_changes + assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.changed_attributes + end + + test "reset_changes is deprecated" do + @model.name = 'Dmitry' + @model.name_changed? + @model.save + @model.name = 'Bob' + + assert_equal [nil, 'Dmitry'], @model.previous_changes['name'] + assert_equal 'Dmitry', @model.changed_attributes['name'] + + assert_deprecated do + @model.deprecated_reload + end + + assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.previous_changes + assert_equal ActiveSupport::HashWithIndifferentAccess.new, @model.changed_attributes + end + + test "restore_attributes should restore all previous data" do + @model.name = 'Dmitry' + @model.color = 'Red' + @model.save + @model.name = 'Bob' + @model.color = 'White' + + @model.restore_attributes + + assert_not @model.changed? + assert_equal 'Dmitry', @model.name + assert_equal 'Red', @model.color + end + + test "restore_attributes can restore only some attributes" do + @model.name = 'Dmitry' + @model.color = 'Red' + @model.save + @model.name = 'Bob' + @model.color = 'White' + + @model.restore_attributes(['name']) + + assert @model.changed? + assert_equal 'Dmitry', @model.name + assert_equal 'White', @model.color + end +end diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb new file mode 100644 index 0000000000..42d0365521 --- /dev/null +++ b/activemodel/test/cases/errors_test.rb @@ -0,0 +1,315 @@ +require "cases/helper" + +class ErrorsTest < ActiveModel::TestCase + class Person + extend ActiveModel::Naming + def initialize + @errors = ActiveModel::Errors.new(self) + end + + attr_accessor :name, :age + attr_reader :errors + + def validate! + errors.add(:name, "cannot be nil") if name == nil + end + + def read_attribute_for_validation(attr) + send(attr) + end + + def self.human_attribute_name(attr, options = {}) + attr + end + + def self.lookup_ancestors + [self] + end + end + + def test_delete + errors = ActiveModel::Errors.new(self) + errors[:foo] = 'omg' + errors.delete(:foo) + assert_empty errors[:foo] + end + + def test_include? + errors = ActiveModel::Errors.new(self) + errors[:foo] = 'omg' + assert errors.include?(:foo), 'errors should include :foo' + end + + def test_dup + errors = ActiveModel::Errors.new(self) + errors[:foo] = 'bar' + errors_dup = errors.dup + errors_dup[:bar] = 'omg' + assert_not_same errors_dup.messages, errors.messages + end + + def test_has_key? + errors = ActiveModel::Errors.new(self) + errors[:foo] = 'omg' + assert_equal true, errors.has_key?(:foo), 'errors should have key :foo' + end + + def test_has_no_key + errors = ActiveModel::Errors.new(self) + assert_equal false, errors.has_key?(:name), 'errors should not have key :name' + end + + test "clear errors" do + person = Person.new + person.validate! + + assert_equal 1, person.errors.count + person.errors.clear + assert person.errors.empty? + end + + test "get returns the errors for the provided key" do + errors = ActiveModel::Errors.new(self) + errors[:foo] = "omg" + + assert_equal ["omg"], errors.get(:foo) + end + + test "sets the error with the provided key" do + errors = ActiveModel::Errors.new(self) + errors.set(:foo, "omg") + + assert_equal({ foo: "omg" }, errors.messages) + end + + test "error access is indifferent" do + errors = ActiveModel::Errors.new(self) + errors[:foo] = "omg" + + assert_equal ["omg"], errors["foo"] + end + + test "values returns an array of messages" do + errors = ActiveModel::Errors.new(self) + errors.set(:foo, "omg") + errors.set(:baz, "zomg") + + assert_equal ["omg", "zomg"], errors.values + end + + test "keys returns the error keys" do + errors = ActiveModel::Errors.new(self) + errors.set(:foo, "omg") + errors.set(:baz, "zomg") + + assert_equal [:foo, :baz], errors.keys + end + + test "detecting whether there are errors with empty?, blank?, include?" do + person = Person.new + person.errors[:foo] + assert person.errors.empty? + assert person.errors.blank? + assert !person.errors.include?(:foo) + end + + test "adding errors using conditionals with Person#validate!" do + person = Person.new + person.validate! + assert_equal ["name cannot be nil"], person.errors.full_messages + assert_equal ["cannot be nil"], person.errors[:name] + end + + test "assign error" do + person = Person.new + person.errors[:name] = 'should not be nil' + assert_equal ["should not be nil"], person.errors[:name] + end + + test "add an error message on a specific attribute" do + person = Person.new + person.errors.add(:name, "cannot be blank") + assert_equal ["cannot be blank"], person.errors[:name] + end + + test "add an error with a symbol" do + person = Person.new + person.errors.add(:name, :blank) + message = person.errors.generate_message(:name, :blank) + assert_equal [message], person.errors[:name] + end + + test "add an error with a proc" do + person = Person.new + message = Proc.new { "cannot be blank" } + person.errors.add(:name, message) + assert_equal ["cannot be blank"], person.errors[:name] + end + + test "added? detects if a specific error was added to the object" do + person = Person.new + person.errors.add(:name, "cannot be blank") + assert person.errors.added?(:name, "cannot be blank") + end + + test "added? handles symbol message" do + person = Person.new + person.errors.add(:name, :blank) + assert person.errors.added?(:name, :blank) + end + + test "added? handles proc messages" do + person = Person.new + message = Proc.new { "cannot be blank" } + person.errors.add(:name, message) + assert person.errors.added?(:name, message) + end + + test "added? defaults message to :invalid" do + person = Person.new + person.errors.add(:name) + assert person.errors.added?(:name) + end + + test "added? matches the given message when several errors are present for the same attribute" do + person = Person.new + person.errors.add(:name, "cannot be blank") + person.errors.add(:name, "is invalid") + assert person.errors.added?(:name, "cannot be blank") + end + + test "added? returns false when no errors are present" do + person = Person.new + assert !person.errors.added?(:name) + end + + test "added? returns false when checking a nonexisting error and other errors are present for the given attribute" do + person = Person.new + person.errors.add(:name, "is invalid") + assert !person.errors.added?(:name, "cannot be blank") + end + + test "size calculates the number of error messages" do + person = Person.new + person.errors.add(:name, "cannot be blank") + assert_equal 1, person.errors.size + end + + test "to_a returns the list of errors with complete messages containing the attribute names" do + person = Person.new + person.errors.add(:name, "cannot be blank") + person.errors.add(:name, "cannot be nil") + assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.to_a + end + + test "to_hash returns the error messages hash" do + person = Person.new + person.errors.add(:name, "cannot be blank") + assert_equal({ name: ["cannot be blank"] }, person.errors.to_hash) + end + + test "full_messages creates a list of error messages with the attribute name included" do + person = Person.new + person.errors.add(:name, "cannot be blank") + person.errors.add(:name, "cannot be nil") + assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.full_messages + end + + test "full_messages_for contains all the error messages for the given attribute" do + person = Person.new + person.errors.add(:name, "cannot be blank") + person.errors.add(:name, "cannot be nil") + assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.full_messages_for(:name) + end + + test "full_messages_for does not contain error messages from other attributes" do + person = Person.new + person.errors.add(:name, "cannot be blank") + person.errors.add(:email, "cannot be blank") + assert_equal ["name cannot be blank"], person.errors.full_messages_for(:name) + end + + test "full_messages_for returns an empty list in case there are no errors for the given attribute" do + person = Person.new + person.errors.add(:name, "cannot be blank") + assert_equal [], person.errors.full_messages_for(:email) + end + + test "full_message returns the given message when attribute is :base" do + person = Person.new + assert_equal "press the button", person.errors.full_message(:base, "press the button") + end + + test "full_message returns the given message with the attribute name included" do + person = Person.new + assert_equal "name cannot be blank", person.errors.full_message(:name, "cannot be blank") + assert_equal "name_test cannot be blank", person.errors.full_message(:name_test, "cannot be blank") + end + + test "as_json creates a json formatted representation of the errors hash" do + person = Person.new + person.validate! + + assert_equal({ name: ["cannot be nil"] }, person.errors.as_json) + end + + test "as_json with :full_messages option creates a json formatted representation of the errors containing complete messages" do + person = Person.new + person.validate! + + assert_equal({ name: ["name cannot be nil"] }, person.errors.as_json(full_messages: true)) + end + + test "generate_message works without i18n_scope" do + person = Person.new + assert !Person.respond_to?(:i18n_scope) + assert_nothing_raised { + person.errors.generate_message(:name, :blank) + } + end + + test "add_on_empty generates message" do + person = Person.new + person.errors.expects(:generate_message).with(:name, :empty, {}) + person.errors.add_on_empty :name + end + + test "add_on_empty generates message for multiple attributes" do + person = Person.new + person.errors.expects(:generate_message).with(:name, :empty, {}) + person.errors.expects(:generate_message).with(:age, :empty, {}) + person.errors.add_on_empty [:name, :age] + end + + test "add_on_empty generates message with custom default message" do + person = Person.new + person.errors.expects(:generate_message).with(:name, :empty, { message: 'custom' }) + person.errors.add_on_empty :name, message: 'custom' + end + + test "add_on_empty generates message with empty string value" do + person = Person.new + person.name = '' + person.errors.expects(:generate_message).with(:name, :empty, {}) + person.errors.add_on_empty :name + end + + test "add_on_blank generates message" do + person = Person.new + person.errors.expects(:generate_message).with(:name, :blank, {}) + person.errors.add_on_blank :name + end + + test "add_on_blank generates message for multiple attributes" do + person = Person.new + person.errors.expects(:generate_message).with(:name, :blank, {}) + person.errors.expects(:generate_message).with(:age, :blank, {}) + person.errors.add_on_blank [:name, :age] + end + + test "add_on_blank generates message with custom default message" do + person = Person.new + person.errors.expects(:generate_message).with(:name, :blank, { message: 'custom' }) + person.errors.add_on_blank :name, message: 'custom' + end +end diff --git a/activemodel/test/cases/forbidden_attributes_protection_test.rb b/activemodel/test/cases/forbidden_attributes_protection_test.rb new file mode 100644 index 0000000000..3cb204a2c5 --- /dev/null +++ b/activemodel/test/cases/forbidden_attributes_protection_test.rb @@ -0,0 +1,36 @@ +require 'cases/helper' +require 'active_support/core_ext/hash/indifferent_access' +require 'models/account' + +class ProtectedParams < ActiveSupport::HashWithIndifferentAccess + attr_accessor :permitted + alias :permitted? :permitted + + def initialize(attributes) + super(attributes) + @permitted = false + end + + def permit! + @permitted = true + self + end +end + +class ActiveModelMassUpdateProtectionTest < ActiveSupport::TestCase + test "forbidden attributes cannot be used for mass updating" do + params = ProtectedParams.new({ "a" => "b" }) + assert_raises(ActiveModel::ForbiddenAttributesError) do + Account.new.sanitize_for_mass_assignment(params) + end + end + + test "permitted attributes can be used for mass updating" do + params = ProtectedParams.new({ "a" => "b" }).permit! + assert_equal({ "a" => "b" }, Account.new.sanitize_for_mass_assignment(params)) + end + + test "regular attributes should still be allowed" do + assert_equal({ a: "b" }, Account.new.sanitize_for_mass_assignment(a: "b")) + end +end diff --git a/activemodel/test/cases/helper.rb b/activemodel/test/cases/helper.rb new file mode 100644 index 0000000000..804e0c24f6 --- /dev/null +++ b/activemodel/test/cases/helper.rb @@ -0,0 +1,15 @@ +require File.expand_path('../../../../load_paths', __FILE__) + +require 'config' +require 'active_model' +require 'active_support/core_ext/string/access' + +# Show backtraces for deprecated behavior for quicker cleanup. +ActiveSupport::Deprecation.debug = true + +# Disable available locale checks to avoid warnings running the test suite. +I18n.enforce_available_locales = false + +require 'active_support/testing/autorun' + +require 'mocha/setup' # FIXME: stop using mocha diff --git a/activemodel/test/cases/lint_test.rb b/activemodel/test/cases/lint_test.rb new file mode 100644 index 0000000000..8faf93c056 --- /dev/null +++ b/activemodel/test/cases/lint_test.rb @@ -0,0 +1,20 @@ +require 'cases/helper' + +class LintTest < ActiveModel::TestCase + include ActiveModel::Lint::Tests + + class CompliantModel + extend ActiveModel::Naming + include ActiveModel::Conversion + + def persisted?() false end + + def errors + Hash.new([]) + end + end + + def setup + @model = CompliantModel.new + end +end diff --git a/activemodel/test/cases/model_test.rb b/activemodel/test/cases/model_test.rb new file mode 100644 index 0000000000..ee0fa26546 --- /dev/null +++ b/activemodel/test/cases/model_test.rb @@ -0,0 +1,75 @@ +require 'cases/helper' + +class ModelTest < ActiveModel::TestCase + include ActiveModel::Lint::Tests + + module DefaultValue + def self.included(klass) + klass.class_eval { attr_accessor :hello } + end + + def initialize(*args) + @attr ||= 'default value' + super + end + end + + class BasicModel + include DefaultValue + include ActiveModel::Model + attr_accessor :attr + end + + class BasicModelWithReversedMixins + include ActiveModel::Model + include DefaultValue + attr_accessor :attr + end + + class SimpleModel + include ActiveModel::Model + attr_accessor :attr + end + + def setup + @model = BasicModel.new + end + + def test_initialize_with_params + object = BasicModel.new(attr: "value") + assert_equal "value", object.attr + end + + def test_initialize_with_params_and_mixins_reversed + object = BasicModelWithReversedMixins.new(attr: "value") + assert_equal "value", object.attr + end + + def test_initialize_with_nil_or_empty_hash_params_does_not_explode + assert_nothing_raised do + BasicModel.new() + BasicModel.new(nil) + BasicModel.new({}) + SimpleModel.new(attr: 'value') + end + end + + def test_persisted_is_always_false + object = BasicModel.new(attr: "value") + assert object.persisted? == false + end + + def test_mixin_inclusion_chain + object = BasicModel.new + assert_equal 'default value', object.attr + end + + def test_mixin_initializer_when_args_exist + object = BasicModel.new(hello: 'world') + assert_equal 'world', object.hello + end + + def test_mixin_initializer_when_args_dont_exist + assert_raises(NoMethodError) { SimpleModel.new(hello: 'world') } + end +end diff --git a/activemodel/test/cases/naming_test.rb b/activemodel/test/cases/naming_test.rb new file mode 100644 index 0000000000..7b8287edbf --- /dev/null +++ b/activemodel/test/cases/naming_test.rb @@ -0,0 +1,280 @@ +require 'cases/helper' +require 'models/contact' +require 'models/sheep' +require 'models/track_back' +require 'models/blog_post' + +class NamingTest < ActiveModel::TestCase + def setup + @model_name = ActiveModel::Name.new(Post::TrackBack) + end + + def test_singular + assert_equal 'post_track_back', @model_name.singular + end + + def test_plural + assert_equal 'post_track_backs', @model_name.plural + end + + def test_element + assert_equal 'track_back', @model_name.element + end + + def test_collection + assert_equal 'post/track_backs', @model_name.collection + end + + def test_human + assert_equal 'Track back', @model_name.human + end + + def test_route_key + assert_equal 'post_track_backs', @model_name.route_key + end + + def test_param_key + assert_equal 'post_track_back', @model_name.param_key + end + + def test_i18n_key + assert_equal :"post/track_back", @model_name.i18n_key + end +end + +class NamingWithNamespacedModelInIsolatedNamespaceTest < ActiveModel::TestCase + def setup + @model_name = ActiveModel::Name.new(Blog::Post, Blog) + end + + def test_singular + assert_equal 'blog_post', @model_name.singular + end + + def test_plural + assert_equal 'blog_posts', @model_name.plural + end + + def test_element + assert_equal 'post', @model_name.element + end + + def test_collection + assert_equal 'blog/posts', @model_name.collection + end + + def test_human + assert_equal 'Post', @model_name.human + end + + def test_route_key + assert_equal 'posts', @model_name.route_key + end + + def test_param_key + assert_equal 'post', @model_name.param_key + end + + def test_i18n_key + assert_equal :"blog/post", @model_name.i18n_key + end +end + +class NamingWithNamespacedModelInSharedNamespaceTest < ActiveModel::TestCase + def setup + @model_name = ActiveModel::Name.new(Blog::Post) + end + + def test_singular + assert_equal 'blog_post', @model_name.singular + end + + def test_plural + assert_equal 'blog_posts', @model_name.plural + end + + def test_element + assert_equal 'post', @model_name.element + end + + def test_collection + assert_equal 'blog/posts', @model_name.collection + end + + def test_human + assert_equal 'Post', @model_name.human + end + + def test_route_key + assert_equal 'blog_posts', @model_name.route_key + end + + def test_param_key + assert_equal 'blog_post', @model_name.param_key + end + + def test_i18n_key + assert_equal :"blog/post", @model_name.i18n_key + end +end + +class NamingWithSuppliedModelNameTest < ActiveModel::TestCase + def setup + @model_name = ActiveModel::Name.new(Blog::Post, nil, 'Article') + end + + def test_singular + assert_equal 'article', @model_name.singular + end + + def test_plural + assert_equal 'articles', @model_name.plural + end + + def test_element + assert_equal 'article', @model_name.element + end + + def test_collection + assert_equal 'articles', @model_name.collection + end + + def test_human + assert_equal 'Article', @model_name.human + end + + def test_route_key + assert_equal 'articles', @model_name.route_key + end + + def test_param_key + assert_equal 'article', @model_name.param_key + end + + def test_i18n_key + assert_equal :"article", @model_name.i18n_key + end +end + +class NamingUsingRelativeModelNameTest < ActiveModel::TestCase + def setup + @model_name = Blog::Post.model_name + end + + def test_singular + assert_equal 'blog_post', @model_name.singular + end + + def test_plural + assert_equal 'blog_posts', @model_name.plural + end + + def test_element + assert_equal 'post', @model_name.element + end + + def test_collection + assert_equal 'blog/posts', @model_name.collection + end + + def test_human + assert_equal 'Post', @model_name.human + end + + def test_route_key + assert_equal 'posts', @model_name.route_key + end + + def test_param_key + assert_equal 'post', @model_name.param_key + end + + def test_i18n_key + assert_equal :"blog/post", @model_name.i18n_key + end +end + +class NamingHelpersTest < ActiveModel::TestCase + def setup + @klass = Contact + @record = @klass.new + @singular = 'contact' + @plural = 'contacts' + @uncountable = Sheep + @singular_route_key = 'contact' + @route_key = 'contacts' + @param_key = 'contact' + end + + def test_to_model_called_on_record + assert_equal 'post_named_track_backs', plural(Post::TrackBack.new) + end + + def test_singular + assert_equal @singular, singular(@record) + end + + def test_singular_for_class + assert_equal @singular, singular(@klass) + end + + def test_plural + assert_equal @plural, plural(@record) + end + + def test_plural_for_class + assert_equal @plural, plural(@klass) + end + + def test_route_key + assert_equal @route_key, route_key(@record) + assert_equal @singular_route_key, singular_route_key(@record) + end + + def test_route_key_for_class + assert_equal @route_key, route_key(@klass) + assert_equal @singular_route_key, singular_route_key(@klass) + end + + def test_param_key + assert_equal @param_key, param_key(@record) + end + + def test_param_key_for_class + assert_equal @param_key, param_key(@klass) + end + + def test_uncountable + assert uncountable?(@uncountable), "Expected 'sheep' to be uncountable" + assert !uncountable?(@klass), "Expected 'contact' to be countable" + end + + def test_uncountable_route_key + assert_equal "sheep", singular_route_key(@uncountable) + assert_equal "sheep_index", route_key(@uncountable) + end + + private + def method_missing(method, *args) + ActiveModel::Naming.send(method, *args) + end +end + +class NameWithAnonymousClassTest < ActiveModel::TestCase + def test_anonymous_class_without_name_argument + assert_raises(ArgumentError) do + ActiveModel::Name.new(Class.new) + end + end + + def test_anonymous_class_with_name_argument + model_name = ActiveModel::Name.new(Class.new, nil, "Anonymous") + assert_equal "Anonymous", model_name + end +end + +class NamingMethodDelegationTest < ActiveModel::TestCase + def test_model_name + assert_equal Blog::Post.model_name, Blog::Post.new.model_name + end +end diff --git a/activemodel/test/cases/railtie_test.rb b/activemodel/test/cases/railtie_test.rb new file mode 100644 index 0000000000..96b3b07e50 --- /dev/null +++ b/activemodel/test/cases/railtie_test.rb @@ -0,0 +1,32 @@ +require 'cases/helper' +require 'active_support/testing/isolation' + +class RailtieTest < ActiveModel::TestCase + include ActiveSupport::Testing::Isolation + + def setup + require 'active_model/railtie' + + # Set a fake logger to avoid creating the log directory automatically + fake_logger = Logger.new(nil) + + @app ||= Class.new(::Rails::Application) do + config.eager_load = false + config.logger = fake_logger + end + end + + test 'secure password min_cost is false in the development environment' do + Rails.env = 'development' + @app.initialize! + + assert_equal false, ActiveModel::SecurePassword.min_cost + end + + test 'secure password min_cost is true in the test environment' do + Rails.env = 'test' + @app.initialize! + + assert_equal true, ActiveModel::SecurePassword.min_cost + end +end diff --git a/activemodel/test/cases/secure_password_test.rb b/activemodel/test/cases/secure_password_test.rb new file mode 100644 index 0000000000..6d56c8344a --- /dev/null +++ b/activemodel/test/cases/secure_password_test.rb @@ -0,0 +1,218 @@ +require 'cases/helper' +require 'models/user' +require 'models/visitor' + +class SecurePasswordTest < ActiveModel::TestCase + setup do + # Used only to speed up tests + @original_min_cost = ActiveModel::SecurePassword.min_cost + ActiveModel::SecurePassword.min_cost = true + + @user = User.new + @visitor = Visitor.new + + # Simulate loading an existing user from the DB + @existing_user = User.new + @existing_user.password_digest = BCrypt::Password.create('password', cost: BCrypt::Engine::MIN_COST) + end + + teardown do + ActiveModel::SecurePassword.min_cost = @original_min_cost + end + + test "automatically include ActiveModel::Validations when validations are enabled" do + assert_respond_to @user, :valid? + end + + test "don't include ActiveModel::Validations when validations are disabled" do + assert_not_respond_to @visitor, :valid? + end + + test "create a new user with validations and valid password/confirmation" do + @user.password = 'password' + @user.password_confirmation = 'password' + + assert @user.valid?(:create), 'user should be valid' + + @user.password = 'a' * 72 + @user.password_confirmation = 'a' * 72 + + assert @user.valid?(:create), 'user should be valid' + end + + test "create a new user with validation and a spaces only password" do + @user.password = ' ' * 72 + assert @user.valid?(:create), 'user should be valid' + end + + test "create a new user with validation and a blank password" do + @user.password = '' + assert !@user.valid?(:create), 'user should be invalid' + assert_equal 1, @user.errors.count + assert_equal ["can't be blank"], @user.errors[:password] + end + + test "create a new user with validation and a nil password" do + @user.password = nil + assert !@user.valid?(:create), 'user should be invalid' + assert_equal 1, @user.errors.count + assert_equal ["can't be blank"], @user.errors[:password] + end + + test 'create a new user with validation and password length greater than 72' do + @user.password = 'a' * 73 + @user.password_confirmation = 'a' * 73 + assert !@user.valid?(:create), 'user should be invalid' + assert_equal 1, @user.errors.count + assert_equal ["is too long (maximum is 72 characters)"], @user.errors[:password] + end + + test "create a new user with validation and a blank password confirmation" do + @user.password = 'password' + @user.password_confirmation = '' + assert !@user.valid?(:create), 'user should be invalid' + assert_equal 1, @user.errors.count + assert_equal ["doesn't match Password"], @user.errors[:password_confirmation] + end + + test "create a new user with validation and a nil password confirmation" do + @user.password = 'password' + @user.password_confirmation = nil + assert @user.valid?(:create), 'user should be valid' + end + + test "create a new user with validation and an incorrect password confirmation" do + @user.password = 'password' + @user.password_confirmation = 'something else' + assert !@user.valid?(:create), 'user should be invalid' + assert_equal 1, @user.errors.count + assert_equal ["doesn't match Password"], @user.errors[:password_confirmation] + end + + test "update an existing user with validation and no change in password" do + assert @existing_user.valid?(:update), 'user should be valid' + end + + test "update an existing user with validations and valid password/confirmation" do + @existing_user.password = 'password' + @existing_user.password_confirmation = 'password' + + assert @existing_user.valid?(:update), 'user should be valid' + + @existing_user.password = 'a' * 72 + @existing_user.password_confirmation = 'a' * 72 + + assert @existing_user.valid?(:update), 'user should be valid' + end + + test "updating an existing user with validation and a blank password" do + @existing_user.password = '' + assert @existing_user.valid?(:update), 'user should be valid' + end + + test "updating an existing user with validation and a spaces only password" do + @user.password = ' ' * 72 + assert @user.valid?(:update), 'user should be valid' + end + + test "updating an existing user with validation and a blank password and password_confirmation" do + @existing_user.password = '' + @existing_user.password_confirmation = '' + assert @existing_user.valid?(:update), 'user should be valid' + end + + test "updating an existing user with validation and a nil password" do + @existing_user.password = nil + assert !@existing_user.valid?(:update), 'user should be invalid' + assert_equal 1, @existing_user.errors.count + assert_equal ["can't be blank"], @existing_user.errors[:password] + end + + test 'updating an existing user with validation and password length greater than 72' do + @existing_user.password = 'a' * 73 + @existing_user.password_confirmation = 'a' * 73 + assert !@existing_user.valid?(:update), 'user should be invalid' + assert_equal 1, @existing_user.errors.count + assert_equal ["is too long (maximum is 72 characters)"], @existing_user.errors[:password] + end + + test "updating an existing user with validation and a blank password confirmation" do + @existing_user.password = 'password' + @existing_user.password_confirmation = '' + assert !@existing_user.valid?(:update), 'user should be invalid' + assert_equal 1, @existing_user.errors.count + assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation] + end + + test "updating an existing user with validation and a nil password confirmation" do + @existing_user.password = 'password' + @existing_user.password_confirmation = nil + assert @existing_user.valid?(:update), 'user should be valid' + end + + test "updating an existing user with validation and an incorrect password confirmation" do + @existing_user.password = 'password' + @existing_user.password_confirmation = 'something else' + assert !@existing_user.valid?(:update), 'user should be invalid' + assert_equal 1, @existing_user.errors.count + assert_equal ["doesn't match Password"], @existing_user.errors[:password_confirmation] + end + + test "updating an existing user with validation and a blank password digest" do + @existing_user.password_digest = '' + assert !@existing_user.valid?(:update), 'user should be invalid' + assert_equal 1, @existing_user.errors.count + assert_equal ["can't be blank"], @existing_user.errors[:password] + end + + test "updating an existing user with validation and a nil password digest" do + @existing_user.password_digest = nil + assert !@existing_user.valid?(:update), 'user should be invalid' + assert_equal 1, @existing_user.errors.count + assert_equal ["can't be blank"], @existing_user.errors[:password] + end + + test "setting a blank password should not change an existing password" do + @existing_user.password = '' + assert @existing_user.password_digest == 'password' + end + + test "setting a nil password should clear an existing password" do + @existing_user.password = nil + assert_equal nil, @existing_user.password_digest + end + + test "authenticate" do + @user.password = "secret" + + assert !@user.authenticate("wrong") + assert @user.authenticate("secret") + end + + test "Password digest cost defaults to bcrypt default cost when min_cost is false" do + ActiveModel::SecurePassword.min_cost = false + + @user.password = "secret" + assert_equal BCrypt::Engine::DEFAULT_COST, @user.password_digest.cost + end + + test "Password digest cost honors bcrypt cost attribute when min_cost is false" do + begin + original_bcrypt_cost = BCrypt::Engine.cost + ActiveModel::SecurePassword.min_cost = false + BCrypt::Engine.cost = 5 + + @user.password = "secret" + assert_equal BCrypt::Engine.cost, @user.password_digest.cost + ensure + BCrypt::Engine.cost = original_bcrypt_cost + end + end + + test "Password digest cost can be set to bcrypt min cost to speed up tests" do + ActiveModel::SecurePassword.min_cost = true + + @user.password = "secret" + assert_equal BCrypt::Engine::MIN_COST, @user.password_digest.cost + end +end diff --git a/activemodel/test/cases/serialization_test.rb b/activemodel/test/cases/serialization_test.rb new file mode 100644 index 0000000000..4ae41aa19c --- /dev/null +++ b/activemodel/test/cases/serialization_test.rb @@ -0,0 +1,168 @@ +require "cases/helper" +require 'active_support/core_ext/object/instance_variables' + +class SerializationTest < ActiveModel::TestCase + class User + include ActiveModel::Serialization + + attr_accessor :name, :email, :gender, :address, :friends + + def initialize(name, email, gender) + @name, @email, @gender = name, email, gender + @friends = [] + end + + def attributes + instance_values.except("address", "friends") + end + + def foo + 'i_am_foo' + end + end + + class Address + include ActiveModel::Serialization + + attr_accessor :street, :city, :state, :zip + + def attributes + instance_values + end + end + + setup do + @user = User.new('David', 'david@example.com', 'male') + @user.address = Address.new + @user.address.street = "123 Lane" + @user.address.city = "Springfield" + @user.address.state = "CA" + @user.address.zip = 11111 + @user.friends = [User.new('Joe', 'joe@example.com', 'male'), + User.new('Sue', 'sue@example.com', 'female')] + end + + def test_method_serializable_hash_should_work + expected = {"name"=>"David", "gender"=>"male", "email"=>"david@example.com"} + assert_equal expected, @user.serializable_hash + end + + def test_method_serializable_hash_should_work_with_only_option + expected = {"name"=>"David"} + assert_equal expected, @user.serializable_hash(only: [:name]) + end + + def test_method_serializable_hash_should_work_with_except_option + expected = {"gender"=>"male", "email"=>"david@example.com"} + assert_equal expected, @user.serializable_hash(except: [:name]) + end + + def test_method_serializable_hash_should_work_with_methods_option + expected = {"name"=>"David", "gender"=>"male", "foo"=>"i_am_foo", "email"=>"david@example.com"} + assert_equal expected, @user.serializable_hash(methods: [:foo]) + end + + def test_method_serializable_hash_should_work_with_only_and_methods + expected = {"foo"=>"i_am_foo"} + assert_equal expected, @user.serializable_hash(only: [], methods: [:foo]) + end + + def test_method_serializable_hash_should_work_with_except_and_methods + expected = {"gender"=>"male", "foo"=>"i_am_foo"} + assert_equal expected, @user.serializable_hash(except: [:name, :email], methods: [:foo]) + end + + def test_should_not_call_methods_that_dont_respond + expected = {"name"=>"David", "gender"=>"male", "email"=>"david@example.com"} + assert_equal expected, @user.serializable_hash(methods: [:bar]) + end + + def test_should_use_read_attribute_for_serialization + def @user.read_attribute_for_serialization(n) + "Jon" + end + + expected = { "name" => "Jon" } + assert_equal expected, @user.serializable_hash(only: :name) + end + + def test_include_option_with_singular_association + expected = {"name"=>"David", "gender"=>"male", "email"=>"david@example.com", + "address"=>{"street"=>"123 Lane", "city"=>"Springfield", "state"=>"CA", "zip"=>11111}} + assert_equal expected, @user.serializable_hash(include: :address) + end + + def test_include_option_with_plural_association + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", + "friends"=>[{"name"=>'Joe', "email"=>'joe@example.com', "gender"=>'male'}, + {"name"=>'Sue', "email"=>'sue@example.com', "gender"=>'female'}]} + assert_equal expected, @user.serializable_hash(include: :friends) + end + + def test_include_option_with_empty_association + @user.friends = [] + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", "friends"=>[]} + assert_equal expected, @user.serializable_hash(include: :friends) + end + + class FriendList + def initialize(friends) + @friends = friends + end + + def to_ary + @friends + end + end + + def test_include_option_with_ary + @user.friends = FriendList.new(@user.friends) + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", + "friends"=>[{"name"=>'Joe', "email"=>'joe@example.com', "gender"=>'male'}, + {"name"=>'Sue', "email"=>'sue@example.com', "gender"=>'female'}]} + assert_equal expected, @user.serializable_hash(include: :friends) + end + + def test_multiple_includes + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", + "address"=>{"street"=>"123 Lane", "city"=>"Springfield", "state"=>"CA", "zip"=>11111}, + "friends"=>[{"name"=>'Joe', "email"=>'joe@example.com', "gender"=>'male'}, + {"name"=>'Sue', "email"=>'sue@example.com', "gender"=>'female'}]} + assert_equal expected, @user.serializable_hash(include: [:address, :friends]) + end + + def test_include_with_options + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", + "address"=>{"street"=>"123 Lane"}} + assert_equal expected, @user.serializable_hash(include: { address: { only: "street" } }) + end + + def test_nested_include + @user.friends.first.friends = [@user] + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", + "friends"=>[{"name"=>'Joe', "email"=>'joe@example.com', "gender"=>'male', + "friends"=> [{"email"=>"david@example.com", "gender"=>"male", "name"=>"David"}]}, + {"name"=>'Sue', "email"=>'sue@example.com', "gender"=>'female', "friends"=> []}]} + assert_equal expected, @user.serializable_hash(include: { friends: { include: :friends } }) + end + + def test_only_include + expected = {"name"=>"David", "friends" => [{"name" => "Joe"}, {"name" => "Sue"}]} + assert_equal expected, @user.serializable_hash(only: :name, include: { friends: { only: :name } }) + end + + def test_except_include + expected = {"name"=>"David", "email"=>"david@example.com", + "friends"=> [{"name" => 'Joe', "email" => 'joe@example.com'}, + {"name" => "Sue", "email" => 'sue@example.com'}]} + assert_equal expected, @user.serializable_hash(except: :gender, include: { friends: { except: :gender } }) + end + + def test_multiple_includes_with_options + expected = {"email"=>"david@example.com", "gender"=>"male", "name"=>"David", + "address"=>{"street"=>"123 Lane"}, + "friends"=>[{"name"=>'Joe', "email"=>'joe@example.com', "gender"=>'male'}, + {"name"=>'Sue', "email"=>'sue@example.com', "gender"=>'female'}]} + assert_equal expected, @user.serializable_hash(include: [{ address: {only: "street" } }, :friends]) + end +end diff --git a/activemodel/test/cases/serializers/json_serialization_test.rb b/activemodel/test/cases/serializers/json_serialization_test.rb new file mode 100644 index 0000000000..734656b749 --- /dev/null +++ b/activemodel/test/cases/serializers/json_serialization_test.rb @@ -0,0 +1,215 @@ +require 'cases/helper' +require 'models/contact' +require 'active_support/core_ext/object/instance_variables' + +class Contact + include ActiveModel::Serializers::JSON + include ActiveModel::Validations + + def attributes=(hash) + hash.each do |k, v| + instance_variable_set("@#{k}", v) + end + end + + remove_method :attributes if method_defined?(:attributes) + + def attributes + instance_values + end +end + +class JsonSerializationTest < ActiveModel::TestCase + def setup + @contact = Contact.new + @contact.name = 'Konata Izumi' + @contact.age = 16 + @contact.created_at = Time.utc(2006, 8, 1) + @contact.awesome = true + @contact.preferences = { 'shows' => 'anime' } + end + + test "should not include root in json (class method)" do + json = @contact.to_json + + assert_no_match %r{^\{"contact":\{}, json + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"awesome":true}, json + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + + test "should include root in json if include_root_in_json is true" do + begin + original_include_root_in_json = Contact.include_root_in_json + Contact.include_root_in_json = true + json = @contact.to_json + + assert_match %r{^\{"contact":\{}, json + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"awesome":true}, json + assert_match %r{"preferences":\{"shows":"anime"\}}, json + ensure + Contact.include_root_in_json = original_include_root_in_json + end + end + + test "should include root in json (option) even if the default is set to false" do + json = @contact.to_json(root: true) + + assert_match %r{^\{"contact":\{}, json + end + + test "should not include root in json (option)" do + json = @contact.to_json(root: false) + + assert_no_match %r{^\{"contact":\{}, json + end + + test "should include custom root in json" do + json = @contact.to_json(root: 'json_contact') + + assert_match %r{^\{"json_contact":\{}, json + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"awesome":true}, json + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + + test "should encode all encodable attributes" do + json = @contact.to_json + + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"awesome":true}, json + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + + test "should allow attribute filtering with only" do + json = @contact.to_json(only: [:name, :age]) + + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert_no_match %r{"awesome":true}, json + assert !json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_no_match %r{"preferences":\{"shows":"anime"\}}, json + end + + test "should allow attribute filtering with except" do + json = @contact.to_json(except: [:name, :age]) + + assert_no_match %r{"name":"Konata Izumi"}, json + assert_no_match %r{"age":16}, json + assert_match %r{"awesome":true}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + + test "methods are called on object" do + # Define methods on fixture. + def @contact.label; "Has cheezburger"; end + def @contact.favorite_quote; "Constraints are liberating"; end + + # Single method. + assert_match %r{"label":"Has cheezburger"}, @contact.to_json(only: :name, methods: :label) + + # Both methods. + methods_json = @contact.to_json(only: :name, methods: [:label, :favorite_quote]) + assert_match %r{"label":"Has cheezburger"}, methods_json + assert_match %r{"favorite_quote":"Constraints are liberating"}, methods_json + end + + test "should return Hash for errors" do + contact = Contact.new + contact.errors.add :name, "can't be blank" + contact.errors.add :name, "is too short (minimum is 2 characters)" + contact.errors.add :age, "must be 16 or over" + + hash = {} + hash[:name] = ["can't be blank", "is too short (minimum is 2 characters)"] + hash[:age] = ["must be 16 or over"] + assert_equal hash.to_json, contact.errors.to_json + end + + test "serializable_hash should not modify options passed in argument" do + options = { except: :name } + @contact.serializable_hash(options) + + assert_nil options[:only] + assert_equal :name, options[:except] + end + + test "as_json should return a hash if include_root_in_json is true" do + begin + original_include_root_in_json = Contact.include_root_in_json + Contact.include_root_in_json = true + json = @contact.as_json + + assert_kind_of Hash, json + assert_kind_of Hash, json['contact'] + %w(name age created_at awesome preferences).each do |field| + assert_equal @contact.send(field), json['contact'][field] + end + ensure + Contact.include_root_in_json = original_include_root_in_json + end + end + + test "from_json should work without a root (class attribute)" do + json = @contact.to_json + result = Contact.new.from_json(json) + + assert_equal result.name, @contact.name + assert_equal result.age, @contact.age + assert_equal Time.parse(result.created_at), @contact.created_at + assert_equal result.awesome, @contact.awesome + assert_equal result.preferences, @contact.preferences + end + + test "from_json should work without a root (method parameter)" do + json = @contact.to_json + result = Contact.new.from_json(json, false) + + assert_equal result.name, @contact.name + assert_equal result.age, @contact.age + assert_equal Time.parse(result.created_at), @contact.created_at + assert_equal result.awesome, @contact.awesome + assert_equal result.preferences, @contact.preferences + end + + test "from_json should work with a root (method parameter)" do + json = @contact.to_json(root: :true) + result = Contact.new.from_json(json, true) + + assert_equal result.name, @contact.name + assert_equal result.age, @contact.age + assert_equal Time.parse(result.created_at), @contact.created_at + assert_equal result.awesome, @contact.awesome + assert_equal result.preferences, @contact.preferences + end + + test "custom as_json should be honored when generating json" do + def @contact.as_json(options); { name: name, created_at: created_at }; end + json = @contact.to_json + + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))}}, json + assert_no_match %r{"awesome":}, json + assert_no_match %r{"preferences":}, json + end + + test "custom as_json options should be extensible" do + def @contact.as_json(options = {}); super(options.merge(only: [:name])); end + json = @contact.to_json + + assert_match %r{"name":"Konata Izumi"}, json + assert_no_match %r{"created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))}}, json + assert_no_match %r{"awesome":}, json + assert_no_match %r{"preferences":}, json + end +end diff --git a/activemodel/test/cases/serializers/xml_serialization_test.rb b/activemodel/test/cases/serializers/xml_serialization_test.rb new file mode 100644 index 0000000000..5db14c8157 --- /dev/null +++ b/activemodel/test/cases/serializers/xml_serialization_test.rb @@ -0,0 +1,262 @@ +require 'cases/helper' +require 'models/contact' +require 'active_support/core_ext/object/instance_variables' +require 'ostruct' + +class Contact + include ActiveModel::Serializers::Xml + + attr_accessor :address, :friends, :contact + + remove_method :attributes if method_defined?(:attributes) + + def attributes + instance_values.except("address", "friends", "contact") + end +end + +module Admin + class Contact < ::Contact + end +end + +class Customer < Struct.new(:name) +end + +class Address + include ActiveModel::Serializers::Xml + + attr_accessor :street, :city, :state, :zip, :apt_number + + def attributes + instance_values + end +end + +class SerializableContact < Contact + def serializable_hash(options={}) + super(options.merge(only: [:name, :age])) + end +end + +class XmlSerializationTest < ActiveModel::TestCase + def setup + @contact = Contact.new + @contact.name = 'aaron stack' + @contact.age = 25 + @contact.created_at = Time.utc(2006, 8, 1) + @contact.awesome = false + customer = Customer.new + customer.name = "John" + @contact.preferences = customer + @contact.address = Address.new + @contact.address.city = "Springfield" + @contact.address.apt_number = 35 + @contact.friends = [Contact.new, Contact.new] + @contact.contact = SerializableContact.new + end + + test "should serialize default root" do + xml = @contact.to_xml + assert_match %r{^<contact>}, xml + assert_match %r{</contact>$}, xml + end + + test "should serialize namespaced root" do + xml = Admin::Contact.new(@contact.attributes).to_xml + assert_match %r{^<contact>}, xml + assert_match %r{</contact>$}, xml + end + + test "should serialize default root with namespace" do + xml = @contact.to_xml namespace: "http://xml.rubyonrails.org/contact" + assert_match %r{^<contact xmlns="http://xml.rubyonrails.org/contact">}, xml + assert_match %r{</contact>$}, xml + end + + test "should serialize custom root" do + xml = @contact.to_xml root: 'xml_contact' + assert_match %r{^<xml-contact>}, xml + assert_match %r{</xml-contact>$}, xml + end + + test "should allow undasherized tags" do + xml = @contact.to_xml root: 'xml_contact', dasherize: false + assert_match %r{^<xml_contact>}, xml + assert_match %r{</xml_contact>$}, xml + assert_match %r{<created_at}, xml + end + + test "should allow camelized tags" do + xml = @contact.to_xml root: 'xml_contact', camelize: true + assert_match %r{^<XmlContact>}, xml + assert_match %r{</XmlContact>$}, xml + assert_match %r{<CreatedAt}, xml + end + + test "should allow lower-camelized tags" do + xml = @contact.to_xml root: 'xml_contact', camelize: :lower + assert_match %r{^<xmlContact>}, xml + assert_match %r{</xmlContact>$}, xml + assert_match %r{<createdAt}, xml + end + + test "should use serializable hash" do + @contact = SerializableContact.new + @contact.name = 'aaron stack' + @contact.age = 25 + + xml = @contact.to_xml + assert_match %r{<name>aaron stack</name>}, xml + assert_match %r{<age type="integer">25</age>}, xml + assert_no_match %r{<awesome>}, xml + end + + test "should allow skipped types" do + xml = @contact.to_xml skip_types: true + assert_match %r{<age>25</age>}, xml + end + + test "should include yielded additions" do + xml_output = @contact.to_xml do |xml| + xml.creator "David" + end + assert_match %r{<creator>David</creator>}, xml_output + end + + test "should serialize string" do + assert_match %r{<name>aaron stack</name>}, @contact.to_xml + end + + test "should serialize nil" do + assert_match %r{<pseudonyms nil="true"/>}, @contact.to_xml(methods: :pseudonyms) + end + + test "should serialize integer" do + assert_match %r{<age type="integer">25</age>}, @contact.to_xml + end + + test "should serialize datetime" do + assert_match %r{<created-at type="dateTime">2006-08-01T00:00:00Z</created-at>}, @contact.to_xml + end + + test "should serialize boolean" do + assert_match %r{<awesome type="boolean">false</awesome>}, @contact.to_xml + end + + test "should serialize array" do + assert_match %r{<social type="array">\s*<social>twitter</social>\s*<social>github</social>\s*</social>}, @contact.to_xml(methods: :social) + end + + test "should serialize hash" do + assert_match %r{<network>\s*<git type="symbol">github</git>\s*</network>}, @contact.to_xml(methods: :network) + end + + test "should serialize yaml" do + assert_match %r{<preferences type="yaml">--- !ruby/struct:Customer(\s*)\nname: John\n</preferences>}, @contact.to_xml + end + + test "should call proc on object" do + proc = Proc.new { |options| options[:builder].tag!('nationality', 'unknown') } + xml = @contact.to_xml(procs: [ proc ]) + assert_match %r{<nationality>unknown</nationality>}, xml + end + + test "should supply serializable to second proc argument" do + proc = Proc.new { |options, record| options[:builder].tag!('name-reverse', record.name.reverse) } + xml = @contact.to_xml(procs: [ proc ]) + assert_match %r{<name-reverse>kcats noraa</name-reverse>}, xml + end + + test "should serialize string correctly when type passed" do + xml = @contact.to_xml type: 'Contact' + assert_match %r{<contact type="Contact">}, xml + assert_match %r{<name>aaron stack</name>}, xml + end + + test "include option with singular association" do + xml = @contact.to_xml include: :address, indent: 0 + assert xml.include?(@contact.address.to_xml(indent: 0, skip_instruct: true)) + end + + test "include option with plural association" do + xml = @contact.to_xml include: :friends, indent: 0 + assert_match %r{<friends type="array">}, xml + assert_match %r{<friend type="Contact">}, xml + end + + class FriendList + def initialize(friends) + @friends = friends + end + + def to_ary + @friends + end + end + + test "include option with ary" do + @contact.friends = FriendList.new(@contact.friends) + xml = @contact.to_xml include: :friends, indent: 0 + assert_match %r{<friends type="array">}, xml + assert_match %r{<friend type="Contact">}, xml + end + + test "multiple includes" do + xml = @contact.to_xml indent: 0, skip_instruct: true, include: [ :address, :friends ] + assert xml.include?(@contact.address.to_xml(indent: 0, skip_instruct: true)) + assert_match %r{<friends type="array">}, xml + assert_match %r{<friend type="Contact">}, xml + end + + test "include with options" do + xml = @contact.to_xml indent: 0, skip_instruct: true, include: { address: { only: :city } } + assert xml.include?(%(><address><city>Springfield</city></address>)) + end + + test "propagates skip_types option to included associations" do + xml = @contact.to_xml include: :friends, indent: 0, skip_types: true + assert_match %r{<friends>}, xml + assert_match %r{<friend>}, xml + end + + test "propagates skip-types option to included associations and attributes" do + xml = @contact.to_xml skip_types: true, include: :address, indent: 0 + assert_match %r{<address>}, xml + assert_match %r{<apt-number>}, xml + end + + test "propagates camelize option to included associations and attributes" do + xml = @contact.to_xml camelize: true, include: :address, indent: 0 + assert_match %r{<Address>}, xml + assert_match %r{<AptNumber type="integer">}, xml + end + + test "propagates dasherize option to included associations and attributes" do + xml = @contact.to_xml dasherize: false, include: :address, indent: 0 + assert_match %r{<apt_number type="integer">}, xml + end + + test "don't propagate skip_types if skip_types is defined at the included association level" do + xml = @contact.to_xml skip_types: true, include: { address: { skip_types: false } }, indent: 0 + assert_match %r{<address>}, xml + assert_match %r{<apt-number type="integer">}, xml + end + + test "don't propagate camelize if camelize is defined at the included association level" do + xml = @contact.to_xml camelize: true, include: { address: { camelize: false } }, indent: 0 + assert_match %r{<address>}, xml + assert_match %r{<apt-number type="integer">}, xml + end + + test "don't propagate dasherize if dasherize is defined at the included association level" do + xml = @contact.to_xml dasherize: false, include: { address: { dasherize: true } }, indent: 0 + assert_match %r{<address>}, xml + assert_match %r{<apt-number type="integer">}, xml + end + + test "association with sti" do + xml = @contact.to_xml(include: :contact) + assert xml.include?(%(<contact type="SerializableContact">)) + end +end diff --git a/activemodel/test/cases/translation_test.rb b/activemodel/test/cases/translation_test.rb new file mode 100644 index 0000000000..cedc812ec7 --- /dev/null +++ b/activemodel/test/cases/translation_test.rb @@ -0,0 +1,108 @@ +require 'cases/helper' +require 'models/person' + +class ActiveModelI18nTests < ActiveModel::TestCase + + def setup + I18n.backend = I18n::Backend::Simple.new + end + + def teardown + I18n.backend.reload! + end + + def test_translated_model_attributes + I18n.backend.store_translations 'en', activemodel: { attributes: { person: { name: 'person name attribute' } } } + assert_equal 'person name attribute', Person.human_attribute_name('name') + end + + def test_translated_model_attributes_with_default + I18n.backend.store_translations 'en', attributes: { name: 'name default attribute' } + assert_equal 'name default attribute', Person.human_attribute_name('name') + end + + def test_translated_model_attributes_using_default_option + assert_equal 'name default attribute', Person.human_attribute_name('name', default: "name default attribute") + end + + def test_translated_model_attributes_using_default_option_as_symbol + I18n.backend.store_translations 'en', default_name: 'name default attribute' + assert_equal 'name default attribute', Person.human_attribute_name('name', default: :default_name) + end + + def test_translated_model_attributes_falling_back_to_default + assert_equal 'Name', Person.human_attribute_name('name') + end + + def test_translated_model_attributes_using_default_option_as_symbol_and_falling_back_to_default + assert_equal 'Name', Person.human_attribute_name('name', default: :default_name) + end + + def test_translated_model_attributes_with_symbols + I18n.backend.store_translations 'en', activemodel: { attributes: { person: { name: 'person name attribute'} } } + assert_equal 'person name attribute', Person.human_attribute_name(:name) + end + + def test_translated_model_attributes_with_ancestor + I18n.backend.store_translations 'en', activemodel: { attributes: { child: { name: 'child name attribute'} } } + assert_equal 'child name attribute', Child.human_attribute_name('name') + end + + def test_translated_model_attributes_with_ancestors_fallback + I18n.backend.store_translations 'en', activemodel: { attributes: { person: { name: 'person name attribute'} } } + assert_equal 'person name attribute', Child.human_attribute_name('name') + end + + def test_translated_model_attributes_with_attribute_matching_namespaced_model_name + I18n.backend.store_translations 'en', activemodel: { attributes: { + person: { gender: 'person gender'}, + :"person/gender" => { attribute: 'person gender attribute' } + } } + + assert_equal 'person gender', Person.human_attribute_name('gender') + assert_equal 'person gender attribute', Person::Gender.human_attribute_name('attribute') + end + + def test_translated_deeply_nested_model_attributes + I18n.backend.store_translations 'en', activemodel: { attributes: { :"person/contacts/addresses" => { street: 'Deeply Nested Address Street' } } } + assert_equal 'Deeply Nested Address Street', Person.human_attribute_name('contacts.addresses.street') + end + + def test_translated_nested_model_attributes + I18n.backend.store_translations 'en', activemodel: { attributes: { :"person/addresses" => { street: 'Person Address Street' } } } + assert_equal 'Person Address Street', Person.human_attribute_name('addresses.street') + end + + def test_translated_nested_model_attributes_with_namespace_fallback + I18n.backend.store_translations 'en', activemodel: { attributes: { addresses: { street: 'Cool Address Street' } } } + assert_equal 'Cool Address Street', Person.human_attribute_name('addresses.street') + end + + def test_translated_model_names + I18n.backend.store_translations 'en', activemodel: { models: { person: 'person model' } } + assert_equal 'person model', Person.model_name.human + end + + def test_translated_model_names_with_sti + I18n.backend.store_translations 'en', activemodel: { models: { child: 'child model' } } + assert_equal 'child model', Child.model_name.human + end + + def test_translated_model_names_with_ancestors_fallback + I18n.backend.store_translations 'en', activemodel: { models: { person: 'person model' } } + assert_equal 'person model', Child.model_name.human + end + + def test_human_does_not_modify_options + options = { default: 'person model' } + Person.model_name.human(options) + assert_equal({ default: 'person model' }, options) + end + + def test_human_attribute_name_does_not_modify_options + options = { default: 'Cool gender' } + Person.human_attribute_name('gender', options) + assert_equal({ default: 'Cool gender' }, options) + end +end + diff --git a/activemodel/test/cases/validations/absence_validation_test.rb b/activemodel/test/cases/validations/absence_validation_test.rb new file mode 100644 index 0000000000..ebfe1cf4e4 --- /dev/null +++ b/activemodel/test/cases/validations/absence_validation_test.rb @@ -0,0 +1,68 @@ +# encoding: utf-8 +require 'cases/helper' +require 'models/topic' +require 'models/person' +require 'models/custom_reader' + +class AbsenceValidationTest < ActiveModel::TestCase + teardown do + Topic.clear_validators! + Person.clear_validators! + CustomReader.clear_validators! + end + + def test_validates_absence_of + Topic.validates_absence_of(:title, :content) + t = Topic.new + t.title = "foo" + t.content = "bar" + assert t.invalid? + assert_equal ["must be blank"], t.errors[:title] + assert_equal ["must be blank"], t.errors[:content] + t.title = "" + t.content = "something" + assert t.invalid? + assert_equal ["must be blank"], t.errors[:content] + assert_equal [], t.errors[:title] + t.content = "" + assert t.valid? + end + + def test_validates_absence_of_with_array_arguments + Topic.validates_absence_of %w(title content) + t = Topic.new + t.title = "foo" + t.content = "bar" + assert t.invalid? + assert_equal ["must be blank"], t.errors[:title] + assert_equal ["must be blank"], t.errors[:content] + end + + def test_validates_absence_of_with_custom_error_using_quotes + Person.validates_absence_of :karma, message: "This string contains 'single' and \"double\" quotes" + p = Person.new + p.karma = "good" + assert p.invalid? + assert_equal "This string contains 'single' and \"double\" quotes", p.errors[:karma].last + end + + def test_validates_absence_of_for_ruby_class + Person.validates_absence_of :karma + p = Person.new + p.karma = "good" + assert p.invalid? + assert_equal ["must be blank"], p.errors[:karma] + p.karma = nil + assert p.valid? + end + + def test_validates_absence_of_for_ruby_class_with_custom_reader + CustomReader.validates_absence_of :karma + p = CustomReader.new + p[:karma] = "excellent" + assert p.invalid? + assert_equal ["must be blank"], p.errors[:karma] + p[:karma] = "" + assert p.valid? + end +end diff --git a/activemodel/test/cases/validations/acceptance_validation_test.rb b/activemodel/test/cases/validations/acceptance_validation_test.rb new file mode 100644 index 0000000000..e78aa1adaf --- /dev/null +++ b/activemodel/test/cases/validations/acceptance_validation_test.rb @@ -0,0 +1,68 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/reply' +require 'models/person' + +class AcceptanceValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + def test_terms_of_service_agreement_no_acceptance + Topic.validates_acceptance_of(:terms_of_service) + + t = Topic.new("title" => "We should not be confirmed") + assert t.valid? + end + + def test_terms_of_service_agreement + Topic.validates_acceptance_of(:terms_of_service) + + t = Topic.new("title" => "We should be confirmed","terms_of_service" => "") + assert t.invalid? + assert_equal ["must be accepted"], t.errors[:terms_of_service] + + t.terms_of_service = "1" + assert t.valid? + end + + def test_eula + Topic.validates_acceptance_of(:eula, message: "must be abided") + + t = Topic.new("title" => "We should be confirmed","eula" => "") + assert t.invalid? + assert_equal ["must be abided"], t.errors[:eula] + + t.eula = "1" + assert t.valid? + end + + def test_terms_of_service_agreement_with_accept_value + Topic.validates_acceptance_of(:terms_of_service, accept: "I agree.") + + t = Topic.new("title" => "We should be confirmed", "terms_of_service" => "") + assert t.invalid? + assert_equal ["must be accepted"], t.errors[:terms_of_service] + + t.terms_of_service = "I agree." + assert t.valid? + end + + def test_validates_acceptance_of_for_ruby_class + Person.validates_acceptance_of :karma + + p = Person.new + p.karma = "" + + assert p.invalid? + assert_equal ["must be accepted"], p.errors[:karma] + + p.karma = "1" + assert p.valid? + ensure + Person.clear_validators! + end +end diff --git a/activemodel/test/cases/validations/callbacks_test.rb b/activemodel/test/cases/validations/callbacks_test.rb new file mode 100644 index 0000000000..6cd0f4ed4d --- /dev/null +++ b/activemodel/test/cases/validations/callbacks_test.rb @@ -0,0 +1,98 @@ +# encoding: utf-8 +require 'cases/helper' + +class Dog + include ActiveModel::Validations + include ActiveModel::Validations::Callbacks + + attr_accessor :name, :history + + def initialize + @history = [] + end +end + +class DogWithMethodCallbacks < Dog + before_validation :set_before_validation_marker + after_validation :set_after_validation_marker + + def set_before_validation_marker; self.history << 'before_validation_marker'; end + def set_after_validation_marker; self.history << 'after_validation_marker' ; end +end + +class DogValidatorsAreProc < Dog + before_validation { self.history << 'before_validation_marker' } + after_validation { self.history << 'after_validation_marker' } +end + +class DogWithTwoValidators < Dog + before_validation { self.history << 'before_validation_marker1' } + before_validation { self.history << 'before_validation_marker2' } +end + +class DogValidatorReturningFalse < Dog + before_validation { false } + before_validation { self.history << 'before_validation_marker2' } +end + +class DogWithMissingName < Dog + before_validation { self.history << 'before_validation_marker' } + validates_presence_of :name +end + +class DogValidatorWithIfCondition < Dog + before_validation :set_before_validation_marker1, if: -> { true } + before_validation :set_before_validation_marker2, if: -> { false } + + after_validation :set_after_validation_marker1, if: -> { true } + after_validation :set_after_validation_marker2, if: -> { false } + + def set_before_validation_marker1; self.history << 'before_validation_marker1'; end + def set_before_validation_marker2; self.history << 'before_validation_marker2' ; end + + def set_after_validation_marker1; self.history << 'after_validation_marker1'; end + def set_after_validation_marker2; self.history << 'after_validation_marker2' ; end +end + + +class CallbacksWithMethodNamesShouldBeCalled < ActiveModel::TestCase + + def test_if_condition_is_respected_for_before_validation + d = DogValidatorWithIfCondition.new + d.valid? + assert_equal ["before_validation_marker1", "after_validation_marker1"], d.history + end + + def test_before_validation_and_after_validation_callbacks_should_be_called + d = DogWithMethodCallbacks.new + d.valid? + assert_equal ['before_validation_marker', 'after_validation_marker'], d.history + end + + def test_before_validation_and_after_validation_callbacks_should_be_called_with_proc + d = DogValidatorsAreProc.new + d.valid? + assert_equal ['before_validation_marker', 'after_validation_marker'], d.history + end + + def test_before_validation_and_after_validation_callbacks_should_be_called_in_declared_order + d = DogWithTwoValidators.new + d.valid? + assert_equal ['before_validation_marker1', 'before_validation_marker2'], d.history + end + + def test_further_callbacks_should_not_be_called_if_before_validation_returns_false + d = DogValidatorReturningFalse.new + output = d.valid? + assert_equal [], d.history + assert_equal false, output + end + + def test_validation_test_should_be_done + d = DogWithMissingName.new + output = d.valid? + assert_equal ['before_validation_marker'], d.history + assert_equal false, output + end + +end diff --git a/activemodel/test/cases/validations/conditional_validation_test.rb b/activemodel/test/cases/validations/conditional_validation_test.rb new file mode 100644 index 0000000000..1261937b56 --- /dev/null +++ b/activemodel/test/cases/validations/conditional_validation_test.rb @@ -0,0 +1,139 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' + +class ConditionalValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + def test_if_validation_using_method_true + # When the method returns true + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: :condition_is_true) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_unless_validation_using_method_true + # When the method returns true + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: :condition_is_true) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.valid? + assert_empty t.errors[:title] + end + + def test_if_validation_using_method_false + # When the method returns false + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: :condition_is_true_but_its_not) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.valid? + assert_empty t.errors[:title] + end + + def test_unless_validation_using_method_false + # When the method returns false + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: :condition_is_true_but_its_not) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_if_validation_using_string_true + # When the evaluated string returns true + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: "a = 1; a == 1") + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_unless_validation_using_string_true + # When the evaluated string returns true + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: "a = 1; a == 1") + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.valid? + assert_empty t.errors[:title] + end + + def test_if_validation_using_string_false + # When the evaluated string returns false + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", if: "false") + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.valid? + assert_empty t.errors[:title] + end + + def test_unless_validation_using_string_false + # When the evaluated string returns false + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", unless: "false") + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_if_validation_using_block_true + # When the block returns true + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", + if: Proc.new { |r| r.content.size > 4 }) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_unless_validation_using_block_true + # When the block returns true + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", + unless: Proc.new { |r| r.content.size > 4 }) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.valid? + assert_empty t.errors[:title] + end + + def test_if_validation_using_block_false + # When the block returns false + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", + if: Proc.new { |r| r.title != "uhohuhoh"}) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.valid? + assert_empty t.errors[:title] + end + + def test_unless_validation_using_block_false + # When the block returns false + Topic.validates_length_of(:title, maximum: 5, too_long: "hoo %{count}", + unless: Proc.new { |r| r.title != "uhohuhoh"} ) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + # previous implementation of validates_presence_of eval'd the + # string with the wrong binding, this regression test is to + # ensure that it works correctly + def test_validation_with_if_as_string + Topic.validates_presence_of(:title) + Topic.validates_presence_of(:author_name, if: "title.to_s.match('important')") + + t = Topic.new + assert t.invalid?, "A topic without a title should not be valid" + assert_empty t.errors[:author_name], "A topic without an 'important' title should not require an author" + + t.title = "Just a title" + assert t.valid?, "A topic with a basic title should be valid" + + t.title = "A very important title" + assert t.invalid?, "A topic with an important title, but without an author, should not be valid" + assert t.errors[:author_name].any?, "A topic with an 'important' title should require an author" + + t.author_name = "Hubert J. Farnsworth" + assert t.valid?, "A topic with an important title and author should be valid" + end +end diff --git a/activemodel/test/cases/validations/confirmation_validation_test.rb b/activemodel/test/cases/validations/confirmation_validation_test.rb new file mode 100644 index 0000000000..65a2a1eb49 --- /dev/null +++ b/activemodel/test/cases/validations/confirmation_validation_test.rb @@ -0,0 +1,108 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/person' + +class ConfirmationValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + def test_no_title_confirmation + Topic.validates_confirmation_of(:title) + + t = Topic.new(author_name: "Plutarch") + assert t.valid? + + t.title_confirmation = "Parallel Lives" + assert t.invalid? + + t.title_confirmation = nil + t.title = "Parallel Lives" + assert t.valid? + + t.title_confirmation = "Parallel Lives" + assert t.valid? + end + + def test_title_confirmation + Topic.validates_confirmation_of(:title) + + t = Topic.new("title" => "We should be confirmed","title_confirmation" => "") + assert t.invalid? + + t.title_confirmation = "We should be confirmed" + assert t.valid? + end + + def test_validates_confirmation_of_for_ruby_class + Person.validates_confirmation_of :karma + + p = Person.new + p.karma_confirmation = "None" + assert p.invalid? + + assert_equal ["doesn't match Karma"], p.errors[:karma_confirmation] + + p.karma = "None" + assert p.valid? + ensure + Person.clear_validators! + end + + def test_title_confirmation_with_i18n_attribute + begin + @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend + I18n.load_path.clear + I18n.backend = I18n::Backend::Simple.new + I18n.backend.store_translations('en', { + errors: { messages: { confirmation: "doesn't match %{attribute}" } }, + activemodel: { attributes: { topic: { title: 'Test Title'} } } + }) + + Topic.validates_confirmation_of(:title) + + t = Topic.new("title" => "We should be confirmed","title_confirmation" => "") + assert t.invalid? + assert_equal ["doesn't match Test Title"], t.errors[:title_confirmation] + ensure + I18n.load_path.replace @old_load_path + I18n.backend = @old_backend + I18n.backend.reload! + end + end + + test "does not override confirmation reader if present" do + klass = Class.new do + include ActiveModel::Validations + + def title_confirmation + "expected title" + end + + validates_confirmation_of :title + end + + assert_equal "expected title", klass.new.title_confirmation, + "confirmation validation should not override the reader" + end + + test "does not override confirmation writer if present" do + klass = Class.new do + include ActiveModel::Validations + + def title_confirmation=(value) + @title_confirmation = "expected title" + end + + validates_confirmation_of :title + end + + model = klass.new + model.title_confirmation = "new title" + assert_equal "expected title", model.title_confirmation, + "confirmation validation should not override the writer" + end +end diff --git a/activemodel/test/cases/validations/exclusion_validation_test.rb b/activemodel/test/cases/validations/exclusion_validation_test.rb new file mode 100644 index 0000000000..1ce41f9bc9 --- /dev/null +++ b/activemodel/test/cases/validations/exclusion_validation_test.rb @@ -0,0 +1,92 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/person' + +class ExclusionValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + def test_validates_exclusion_of + Topic.validates_exclusion_of(:title, in: %w( abe monkey )) + + assert Topic.new("title" => "something", "content" => "abc").valid? + assert Topic.new("title" => "monkey", "content" => "abc").invalid? + end + + def test_validates_exclusion_of_with_formatted_message + Topic.validates_exclusion_of(:title, in: %w( abe monkey ), message: "option %{value} is restricted") + + assert Topic.new("title" => "something", "content" => "abc") + + t = Topic.new("title" => "monkey") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["option monkey is restricted"], t.errors[:title] + end + + def test_validates_exclusion_of_with_within_option + Topic.validates_exclusion_of(:title, within: %w( abe monkey )) + + assert Topic.new("title" => "something", "content" => "abc") + + t = Topic.new("title" => "monkey") + assert t.invalid? + assert t.errors[:title].any? + end + + def test_validates_exclusion_of_for_ruby_class + Person.validates_exclusion_of :karma, in: %w( abe monkey ) + + p = Person.new + p.karma = "abe" + assert p.invalid? + + assert_equal ["is reserved"], p.errors[:karma] + + p.karma = "Lifo" + assert p.valid? + ensure + Person.clear_validators! + end + + def test_validates_exclusion_of_with_lambda + Topic.validates_exclusion_of :title, in: lambda { |topic| topic.author_name == "sikachu" ? %w( monkey elephant ) : %w( abe wasabi ) } + + t = Topic.new + t.title = "elephant" + t.author_name = "sikachu" + assert t.invalid? + + t.title = "wasabi" + assert t.valid? + end + + def test_validates_inclusion_of_with_symbol + Person.validates_exclusion_of :karma, in: :reserved_karmas + + p = Person.new + p.karma = "abe" + + def p.reserved_karmas + %w(abe) + end + + assert p.invalid? + assert_equal ["is reserved"], p.errors[:karma] + + p = Person.new + p.karma = "abe" + + def p.reserved_karmas + %w() + end + + assert p.valid? + ensure + Person.clear_validators! + end +end diff --git a/activemodel/test/cases/validations/format_validation_test.rb b/activemodel/test/cases/validations/format_validation_test.rb new file mode 100644 index 0000000000..0f91b73cd7 --- /dev/null +++ b/activemodel/test/cases/validations/format_validation_test.rb @@ -0,0 +1,149 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/person' + +class PresenceValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + def test_validate_format + Topic.validates_format_of(:title, :content, with: /\AValidation\smacros \w+!\z/, message: "is bad data") + + t = Topic.new("title" => "i'm incorrect", "content" => "Validation macros rule!") + assert t.invalid?, "Shouldn't be valid" + assert_equal ["is bad data"], t.errors[:title] + assert t.errors[:content].empty? + + t.title = "Validation macros rule!" + + assert t.valid? + assert t.errors[:title].empty? + + assert_raise(ArgumentError) { Topic.validates_format_of(:title, :content) } + end + + def test_validate_format_with_allow_blank + Topic.validates_format_of(:title, with: /\AValidation\smacros \w+!\z/, allow_blank: true) + assert Topic.new("title" => "Shouldn't be valid").invalid? + assert Topic.new("title" => "").valid? + assert Topic.new("title" => nil).valid? + assert Topic.new("title" => "Validation macros rule!").valid? + end + + # testing ticket #3142 + def test_validate_format_numeric + Topic.validates_format_of(:title, :content, with: /\A[1-9][0-9]*\z/, message: "is bad data") + + t = Topic.new("title" => "72x", "content" => "6789") + assert t.invalid?, "Shouldn't be valid" + + assert_equal ["is bad data"], t.errors[:title] + assert t.errors[:content].empty? + + t.title = "-11" + assert t.invalid?, "Shouldn't be valid" + + t.title = "03" + assert t.invalid?, "Shouldn't be valid" + + t.title = "z44" + assert t.invalid?, "Shouldn't be valid" + + t.title = "5v7" + assert t.invalid?, "Shouldn't be valid" + + t.title = "1" + + assert t.valid? + assert t.errors[:title].empty? + end + + def test_validate_format_with_formatted_message + Topic.validates_format_of(:title, with: /\AValid Title\z/, message: "can't be %{value}") + t = Topic.new(title: 'Invalid title') + assert t.invalid? + assert_equal ["can't be Invalid title"], t.errors[:title] + end + + def test_validate_format_of_with_multiline_regexp_should_raise_error + assert_raise(ArgumentError) { Topic.validates_format_of(:title, with: /^Valid Title$/) } + end + + def test_validate_format_of_with_multiline_regexp_and_option + assert_nothing_raised(ArgumentError) do + Topic.validates_format_of(:title, with: /^Valid Title$/, multiline: true) + end + 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_lambda + Topic.validates_format_of :content, with: lambda { |topic| topic.title == "digit" ? /\A\d+\Z/ : /\A\S+\Z/ } + + t = Topic.new + t.title = "digit" + t.content = "Pixies" + assert t.invalid? + + t.content = "1234" + assert t.valid? + end + + def test_validates_format_of_without_lambda + Topic.validates_format_of :content, without: lambda { |topic| topic.title == "characters" ? /\A\d+\Z/ : /\A\S+\Z/ } + + t = Topic.new + t.title = "characters" + t.content = "1234" + assert t.invalid? + + t.content = "Pixies" + assert t.valid? + end + + def test_validates_format_of_for_ruby_class + Person.validates_format_of :karma, with: /\A\d+\Z/ + + p = Person.new + p.karma = "Pixies" + assert p.invalid? + + assert_equal ["is invalid"], p.errors[:karma] + + p.karma = "1234" + assert p.valid? + ensure + Person.clear_validators! + end +end diff --git a/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb b/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb new file mode 100644 index 0000000000..3eeb80a48b --- /dev/null +++ b/activemodel/test/cases/validations/i18n_generate_message_validation_test.rb @@ -0,0 +1,150 @@ +require "cases/helper" + +require 'models/person' + +class I18nGenerateMessageValidationTest < ActiveModel::TestCase + def setup + Person.clear_validators! + @person = Person.new + end + + # validates_inclusion_of: generate_message(attr_name, :inclusion, message: custom_message, value: value) + def test_generate_message_inclusion_with_default_message + assert_equal 'is not included in the list', @person.errors.generate_message(:title, :inclusion, value: 'title') + end + + def test_generate_message_inclusion_with_custom_message + assert_equal 'custom message title', @person.errors.generate_message(:title, :inclusion, message: 'custom message %{value}', value: 'title') + end + + # validates_exclusion_of: generate_message(attr_name, :exclusion, message: custom_message, value: value) + def test_generate_message_exclusion_with_default_message + assert_equal 'is reserved', @person.errors.generate_message(:title, :exclusion, value: 'title') + end + + def test_generate_message_exclusion_with_custom_message + assert_equal 'custom message title', @person.errors.generate_message(:title, :exclusion, message: 'custom message %{value}', value: 'title') + end + + # validates_format_of: generate_message(attr_name, :invalid, message: custom_message, value: value) + def test_generate_message_invalid_with_default_message + assert_equal 'is invalid', @person.errors.generate_message(:title, :invalid, value: 'title') + end + + def test_generate_message_invalid_with_custom_message + assert_equal 'custom message title', @person.errors.generate_message(:title, :invalid, message: 'custom message %{value}', value: 'title') + end + + # validates_confirmation_of: generate_message(attr_name, :confirmation, message: custom_message) + def test_generate_message_confirmation_with_default_message + assert_equal "doesn't match Title", @person.errors.generate_message(:title, :confirmation) + end + + def test_generate_message_confirmation_with_custom_message + assert_equal 'custom message', @person.errors.generate_message(:title, :confirmation, message: 'custom message') + end + + # validates_acceptance_of: generate_message(attr_name, :accepted, message: custom_message) + def test_generate_message_accepted_with_default_message + assert_equal "must be accepted", @person.errors.generate_message(:title, :accepted) + end + + def test_generate_message_accepted_with_custom_message + assert_equal 'custom message', @person.errors.generate_message(:title, :accepted, message: 'custom message') + end + + # add_on_empty: generate_message(attr, :empty, message: custom_message) + def test_generate_message_empty_with_default_message + assert_equal "can't be empty", @person.errors.generate_message(:title, :empty) + end + + def test_generate_message_empty_with_custom_message + assert_equal 'custom message', @person.errors.generate_message(:title, :empty, message: 'custom message') + end + + # add_on_blank: generate_message(attr, :blank, message: custom_message) + def test_generate_message_blank_with_default_message + assert_equal "can't be blank", @person.errors.generate_message(:title, :blank) + end + + def test_generate_message_blank_with_custom_message + assert_equal 'custom message', @person.errors.generate_message(:title, :blank, message: 'custom message') + end + + # validates_length_of: generate_message(attr, :too_long, message: custom_message, count: option_value.end) + def test_generate_message_too_long_with_default_message_plural + assert_equal "is too long (maximum is 10 characters)", @person.errors.generate_message(:title, :too_long, count: 10) + end + + def test_generate_message_too_long_with_default_message_singular + assert_equal "is too long (maximum is 1 character)", @person.errors.generate_message(:title, :too_long, count: 1) + end + + def test_generate_message_too_long_with_custom_message + assert_equal 'custom message 10', @person.errors.generate_message(:title, :too_long, message: 'custom message %{count}', count: 10) + end + + # validates_length_of: generate_message(attr, :too_short, default: custom_message, count: option_value.begin) + def test_generate_message_too_short_with_default_message_plural + assert_equal "is too short (minimum is 10 characters)", @person.errors.generate_message(:title, :too_short, count: 10) + end + + def test_generate_message_too_short_with_default_message_singular + assert_equal "is too short (minimum is 1 character)", @person.errors.generate_message(:title, :too_short, count: 1) + end + + def test_generate_message_too_short_with_custom_message + assert_equal 'custom message 10', @person.errors.generate_message(:title, :too_short, message: 'custom message %{count}', count: 10) + end + + # validates_length_of: generate_message(attr, :wrong_length, message: custom_message, count: option_value) + def test_generate_message_wrong_length_with_default_message_plural + assert_equal "is the wrong length (should be 10 characters)", @person.errors.generate_message(:title, :wrong_length, count: 10) + end + + def test_generate_message_wrong_length_with_default_message_singular + assert_equal "is the wrong length (should be 1 character)", @person.errors.generate_message(:title, :wrong_length, count: 1) + end + + def test_generate_message_wrong_length_with_custom_message + assert_equal 'custom message 10', @person.errors.generate_message(:title, :wrong_length, message: 'custom message %{count}', count: 10) + end + + # validates_numericality_of: generate_message(attr_name, :not_a_number, value: raw_value, message: custom_message) + def test_generate_message_not_a_number_with_default_message + assert_equal "is not a number", @person.errors.generate_message(:title, :not_a_number, value: 'title') + end + + def test_generate_message_not_a_number_with_custom_message + assert_equal 'custom message title', @person.errors.generate_message(:title, :not_a_number, message: 'custom message %{value}', value: 'title') + end + + # validates_numericality_of: generate_message(attr_name, option, value: raw_value, default: custom_message) + def test_generate_message_greater_than_with_default_message + assert_equal "must be greater than 10", @person.errors.generate_message(:title, :greater_than, value: 'title', count: 10) + end + + def test_generate_message_greater_than_or_equal_to_with_default_message + assert_equal "must be greater than or equal to 10", @person.errors.generate_message(:title, :greater_than_or_equal_to, value: 'title', count: 10) + end + + def test_generate_message_equal_to_with_default_message + assert_equal "must be equal to 10", @person.errors.generate_message(:title, :equal_to, value: 'title', count: 10) + end + + def test_generate_message_less_than_with_default_message + assert_equal "must be less than 10", @person.errors.generate_message(:title, :less_than, value: 'title', count: 10) + end + + def test_generate_message_less_than_or_equal_to_with_default_message + assert_equal "must be less than or equal to 10", @person.errors.generate_message(:title, :less_than_or_equal_to, value: 'title', count: 10) + end + + def test_generate_message_odd_with_default_message + assert_equal "must be odd", @person.errors.generate_message(:title, :odd, value: 'title', count: 10) + end + + def test_generate_message_even_with_default_message + assert_equal "must be even", @person.errors.generate_message(:title, :even, value: 'title', count: 10) + end +end diff --git a/activemodel/test/cases/validations/i18n_validation_test.rb b/activemodel/test/cases/validations/i18n_validation_test.rb new file mode 100644 index 0000000000..96084a32ba --- /dev/null +++ b/activemodel/test/cases/validations/i18n_validation_test.rb @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- + +require "cases/helper" +require 'models/person' + +class I18nValidationTest < ActiveModel::TestCase + + def setup + Person.clear_validators! + @person = Person.new + + @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend + I18n.load_path.clear + I18n.backend = I18n::Backend::Simple.new + I18n.backend.store_translations('en', errors: { messages: { custom: nil } }) + end + + def teardown + Person.clear_validators! + I18n.load_path.replace @old_load_path + I18n.backend = @old_backend + I18n.backend.reload! + end + + def test_full_message_encoding + I18n.backend.store_translations('en', errors: { + messages: { too_short: '猫舌' } }) + Person.validates_length_of :title, within: 3..5 + @person.valid? + assert_equal ['Title 猫舌'], @person.errors.full_messages + end + + def test_errors_full_messages_translates_human_attribute_name_for_model_attributes + @person.errors.add(:name, 'not found') + Person.expects(:human_attribute_name).with(:name, default: 'Name').returns("Person's name") + assert_equal ["Person's name not found"], @person.errors.full_messages + end + + def test_errors_full_messages_uses_format + I18n.backend.store_translations('en', errors: { format: "Field %{attribute} %{message}" }) + @person.errors.add('name', 'empty') + assert_equal ["Field Name empty"], @person.errors.full_messages + end + + # ActiveModel::Validations + + # A set of common cases for ActiveModel::Validations message generation that + # are used to generate tests to keep things DRY + # + COMMON_CASES = [ + # [ case, validation_options, generate_message_options] + [ "given no options", {}, {}], + [ "given custom message", { message: "custom" }, { message: "custom" }], + [ "given if condition", { if: lambda { true }}, {}], + [ "given unless condition", { unless: lambda { false }}, {}], + [ "given option that is not reserved", { format: "jpg" }, { format: "jpg" }] + ] + + # validates_confirmation_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_confirmation_of on generated message #{name}" do + Person.validates_confirmation_of :title, validation_options + @person.title_confirmation = 'foo' + @person.errors.expects(:generate_message).with(:title_confirmation, :confirmation, generate_message_options.merge(attribute: 'Title')) + @person.valid? + end + end + + # validates_acceptance_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_acceptance_of on generated message #{name}" do + Person.validates_acceptance_of :title, validation_options.merge(allow_nil: false) + @person.errors.expects(:generate_message).with(:title, :accepted, generate_message_options) + @person.valid? + end + end + + # validates_presence_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_presence_of on generated message #{name}" do + Person.validates_presence_of :title, validation_options + @person.errors.expects(:generate_message).with(:title, :blank, generate_message_options) + @person.valid? + end + end + + # validates_length_of :within too short w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_length_of for :withing on generated message when too short #{name}" do + Person.validates_length_of :title, validation_options.merge(within: 3..5) + @person.errors.expects(:generate_message).with(:title, :too_short, generate_message_options.merge(count: 3)) + @person.valid? + end + end + + # validates_length_of :within too long w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_length_of for :too_long generated message #{name}" do + Person.validates_length_of :title, validation_options.merge(within: 3..5) + @person.title = 'this title is too long' + @person.errors.expects(:generate_message).with(:title, :too_long, generate_message_options.merge(count: 5)) + @person.valid? + end + end + + # validates_length_of :is w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_length_of for :is on generated message #{name}" do + Person.validates_length_of :title, validation_options.merge(is: 5) + @person.errors.expects(:generate_message).with(:title, :wrong_length, generate_message_options.merge(count: 5)) + @person.valid? + end + end + + # validates_format_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_format_of on generated message #{name}" do + Person.validates_format_of :title, validation_options.merge(with: /\A[1-9][0-9]*\z/) + @person.title = '72x' + @person.errors.expects(:generate_message).with(:title, :invalid, generate_message_options.merge(value: '72x')) + @person.valid? + end + end + + # validates_inclusion_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_inclusion_of on generated message #{name}" do + Person.validates_inclusion_of :title, validation_options.merge(in: %w(a b c)) + @person.title = 'z' + @person.errors.expects(:generate_message).with(:title, :inclusion, generate_message_options.merge(value: 'z')) + @person.valid? + end + end + + # validates_inclusion_of using :within w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_inclusion_of using :within on generated message #{name}" do + Person.validates_inclusion_of :title, validation_options.merge(within: %w(a b c)) + @person.title = 'z' + @person.errors.expects(:generate_message).with(:title, :inclusion, generate_message_options.merge(value: 'z')) + @person.valid? + end + end + + # validates_exclusion_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_exclusion_of generated message #{name}" do + Person.validates_exclusion_of :title, validation_options.merge(in: %w(a b c)) + @person.title = 'a' + @person.errors.expects(:generate_message).with(:title, :exclusion, generate_message_options.merge(value: 'a')) + @person.valid? + end + end + + # validates_exclusion_of using :within w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_exclusion_of using :within generated message #{name}" do + Person.validates_exclusion_of :title, validation_options.merge(within: %w(a b c)) + @person.title = 'a' + @person.errors.expects(:generate_message).with(:title, :exclusion, generate_message_options.merge(value: 'a')) + @person.valid? + end + end + + # validates_numericality_of without :only_integer w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_numericality_of generated message #{name}" do + Person.validates_numericality_of :title, validation_options + @person.title = 'a' + @person.errors.expects(:generate_message).with(:title, :not_a_number, generate_message_options.merge(value: 'a')) + @person.valid? + end + end + + # validates_numericality_of with :only_integer w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_numericality_of for :only_integer on generated message #{name}" do + Person.validates_numericality_of :title, validation_options.merge(only_integer: true) + @person.title = '0.0' + @person.errors.expects(:generate_message).with(:title, :not_an_integer, generate_message_options.merge(value: '0.0')) + @person.valid? + end + end + + # validates_numericality_of :odd w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_numericality_of for :odd on generated message #{name}" do + Person.validates_numericality_of :title, validation_options.merge(only_integer: true, odd: true) + @person.title = 0 + @person.errors.expects(:generate_message).with(:title, :odd, generate_message_options.merge(value: 0)) + @person.valid? + end + end + + # validates_numericality_of :less_than w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_numericality_of for :less_than on generated message #{name}" do + Person.validates_numericality_of :title, validation_options.merge(only_integer: true, less_than: 0) + @person.title = 1 + @person.errors.expects(:generate_message).with(:title, :less_than, generate_message_options.merge(value: 1, count: 0)) + @person.valid? + end + end + + + # To make things DRY this macro is defined to define 3 tests for every validation case. + def self.set_expectations_for_validation(validation, error_type, &block_that_sets_validation) + if error_type == :confirmation + attribute = :title_confirmation + else + attribute = :title + end + # test "validates_confirmation_of finds custom model key translation when blank" + test "#{validation} finds custom model key translation when #{error_type}" do + I18n.backend.store_translations 'en', activemodel: { errors: { models: { person: { attributes: { attribute => { error_type => 'custom message' } } } } } } + I18n.backend.store_translations 'en', errors: { messages: { error_type => 'global message'}} + + yield(@person, {}) + @person.valid? + assert_equal ['custom message'], @person.errors[attribute] + end + + # test "validates_confirmation_of finds custom model key translation with interpolation when blank" + test "#{validation} finds custom model key translation with interpolation when #{error_type}" do + I18n.backend.store_translations 'en', activemodel: { errors: { models: { person: { attributes: { attribute => { error_type => 'custom message with %{extra}' } } } } } } + I18n.backend.store_translations 'en', errors: { messages: {error_type => 'global message'} } + + yield(@person, { extra: "extra information" }) + @person.valid? + assert_equal ['custom message with extra information'], @person.errors[attribute] + end + + # test "validates_confirmation_of finds global default key translation when blank" + test "#{validation} finds global default key translation when #{error_type}" do + I18n.backend.store_translations 'en', errors: { messages: {error_type => 'global message'} } + + yield(@person, {}) + @person.valid? + assert_equal ['global message'], @person.errors[attribute] + end + end + + # validates_confirmation_of w/o mocha + + set_expectations_for_validation "validates_confirmation_of", :confirmation do |person, options_to_merge| + Person.validates_confirmation_of :title, options_to_merge + person.title_confirmation = 'foo' + end + + # validates_acceptance_of w/o mocha + + set_expectations_for_validation "validates_acceptance_of", :accepted do |person, options_to_merge| + Person.validates_acceptance_of :title, options_to_merge.merge(allow_nil: false) + end + + # validates_presence_of w/o mocha + + set_expectations_for_validation "validates_presence_of", :blank do |person, options_to_merge| + Person.validates_presence_of :title, options_to_merge + end + + # validates_length_of :within w/o mocha + + set_expectations_for_validation "validates_length_of", :too_short do |person, options_to_merge| + Person.validates_length_of :title, options_to_merge.merge(within: 3..5) + end + + set_expectations_for_validation "validates_length_of", :too_long do |person, options_to_merge| + Person.validates_length_of :title, options_to_merge.merge(within: 3..5) + person.title = "too long" + end + + # validates_length_of :is w/o mocha + + set_expectations_for_validation "validates_length_of", :wrong_length do |person, options_to_merge| + Person.validates_length_of :title, options_to_merge.merge(is: 5) + end + + # validates_format_of w/o mocha + + set_expectations_for_validation "validates_format_of", :invalid do |person, options_to_merge| + Person.validates_format_of :title, options_to_merge.merge(with: /\A[1-9][0-9]*\z/) + end + + # validates_inclusion_of w/o mocha + + set_expectations_for_validation "validates_inclusion_of", :inclusion do |person, options_to_merge| + Person.validates_inclusion_of :title, options_to_merge.merge(in: %w(a b c)) + end + + # validates_exclusion_of w/o mocha + + set_expectations_for_validation "validates_exclusion_of", :exclusion do |person, options_to_merge| + Person.validates_exclusion_of :title, options_to_merge.merge(in: %w(a b c)) + person.title = 'a' + end + + # validates_numericality_of without :only_integer w/o mocha + + set_expectations_for_validation "validates_numericality_of", :not_a_number do |person, options_to_merge| + Person.validates_numericality_of :title, options_to_merge + person.title = 'a' + end + + # validates_numericality_of with :only_integer w/o mocha + + set_expectations_for_validation "validates_numericality_of", :not_an_integer do |person, options_to_merge| + Person.validates_numericality_of :title, options_to_merge.merge(only_integer: true) + person.title = '1.0' + end + + # validates_numericality_of :odd w/o mocha + + set_expectations_for_validation "validates_numericality_of", :odd do |person, options_to_merge| + Person.validates_numericality_of :title, options_to_merge.merge(only_integer: true, odd: true) + person.title = 0 + end + + # validates_numericality_of :less_than w/o mocha + + set_expectations_for_validation "validates_numericality_of", :less_than do |person, options_to_merge| + Person.validates_numericality_of :title, options_to_merge.merge(only_integer: true, less_than: 0) + person.title = 1 + end + + # test with validates_with + + def test_validations_with_message_symbol_must_translate + I18n.backend.store_translations 'en', errors: { messages: { custom_error: "I am a custom error" } } + Person.validates_presence_of :title, message: :custom_error + @person.title = nil + @person.valid? + assert_equal ["I am a custom error"], @person.errors[:title] + end + + def test_validates_with_message_symbol_must_translate_per_attribute + I18n.backend.store_translations 'en', activemodel: { errors: { models: { person: { attributes: { title: { custom_error: "I am a custom error" } } } } } } + Person.validates_presence_of :title, message: :custom_error + @person.title = nil + @person.valid? + assert_equal ["I am a custom error"], @person.errors[:title] + end + + def test_validates_with_message_symbol_must_translate_per_model + I18n.backend.store_translations 'en', activemodel: { errors: { models: { person: { custom_error: "I am a custom error" } } } } + Person.validates_presence_of :title, message: :custom_error + @person.title = nil + @person.valid? + assert_equal ["I am a custom error"], @person.errors[:title] + end + + def test_validates_with_message_string + Person.validates_presence_of :title, message: "I am a custom error" + @person.title = nil + @person.valid? + assert_equal ["I am a custom error"], @person.errors[:title] + end +end diff --git a/activemodel/test/cases/validations/inclusion_validation_test.rb b/activemodel/test/cases/validations/inclusion_validation_test.rb new file mode 100644 index 0000000000..3a8f3080e1 --- /dev/null +++ b/activemodel/test/cases/validations/inclusion_validation_test.rb @@ -0,0 +1,147 @@ +# encoding: utf-8 +require 'cases/helper' +require 'active_support/all' + +require 'models/topic' +require 'models/person' + +class InclusionValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + def test_validates_inclusion_of_range + Topic.validates_inclusion_of(:title, in: 'aaa'..'bbb') + assert Topic.new("title" => "bbc", "content" => "abc").invalid? + assert Topic.new("title" => "aa", "content" => "abc").invalid? + assert Topic.new("title" => "aaab", "content" => "abc").invalid? + assert Topic.new("title" => "aaa", "content" => "abc").valid? + assert Topic.new("title" => "abc", "content" => "abc").valid? + assert Topic.new("title" => "bbb", "content" => "abc").valid? + end + + def test_validates_inclusion_of_time_range + Topic.validates_inclusion_of(:created_at, in: 1.year.ago..Time.now) + assert Topic.new(title: 'aaa', created_at: 2.years.ago).invalid? + assert Topic.new(title: 'aaa', created_at: 3.months.ago).valid? + assert Topic.new(title: 'aaa', created_at: 37.weeks.from_now).invalid? + end + + def test_validates_inclusion_of_date_range + Topic.validates_inclusion_of(:created_at, in: 1.year.until(Date.today)..Date.today) + assert Topic.new(title: 'aaa', created_at: 2.years.until(Date.today)).invalid? + assert Topic.new(title: 'aaa', created_at: 3.months.until(Date.today)).valid? + assert Topic.new(title: 'aaa', created_at: 37.weeks.since(Date.today)).invalid? + end + + def test_validates_inclusion_of_date_time_range + Topic.validates_inclusion_of(:created_at, in: 1.year.until(DateTime.current)..DateTime.current) + assert Topic.new(title: 'aaa', created_at: 2.years.until(DateTime.current)).invalid? + assert Topic.new(title: 'aaa', created_at: 3.months.until(DateTime.current)).valid? + assert Topic.new(title: 'aaa', created_at: 37.weeks.since(DateTime.current)).invalid? + end + + def test_validates_inclusion_of + Topic.validates_inclusion_of(:title, in: %w( a b c d e f g )) + + assert Topic.new("title" => "a!", "content" => "abc").invalid? + assert Topic.new("title" => "a b", "content" => "abc").invalid? + assert Topic.new("title" => nil, "content" => "def").invalid? + + t = Topic.new("title" => "a", "content" => "I know you are but what am I?") + assert t.valid? + t.title = "uhoh" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is not included in the list"], t.errors[:title] + + assert_raise(ArgumentError) { Topic.validates_inclusion_of(:title, in: nil) } + assert_raise(ArgumentError) { Topic.validates_inclusion_of(:title, in: 0) } + + assert_nothing_raised(ArgumentError) { Topic.validates_inclusion_of(:title, in: "hi!") } + assert_nothing_raised(ArgumentError) { Topic.validates_inclusion_of(:title, in: {}) } + assert_nothing_raised(ArgumentError) { Topic.validates_inclusion_of(:title, in: []) } + end + + def test_validates_inclusion_of_with_allow_nil + Topic.validates_inclusion_of(:title, in: %w( a b c d e f g ), allow_nil: true) + + assert Topic.new("title" => "a!", "content" => "abc").invalid? + assert Topic.new("title" => "", "content" => "abc").invalid? + assert Topic.new("title" => nil, "content" => "abc").valid? + end + + def test_validates_inclusion_of_with_formatted_message + Topic.validates_inclusion_of(:title, in: %w( a b c d e f g ), message: "option %{value} is not in the list") + + assert Topic.new("title" => "a", "content" => "abc").valid? + + t = Topic.new("title" => "uhoh", "content" => "abc") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["option uhoh is not in the list"], t.errors[:title] + end + + def test_validates_inclusion_of_with_within_option + Topic.validates_inclusion_of(:title, within: %w( a b c d e f g )) + + assert Topic.new("title" => "a", "content" => "abc").valid? + + t = Topic.new("title" => "uhoh", "content" => "abc") + assert t.invalid? + assert t.errors[:title].any? + end + + def test_validates_inclusion_of_for_ruby_class + Person.validates_inclusion_of :karma, in: %w( abe monkey ) + + p = Person.new + p.karma = "Lifo" + assert p.invalid? + + assert_equal ["is not included in the list"], p.errors[:karma] + + p.karma = "monkey" + assert p.valid? + ensure + Person.clear_validators! + end + + def test_validates_inclusion_of_with_lambda + Topic.validates_inclusion_of :title, in: lambda{ |topic| topic.author_name == "sikachu" ? %w( monkey elephant ) : %w( abe wasabi ) } + + t = Topic.new + t.title = "wasabi" + t.author_name = "sikachu" + assert t.invalid? + + t.title = "elephant" + assert t.valid? + end + + def test_validates_inclusion_of_with_symbol + Person.validates_inclusion_of :karma, in: :available_karmas + + p = Person.new + p.karma = "Lifo" + + def p.available_karmas + %w() + end + + assert p.invalid? + assert_equal ["is not included in the list"], p.errors[:karma] + + p = Person.new + p.karma = "Lifo" + + def p.available_karmas + %w(Lifo) + end + + assert p.valid? + ensure + Person.clear_validators! + end +end diff --git a/activemodel/test/cases/validations/length_validation_test.rb b/activemodel/test/cases/validations/length_validation_test.rb new file mode 100644 index 0000000000..046ffcb16f --- /dev/null +++ b/activemodel/test/cases/validations/length_validation_test.rb @@ -0,0 +1,424 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/person' + +class LengthValidationTest < ActiveModel::TestCase + def teardown + Topic.clear_validators! + end + + def test_validates_length_of_with_allow_nil + Topic.validates_length_of( :title, is: 5, allow_nil: true ) + + assert Topic.new("title" => "ab").invalid? + assert Topic.new("title" => "").invalid? + assert Topic.new("title" => nil).valid? + assert Topic.new("title" => "abcde").valid? + end + + def test_validates_length_of_with_allow_blank + Topic.validates_length_of( :title, is: 5, allow_blank: true ) + + assert Topic.new("title" => "ab").invalid? + assert Topic.new("title" => "").valid? + assert Topic.new("title" => nil).valid? + assert Topic.new("title" => "abcde").valid? + end + + def test_validates_length_of_using_minimum + Topic.validates_length_of :title, minimum: 5 + + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.valid? + + t.title = "not" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is too short (minimum is 5 characters)"], t.errors[:title] + + t.title = "" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is too short (minimum is 5 characters)"], t.errors[:title] + + t.title = nil + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is too short (minimum is 5 characters)"], t.errors["title"] + end + + def test_validates_length_of_using_maximum_should_allow_nil + Topic.validates_length_of :title, maximum: 10 + t = Topic.new + assert t.valid? + end + + def test_optionally_validates_length_of_using_minimum + Topic.validates_length_of :title, minimum: 5, allow_nil: true + + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.valid? + + t.title = nil + assert t.valid? + end + + def test_validates_length_of_using_maximum + Topic.validates_length_of :title, maximum: 5 + + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.valid? + + t.title = "notvalid" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is too long (maximum is 5 characters)"], t.errors[:title] + + t.title = "" + assert t.valid? + end + + def test_optionally_validates_length_of_using_maximum + Topic.validates_length_of :title, maximum: 5, allow_nil: true + + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.valid? + + t.title = nil + assert t.valid? + end + + def test_validates_length_of_using_within + Topic.validates_length_of(:title, :content, within: 3..5) + + t = Topic.new("title" => "a!", "content" => "I'm ooooooooh so very long") + assert t.invalid? + assert_equal ["is too short (minimum is 3 characters)"], t.errors[:title] + assert_equal ["is too long (maximum is 5 characters)"], t.errors[:content] + + t.title = nil + t.content = nil + assert t.invalid? + assert_equal ["is too short (minimum is 3 characters)"], t.errors[:title] + assert_equal ["is too short (minimum is 3 characters)"], t.errors[:content] + + t.title = "abe" + t.content = "mad" + assert t.valid? + end + + def test_validates_length_of_using_within_with_exclusive_range + Topic.validates_length_of(:title, within: 4...10) + + t = Topic.new("title" => "9 chars!!") + assert t.valid? + + t.title = "Now I'm 10" + assert t.invalid? + assert_equal ["is too long (maximum is 9 characters)"], t.errors[:title] + + t.title = "Four" + assert t.valid? + end + + def test_optionally_validates_length_of_using_within + Topic.validates_length_of :title, :content, within: 3..5, allow_nil: true + + t = Topic.new('title' => 'abc', 'content' => 'abcd') + assert t.valid? + + t.title = nil + assert t.valid? + end + + def test_validates_length_of_using_is + Topic.validates_length_of :title, is: 5 + + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.valid? + + t.title = "notvalid" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is the wrong length (should be 5 characters)"], t.errors[:title] + + t.title = "" + assert t.invalid? + + t.title = nil + assert t.invalid? + end + + def test_optionally_validates_length_of_using_is + Topic.validates_length_of :title, is: 5, allow_nil: true + + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.valid? + + t.title = nil + assert t.valid? + end + + def test_validates_length_of_using_bignum + bigmin = 2 ** 30 + bigmax = 2 ** 32 + bigrange = bigmin...bigmax + assert_nothing_raised do + Topic.validates_length_of :title, is: bigmin + 5 + Topic.validates_length_of :title, within: bigrange + Topic.validates_length_of :title, in: bigrange + Topic.validates_length_of :title, minimum: bigmin + Topic.validates_length_of :title, maximum: bigmax + end + end + + def test_validates_length_of_nasty_params + assert_raise(ArgumentError) { Topic.validates_length_of(:title, is: -6) } + assert_raise(ArgumentError) { Topic.validates_length_of(:title, within: 6) } + assert_raise(ArgumentError) { Topic.validates_length_of(:title, minimum: "a") } + assert_raise(ArgumentError) { Topic.validates_length_of(:title, maximum: "a") } + assert_raise(ArgumentError) { Topic.validates_length_of(:title, within: "a") } + assert_raise(ArgumentError) { Topic.validates_length_of(:title, is: "a") } + end + + def test_validates_length_of_custom_errors_for_minimum_with_message + Topic.validates_length_of( :title, minimum: 5, message: "boo %{count}" ) + t = Topic.new("title" => "uhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["boo 5"], t.errors[:title] + end + + def test_validates_length_of_custom_errors_for_minimum_with_too_short + Topic.validates_length_of( :title, minimum: 5, too_short: "hoo %{count}" ) + t = Topic.new("title" => "uhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors[:title] + end + + def test_validates_length_of_custom_errors_for_maximum_with_message + Topic.validates_length_of( :title, maximum: 5, message: "boo %{count}" ) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["boo 5"], t.errors[:title] + end + + def test_validates_length_of_custom_errors_for_in + Topic.validates_length_of(:title, in: 10..20, message: "hoo %{count}") + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 10"], t.errors["title"] + + t = Topic.new("title" => "uhohuhohuhohuhohuhohuhohuhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 20"], t.errors["title"] + end + + def test_validates_length_of_custom_errors_for_maximum_with_too_long + Topic.validates_length_of( :title, maximum: 5, too_long: "hoo %{count}" ) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_validates_length_of_custom_errors_for_both_too_short_and_too_long + Topic.validates_length_of :title, minimum: 3, maximum: 5, too_short: 'too short', too_long: 'too long' + + t = Topic.new(title: 'a') + assert t.invalid? + assert t.errors[:title].any? + assert_equal ['too short'], t.errors['title'] + + t = Topic.new(title: 'aaaaaa') + assert t.invalid? + assert t.errors[:title].any? + assert_equal ['too long'], t.errors['title'] + end + + def test_validates_length_of_custom_errors_for_is_with_message + Topic.validates_length_of( :title, is: 5, message: "boo %{count}" ) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["boo 5"], t.errors["title"] + end + + def test_validates_length_of_custom_errors_for_is_with_wrong_length + Topic.validates_length_of( :title, is: 5, wrong_length: "hoo %{count}" ) + t = Topic.new("title" => "uhohuhoh", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["hoo 5"], t.errors["title"] + end + + def test_validates_length_of_using_minimum_utf8 + Topic.validates_length_of :title, minimum: 5 + + t = Topic.new("title" => "一二三四五", "content" => "whatever") + assert t.valid? + + t.title = "一二三四" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is too short (minimum is 5 characters)"], t.errors["title"] + end + + def test_validates_length_of_using_maximum_utf8 + Topic.validates_length_of :title, maximum: 5 + + t = Topic.new("title" => "一二三四五", "content" => "whatever") + assert t.valid? + + t.title = "一二34五å…" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is too long (maximum is 5 characters)"], t.errors["title"] + end + + def test_validates_length_of_using_within_utf8 + Topic.validates_length_of(:title, :content, within: 3..5) + + t = Topic.new("title" => "一二", "content" => "12三四五å…七") + assert t.invalid? + assert_equal ["is too short (minimum is 3 characters)"], t.errors[:title] + assert_equal ["is too long (maximum is 5 characters)"], t.errors[:content] + t.title = "一二三" + t.content = "12三" + assert t.valid? + end + + def test_optionally_validates_length_of_using_within_utf8 + Topic.validates_length_of :title, within: 3..5, allow_nil: true + + t = Topic.new(title: "一二三四五") + assert t.valid?, t.errors.inspect + + t = Topic.new(title: "一二三") + assert t.valid?, t.errors.inspect + + t.title = nil + assert t.valid?, t.errors.inspect + end + + def test_validates_length_of_using_is_utf8 + Topic.validates_length_of :title, is: 5 + + t = Topic.new("title" => "一二345", "content" => "whatever") + assert t.valid? + + t.title = "一二345å…" + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["is the wrong length (should be 5 characters)"], t.errors["title"] + end + + def test_validates_length_of_with_block + Topic.validates_length_of :content, minimum: 5, too_short: "Your essay must be at least %{count} words.", + tokenizer: lambda {|str| str.scan(/\w+/) } + t = Topic.new(content: "this content should be long enough") + assert t.valid? + + t.content = "not long enough" + assert t.invalid? + assert t.errors[:content].any? + assert_equal ["Your essay must be at least 5 words."], t.errors[:content] + end + + def test_validates_length_of_for_fixnum + Topic.validates_length_of(:approved, is: 4) + + t = Topic.new("title" => "uhohuhoh", "content" => "whatever", approved: 1) + assert t.invalid? + assert t.errors[:approved].any? + + t = Topic.new("title" => "uhohuhoh", "content" => "whatever", approved: 1234) + assert t.valid? + end + + def test_validates_length_of_for_ruby_class + Person.validates_length_of :karma, minimum: 5 + + p = Person.new + p.karma = "Pix" + assert p.invalid? + + assert_equal ["is too short (minimum is 5 characters)"], p.errors[:karma] + + p.karma = "The Smiths" + assert p.valid? + ensure + Person.clear_validators! + end + + def test_validates_length_of_for_infinite_maxima + Topic.validates_length_of(:title, within: 5..Float::INFINITY) + + t = Topic.new("title" => "1234") + assert t.invalid? + assert t.errors[:title].any? + + t.title = "12345" + assert t.valid? + + Topic.validates_length_of(:author_name, maximum: Float::INFINITY) + + assert t.valid? + + t.author_name = "A very long author name that should still be valid." * 100 + assert t.valid? + end + + def test_validates_length_of_using_maximum_should_not_allow_nil_when_nil_not_allowed + Topic.validates_length_of :title, maximum: 10, allow_nil: false + t = Topic.new + assert t.invalid? + end + + def test_validates_length_of_using_maximum_should_not_allow_nil_and_empty_string_when_blank_not_allowed + Topic.validates_length_of :title, maximum: 10, allow_blank: false + t = Topic.new + assert t.invalid? + + t.title = "" + assert t.invalid? + end + + def test_validates_length_of_using_both_minimum_and_maximum_should_not_allow_nil + Topic.validates_length_of :title, minimum: 5, maximum: 10 + t = Topic.new + assert t.invalid? + end + + def test_validates_length_of_using_minimum_0_should_not_allow_nil + Topic.validates_length_of :title, minimum: 0 + t = Topic.new + assert t.invalid? + + t.title = "" + assert t.valid? + end + + def test_validates_length_of_using_is_0_should_not_allow_nil + Topic.validates_length_of :title, is: 0 + t = Topic.new + assert t.invalid? + + t.title = "" + assert t.valid? + end + + def test_validates_with_diff_in_option + Topic.validates_length_of(:title, is: 5) + Topic.validates_length_of(:title, is: 5, if: Proc.new { false } ) + + assert Topic.new("title" => "david").valid? + assert Topic.new("title" => "david2").invalid? + end +end diff --git a/activemodel/test/cases/validations/numericality_validation_test.rb b/activemodel/test/cases/validations/numericality_validation_test.rb new file mode 100644 index 0000000000..3834d327ea --- /dev/null +++ b/activemodel/test/cases/validations/numericality_validation_test.rb @@ -0,0 +1,211 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/person' + +require 'bigdecimal' + +class NumericalityValidationTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + NIL = [nil] + BLANK = ["", " ", " \t \r \n"] + BIGDECIMAL_STRINGS = %w(12345678901234567890.1234567890) # 30 significant digits + FLOAT_STRINGS = %w(0.0 +0.0 -0.0 10.0 10.5 -10.5 -0.0001 -090.1 90.1e1 -90.1e5 -90.1e-5 90e-5) + INTEGER_STRINGS = %w(0 +0 -0 10 +10 -10 0090 -090) + FLOATS = [0.0, 10.0, 10.5, -10.5, -0.0001] + FLOAT_STRINGS + INTEGERS = [0, 10, -10] + INTEGER_STRINGS + BIGDECIMAL = BIGDECIMAL_STRINGS.collect! { |bd| BigDecimal.new(bd) } + JUNK = ["not a number", "42 not a number", "0xdeadbeef", "0xinvalidhex", "0Xdeadbeef", "00-1", "--3", "+-3", "+3-1", "-+019.0", "12.12.13.12", "123\nnot a number"] + INFINITY = [1.0/0.0] + + def test_default_validates_numericality_of + Topic.validates_numericality_of :approved + invalid!(NIL + BLANK + JUNK) + valid!(FLOATS + INTEGERS + BIGDECIMAL + INFINITY) + end + + def test_validates_numericality_of_with_nil_allowed + Topic.validates_numericality_of :approved, allow_nil: true + + invalid!(JUNK + BLANK) + valid!(NIL + FLOATS + INTEGERS + BIGDECIMAL + INFINITY) + end + + def test_validates_numericality_of_with_integer_only + Topic.validates_numericality_of :approved, only_integer: true + + invalid!(NIL + BLANK + JUNK + FLOATS + BIGDECIMAL + INFINITY) + valid!(INTEGERS) + end + + def test_validates_numericality_of_with_integer_only_and_nil_allowed + Topic.validates_numericality_of :approved, only_integer: true, allow_nil: true + + invalid!(JUNK + BLANK + FLOATS + BIGDECIMAL + INFINITY) + valid!(NIL + INTEGERS) + end + + def test_validates_numericality_of_with_integer_only_and_symbol_as_value + Topic.validates_numericality_of :approved, only_integer: :condition_is_true_but_its_not + + invalid!(NIL + BLANK + JUNK) + valid!(FLOATS + INTEGERS + BIGDECIMAL + INFINITY) + end + + def test_validates_numericality_of_with_integer_only_and_proc_as_value + Topic.send(:define_method, :allow_only_integers?, lambda { false }) + Topic.validates_numericality_of :approved, only_integer: Proc.new {|topic| topic.allow_only_integers? } + + invalid!(NIL + BLANK + JUNK) + valid!(FLOATS + INTEGERS + BIGDECIMAL + INFINITY) + end + + def test_validates_numericality_with_greater_than + Topic.validates_numericality_of :approved, greater_than: 10 + + invalid!([-10, 10], 'must be greater than 10') + valid!([11]) + end + + def test_validates_numericality_with_greater_than_or_equal + Topic.validates_numericality_of :approved, greater_than_or_equal_to: 10 + + invalid!([-9, 9], 'must be greater than or equal to 10') + valid!([10]) + end + + def test_validates_numericality_with_equal_to + Topic.validates_numericality_of :approved, equal_to: 10 + + invalid!([-10, 11] + INFINITY, 'must be equal to 10') + valid!([10]) + end + + def test_validates_numericality_with_less_than + Topic.validates_numericality_of :approved, less_than: 10 + + invalid!([10], 'must be less than 10') + valid!([-9, 9]) + end + + def test_validates_numericality_with_less_than_or_equal_to + Topic.validates_numericality_of :approved, less_than_or_equal_to: 10 + + invalid!([11], 'must be less than or equal to 10') + valid!([-10, 10]) + end + + def test_validates_numericality_with_odd + Topic.validates_numericality_of :approved, odd: true + + invalid!([-2, 2], 'must be odd') + valid!([-1, 1]) + end + + def test_validates_numericality_with_even + Topic.validates_numericality_of :approved, even: true + + invalid!([-1, 1], 'must be even') + valid!([-2, 2]) + end + + def test_validates_numericality_with_greater_than_less_than_and_even + Topic.validates_numericality_of :approved, greater_than: 1, less_than: 4, even: true + + invalid!([1, 3, 4]) + valid!([2]) + end + + def test_validates_numericality_with_other_than + Topic.validates_numericality_of :approved, other_than: 0 + + invalid!([0, 0.0]) + valid!([-1, 42]) + 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]) + ensure + 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]) + ensure + 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) + + assert !topic.valid? + assert_equal ["smaller than 4"], topic.errors[:approved] + + Topic.validates_numericality_of :approved, greater_than: 4, message: "greater than %{count}" + topic = Topic.new("title" => "numeric test", "approved" => 1) + + assert !topic.valid? + assert_equal ["greater than 4"], topic.errors[:approved] + end + + def test_validates_numericality_of_for_ruby_class + Person.validates_numericality_of :karma, allow_nil: false + + p = Person.new + p.karma = "Pix" + assert p.invalid? + + assert_equal ["is not a number"], p.errors[:karma] + + p.karma = "1234" + assert p.valid? + ensure + Person.clear_validators! + end + + def test_validates_numericality_with_invalid_args + assert_raise(ArgumentError){ Topic.validates_numericality_of :approved, greater_than_or_equal_to: "foo" } + assert_raise(ArgumentError){ Topic.validates_numericality_of :approved, less_than_or_equal_to: "foo" } + assert_raise(ArgumentError){ Topic.validates_numericality_of :approved, greater_than: "foo" } + assert_raise(ArgumentError){ Topic.validates_numericality_of :approved, less_than: "foo" } + assert_raise(ArgumentError){ Topic.validates_numericality_of :approved, equal_to: "foo" } + end + + private + + def invalid!(values, error = nil) + with_each_topic_approved_value(values) do |topic, value| + assert topic.invalid?, "#{value.inspect} not rejected as a number" + assert topic.errors[:approved].any?, "FAILED for #{value.inspect}" + assert_equal error, topic.errors[:approved].first if error + end + end + + def valid!(values) + with_each_topic_approved_value(values) do |topic, value| + assert topic.valid?, "#{value.inspect} not accepted as a number" + end + end + + def with_each_topic_approved_value(values) + topic = Topic.new(title: "numeric test", content: "whatever") + values.each do |value| + topic.approved = value + yield topic, value + end + end +end diff --git a/activemodel/test/cases/validations/presence_validation_test.rb b/activemodel/test/cases/validations/presence_validation_test.rb new file mode 100644 index 0000000000..ecf16d1e16 --- /dev/null +++ b/activemodel/test/cases/validations/presence_validation_test.rb @@ -0,0 +1,107 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/person' +require 'models/custom_reader' + +class PresenceValidationTest < ActiveModel::TestCase + + teardown do + Topic.clear_validators! + Person.clear_validators! + CustomReader.clear_validators! + end + + def test_validate_presences + Topic.validates_presence_of(:title, :content) + + t = Topic.new + assert t.invalid? + assert_equal ["can't be blank"], t.errors[:title] + assert_equal ["can't be blank"], t.errors[:content] + + t.title = "something" + t.content = " " + + assert t.invalid? + assert_equal ["can't be blank"], t.errors[:content] + + t.content = "like stuff" + + assert t.valid? + end + + def test_accepts_array_arguments + Topic.validates_presence_of %w(title content) + t = Topic.new + assert t.invalid? + assert_equal ["can't be blank"], t.errors[:title] + assert_equal ["can't be blank"], t.errors[:content] + end + + def test_validates_acceptance_of_with_custom_error_using_quotes + Person.validates_presence_of :karma, message: "This string contains 'single' and \"double\" quotes" + p = Person.new + assert p.invalid? + assert_equal "This string contains 'single' and \"double\" quotes", p.errors[:karma].last + end + + def test_validates_presence_of_for_ruby_class + Person.validates_presence_of :karma + + p = Person.new + assert p.invalid? + + assert_equal ["can't be blank"], p.errors[:karma] + + p.karma = "Cold" + assert p.valid? + end + + def test_validates_presence_of_for_ruby_class_with_custom_reader + CustomReader.validates_presence_of :karma + + p = CustomReader.new + assert p.invalid? + + assert_equal ["can't be blank"], p.errors[:karma] + + p[:karma] = "Cold" + assert p.valid? + end + + def test_validates_presence_of_with_allow_nil_option + Topic.validates_presence_of(:title, allow_nil: true) + + t = Topic.new(title: "something") + assert t.valid?, t.errors.full_messages + + t.title = "" + assert t.invalid? + assert_equal ["can't be blank"], t.errors[:title] + + t.title = " " + assert t.invalid?, t.errors.full_messages + assert_equal ["can't be blank"], t.errors[:title] + + t.title = nil + assert t.valid?, t.errors.full_messages + end + + def test_validates_presence_of_with_allow_blank_option + Topic.validates_presence_of(:title, allow_blank: true) + + t = Topic.new(title: "something") + assert t.valid?, t.errors.full_messages + + t.title = "" + assert t.valid?, t.errors.full_messages + + t.title = " " + assert t.valid?, t.errors.full_messages + + t.title = nil + assert t.valid?, t.errors.full_messages + end +end diff --git a/activemodel/test/cases/validations/validates_test.rb b/activemodel/test/cases/validations/validates_test.rb new file mode 100644 index 0000000000..699a872e42 --- /dev/null +++ b/activemodel/test/cases/validations/validates_test.rb @@ -0,0 +1,159 @@ +# encoding: utf-8 +require 'cases/helper' +require 'models/person' +require 'models/topic' +require 'models/person_with_validator' +require 'validators/email_validator' +require 'validators/namespace/email_validator' + +class ValidatesTest < ActiveModel::TestCase + setup :reset_callbacks + teardown :reset_callbacks + + def reset_callbacks + Person.clear_validators! + Topic.clear_validators! + PersonWithValidator.clear_validators! + end + + def test_validates_with_messages_empty + Person.validates :title, presence: { message: "" } + person = Person.new + assert !person.valid?, 'person should not be valid.' + end + + def test_validates_with_built_in_validation + Person.validates :title, numericality: true + person = Person.new + person.valid? + assert_equal ['is not a number'], person.errors[:title] + end + + def test_validates_with_attribute_specified_as_string + Person.validates "title", numericality: true + person = Person.new + person.valid? + assert_equal ['is not a number'], person.errors[:title] + + person = Person.new + person.title = 123 + assert person.valid? + end + + def test_validates_with_built_in_validation_and_options + Person.validates :salary, numericality: { message: 'my custom message' } + person = Person.new + person.valid? + assert_equal ['my custom message'], person.errors[:salary] + end + + def test_validates_with_validator_class + Person.validates :karma, email: true + person = Person.new + person.valid? + assert_equal ['is not an email'], person.errors[:karma] + end + + def test_validates_with_namespaced_validator_class + Person.validates :karma, :'namespace/email' => true + person = Person.new + person.valid? + assert_equal ['is not an email'], person.errors[:karma] + end + + def test_validates_with_if_as_local_conditions + Person.validates :karma, presence: true, email: { unless: :condition_is_true } + person = Person.new + person.valid? + assert_equal ["can't be blank"], person.errors[:karma] + end + + def test_validates_with_if_as_shared_conditions + Person.validates :karma, presence: true, email: true, if: :condition_is_true + person = Person.new + person.valid? + assert_equal ["can't be blank", "is not an email"], person.errors[:karma].sort + end + + def test_validates_with_unless_shared_conditions + Person.validates :karma, presence: true, email: true, unless: :condition_is_true + person = Person.new + assert person.valid? + end + + def test_validates_with_allow_nil_shared_conditions + Person.validates :karma, length: { minimum: 20 }, email: true, allow_nil: true + person = Person.new + assert person.valid? + end + + def test_validates_with_regexp + Person.validates :karma, format: /positive|negative/ + person = Person.new + assert person.invalid? + assert_equal ['is invalid'], person.errors[:karma] + person.karma = "positive" + assert person.valid? + end + + def test_validates_with_array + Person.validates :gender, inclusion: %w(m f) + person = Person.new + assert person.invalid? + assert_equal ['is not included in the list'], person.errors[:gender] + person.gender = "m" + assert person.valid? + end + + def test_validates_with_range + Person.validates :karma, length: 6..20 + person = Person.new + assert person.invalid? + assert_equal ['is too short (minimum is 6 characters)'], person.errors[:karma] + person.karma = 'something' + assert person.valid? + end + + def test_validates_with_validator_class_and_options + Person.validates :karma, email: { message: 'my custom message' } + person = Person.new + person.valid? + assert_equal ['my custom message'], person.errors[:karma] + end + + def test_validates_with_unknown_validator + assert_raise(ArgumentError) { Person.validates :karma, unknown: true } + end + + def test_validates_with_included_validator + PersonWithValidator.validates :title, presence: true + person = PersonWithValidator.new + person.valid? + assert_equal ['Local validator'], person.errors[:title] + end + + def test_validates_with_included_validator_and_options + PersonWithValidator.validates :title, presence: { custom: ' please' } + person = PersonWithValidator.new + person.valid? + assert_equal ['Local validator please'], person.errors[:title] + end + + def test_validates_with_included_validator_and_wildcard_shortcut + # Shortcut for PersonWithValidator.validates :title, like: { with: "Mr." } + PersonWithValidator.validates :title, like: "Mr." + person = PersonWithValidator.new + person.title = "Ms. Pacman" + person.valid? + assert_equal ['does not appear to be like Mr.'], person.errors[:title] + end + + def test_defining_extra_default_keys_for_validates + Topic.validates :title, confirmation: true, message: 'Y U NO CONFIRM' + topic = Topic.new + topic.title = "What's happening" + topic.title_confirmation = "Not this" + assert !topic.valid? + assert_equal ['Y U NO CONFIRM'], topic.errors[:title_confirmation] + end +end diff --git a/activemodel/test/cases/validations/validations_context_test.rb b/activemodel/test/cases/validations/validations_context_test.rb new file mode 100644 index 0000000000..005bf118c6 --- /dev/null +++ b/activemodel/test/cases/validations/validations_context_test.rb @@ -0,0 +1,50 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' + +class ValidationsContextTest < ActiveModel::TestCase + def teardown + Topic.clear_validators! + end + + ERROR_MESSAGE = "Validation error from validator" + + class ValidatorThatAddsErrors < ActiveModel::Validator + def validate(record) + record.errors[:base] << ERROR_MESSAGE + end + end + + test "with a class that adds errors on create and validating a new model with no arguments" do + Topic.validates_with(ValidatorThatAddsErrors, on: :create) + topic = Topic.new + assert topic.valid?, "Validation doesn't run on valid? if 'on' is set to create" + end + + test "with a class that adds errors on update and validating a new model" do + Topic.validates_with(ValidatorThatAddsErrors, on: :update) + topic = Topic.new + assert topic.valid?(:create), "Validation doesn't run on create if 'on' is set to update" + end + + test "with a class that adds errors on create and validating a new model" do + Topic.validates_with(ValidatorThatAddsErrors, on: :create) + topic = Topic.new + assert topic.invalid?(:create), "Validation does run on create if 'on' is set to create" + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "with a class that adds errors on multiple contexts and validating a new model" do + Topic.validates_with(ValidatorThatAddsErrors, on: [:context1, :context2]) + + topic = Topic.new + assert topic.valid?, "Validation ran with no context given when 'on' is set to context1 and context2" + + assert topic.invalid?(:context1), "Validation did not run on context1 when 'on' is set to context1 and context2" + assert topic.errors[:base].include?(ERROR_MESSAGE) + + assert topic.invalid?(:context2), "Validation did not run on context2 when 'on' is set to context1 and context2" + assert topic.errors[:base].include?(ERROR_MESSAGE) + end +end 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..736c2deea8 --- /dev/null +++ b/activemodel/test/cases/validations/with_validation_test.rb @@ -0,0 +1,172 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' + +class ValidatesWithTest < ActiveModel::TestCase + + def teardown + Topic.clear_validators! + end + + ERROR_MESSAGE = "Validation error from validator" + OTHER_ERROR_MESSAGE = "Validation error from other validator" + + class ValidatorThatAddsErrors < ActiveModel::Validator + def validate(record) + record.errors[:base] << ERROR_MESSAGE + end + end + + class OtherValidatorThatAddsErrors < ActiveModel::Validator + def validate(record) + record.errors[:base] << OTHER_ERROR_MESSAGE + end + end + + class ValidatorThatDoesNotAddErrors < ActiveModel::Validator + def validate(record) + end + end + + class ValidatorThatValidatesOptions < ActiveModel::Validator + def validate(record) + if options[:field] == :first_name + record.errors[:base] << ERROR_MESSAGE + end + end + end + + class ValidatorPerEachAttribute < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors[attribute] << "Value is #{value}" + end + end + + class ValidatorCheckValidity < ActiveModel::EachValidator + def check_validity! + raise "boom!" + end + end + + test "validation with class that adds errors" do + Topic.validates_with(ValidatorThatAddsErrors) + topic = Topic.new + assert topic.invalid?, "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 multiple classes" do + Topic.validates_with(ValidatorThatAddsErrors, OtherValidatorThatAddsErrors) + topic = Topic.new + assert topic.invalid? + 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.invalid? + 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.invalid? + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "passes all configuration options to the validator class" do + topic = Topic.new + validator = mock() + validator.expects(:new).with(foo: :bar, if: "1 == 1", class: Topic).returns(validator) + validator.expects(:validate).with(topic) + + 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.invalid? + assert topic.errors[:base].include?(ERROR_MESSAGE) + end + + test "validates_with each validator" do + Topic.validates_with(ValidatorPerEachAttribute, attributes: [:title, :content]) + topic = Topic.new title: "Title", content: "Content" + assert topic.invalid? + assert_equal ["Value is Title"], topic.errors[:title] + assert_equal ["Value is Content"], topic.errors[:content] + end + + test "each validator checks validity" do + assert_raise RuntimeError do + Topic.validates_with(ValidatorCheckValidity, attributes: [:title]) + end + end + + test "each validator expects attributes to be given" do + assert_raise ArgumentError do + Topic.validates_with(ValidatorPerEachAttribute) + end + end + + test "each validator skip nil values if :allow_nil is set to true" do + Topic.validates_with(ValidatorPerEachAttribute, attributes: [:title, :content], allow_nil: true) + topic = Topic.new content: "" + assert topic.invalid? + assert topic.errors[:title].empty? + assert_equal ["Value is "], topic.errors[:content] + end + + test "each validator skip blank values if :allow_blank is set to true" do + Topic.validates_with(ValidatorPerEachAttribute, attributes: [:title, :content], allow_blank: true) + topic = Topic.new content: "" + assert topic.valid? + assert topic.errors[:title].empty? + assert topic.errors[:content].empty? + end + + test "validates_with can validate with an instance method" do + Topic.validates :title, with: :my_validation + + topic = Topic.new title: "foo" + assert topic.valid? + assert topic.errors[:title].empty? + + topic = Topic.new + assert !topic.valid? + assert_equal ['is missing'], topic.errors[:title] + end + + test "optionally pass in the attribute being validated when validating with an instance method" do + Topic.validates :title, :content, with: :my_validation_with_arg + + topic = Topic.new title: "foo" + assert !topic.valid? + assert topic.errors[:title].empty? + assert_equal ['is missing'], topic.errors[:content] + end +end diff --git a/activemodel/test/cases/validations_test.rb b/activemodel/test/cases/validations_test.rb new file mode 100644 index 0000000000..ba0aacc2a5 --- /dev/null +++ b/activemodel/test/cases/validations_test.rb @@ -0,0 +1,392 @@ +# encoding: utf-8 +require 'cases/helper' + +require 'models/topic' +require 'models/reply' +require 'models/custom_reader' + +require 'active_support/json' +require 'active_support/xml_mini' + +class ValidationsTest < ActiveModel::TestCase + class CustomStrictValidationException < StandardError; end + + def teardown + Topic.clear_validators! + end + + def test_single_field_validation + r = Reply.new + r.title = "There's no content!" + assert r.invalid?, "A reply without content shouldn't be savable" + assert r.after_validation_performed, "after_validation callback should be called" + + r.content = "Messa content!" + assert r.valid?, "A reply with content should be savable" + assert r.after_validation_performed, "after_validation callback should be called" + end + + def test_single_attr_validation_and_error_msg + r = Reply.new + r.title = "There's no content!" + assert r.invalid? + assert r.errors[:content].any?, "A reply without content should mark that attribute as invalid" + assert_equal ["is Empty"], r.errors["content"], "A reply without content should contain an error" + assert_equal 1, r.errors.count + end + + def test_double_attr_validation_and_error_msg + r = Reply.new + assert r.invalid? + + assert r.errors[:title].any?, "A reply without title should mark that attribute as invalid" + assert_equal ["is Empty"], r.errors["title"], "A reply without title should contain an error" + + assert r.errors[:content].any?, "A reply without content should mark that attribute as invalid" + assert_equal ["is Empty"], r.errors["content"], "A reply without content should contain an error" + + assert_equal 2, r.errors.count + end + + def test_single_error_per_attr_iteration + r = Reply.new + r.valid? + + errors = r.errors.collect {|attr, messages| [attr.to_s, messages]} + + assert errors.include?(["title", "is Empty"]) + assert errors.include?(["content", "is Empty"]) + end + + def test_multiple_errors_per_attr_iteration_with_full_error_composition + r = Reply.new + r.title = "" + r.content = "" + r.valid? + + errors = r.errors.to_a + + assert_equal "Content is Empty", errors[0] + assert_equal "Title is Empty", errors[1] + assert_equal 2, r.errors.count + end + + def test_errors_on_nested_attributes_expands_name + t = Topic.new + t.errors["replies.name"] << "can't be blank" + assert_equal ["Replies name can't be blank"], t.errors.full_messages + end + + def test_errors_on_base + r = Reply.new + r.content = "Mismatch" + r.valid? + r.errors.add(:base, "Reply is not dignifying") + + errors = r.errors.to_a.inject([]) { |result, error| result + [error] } + + assert_equal ["Reply is not dignifying"], r.errors[:base] + + assert errors.include?("Title is Empty") + assert errors.include?("Reply is not dignifying") + assert_equal 2, r.errors.count + end + + def test_errors_on_base_with_symbol_message + r = Reply.new + r.content = "Mismatch" + r.valid? + r.errors.add(:base, :invalid) + + errors = r.errors.to_a.inject([]) { |result, error| result + [error] } + + assert_equal ["is invalid"], r.errors[:base] + + assert errors.include?("Title is Empty") + assert errors.include?("is invalid") + + assert_equal 2, r.errors.count + end + + def test_errors_empty_after_errors_on_check + t = Topic.new + assert t.errors[:id].empty? + assert t.errors.empty? + end + + def test_validates_each + hits = 0 + Topic.validates_each(:title, :content, [:title, :content]) do |record, attr| + record.errors.add attr, 'gotcha' + hits += 1 + end + t = Topic.new("title" => "valid", "content" => "whatever") + assert t.invalid? + assert_equal 4, hits + assert_equal %w(gotcha gotcha), t.errors[:title] + assert_equal %w(gotcha gotcha), t.errors[:content] + end + + def test_validates_each_custom_reader + hits = 0 + CustomReader.validates_each(:title, :content, [:title, :content]) do |record, attr| + record.errors.add attr, 'gotcha' + hits += 1 + end + t = CustomReader.new("title" => "valid", "content" => "whatever") + assert t.invalid? + assert_equal 4, hits + assert_equal %w(gotcha gotcha), t.errors[:title] + assert_equal %w(gotcha gotcha), t.errors[:content] + ensure + CustomReader.clear_validators! + end + + def test_validate_block + Topic.validate { errors.add("title", "will never be valid") } + t = Topic.new("title" => "Title", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["will never be valid"], t.errors["title"] + end + + def test_validate_block_with_params + Topic.validate { |topic| topic.errors.add("title", "will never be valid") } + t = Topic.new("title" => "Title", "content" => "whatever") + assert t.invalid? + assert t.errors[:title].any? + assert_equal ["will never be valid"], t.errors["title"] + end + + def test_invalid_validator + Topic.validate :i_dont_exist + assert_raises(NoMethodError) do + t = Topic.new + t.valid? + end + end + + def test_invalid_options_to_validate + assert_raises(ArgumentError) do + # A common mistake -- we meant to call 'validates' + Topic.validate :title, presence: true + end + end + + def test_errors_conversions + Topic.validates_presence_of %w(title content) + t = Topic.new + assert t.invalid? + + xml = t.errors.to_xml + assert_match %r{<errors>}, xml + assert_match %r{<error>Title can't be blank</error>}, xml + assert_match %r{<error>Content can't be blank</error>}, xml + + hash = {} + hash[:title] = ["can't be blank"] + hash[:content] = ["can't be blank"] + assert_equal t.errors.to_json, hash.to_json + end + + def test_validation_order + Topic.validates_presence_of :title + Topic.validates_length_of :title, minimum: 2 + + t = Topic.new("title" => "") + assert t.invalid? + assert_equal "can't be blank", t.errors["title"].first + Topic.validates_presence_of :title, :author_name + Topic.validate {errors.add('author_email_address', 'will never be valid')} + Topic.validates_length_of :title, :content, minimum: 2 + + t = Topic.new title: '' + assert t.invalid? + + assert_equal :title, key = t.errors.keys[0] + assert_equal "can't be blank", t.errors[key][0] + assert_equal 'is too short (minimum is 2 characters)', t.errors[key][1] + assert_equal :author_name, key = t.errors.keys[1] + assert_equal "can't be blank", t.errors[key][0] + assert_equal :author_email_address, key = t.errors.keys[2] + assert_equal 'will never be valid', t.errors[key][0] + assert_equal :content, key = t.errors.keys[3] + assert_equal 'is too short (minimum is 2 characters)', t.errors[key][0] + end + + def test_validation_with_if_and_on + Topic.validates_presence_of :title, if: Proc.new{|x| x.author_name = "bad"; true }, on: :update + + t = Topic.new(title: "") + + # If block should not fire + assert t.valid? + assert t.author_name.nil? + + # If block should fire + assert t.invalid?(:update) + assert t.author_name == "bad" + end + + def test_invalid_should_be_the_opposite_of_valid + Topic.validates_presence_of :title + + t = Topic.new + assert t.invalid? + assert t.errors[:title].any? + + t.title = 'Things are going to change' + assert !t.invalid? + end + + def test_validation_with_message_as_proc + Topic.validates_presence_of(:title, message: proc { "no blanks here".upcase }) + + t = Topic.new + assert t.invalid? + assert_equal ["NO BLANKS HERE"], t.errors[:title] + end + + def test_list_of_validators_for_model + Topic.validates_presence_of :title + Topic.validates_length_of :title, minimum: 2 + + assert_equal 2, Topic.validators.count + assert_equal [:presence, :length], Topic.validators.map(&:kind) + end + + def test_list_of_validators_on_an_attribute + Topic.validates_presence_of :title, :content + Topic.validates_length_of :title, minimum: 2 + + assert_equal 2, Topic.validators_on(:title).count + assert_equal [:presence, :length], Topic.validators_on(:title).map(&:kind) + assert_equal 1, Topic.validators_on(:content).count + assert_equal [:presence], Topic.validators_on(:content).map(&:kind) + end + + def test_accessing_instance_of_validator_on_an_attribute + Topic.validates_length_of :title, minimum: 10 + assert_equal 10, Topic.validators_on(:title).first.options[:minimum] + end + + def test_list_of_validators_on_multiple_attributes + Topic.validates :title, length: { minimum: 10 } + Topic.validates :author_name, presence: true, format: /a/ + + validators = Topic.validators_on(:title, :author_name) + + assert_equal [ + ActiveModel::Validations::FormatValidator, + ActiveModel::Validations::LengthValidator, + ActiveModel::Validations::PresenceValidator + ], validators.map { |v| v.class }.sort_by { |c| c.to_s } + end + + def test_list_of_validators_will_be_empty_when_empty + Topic.validates :title, length: { minimum: 10 } + assert_equal [], Topic.validators_on(:author_name) + end + + def test_validations_on_the_instance_level + Topic.validates :title, :author_name, presence: true + Topic.validates :content, length: { minimum: 10 } + + topic = Topic.new + assert topic.invalid? + assert_equal 3, topic.errors.size + + topic.title = 'Some Title' + topic.author_name = 'Some Author' + topic.content = 'Some Content Whose Length is more than 10.' + assert topic.valid? + end + + def test_validate + Topic.validate do + validates_presence_of :title, :author_name + validates_length_of :content, minimum: 10 + end + + topic = Topic.new + assert_empty topic.errors + + topic.validate + assert_not_empty topic.errors + end + + def test_strict_validation_in_validates + Topic.validates :title, strict: true, presence: true + assert_raises ActiveModel::StrictValidationFailed do + Topic.new.valid? + end + end + + def test_strict_validation_not_fails + Topic.validates :title, strict: true, presence: true + assert Topic.new(title: "hello").valid? + end + + def test_strict_validation_particular_validator + Topic.validates :title, presence: { strict: true } + assert_raises ActiveModel::StrictValidationFailed do + Topic.new.valid? + end + end + + def test_strict_validation_in_custom_validator_helper + Topic.validates_presence_of :title, strict: true + assert_raises ActiveModel::StrictValidationFailed do + Topic.new.valid? + end + end + + def test_strict_validation_custom_exception + Topic.validates_presence_of :title, strict: CustomStrictValidationException + assert_raises CustomStrictValidationException do + Topic.new.valid? + end + end + + def test_validates_with_bang + Topic.validates! :title, presence: true + assert_raises ActiveModel::StrictValidationFailed do + Topic.new.valid? + end + end + + def test_validates_with_false_hash_value + Topic.validates :title, presence: false + assert Topic.new.valid? + end + + def test_strict_validation_error_message + Topic.validates :title, strict: true, presence: true + + exception = assert_raises(ActiveModel::StrictValidationFailed) do + Topic.new.valid? + end + assert_equal "Title can't be blank", exception.message + end + + def test_does_not_modify_options_argument + options = { presence: true } + Topic.validates :title, options + assert_equal({ presence: true }, options) + end + + def test_dup_validity_is_independent + Topic.validates_presence_of :title + topic = Topic.new("title" => "Literature") + topic.valid? + + duped = topic.dup + duped.title = nil + assert duped.invalid? + + topic.title = nil + duped.title = 'Mathematics' + assert topic.invalid? + assert duped.valid? + end +end diff --git a/activemodel/test/config.rb b/activemodel/test/config.rb new file mode 100644 index 0000000000..0b577a9936 --- /dev/null +++ b/activemodel/test/config.rb @@ -0,0 +1,3 @@ +TEST_ROOT = File.expand_path(File.dirname(__FILE__)) +FIXTURES_ROOT = TEST_ROOT + "/fixtures" +SCHEMA_FILE = TEST_ROOT + "/schema.rb" diff --git a/activemodel/test/models/account.rb b/activemodel/test/models/account.rb new file mode 100644 index 0000000000..eed668d38f --- /dev/null +++ b/activemodel/test/models/account.rb @@ -0,0 +1,5 @@ +class Account + include ActiveModel::ForbiddenAttributesProtection + + public :sanitize_for_mass_assignment +end diff --git a/activemodel/test/models/blog_post.rb b/activemodel/test/models/blog_post.rb new file mode 100644 index 0000000000..46eba857df --- /dev/null +++ b/activemodel/test/models/blog_post.rb @@ -0,0 +1,9 @@ +module Blog + def self.use_relative_model_naming? + true + end + + class Post + extend ActiveModel::Naming + end +end diff --git a/activemodel/test/models/contact.rb b/activemodel/test/models/contact.rb new file mode 100644 index 0000000000..c25be28e1d --- /dev/null +++ b/activemodel/test/models/contact.rb @@ -0,0 +1,26 @@ +class Contact + extend ActiveModel::Naming + include ActiveModel::Conversion + + attr_accessor :id, :name, :age, :created_at, :awesome, :preferences + + def social + %w(twitter github) + end + + def network + { git: :github } + end + + def initialize(options = {}) + options.each { |name, value| send("#{name}=", value) } + end + + def pseudonyms + nil + end + + def persisted? + id + end +end diff --git a/activemodel/test/models/custom_reader.rb b/activemodel/test/models/custom_reader.rb new file mode 100644 index 0000000000..2fbcf79c3d --- /dev/null +++ b/activemodel/test/models/custom_reader.rb @@ -0,0 +1,15 @@ +class CustomReader + include ActiveModel::Validations + + def initialize(data = {}) + @data = data + end + + def []=(key, value) + @data[key] = value + end + + def read_attribute_for_validation(key) + @data[key] + end +end
\ No newline at end of file diff --git a/activemodel/test/models/helicopter.rb b/activemodel/test/models/helicopter.rb new file mode 100644 index 0000000000..933f3c463a --- /dev/null +++ b/activemodel/test/models/helicopter.rb @@ -0,0 +1,7 @@ +class Helicopter + include ActiveModel::Conversion +end + +class Helicopter::Comanche + include ActiveModel::Conversion +end diff --git a/activemodel/test/models/person.rb b/activemodel/test/models/person.rb new file mode 100644 index 0000000000..e896e90f98 --- /dev/null +++ b/activemodel/test/models/person.rb @@ -0,0 +1,17 @@ +class Person + include ActiveModel::Validations + extend ActiveModel::Translation + + attr_accessor :title, :karma, :salary, :gender + + def condition_is_true + true + end +end + +class Person::Gender + extend ActiveModel::Translation +end + +class Child < Person +end diff --git a/activemodel/test/models/person_with_validator.rb b/activemodel/test/models/person_with_validator.rb new file mode 100644 index 0000000000..505ed880c1 --- /dev/null +++ b/activemodel/test/models/person_with_validator.rb @@ -0,0 +1,24 @@ +class PersonWithValidator + include ActiveModel::Validations + + class PresenceValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors[attribute] << "Local validator#{options[:custom]}" if value.blank? + end + end + + class LikeValidator < ActiveModel::EachValidator + def initialize(options) + @with = options[:with] + super + end + + def validate_each(record, attribute, value) + unless value[@with] + record.errors.add attribute, "does not appear to be like #{@with}" + end + end + end + + attr_accessor :title, :karma +end diff --git a/activemodel/test/models/project.rb b/activemodel/test/models/project.rb new file mode 100644 index 0000000000..581b6dc0b3 --- /dev/null +++ b/activemodel/test/models/project.rb @@ -0,0 +1,3 @@ +class Project + include ActiveModel::DeprecatedMassAssignmentSecurity +end diff --git a/activemodel/test/models/reply.rb b/activemodel/test/models/reply.rb new file mode 100644 index 0000000000..b77910e671 --- /dev/null +++ b/activemodel/test/models/reply.rb @@ -0,0 +1,32 @@ +require 'models/topic' + +class Reply < Topic + validate :errors_on_empty_content + validate :title_is_wrong_create, on: :create + + validate :check_empty_title + validate :check_content_mismatch, on: :create + validate :check_wrong_update, on: :update + + def check_empty_title + errors[:title] << "is Empty" unless title && title.size > 0 + end + + def errors_on_empty_content + errors[:content] << "is Empty" unless content && content.size > 0 + end + + def check_content_mismatch + if title && content && content == "Mismatch" + errors[:title] << "is Content Mismatch" + end + end + + def title_is_wrong_create + errors[:title] << "is Wrong Create" if title && title == "Wrong Create" + end + + def check_wrong_update + errors[:title] << "is Wrong Update" if title && title == "Wrong Update" + end +end diff --git a/activemodel/test/models/sheep.rb b/activemodel/test/models/sheep.rb new file mode 100644 index 0000000000..7aba055c4f --- /dev/null +++ b/activemodel/test/models/sheep.rb @@ -0,0 +1,3 @@ +class Sheep + extend ActiveModel::Naming +end diff --git a/activemodel/test/models/topic.rb b/activemodel/test/models/topic.rb new file mode 100644 index 0000000000..1411a093e9 --- /dev/null +++ b/activemodel/test/models/topic.rb @@ -0,0 +1,40 @@ +class Topic + include ActiveModel::Validations + include ActiveModel::Validations::Callbacks + + def self._validates_default_keys + super | [ :message ] + end + + attr_accessor :title, :author_name, :content, :approved, :created_at + attr_accessor :after_validation_performed + + after_validation :perform_after_validation + + def initialize(attributes = {}) + attributes.each do |key, value| + send "#{key}=", value + end + end + + def condition_is_true + true + end + + def condition_is_true_but_its_not + false + end + + def perform_after_validation + self.after_validation_performed = true + end + + def my_validation + errors.add :title, "is missing" unless title + end + + def my_validation_with_arg(attr) + errors.add attr, "is missing" unless send(attr) + end + +end diff --git a/activemodel/test/models/track_back.rb b/activemodel/test/models/track_back.rb new file mode 100644 index 0000000000..768c96ecf0 --- /dev/null +++ b/activemodel/test/models/track_back.rb @@ -0,0 +1,11 @@ +class Post + class TrackBack + def to_model + NamedTrackBack.new + end + end + + class NamedTrackBack + extend ActiveModel::Naming + end +end
\ No newline at end of file diff --git a/activemodel/test/models/user.rb b/activemodel/test/models/user.rb new file mode 100644 index 0000000000..1ec6001c48 --- /dev/null +++ b/activemodel/test/models/user.rb @@ -0,0 +1,10 @@ +class User + extend ActiveModel::Callbacks + include ActiveModel::SecurePassword + + define_model_callbacks :create + + has_secure_password + + attr_accessor :password_digest +end diff --git a/activemodel/test/models/visitor.rb b/activemodel/test/models/visitor.rb new file mode 100644 index 0000000000..22ad1a3c3d --- /dev/null +++ b/activemodel/test/models/visitor.rb @@ -0,0 +1,10 @@ +class Visitor + extend ActiveModel::Callbacks + include ActiveModel::SecurePassword + + define_model_callbacks :create + + has_secure_password(validations: false) + + attr_accessor :password_digest, :password_confirmation +end diff --git a/activemodel/test/validators/email_validator.rb b/activemodel/test/validators/email_validator.rb new file mode 100644 index 0000000000..cff47ac230 --- /dev/null +++ b/activemodel/test/validators/email_validator.rb @@ -0,0 +1,6 @@ +class EmailValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors[attribute] << (options[:message] || "is not an email") unless + value =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i + end +end
\ No newline at end of file diff --git a/activemodel/test/validators/namespace/email_validator.rb b/activemodel/test/validators/namespace/email_validator.rb new file mode 100644 index 0000000000..57e2793ce2 --- /dev/null +++ b/activemodel/test/validators/namespace/email_validator.rb @@ -0,0 +1,6 @@ +require 'validators/email_validator' + +module Namespace + class EmailValidator < ::EmailValidator + end +end |