class AtLeastOneOfValidator < ActiveModel::Validator
def validate(record)
fields = Array(options[:fields])
raise ArgumentError, "provide :fields" if fields.empty?
return if fields.any? { |field| filled?(record.public_send(field)) }
record.errors.add(:base, message(fields))
end
private
def filled?(value)
value = value.strip if value.is_a?(String)
value.present?
end
def message(fields)
return options[:message] if options[:message]
names = fields.map { |f| f.to_s.humanize.downcase }.join(", ")
"must provide at least one of: #{names}"
end
end
class Contact < ApplicationRecord
EMAIL_FORMAT = /\A[^@\s]+@[^@\s]+\z/
PHONE_FORMAT = /\A\+?[0-9\-\s()]{7,}\z/
validates_with AtLeastOneOfValidator,
fields: %i[email phone mailing_address]
validates :email, format: { with: EMAIL_FORMAT }, allow_blank: true
validates :phone, format: { with: PHONE_FORMAT }, allow_blank: true
before_validation :normalize_email
def preferred_channel
return :email if email.present?
return :phone if phone.present?
:mail
end
private
def normalize_email
self.email = email.to_s.strip.downcase.presence
end
end
require "rails_helper"
RSpec.describe Contact, type: :model do
it "is invalid when every contact field is blank" do
contact = described_class.new(email: nil, phone: " ", mailing_address: "")
expect(contact).not_to be_valid
expect(contact.errors[:base]).to include(a_string_matching(/at least one/))
end
it "is valid when a single field is filled" do
contact = described_class.new(phone: "+1 555-123-4567")
expect(contact).to be_valid
end
it "treats whitespace-only values as absent" do
contact = described_class.new(mailing_address: " \n\t ")
expect(contact).not_to be_valid
expect(contact.errors[:base]).to be_present
end
it "still enforces format when a value is present" do
contact = described_class.new(email: "not-an-email")
expect(contact).not_to be_valid
expect(contact.errors[:email]).to be_present
end
end
This snippet solves a recurring modeling problem: a record where several columns are individually optional, but the business rule requires that at least one of them be filled in. A common example is a contact where either a phone number, an email, or a mailing address must be present — none is mandatory on its own, yet a blank contact is invalid. Rather than duplicating a tangle of conditional validate methods on every model, the logic is factored into a reusable ActiveModel::EachValidator-style validator.
The AtLeastOneOfValidator in the first tab subclasses ActiveModel::Validator (a record-level validator) rather than EachValidator, because the rule spans multiple attributes and only needs to run once per record. Its validate method reads the :fields array from options, then uses record.public_send to check each attribute against a present?-based helper filled?. Treating empty strings and whitespace as absent is deliberate: " " should not satisfy the rule, so filled? strips strings before checking. If none of the fields is filled, the error is attached to :base — the record-level error bucket — since no single attribute is at fault. The message is customizable via options[:message] and falls back to a humanized list of the field names.
The second tab, Contact model, wires the validator in with validates_with AtLeastOneOfValidator, fields: [...]. This keeps the model declarative and the intent obvious to future readers. The model also shows a subtle interaction: individual format validations on email and phone use allow_blank: true, so they only fire when a value is actually present. That separation matters — the "at least one" rule guards completeness, while the format rules guard correctness, and they must not fight each other.
The third tab is a focused spec covering the important edge cases: all-blank fails on :base, a single filled field passes, and a whitespace-only value is correctly rejected. The trade-off of the record-level approach is that the error is not tied to a specific input, which callers must handle in their forms. Reach for this pattern whenever a set of fields is collectively required but individually optional.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.