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

Posted . Visible to the public.

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

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
Last edit
Arne Hartherz
License
Source code in this card is licensed under the MIT License.
Posted by Arne Hartherz to makandra dev (2022-09-07 10:29)