Multiple Field custom validations in Rails

Eric PughNovember 8, 2007

I just took a spin around the bowls of ActiveRecord Validations trying to create a validator that would check if one, and only one, field of a set of fields is non blank on an ActiveRecord object. While I know I could have put the logic into an overridden validation method, I wanted to use the nice declarative syntax of validate_either_or :attribute_a, :attribute_b syntax with an eye to reusing it in the future.

I thought what I could do was mimic the existing validates_confirmation_of code and iterate through each attribute looking for one that is not nil. Set a variable, and then if I hit a second nil, add an error. After cycling through the attributes, if none had been found not nil, then also add an exception:

found_non_blank_already = false
validates_each(attr_names, configuration) do |record, attr_name, value|
unless value.blank?
if found_non_blank_already
record.errors.add(:base, configuration[:message])
else
found_non_blank_already = true
end
end
end

if !found_non_blank_already
# currently failing to compile
#record.errors.add(:base, configuration[:message])
end
end

However, what I discovered was that if I have an ActiveRecord object declare like this:

validate_either_or :discount_amount, :discount_percentage

and call it multiple times like this:

promo = Promo.new(:discount_percentage => nil, :discount_amount => 5)
promo.valid?
promo = Promo.new(:discount_percentage => 25, :discount_amount => nil)
promo.valid?

The second time the valid call fails validation because the line found_non_blank_already = false executed only once when the validation class is first loaded, and when you call .valid?, only the method validates_each chunk gets executed! So the second promo.valid? starts out thinking it has already hit a non null, and fails on the very first attribute! Clearly, the pattern used by most of the validate_* methods use is meant to validate each field passed in individually in isolation.

I then dug into what validates_each does and duplicated a lot of what it did, ending up with:

def self.validate_either_or(*attrs)
configuration = { :message => "one of #{attrs.to_sentence :connector => ‘or’} must be set", :on => :save }
configuration.update(attrs.pop) if attrs.last.is_a?(Hash)

options = configuration
send(validation_method(options[:on] || :save)) do |record|
found_non_blank_already = false
attrs.each do |attr|
value = record.send(attr)
unless value.blank?

if found_non_blank_already
record.errors.add(:base, configuration[:message])
else
found_non_blank_already = true
end
end
end
if !found_non_blank_already
record.errors.add(:base, configuration[:message])
end
end
end

There still seems to be a lot of black magic going on, for instance, the line send(validation_method(options[:on] || :save)) do |record| works, but I can’t figure out how it works, or where the validation_method comes from. If you want to test the code, here is what my specification looks like:

it "should allow either a discount_amount OR a discount_percentage, but not both" do
promo = Promo.new(:discount_percentage => 25, :discount_amount => 5)
promo.should_not be_valid
promo.errors.should have(1).error_on(:base)

promo = Promo.new(:discount_percentage => 25, :discount_amount => nil)
promo.should be_valid
promo.errors.should have(0).error_on(:base)
end

Oh, and thanks to Jay Fields for his post on to_sentence, it made the error message look nice and pretty!




More blog articles:


Let's do a project together!

We provide tailored search, discovery and analytics solutions using Solr and Elasticsearch. Learn more about our service offerings