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. TheEachValidator
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 baseValidator
and implement onevalidate
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 []>