Verify That an ActiveRecord Object has at Least One Attribute
Rails ActiveRecord callbacks make validation a breeze, but what happens if you want to verify that a record has at least one attribute present, but you don't care which attribute it is? This was a recent case I was working on for a client who was having their operators fill in a form with a lot of uncertainty. They couldn't know which record would be needed, but we didn't want to allow all records to be blank.
Start with the Spec
Let's describe the desired behavior in RSpect and FactoryBot first for an imaginary Report
object.
spec/models/report_spec.rb
RSpec.describe Report, type: :model do
# I set up all FactoryBot factories to build with the
# required fields. In this case no fields are actually
# required so I sub them in whenever they are under test.
# In this case the absensce case is under test, so no attribute
# is passed
let(:report) { FactoryBot.build(:report) }
describe 'validations' do
it 'requires at least one field to exist' do
report.valid?
# important: make sure you are looking for the custom
# key that you create, not a generic error
expect(report.errors[:report]).not_to be_empty
end
end
end
Build the Custom Validator
Rails offers two kinds of custom validators: Validator
and EachValidator
. In this case, we want to pass in the entire record rather than a specific attribute, so we will go with Validator
.
app/models/concerns/record_not_empty_validator.rb
class RecordNotEmptyValidator < ActiveModel::Validator
def validate(record)
unless non_id_attributes(record).any? { |attr| record.attribute_present?(attr) }
record.errors.add(record.model_name.human.downcase!, 'must contain at least one attribute')
end
end
private
def non_id_attributes(record)
record.attribute_names.reject!{ |attr| attr.include?('_id') }
end
end
This code uses some Rails convenience methods to first remove any attribute names which contain the substring _id
(which is useful for models with associations because depending on how you build the objects this field may not be nil
) and then checks that at least one of the remaining attributes have a value. One thing to note is that if you examine the object ancestor chain you don't have a lot of ActiveSupport modules to work with so .human
was the best method I could find to transform the model name. It's not strictly necessary to downcase the resulting key because it will be capitalized when shown in the view regardless, but my specs all assume that keys are lowercase so I include it.
Include it on the Model
Finally, you just need to call the validator on the model itself.
app/models/report.rb
class Report < ApplicationRecord
validates_with RecordNotEmptyValidator
...
end
Handling Updates
The above code will work, but only for creating new objects. Let's say an object has been created and your user edits that object to remove all the fields. You would expect it to error, but it won't because there is now an id
field that is not blank as well as created_at
and updated_at
fields (if you included timestamps in your migration).
To handle these we'll need to add some additional matchers:
app/models/concerns/record_not_empty_validator.rb
def non_id_attributes(record)
record
.attribute_names
.reject!{ |attr| attr.include?('_id') }
.reject!{ |attr| attr.include?('_at') }
.reject!{ |attr| attr.match(/\bid\b/)
end
For the second reject statement, we'll assume that none of your real data fields include the _at
suffix so we can match both created_at
and updated_at
fields. For the third, we will throw out any field that only contains id
(which will only be the record primary key).
With those two additions you can now validate against updated records and all that's left to do is update the spec:
spec/models/report_spec.rb
RSpec.describe Report, type: :model do
let(:report) { FactoryBot.build(:report) }
# Assume report has a title field
let(:saved_report) { FactoryBot.create(:report, title: 'title') }
describe 'validations' do
...
it 'precludes removing all fields' do
saved_report.title = ''
saved_report.valid?
expect(saved_report.errors[:report]).not_to be_empty
end
end
end
If you are going to be doing this validation on a lot of models, you will probably want to pull this out into a shared example so it can included with one line.
As a final note: the docs indicate that you need include ActiveModel::Validations
on the model, but I don't find that to be necessary.