LoginSignup
18
11

More than 5 years have passed since last update.

ValidationSchemaでRailsのValidationをDRYにする

Last updated at Posted at 2016-12-31

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

UserValidationSchemaUser モデルでは次のように使います。

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 メソッドを用いると、スキーマの名前と lambdaHashWithIndifferentAccess に記録することが出来ます。
ここで記録した lambda#define メソッドで評価することにより、バリデーションを定義できるようにしています。
lambdaUserValidationSchema を呼び出したコンテキストで評価する必要があるため、 UserValidationSchema.new(self) により UserValidationSchema.new を実行するクラス(つまりバリデーションを定義しようとしているクラス)の情報を渡します。
#definecontext.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 をルール毎に使いまわせるようにしました。私自身作ってみたばかりなので、もう少し使ってみて感触を得たいと考えていますが、今まで悩ましい問題のひとつだったバリデーションの定義が解決できそうな期待はあります。

18
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
11