Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

Already on GitHub? Sign in to your account

Add ActiveModel subset validation helper #5236

Closed
wants to merge 1 commit into
from
Jump to file or symbol
Failed to load files and symbols.
+220 −1
Split
@@ -6,6 +6,7 @@ en:
# The values :model, :attribute and :value are always available for interpolation
# The value :count is available when applicable. Can be used for pluralization.
messages:
+ subset: "is not a subset of the list"
inclusion: "is not included in the list"
exclusion: "is reserved"
invalid: "is invalid"
@@ -0,0 +1,78 @@
+module ActiveModel
+
+ # == Active Model Subset Validator
+ module Validations
+ class SubsetValidator < EachValidator
+ ERROR_MESSAGE = "An object with the method #include? or a proc or lambda is required, " <<
+ "and must be supplied as the :in option of the configuration hash"
+
+ def check_validity!
+ unless [:include?, :call].any?{ |method| options[:in].respond_to?(method) }
+ raise ArgumentError, ERROR_MESSAGE
+ end
+ end
+
+ def validate_each(record, attribute, value)
+ delimiter = options[:in]
+
+ superset = delimiter.respond_to?(:call) ? delimiter.call(record) : delimiter
+
+ if value.blank? || !value.respond_to?(:each)
+ record.errors.add(attribute, :subset, options.except(:in).merge!(:value => value))
+ return
+ end
+
+ value.each do |elem|
+ if !superset.send(inclusion_method(superset), elem)
+ record.errors.add(attribute, :subset, options.except(:in).merge!(:value => value))
+ break
+ end
+ end
+ end
+
+ private
+
+ # In Ruby 1.9 <tt>Range#include?</tt> on non-numeric ranges checks all possible values in the
+ # range for equality, so it may be slow for large ranges. The new <tt>Range#cover?</tt>
+ # uses the previous logic of comparing a value with the range endpoints.
+ def inclusion_method(enumerable)
+ enumerable.is_a?(Range) ? :cover? : :include?
+ end
+ end
+
+ module HelperMethods
+ # Validates whether the specified attribute is a subset of a particular enumerable object.
+ # The attribute must support iteration via the :each method (typically an Array).
+ #
+ # class Person < ActiveRecord::Base
+ # validates_subset_of :fantasies, :in => ['unicorns', 'rainbows', 'horsies']
+ # validates_subset_of :ages, :in => 0..99
+ # validates_subset_of :animals, :in => ['dogs', 'cats', 'frogs'], :message => "%{value} is not a subset of the list"
+ # validates_subset_of :ingredients, :in => lambda{ |f| f.type == 'fruit' ? ['apples', 'bananas'] : ['lettuce', 'carrots'] }
+ # end
+ #
+ # Configuration options:
+ # * <tt>:in</tt> - An enumerable object of available items. This can be
+ # supplied as a proc or lambda which returns an enumerable. If the enumerable
+ # is a range the test is performed with <tt>Range#cover?</tt>
+ # (backported in Active Support for 1.8), otherwise with <tt>include?</tt>.
+ # * <tt>:message</tt> - Specifies a custom error message (default is: "is not included in the list").
+ # * <tt>:allow_nil</tt> - If set to true, skips this validation if the attribute is +nil+ (default is +false+).
+ # * <tt>:allow_blank</tt> - If set to true, skips this validation if the attribute is blank (default is +false+).
+ # * <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>:strict</tt> - Specifies whether validation should be strict.
+ # See <tt>ActiveModel::Validation#validates!</tt> for more information
+ def validates_subset_of(*attr_names)
+ validates_with SubsetValidator, _merge_attributes(attr_names)
+ end
+ end
+ end
+end
@@ -0,0 +1,134 @@
+# encoding: utf-8
+require 'cases/helper'
+
+require 'models/person'
+
+class SubsetValidationTest < ActiveModel::TestCase
+
+ def teardown
+ Person.reset_callbacks(:validate)
+ end
+
+ def test_validates_subset_of
+ nicknames = ['Bubba', 'Jimbo', 'Jr.', 'Rodney', 'Edmundo']
+
+ Person.validates_subset_of(:nicknames, :in => nicknames)
+
+ assert Person.new(:nicknames => "blargle").invalid?
+ assert Person.new(:nicknames => ["blargle"]).invalid?
+ assert Person.new(:nicknames => nicknames + ["blargle"]).invalid?
+ assert Person.new(:nicknames => nicknames.sample(nicknames.length - 1) + ["blargle"]).invalid?
+
+ (1..nicknames.length).each do |i|
+ assert Person.new(:nicknames => nicknames.sample(i)).valid?
+ end
+ end
+
+ def test_validates_subset_of_with_allow_nil
+ nicknames = ['Bubba', 'Jimbo', 'Jr.', 'Rodney', 'Edmundo']
+
+ Person.validates_subset_of(:nicknames, :in => nicknames, :allow_nil => true)
+
+ assert Person.new(:nicknames => nil).valid?
+
+ Person.reset_callbacks(:validate)
+
+ Person.validates_subset_of(:nicknames, :in => nicknames, :allow_nil => false)
+
+ assert Person.new(:nicknames => nil).invalid?
+
+ Person.reset_callbacks(:validate)
+
+ Person.validates_subset_of(:nicknames, :in => nicknames)
+
+ assert Person.new(:nicknames => nil).invalid?
+ end
+
+ def test_validates_subset_of_with_allow_blank
+ nicknames = ['Bubba', 'Jimbo', 'Jr.', 'Rodney', 'Edmundo']
+
+ Person.validates_subset_of(:nicknames, :in => nicknames, :allow_blank => true)
+
+ assert Person.new(:nicknames => nil).valid?
+ assert Person.new(:nicknames => "").valid?
+ assert Person.new(:nicknames => []).valid?
+
+ Person.reset_callbacks(:validate)
+
+ Person.validates_subset_of(:nicknames, :in => nicknames, :allow_blank => false)
+
+ assert Person.new(:nicknames => nil).invalid?
+ assert Person.new(:nicknames => "").invalid?
+ assert Person.new(:nicknames => []).invalid?
+
+ Person.reset_callbacks(:validate)
+
+ Person.validates_subset_of(:nicknames, :in => nicknames)
+
+ assert Person.new(:nicknames => nil).invalid?
+ assert Person.new(:nicknames => "").invalid?
+ assert Person.new(:nicknames => []).invalid?
+ end
+
+ def test_validates_subset_of_with_default_message
+ Person.validates_subset_of(:nicknames, :in => ['Bubba', 'Jimbo', 'Jr.', 'Rodney', 'Edmundo'])
+
+ nicknames = ["Bubbajim"]
+ p = Person.new(:nicknames => nicknames)
+
+ assert p.invalid?
+ assert_equal ["is not a subset of the list"], p.errors[:nicknames]
+ end
+
+ def test_validates_subset_of_with_formatted_message
+ Person.validates_subset_of(:nicknames, :in => ['Bubba', 'Jimbo', 'Jr.', 'Rodney', 'Edmundo'], :message => "%{value} ain't a subset")
+
+ nicknames = ["Bubbajim"]
+ p = Person.new(:nicknames => nicknames)
+
+ assert p.invalid?
+ assert_equal ["#{nicknames} ain't a subset"], p.errors[:nicknames]
+ end
+
+ def test_validates_subset_of_with_lambda
+ Person.validates_subset_of :nicknames, :in => lambda{ |p| p.title == "Duke of Rodney" ? ["Rodney", "Duke"] : ["Edmundo", "Sir"] }
+
+ p = Person.new
+ p.title = "Duke of Rodney"
+
+ p.nicknames = ["Sir"]
+ assert p.invalid?
+ p.nicknames = ["Edmundo"]
+ assert p.invalid?
+ p.nicknames = ["Sir", "Edmundo"]
+ assert p.invalid?
+
+ p.nicknames = ["Rodney"]
+ assert p.valid?
+ p.nicknames = ["Duke"]
+ assert p.valid?
+ p.nicknames = ["Rodney", "Duke"]
+ assert p.valid?
+
+ p.title = "Sir Edmundo"
+
+ p.nicknames = ["Rodney"]
+ assert p.invalid?
+ p.nicknames = ["Duke"]
+ assert p.invalid?
+ p.nicknames = ["Rodney", "Duke"]
+ assert p.invalid?
+
+ p.nicknames = ["Sir"]
+ assert p.valid?
+ p.nicknames = ["Edmundo"]
+ assert p.valid?
+ p.nicknames = ["Sir", "Edmundo"]
+ assert p.valid?
+ end
+
+ def test_argument_validation
+ assert_raise(ArgumentError) { Person.validates_subset_of(:nicknames, :in => nil ) }
+ assert_raise(ArgumentError) { Person.validates_subset_of(:nicknames, :in => 0) }
+ end
+end
@@ -2,7 +2,13 @@ class Person
include ActiveModel::Validations
extend ActiveModel::Translation
- attr_accessor :title, :karma, :salary, :gender
+ attr_accessor :title, :karma, :salary, :gender, :nicknames
+
+ def initialize(attributes = {})
+ attributes.each do |key, value|
+ send "#{key}=", value
+ end
+ end
def condition_is_true
true