🔰

カスタムバリデーションはカスタムバリデータクラスで書こう

2024/11/02に公開

この記事でわかること

  • ActiveModel::EachValidator の使い方
  • テストの書き方

事の起こり

ある案件で wareki gem を使って入力された日付を和暦に変換して表示しています。
ある日お客様が入力した日付に誤りがあり、和暦が存在しない年がデータベースに登録され、ページを表示する際にエラーが発生し、表示できなくなってしまいました。
その際はデータを修正することで復旧しましたが、次回から同じ誤りが発生しないよう、バリデーションを追加することになりました。

カスタムメソッドで書く

カスタムメソッドで実装すると、例えばこうなります。

app/models/partient.rb
class Patient < ApplicationRecord
  validate :validate_birthdate

  private
  def validate_birthdate
    return unless birthdate.present?
    Wareki::Date.jd(birthdate)
  rescue Wareki::UnsupportedDateRange
    errors.add :birthdate, "に対応する和暦が見つかりません(#{birthdate})"
  end
end

カスタムメソッドとして実装した後、和暦として正しいか?判定するロジックは、他の属性に対しても使用したいと思いました。でも、属性ごとに同じロジックを書くのはDRYでないですね。そもそも、ある日付をどのようなロジックで判定するかというのは、 Partient クラスの関心ごとなのでしょうか? 誕生日が和暦に対応した日付であることは確認したいですが、具体的に和暦と対応するかどうかは、Patientクラスは知る必要がないと思います。

カスタムバリデータクラス使う

同じバリデーションのロジックを複数の属性や ActiveRecord / ActiveModel で使いまわしたいときは、カスタムバリデータが便利です。

app/models/wareki_validator.rb
# validates :attribute_name, wareki: true
# で使用したい場合、wareki を PascalCase + Validator という命名にする
class WarekiValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    date = value.respond_to?(:to_date) ? value.to_date: value
    if date.present?
      begin
        Wareki::Date.jd(date)
      rescue Wareki::UnsupportedDateRange
        record.errors.add attribute, "に対応する和暦が見つかりません(#{date.to_s})"
      end
    end
  end
end
app/models/partient.rb
class Patient < ApplicationRecord
  validates :birthdate, wareki: true
  validates :joined_on, presence: true, wareki: true
end
app/models/document.rb
class Document < ApplicationRecord
  validates :valid_until, wareki: true
end

テストを書く

例えば次のようにして、カスタムバリデータのテストを書けます。

test/models/wareki_validator_test.rb
require 'test_helper'

class WarekiVarlidatorTest < ActiveSupport::TestCase
  class WarekiModel
    include ActiveModel::Validations
    attr_accessor :date
    validates :date, wareki: true

    def initialize(date)
      self.date = date
    end
  end

  test "validate wareki within nil" do
    record = WarekiModel.new(nil)
    assert record.valid?
  end

  test "validate wareki within valid Date" do
    record = WarekiModel.new(Date.new(2024, 1, 1))
    assert record.valid?
  end

  test "validate wareki within invalid Date" do
    record = WarekiModel.new(Date.new(1, 1, 1))
    assert_not record.valid?
    assert_equal ["に対応する和暦が見つかりません(0001-01-01)"], record.errors[:date]
  end

  test "validate wareki within valid Time" do
    record = WarekiModel.new(Time.new(2024, 1, 1))
    assert record.valid?
  end

  test "validate wareki within invalid Time" do
    record = WarekiModel.new(Time.new(1, 1, 1))
    assert_not record.valid?
    assert_equal ["に対応する和暦が見つかりません(0001-01-03)"], record.errors[:date]
  end

  test "validate wareki within valid TimeWithZone" do
    record = WarekiModel.new(Time.zone.local(2024, 1, 1))
    assert record.valid?
  end

  test "validate wareki within invalid TimeWithZone" do
    record = WarekiModel.new(Time.zone.local(1, 1, 1))
    assert_not record.valid?
    assert_equal ["に対応する和暦が見つかりません(0001-01-03)"], record.errors[:date]
  end

  test "validate wareki within valid String" do
    record = WarekiModel.new("2024-01-01")
    assert record.valid?
  end

  test "validate wareki within invalid String" do
    record = WarekiModel.new("0001-01-01")
    assert_not record.valid?
    assert_equal ["に対応する和暦が見つかりません(0001-01-01)"], record.errors[:date]
  end
end

まとめ

カスタムバリデータを使うことで、バリデーションのロジックを特定のモデルから抽出することができました。

カスタムバリデータを使うことで、複数のモデルクラスで共有できるほか、 モデルクラスから本質的でないロジックを追い出し、モデルやテストの関心ごとを明確にし、コードやテストを見通し良く保つことができます。

タケユー・ウェブ株式会社

Discussion