【Rails】バリデーションの共通化(ActiveModel::EachValidator/ActiveModel::Validater)
この記事は?
Ruby on Rails のモデル層(あるいはフォームオブジェクト層)のバリデーションを共通化する方法として、以下の二つの基底クラスの使い方についてまとめました。
ActiveModel::EachValidator
ActiveModel::Validator
これらの使い方や使い分けについてを解説した日本語記事がインターネット上に少ないと感じたので、備忘録を兼ねて記事としました。
参考文献
Webサイト(無料)
- Railsガイド Active Record バリデーション
- Railsでモデル層からバリデーションを切り出して共通のバリデーションルールを定義する ActiveModel::EachValidator編
- Railsでモデル層からバリデーションを切り出して共通のバリデーションルールを定義する ActiveModel::Validator編
書籍(有料)
いつ使う?
Rails のバリデーションルールは、通常各モデル(あるいはフォームオブジェクト)のファイルに直接記述をします。したがって、似たようなバリデーションルールが、複数のモデルファイルに繰り返し記述されてしまうことが起こりがちです。
当記事で紹介する二つの基底クラスActiveModel::EachValidator
またはActiveModel::Validator
を利用することで、バリデーションルールを特定のモデルから分離したファイルとして定義し、複数のモデルで共通利用することが可能になります、
二つの基底クラスの使い分け
-
ActiveModel::EachValidator
: ある1つの属性について検証するバリデーションを定義する -
ActiveModel::Validator
: 複数の属性について検証するバリデーションを定義する
1. ActiveModel::EachValidator
ある1つの属性について検証するルールを共通化したい場合は、ActiveModel::EachValidator
が利用できます。
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は半角数字)
素直に各モデルファイルにバリデーションルールを記述すると以下の通りです。
class EnterpriseUser < ApplicationRecord
validates :zip_code, :presence: true, format: { with: /[0-9]{3}-?[0-9]{4}/, message: '不正な郵便番号です' }
.
.
.
end
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
として、以下のように実装できます。
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
これを各モデル内で呼び出すことで、汎用化したバリデーションルールとして利用できます。
class EnterpriseUser < ApplicationRecord
# 指定の message がエラーメッセージとして採用される
validates :zip_code, zip_code_format: { message: '出品者の郵便番号に誤りがあります' }
.
.
.
end
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]
に入れてしまってもよい、という場合であれば、さらに簡略化して実装できます。
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
class EnterpriseUser < ApplicationRecord
validates :zip_code, zip_code_format: true
.
.
.
end
class EnterpriseUser < ApplicationRecord
validates :zip_code, zip_code_format: true
.
.
.
end
2. ActiveModel::Validator
複数の属性について検証するルールを共通化したい場合は、ActiveModel::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
のフォームオブジェクトは以下のイメージになります。
# 出品者 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
のフォームオブジェクトは以下のイメージになります。
# 出品者 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
として、以下のように実装できます。
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
で呼び出します。
# 出品者 EnterpriseUser の登録用フォームオブジェクト
class EnterpriseUserRegistrationForm
include ActiveModel::Model
include ActiveModel::Validations
validates_with UserBirthdayValidator
.
.
.
def save
.
.
.
end
end
# 出品者 CustomerUser の登録用フォームオブジェクト
class CustomerUserRegistrationForm
include ActiveModel::Model
include ActiveModel::Validations
validates_with UserBirthdayValidator
.
.
.
def save
.
.
.
end
end
バリデーションを共通化することで、フォームオブジェクトを非常にシンプルに記述できました!
まとめ
-
ActiveModel::EachValidator
を利用することで、ある1つの属性について検証するバリデーションルールを共通化できる。 -
ActiveModel::Validator
を利用することで、複数の属性について検証するバリデーションルールを共通化できる。
以上です!参考になったという方がいらっしゃいましたら、いいねを押していただけると嬉しいです!
また、もし内容に不備等ありましたら、コメントでご指摘いただけますと幸いですmm
Discussion