ruby 79 lines · 3 tabs

Custom Validator for “At Least One of” Fields

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Custom Validator for “At Least One of” Fields — share card
Link copied