Blog

Multiple Field custom validations in Rails

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 = falsevalidates_each(attr_names, configuration) do |record, attr_name, value|unless value.blank?if found_non_blank_alreadyrecord.errors.add(:base, configuration[:message])elsefound_non_blank_already = trueendendendif !found_non_blank_already# currently failing to compile#record.errors.add(:base, configuration[:message])endend

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 = configurationsend(validation_method(options[:on] || :save)) do |record|found_non_blank_already = falseattrs.each do |attr|value = record.send(attr)unless value.blank?if found_non_blank_alreadyrecord.errors.add(:base, configuration[:message])elsefound_non_blank_already = trueendendendif !found_non_blank_alreadyrecord.errors.add(:base, configuration[:message])endendend

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 cant 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" dopromo = Promo.new(:discount_percentage => 25, :discount_amount => 5)promo.should_not be_validpromo.errors.should have(1).error_on(:base)promo = Promo.new(:discount_percentage => 25, :discount_amount => nil)promo.should be_validpromo.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!