🐦

【Rails】バリデーションの共通化(ActiveModel::EachValidator/ActiveModel::Validater)

2022/10/30に公開

この記事は?

Ruby on Rails のモデル層(あるいはフォームオブジェクト層)のバリデーションを共通化する方法として、以下の二つの基底クラスの使い方についてまとめました。

  • ActiveModel::EachValidator
  • ActiveModel::Validator

これらの使い方や使い分けについてを解説した日本語記事がインターネット上に少ないと感じたので、備忘録を兼ねて記事としました。

参考文献

Webサイト(無料)

書籍(有料)

いつ使う?

Rails のバリデーションルールは、通常各モデル(あるいはフォームオブジェクト)のファイルに直接記述をします。したがって、似たようなバリデーションルールが、複数のモデルファイルに繰り返し記述されてしまうことが起こりがちです。

当記事で紹介する二つの基底クラスActiveModel::EachValidatorまたはActiveModel::Validatorを利用することで、バリデーションルールを特定のモデルから分離したファイルとして定義し、複数のモデルで共通利用することが可能になります、

二つの基底クラスの使い分け

  1. ActiveModel::EachValidator: ある1つの属性について検証するバリデーションを定義する
  2. ActiveModel::Validator: 複数の属性について検証するバリデーションを定義する

1. ActiveModel::EachValidator

ある1つの属性について検証するルールを共通化したい場合は、ActiveModel::EachValidatorが利用できます。

app/validators/xxx_validator
class XXXValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)    
    if  {value に対する検証の条件}
      record.errors[atribute] << (options[:message] || 'デフォルトのエラーメッセージ')
    end
  end
end

validate_eachメソッド内に、共通化したいバリデーションルールを記述します。validate_eachメソッドの引数はそれぞれ、

  • record: バリデーション対象のオブジェクト
  • attribute: バリデーション対象の属性名
  • value: バリデーション対象の属性の値

に対応します。

これらを利用することで、record.errors[atribute]に、呼び出し側であるモデル層で記述したエラーメッセージを入れられます。

使用例

ECサイト系のアプリケーションを例に考えてみます。

出品者を表すEnterpriseUserモデルと、顧客を表すCustomerUserモデルが独立して存在し、それぞれの住所郵便番号zip_codeカラムについて、以下のバリデーションルールを定義したいとします。

  • 空でないこと
  • XXX-XXXXという形式であること(Xは半角数字)

素直に各モデルファイルにバリデーションルールを記述すると以下の通りです。

EnterpriseUser.rb
class EnterpriseUser < ApplicationRecord
  validates :zip_code, :presence: true, format: { with: /[0-9]{3}-?[0-9]{4}/, message: '不正な郵便番号です' }
  .
  .
  .
end
CustomerUser.rb
class EnterpriseUser < ApplicationRecord
  validates :zip_code, :presence: true, format: { with: /[0-9]{3}-?[0-9]{4}/, message: '不正な郵便番号です' }
  .
  .
  .
end

ここで定義したバリデーションルールのうち、後者の「XXX-XXXXという形式であること(Xは半角数字)」をActiveModel::EachValidatorを継承したクラスで共通化してみます。

app/validators/zip_code_format_validator.rbファイルを新規作成し、ActiveModel::EachValidatorクラスを継承したZipCodeFormatValidatorとして、以下のように実装できます。

app/validators/zip_code_format_validator.rb
class ZipCodeFormatValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.blank?
    
    unless value =~ /[0-9]{3}-?[0-9]{4}/
      record.errors[atribute] << (options[:message] || '不正な郵便番号です')
    end
  end
end

これを各モデル内で呼び出すことで、汎用化したバリデーションルールとして利用できます。

app/models/enterprise_user.rb
class EnterpriseUser < ApplicationRecord
  # 指定の message がエラーメッセージとして採用される
  validates :zip_code, zip_code_format: { message: '出品者の郵便番号に誤りがあります' }
  .
  .
  .
end
app/models/customer_user.rb
class EnterpriseUser < ApplicationRecord
  # options[:message] を指定していないので、デフォルトの「不正な郵便番号です」がエラーメッセージとして採用される
  validates :zip_code, zip_code_format: true
  .
  .
  .
end

存在性に対するバリデーションは、モデル側のpresence: trueで担保しているので、ZipCodeFormatValidatorの中では、valueが空の場合に早期リターンさせるガード説を設けています(これを記述しないと、valueが空の場合にエラーメッセージが重複表示されてしまいます)

また、record.errors[atribute](attributeは:zip_code)にエラーメッセージが入る仕組みになっているため、モデルに直接バリデーションを定義した場合と同じ感覚でルールを利用できます。

もし、ユースケースごとにエラーメッセージを切り分ける必要がなく、また全てのエラーメッセージをrecord.errors[:base]に入れてしまってもよい、という場合であれば、さらに簡略化して実装できます。

app/validators/zip_code_format_validator.rb
class ZipCodeFormatValidator < ActiveModel::EachValidator
  def validate_each(record, _, value)
    return if value.blank?
    
    unless value =~ /[0-9]{3}-?[0-9]{4}/
      record.errors[:base] << ('不正な郵便番号です')
    end
  end
end
app/models/enterprise_user.rb
class EnterpriseUser < ApplicationRecord
  validates :zip_code, zip_code_format: true
  .
  .
  .
end
app/models/customer_user.rb
class EnterpriseUser < ApplicationRecord
  validates :zip_code, zip_code_format: true
  .
  .
  .
end

2. ActiveModel::Validator

複数の属性について検証するルールを共通化したい場合は、ActiveModel::Validatorが利用できます。

app/validators/xxx_validator
class XXXValidator < ActiveModel::Validator
  def validate(record)
    if {record に対するエラー条件}
      record.errors[:base] << ('エラーメッセージ')
    end
  end
end

validateメソッドの中にバリデーションルールを記述します。引数のrecordは、バリデーション対象のオブジェクトを表します。

複数の属性が関係する(特定の1つの属性によらない)エラーとなるので、エラーメッセージは、record.errors[:base]に入れておくのがベターかと思います。

使用例

先ほどのECサイト系アプリケーションの、出品者EnterpriseUserモデルと顧客CustomerUserモデルの新規登録フローを例に、次のケースを考えてみます。

  • 各ユーザーの登録経路が複数するため、フォームオブジェクトを活用して登録処理をコントローラーから切り離している。
  • 登録ページの生年月日に関するフォームは、年year、月month、日dayをそれぞれプルダウンから選択できる仕様とする。

このとき、生年月日について、以下のバリデーションを、出品者EnterpriseUser、顧客CustomerUserの両方に定義したいとします。

  • year、月month、日dayは、日付として適切な組み合わせでなくてはならない(2月31日、のような組み合わせを許容しない)。
  • 満18歳以上でなくてはならない。

先のzip_codeの例とは異なり、複数の属性が関係するバリデーションになるので、共通化するにはActiveModel::Validatorの利用が必要です。

最初は、バリデーションの共通化をせずに記述してみます。まず、出品者EnterpriseUserのフォームオブジェクトは以下のイメージになります。

app/models/enterprise_user_registration_form.rb
# 出品者 EnterpriseUser の登録用フォームオブジェクト
class EnterpriseUserRegistrationForm  
  include ActiveModel::Model
  include ActiveModel::Validations
  
  MIN_AGE = 18

  validate :valid_birthday
  .
  .
  .
  def save
    .
    .
    .
  end
  
  private
  
    def valid_birthday
      # 値がnilの場合のガード節。別途、presence: true で存在性のバリデーションを定義する
      return unless [year, month, day].all?
             
      # year、month、dayを年齢(Integer)に変換
      age = ((Time.zone.now - Time.zone.local(year, month, day)) / 1.year.seconds).floor
      
      # 日付として不適当か or 年齢が必要最小年齢より低いか
      if !Date.valid_date?(year.to_i, month.to_i, day.to_i) || age < MIN_AGE
        record.errors.add(:base, '生年月日の日付が不正です。')
      end
    end
end

次に、顧客CustomerUserのフォームオブジェクトは以下のイメージになります。

app/models/customer_user_registration_form.rb
# 出品者 CustomerUser の登録用フォームオブジェクト
class CustomerUserRegistrationForm  
  include ActiveModel::Model
  include ActiveModel::Validations
  
  MIN_AGE = 18

  validate :valid_birthday
  .
  .
  .
  def save
    .
    .
    .
  end
  
  private
  
    def valid_birthday
      # 値がnilの場合のガード節。別途、presence: true で存在性のバリデーションを定義する
      return unless [year, month, day].all?
             
      # year、month、dayを年齢(Integer)に変換
      age = ((Time.zone.now - Time.zone.local(year, month, day)) / 1.year.seconds).floor
      
      # 日付として不適当か or 年齢が必要最小年齢より低いか
      if !Date.valid_date?(year.to_i, month.to_i, day.to_i) || age < MIN_AGE
        record.errors.add(:base, '生年月日の日付が不正です。')
      end
    end
end

生年月日に関するバリデーションvalid_birthdayが、それぞれのフォームオブジェクトに繰り返し実装されてしまっています。これを、ActiveModel::Validatorを継承したクラスを用いて共通化します。

app/validators/user_birthday_validator.rbファイルを新規作成し、ActiveModel::Validatorクラスを継承したUserBirthdayValidatorとして、以下のように実装できます。

app/validators/user_birthday_validator.rb
class UserBirthdayValidator < ActiveModel::Validator
  MIN_AGE  = 18
  
  def validate(record)
    year = redord.year&.to_i
    month = redord.month&.to_i
    day = redord.day&.to_i
    
    # 値がnilの場合のガード節。別途、presence: true で存在性のバリデーションを定義する
    return unless [year, month, day].all?
    
    # year、month、dayを年齢(Integer)に変換
    age = ((Time.zone.now - Time.zone.local(year, month, day)) / 1.year.seconds).floor
    
    # 日付として不適当か or 年齢が必要最小年齢より低いか
    if !Date.valid_date?(year, month, day) || age < MIN_AGE
      record.errors[:base] << ('生年月日の日付が不正です。')
    end
  end
end

これ、各フォームオブジェクトで呼び出すことで利用できます。ActiveModel::Validatorで定義した共通バリデーションは、validates_withで呼び出します。

app/models/enterprise_user_registration_form.rb
# 出品者 EnterpriseUser の登録用フォームオブジェクト
class EnterpriseUserRegistrationForm  
  include ActiveModel::Model
  include ActiveModel::Validations
  
  validates_with UserBirthdayValidator
  .
  .
  .
  def save
    .
    .
    .
  end
end
app/models/customer_user_registration_form.rb
# 出品者 CustomerUser の登録用フォームオブジェクト
class CustomerUserRegistrationForm  
  include ActiveModel::Model
  include ActiveModel::Validations
  
  validates_with UserBirthdayValidator
  .
  .
  .
  def save
    .
    .
    .
  end
end

バリデーションを共通化することで、フォームオブジェクトを非常にシンプルに記述できました!

まとめ

  • ActiveModel::EachValidatorを利用することで、ある1つの属性について検証するバリデーションルールを共通化できる。
  • ActiveModel::Validatorを利用することで、複数の属性について検証するバリデーションルールを共通化できる。

以上です!参考になったという方がいらっしゃいましたら、いいねを押していただけると嬉しいです!

また、もし内容に不備等ありましたら、コメントでご指摘いただけますと幸いですmm

Discussion