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.