Read more

Rails: Custom validator for "only one of these" (XOR) presence validation

Arne Hartherz
September 07, 2022Software engineer at makandra GmbH

For Rails models where only one of multiple attributes may be filled out at the same time, there is no built-in validation.

Illustration online protection

Rails professionals since 2007

Our laser focus on a single technology has made us a leader in this space. Need help?

  • We build a solid first version of your product
  • We train your development team
  • We rescue your project in trouble
Read more Show archive.org snapshot

I've seen different solutions in the wild, each with different downsides:

  • Private method referenced via validate: works, but is barely portable and clutters the model.
  • Multiple presence validations with "if other is blank" each: looks pretty, but is incorrect as it allows both values to be filled in; also the error messages for a blank record are misleading.

Here is a third option: Write a custom validator to allow the following.

class User < ApplicationRecord

  validates :name, :nickname, xor_presence: true
  
end

Validator

Here is the validator class; put it somewhere in your project.

class XorPresenceValidator < ActiveModel::Validator

  def initialize(options)
    @attributes = Array.wrap(options[:attributes]).freeze

    if @attributes.many?
      super
    else
      raise ArgumentError, "#{self.class} requires at least 2 attributes"
    end
  end

  attr_reader :attributes

  def validate(record)
    present_values_by_attribute = attributes
      .index_with { |attribute| record.public_send(attribute) }
      .compact_blank

    if present_values_by_attribute.none?
      add_errors(record, :xor_blank, attributes)
    elsif present_values_by_attribute.many?
      add_errors(record, :xor_present, present_values_by_attribute.keys)
    end
  end

  def add_errors(record, type, attributes)
    humanizer = record.class.method(:human_attribute_name)

    attributes.each do |attribute|
      other_attributes_humanized = (attributes - [attribute]).map(&humanizer).join(', ')
      record.errors.add(attribute, type, other: other_attributes_humanized)
    end
  end

end

Note

Custom validators usually/often inherit from ActiveModel::EachValidator since they are only interested in one attribute and its current value at a time. The EachValidator iterates over all given attributes and validates each individually.
Since we care about "none present" (invalid), "exactly one present" (valid), and "multiple present" (also invalid), we inherit from the base Validator and implement one validate method for all.

Note how we add custom errors :xor_blank and :xor_present. If you want to show helpful form errors, simply add them to your application's I18n dictionary, like so:

en:
  errors:
    messages:
      xor_blank: or %{other} must either be filled in
      xor_present: may not be filled in simultaneously with %{other}

Examples:

>> User.new.tap(&:valid?).errors
=> #<ActiveModel::Errors [
  #<ActiveModel::Error attribute=name, type=xor_blank, options={:other=>"Nickname"}>,
  #<ActiveModel::Error attribute=nickname, type=xor_blank, options={:other=>"Name"}>
]>
>> User.new(name: "Bruce", nickname: "Batman").tap(&:valid?).errors
=> #<ActiveModel::Errors [
  #<ActiveModel::Error attribute=name, type=xor_present, options={:other=>"Nickname"}>,
  #<ActiveModel::Error attribute=nickname, type=xor_present, options={:other=>"Name"}>
]>
>> User.new(nickname: "Batman").tap(&:valid?).errors
=> #<ActiveModel::Errors []>
Arne Hartherz
September 07, 2022Software engineer at makandra GmbH
Posted by Arne Hartherz to makandra dev (2022-09-07 12:29)