前回の記事では1つの属性に関するバリデーションルールを共通化出来るActiveModel::EachValidatorについて解説した。 そこで今回は、Railsのバリデーションを定義するためのもう一つの基底クラスである、ActiveModel::Validatorについて解説したいと思う。
ActiveModel::Validatorは複数の属性を組み合わせたバリデーションルールなど、より複雑なルールを定義する際に利用される。
例えば、イベントを管理するモデルを考えて、そのイベントの開始日時と終了日時の差は24時間以内であるというケースを例に実装からテストまで行ってみる。

使い方

Eventモデルは下記のような構成になっているとする。
ここで、start_atとend_atの差が24時間以内であるというバリデーションを設定したい。

# app/models/event.rb

# == Schema Information
#
# Table name: events
#
#  id                :bigint           not null, primary key
#  name              :string           default(""), not null
#  content           :text             default(""), not null
#  start_at          :datetime         not null
#  end_at            :datetime         not null
#  created_at        :datetime         not null
#  updated_at        :datetime         not null

class Organization < ApplicationRecord

  validates_presence_of %i(
    name
    content
    start_at
    end_at
  )
  validate :valide_difference_between_start_at_and_end_at

  private

  def valide_difference_between_start_at_and_end_at
    if (Time.zone.parse(end_at) - Time.zone.parse(start_at)) / 3600 > 24
      errors.add(:base, '開始日時と終了日時の差は24時間以内にしてください')
    end
  end
end

上記のように記述しても問題ないのだが、他のモデルでも同じバリデーションルールを使いたい時や、ファットモデルになってきた時に不都合が出てくる可能性がある。
そこで、ActiveModel::Validatorを使ってバリデーションルールだけ外に切り出してみよう。

# app/validators/event_range_validator.rb

class EventRangeValidator < ActiveModel::Validator
  # マジックナンバーは定数化する
  MAX_HOUR = 24
  SECONDS_OF_AN_HOUR = 3600

  def validate(record)
    if (Time.zone.parse(record.end_at) - Time.zone.parse(record.start_at)) / SECONDS_OF_AN_HOUR > MAX_HOUR
      # 特定の属性に属さないエラーはbaseに格納する
      record.errors.add(:base, '開始日時と終了日時の差は24時間以内にしてください')
    end
  end
end 

上記で作成したバリデーションを、下記のように使う。

# app/models/event.rb

# == Schema Information
#
# Table name: events
#
#  id                :bigint           not null, primary key
#  name              :string           default(""), not null
#  content           :text             default(""), not null
#  start_at          :datetime         not null
#  end_at            :datetime         not null
#  created_at        :datetime         not null
#  updated_at        :datetime         not null

class Organization < ApplicationRecord

  validates_presence_of %i(
    name
    content
    start_at
    end_at
  )
  validates_with EventRangeValidator, unless: -> { start_at.blank? || end_at.blank? }
end

モデルをスッキリさせることが出来た。

テストを書く

前回同様、Vlidatorのテストを書いてみる。
StructクラスにActiveModel::Validationsをincludeする。

spec/validators/event_range_validator_spec.rb

RSpec.describe EventRangeValidator, type: :validator do
  let(:clazz) do
    Struct.new(:start_at, :end_at) do
      include ActiveModel::Validations

      validates_presence_of %i(
        start_at
        end_at
      )
      validates_with EventRangeValidator, unless: -> { start_at.blank? || end_at.blank? }
    end
  end

  describe '#validate' do
    subject { clazz.new(start_at, end_at) }

    context '差が21時間' do
      let(:start_at) { '2021-01-01 01:00:00' }
      let(:end_at) { '2021-01-01 22:00:00' }
      it { is_expected.to be_valid }
    end

    context '差が24時間' do
      let(:start_at) { '2021-01-01 01:00:00' }
      let(:end_at) { '2021-01-02 01:00:00' }
      it { is_expected.to be_valid }
    end
  end

  describe '異常系' do
    subject { clazz.new(start_at, end_at) }

    context '差が25時間' do
      let(:start_at) { '2021-01-01 01:00:00' }
      let(:end_at) { '2021-01-02 02:00:00' }
      it { is_expected.to be_valid }
    end

    context '差が48時間' do
      let(:start_at) { '2021-01-01 01:00:00' }
      let(:end_at) { '2021-01-03 01:00:00' }
      it { is_expected.to be_valid }
    end
  end
end

まとめ

複数の属性に対してバリデーションルールを設定出来るActiveModel::Validatorを解説した。 前回の記事と合わせて読めば、自由自在にバリデーションを共通化させられるのではないだろうか。