Validatorクラスの話はしません。
Validatorクラスのコレジャナイ感は何なんですかねぇ。。。いや、あれはあれで必要なんですけど、同じ Validation を複数のクラスで使いまわすという目的にはちょっと向いてないなと思います。
以上、愚痴終わり。
Validationの定義をDRYにしたい
複数のモデルやサービス層などを扱うRailsプロジェクトでは同じバリデーションを使いまわしたいケースが多々あります。
そういうときよく使われる手段が次の2つです。
- ActiveModel::Validator や ActiveModel::EachValidator を使う
- ActiveSupport::Concern (mixin) を使う
前者の方法は、汎用的に使いまわすようなバリデーションを定義するには便利ですが、局所的にしか使わないバリデーションに対して用いるには、やや冗長であるように感じます。また、既存のValidatorを組み合わせただけのものを定義するのにも向きません (参考: 複数のValidatorを組み合わせたバリデーションルールをcustom validatorとして用意する)
後者は単にバリデーションの定義部分を app/models/concerns
以下に mixin として定義するという意味ですが、 以下の点がいまいちです。
- module単位でしか共通化出来ないので冗長になりがち (大量の細かいmoduleをmixinする事態が発生など)
- サービス層を
app/services/
などの階層で作成している場合、app/models/concerns
以下に定義したものを使いまわしづらい
要するに、DRYにできるのですが、かえって煩雑になりがちです。
dry-validation のアイディア
dry-validation というズバリな名前のgemがあります。
リンク先のドキュメントから引用しますが、次のようにバリデーションのスキーマを定義できます。
UserSchema = Dry::Validation.Schema do
required(:name).filled
required(:email).filled(format?: EMAIL_REGEX)
required(:age).maybe(:int?)
required(:address).schema do
required(:street).filled
required(:city).filled
required(:zipcode).filled
end
end
これはなかなかいい。DSLも直感的ですし、スキーマとして使いまわせるのは便利です。
ただ、 Rails アプリケーションのチーム開発を行う上で、 ActiveModel (ActiveRecord) と異なるバリデーションを用いるのは学習コストなどの面でリスクがあります。
そこでアイディアだけ拝借します。
ActiveModel用のValidationSchemaを作る
やりたい事としては、つぎのような感じです。 (ApplicationValidationSchema
については次節で説明します。)
まず、主に User
モデルで使う想定のバリデーションスキーマである UserValidationSchema
を次のように定義します。
# app/validation_schemas/user_validation_schema.rb
class UserValidationSchema < ApplicationValidationSchema
add_rule :email, -> { validates :email, presence: true, uniqueness: true }
add_rule :password, -> {
validates :password,
presence: true,
confirmation: {allow_blank: true},
length: {within: 8..40, allow_blank: true},
validate -> {
if password == email
errors.add(:password, :invalid)
end
}, if: :password
validates :password_confirmation, presence: true
}
end
UserValidationSchema
は User
モデルでは次のように使います。
class User < ApplicationRecord
# UserValidationSchema で作成した rule を全て使えるようにする
UserValidationSchema.new(self).define_all
end
サービス層などの他のクラスでも使うことが出来ます。
# サービス層。 app/services/user/SignIn.rb
class User::SignIn
include ActiveModel::Model
# email に関する rule のみ使えるようにする
UserValidationSchema.new(self).define(:email)
end
ApplicationValidationSchema について
荒削りですが app/validation_schemas/application_validation_schema.rb
という名前で次のコードを作成しました。
class ApplicationValidationSchema
class_attribute :rules
self.rules = HashWithIndifferentAccess.new
class << self
def add_rule(name, lambda)
self.rules = rules.merge(name => lambda)
end
end
attr_reader :context
def initialize(context)
@context = context
end
def define_all
define(*rules.keys)
end
def define(*keys)
rules.values_at(*keys).each do |schema|
context.instance_exec(&schema)
end
end
end
.add_rule
メソッドを用いると、スキーマの名前と lambda
を HashWithIndifferentAccess
に記録することが出来ます。
ここで記録した lambda
を #define
メソッドで評価することにより、バリデーションを定義できるようにしています。
lambda
を UserValidationSchema
を呼び出したコンテキストで評価する必要があるため、 UserValidationSchema.new(self)
により UserValidationSchema.new
を実行するクラス(つまりバリデーションを定義しようとしているクラス)の情報を渡します。
#define
の context.instance_exec(&schema)
の部分で lambda
が評価されるので、 User
モデルでバリデーションが使えるようになります。
同じバリデーションを複数のカラムで使いまわす
lambda
はかなり柔軟に処理を記述することが出来ます(それが故に注意も必要ですが...)。
例えば先程定義した email
に関するルールを、 email
以外のカラム名でも使えるようにしたい場合、次のようにします。
class UserValidationSchema < ApplicationValidationSchema
def self.email_rule(column_name)
-> { validates column_name, presence: true, uniqueness: true }
end
add_rule :email, email_rule(:email)
add_rule :sub_email, email_rule(:sub_email)
end
要するに add_rule
に適切な lambda
を渡せれば良いので、 lambda
の生成過程は UserValidationSchema
内で自由にできます。
他の ValidationSchema
クラスと共有したい場合は、 module に .email_rule
メソッドを作成して mixin するか、 Module Method として呼び出して使うかすればいいでしょう。
ValidationSchemaの単体テスト
やや気持ち悪いですが単体テストも書けます。次は、RSpecでの例です。
describe UserValidationSchema do
let(:dummy_user_class) {
Class.new {
def self.name
'DummyUser'
end
include ActiveModel::Model
attr_accessor :email
UserValidationSchema.new(self).define(:email)
}
}
subject(:dummy_user) { dummy_user_class.new(email: '') }
example do
dummy_user.valid?
expect(dummy_user.errors.full_messages_for(:email)).to include('Emailを入力してください')
end
end
まとめ
ApplicationValidationSchema
を用いることで Validation をルール毎に使いまわせるようにしました。私自身作ってみたばかりなので、もう少し使ってみて感触を得たいと考えていますが、今まで悩ましい問題のひとつだったバリデーションの定義が解決できそうな期待はあります。