Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Add ActiveModel subset validation helper #5236

Closed
wants to merge 1 commit into from
@rroblak

This is a validation helper that validates that the attribute is a subset of the specified list. Its usage looks like:

validates :answer, :subset, :in => ['unicorns', 'rainbows', 'horsies']

I found this useful in validating an Array attribute on one of my models. The attribute corresponded to a group of checkboxes in the view.

The attribute itself only needs to support the each method for iteration.

@bcardarella

Can't this be accomplished by using the Proc option with the inclusion validation?

@rroblak

Not as far as I can tell. The Proc option of the inclusion validation helper allows one to specify the value against which the attribute will be tested for inclusion (i.e. my_proc.call.include?(my_attribute)), but it doesn't provide for testing inclusion of each value in a set (i.e. my_attribute.each {|v| superset.include?(v)}).

activemodel/lib/active_model/validations/subset.rb
((11 lines not shown))
+ 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.nil? && !options[:allow_nil]
+ record.errors.add(attribute, :allow_nil, options.except(:in).merge!(:value => value))
+ return
+ elsif value.blank? && !options[:allow_blank]
+ record.errors.add(attribute, :allow_blank, options.except(:in).merge!(:value => value))
+ return
+ elsif !value.respond_to?(:each)

I don't think you're supposed to handle :allow_nil and :allow_blank inside your own validator, the basic functionality of these default options are already handled by the EachValidator.

@rroblak
rroblak added a note

You're correct that the :allow_nil handling can be removed from the subset validation helper as it is handled by EachValidator. However, the handling for :allow_blank is needed in order for the following test case to pass:

Person.validates_subset_of(:nicknames, :in => nicknames, :allow_blank => false)
assert Person.new(:nicknames => []).invalid?

I've updated the pull request with the :allow_nil handling removed.

Nice. Just one more addition: I believe you should not add other errors than the subset one. Besides, I think allow_blank is not a valid error message, it uses invalid. I can think about something like this:

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

If the value is blank at this point, it means it already passed the allow_blank check in EachValidator, right?
Wdyt?

@rroblak
rroblak added a note

I like your suggestion-- it makes the code more concise and all the tests still pass. I'll update the pull request with those changes momentarily.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@isaacsanders

Is this still an issue?

@rroblak

Yes, I believe so.

@carlosantoniodasilva

@josevalim @rafaelfranca @jeremy I'd like some feedback on this one, if you think it's valid / worth adding such feature.

@josevalim
Owner

:-1: from me. It is a very specific use case (I for example can't remember using it or needing it).

@dmathieu
Collaborator

:-1: too.
@zenprogrammer why don't you make a gem with it ?

@bogdan

It's useful when serialize attr, Array is used and such attribute is formed with some hardcoded values like:

class User <AR::B
  serialize :roles
  validates :roles, :subset => [ADMIN, CONTROLLER, STRATEGIST, COORDINATOR]
end

NoSQL fans shoud find more usecases to this.
I am +1 for this.

@Hakon

:+1: for the reasons bogdan stated

@rroblak

I was actually surprised to discover that Rails didn't already support this, given its support for deserialization of JSON HTTP parameters and its support for model attribute serialization. Subset validation seems rather fundamental to handling serialized arrays.

Of course I'm happy to make a gem with it if it's not included :)

@rafaelfranca

Yes, it is useful, but I don't think that it should be included by default on Rails.

:-1:

@steveklabnik
Collaborator

Since we have :-1: from two core members, and no commentary in three months, I'm giving this a close. Feel free to make a gem, and if it gets popular, maybe we can consider using it then?

@brycesenz

If this helps anyone, I ran into a similar use case (validating serialized array/hash values). I've created a standalone gem that add some additional serialization validations to ActiveModel. It might be useful for your needs.

https://github.com/brycesenz/validates_serialized

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Mar 2, 2012
  1. @rroblak
This page is out of date. Refresh to see the latest.
View
1  activemodel/lib/active_model/locale/en.yml
@@ -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"
View
78 activemodel/lib/active_model/validations/subset.rb
@@ -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
View
134 activemodel/test/cases/validations/subset_validation_test.rb
@@ -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
View
8 activemodel/test/models/person.rb
@@ -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
Something went wrong with that request. Please try again.