ValidationSchemaでRailsのValidationをDRYにする

[Validatorクラス](http://api.rubyonrails.org/classes/ActiveModel/Validator.html)の話はしません。

Validatorクラスのコレジャナイ感は何なんですかねぇ。。。いや、あれはあれで必要なんですけど、同じ Validation を複数のクラスで使いまわすという目的にはちょっと向いてないなと思います。

以上、愚痴終わり。

## Validationの定義をDRYにしたい

複数のモデルやサービス層などを扱うRailsプロジェクトでは同じバリデーションを使いまわしたいケースが多々あります。
そういうときよく使われる手段が次の2つです。

- ActiveModel::Validator や ActiveModel::EachValidator を使う
- ActiveSupport::Concern (mixin) を使う

前者の方法は、汎用的に使いまわすようなバリデーションを定義するには便利ですが、局所的にしか使わないバリデーションに対して用いるには、やや冗長であるように感じます。また、既存のValidatorを組み合わせただけのものを定義するのにも向きません (参考: [複数のValidatorを組み合わせたバリデーションルールをcustom validatorとして用意する](http://qiita.com/kano-e/items/d848c0e5361a92c72191))

後者は単にバリデーションの定義部分を `app/models/concerns` 以下に mixin として定義するという意味ですが、 以下の点がいまいちです。

- module単位でしか共通化出来ないので冗長になりがち (大量の細かいmoduleをmixinする事態が発生など)
- サービス層を `app/services/` などの階層で作成している場合、 `app/models/concerns` 以下に定義したものを使いまわしづらい

要するに、DRYにできるのですが、かえって煩雑になりがちです。

## dry-validation のアイディア

[dry-validation](http://dry-rb.org/gems/dry-validation/) というズバリな名前のgemがあります。
リンク先のドキュメントから引用しますが、次のようにバリデーションのスキーマを定義できます。

```ruby
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` を次のように定義します。

```ruby
# 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` モデルでは次のように使います。

```ruby
class User < ApplicationRecord
  # UserValidationSchema で作成した rule を全て使えるようにする
  UserValidationSchema.new(self).define_all
end
```

サービス層などの他のクラスでも使うことが出来ます。

```ruby
# サービス層。 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` という名前で次のコードを作成しました。

```ruby
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` 以外のカラム名でも使えるようにしたい場合、次のようにします。

```ruby
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での例です。

```ruby
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 をルール毎に使いまわせるようにしました。私自身作ってみたばかりなので、もう少し使ってみて感触を得たいと考えていますが、今まで悩ましい問題のひとつだったバリデーションの定義が解決できそうな期待はあります。