複数のValidatorを組み合わせたバリデーションルールをcustom validatorとして用意する


Rails 4.0.2 です。

(2016/02/04 追記)
最近のバージョンだと、[ここで紹介する方法は使えなくなった](http://qiita.com/kano-e/items/d848c0e5361a92c72191#comment-8db7e8523e681e471cb5)と情報をいただきました。
コメントで教えていただいた方法か、[Concern を使う](http://qiita.com/kano-e/items/d848c0e5361a92c72191#concern-%E3%81%A7%E3%82%82%E8%89%AF%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%8B%E3%82%82)のが良さそうです。

## 発端

auto increment な id 以外に、ユーザが自分で設定できるユニークな文字列のカラムを用意したい、みたいな時に、用意されているValidatorを組み合わせて、以下のようにモデルに設定する。

```ruby:
class User < ActiveRecord::Base
  validates :username,
    presence: true,                     # 必須にしたい！
    uniqueness: true,                   # URLに使うしユニーク！
    length: { maximum: 16 },            # あんまり長いのも……
    format: { with: /\A[a-z0-9]+\z/i }  # やっぱり半角英数字のみだよね！
end
```

これが、モデル一個だけなら良いのだけれど、複数のモデルで似たようなことをやろうとすると、面倒だったりする。

```ruby:
class Item < ActiveRecord::Base
  # なんだか User と似たようなこと書いてるなー……
  validates :name,
    presence: true,
    uniqueness: { scope: :user_id },
    length: { maximum: 32 },
    format: { with: /\A[a-z0-9]+\z/i }
end
```

なんとか `format` 部分だけ Validator にしたとしても、その手間の割に楽になっている気がしない。

```ruby:
class NameFormatValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    unless /\A[a-z0-9]+\z/i === value
      record.errors[attribute] << (options[:message] || "invalid")
    end
  end
end

class User < ActiveRecord::Base
  # ちっとも楽になった気がしない……
  validates :username,
    presence: true,
    uniqueness: true,
    length: { maximum: 16 },
    name_format: true
end
```


理想はこういう形。

```ruby:
class User < ActiveRecord::Base
  validates :username, name_format: { maximum: 16 }
end
```

```ruby:
class Item < ActiveRecord::Base
  validates :name, name_format: { scope: :user_id, maximum: 32 }
end
```

なので、全部まとめて Validator にしようかと思ったけど、`#validate_each` の中にこのチェック全部入れるの？ と手が止まってしまう。

```ruby:
class NameFormatValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    # 空だったらチェックして……？
    if value.blank?
      # あれ、空の時のエラーメッセージなんだっけ？
    end

    # ユニークかどうかって Validator の中からどうやるんだっけ？
    # ...
  end
end
```

そもそも、その辺りの仕組みはすでにあるのに、`#validate_each` の中に再実装しないといけないっていうのが間違ってる！

## 調べた

じゃあ、そもそもすでにある Validator ってどうなってるの？

というわけで `ActiveModel::Validations` の中身を確認してみた。

`ActiveModel::Validations` は `ActiveModel::Model` に `include` されている。
参考 ⇒ http://apidock.com/rails/ActiveModel/Validations

`ActiveModel::Validations::ClassMethods` の中に `.validates` が定義されている。

```ruby:activemodel/lib/active_model/validations/validates.rb
      def validates(*attributes)
        defaults = attributes.extract_options!.dup
        validations = defaults.slice!(*_validates_default_keys)

        raise ArgumentError, "You need to supply at least one attribute" if attributes.empty?
        raise ArgumentError, "You need to supply at least one validation" if validations.empty?

        defaults[:attributes] = attributes

        validations.each do |key, options|
          next unless options
          key = "#{key.to_s.camelize}Validator"

          begin
            validator = key.include?('::') ? key.constantize : const_get(key)
          rescue NameError
            raise ArgumentError, "Unknown validator: '#{key}'"
          end

          validates_with(validator, defaults.merge(_parse_validates_options(options)))
        end
      end
```

色々やっているけれども、対象のattribute名、Validatorの名前とそのoprions、に分解しValidatorクラスを見つけて `.validates_with` に渡している。

```ruby:activemodel/lib/active_model/validations/with.rb
      def validates_with(*args, &block)
        options = args.extract_options!
        args.each do |klass|
          validator = klass.new(options, &block)
          validator.setup(self) if validator.respond_to?(:setup)

          if validator.respond_to?(:attributes) && !validator.attributes.empty?
            validator.attributes.each do |attribute|
              _validators[attribute.to_sym] << validator
            end
          else
            _validators[nil] << validator
          end

          validate(validator, options)
        end
      end
```

`.validates_with` の中では Validator クラスのインスタンスを作っている。
そのインスタンス作成直後に `validator.setup(self) if validator.respond_to?(:setup)` ということをしている。

どうやら Validator クラスに `#setup` というメソッドがあれば、それを呼ぶらしい。

試しにいくつかの Validator クラスを見てみたけれども `#setup` メソッドを持っているものはそんなにない。
`ActiveModel::Validations::ConfirmationValidator` にあったので、どんな感じか見てみる。

```ruby:activemodel/lib/active_model/validations/confirmation.rb
      def setup(klass)
        klass.send(:attr_reader, *attributes.map do |attribute|
          :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation")
        end.compact)

        klass.send(:attr_writer, *attributes.map do |attribute|
          :"#{attribute}_confirmation" unless klass.method_defined?(:"#{attribute}_confirmation=")
        end.compact)
      end
```

klass を受け取って、その klass に対してあらかじめ何か操作をしたい時に使えるらしい。
いくつかコードを確認して、 `attributes` と `options` が使えることがわかったので、以下のように書ける。

```ruby:
class NameFormatValidator < ActiveModel::EachValidator
  def setup(klass)
    klass.validates(
      *attributes,
      presence: true,
      uniqueness: options[:scope].present? ? options.slice(:scope) : true,
      length: options.slice(:in, :minimum, :maximum, :is),
    )
  end

  def validate_each(record, attribute, value); end
end
```

これで

```ruby:
class Item < ActiveRecord::Base
  validates :name, name_format: { scope: :user_id, maximum: 32 }
end
```

これが実現できる！

## Concern でも良かったかも

と思ったけど Concern でも良かったかなーって、書きながら思い付いた。

```ruby:
module NameFormatColumn
  extend ActiveSupport::Concern

  module Class Methods
    def name_format_column(*attributes)
      options = attributes.extract_options!
      send(
        :validates,
        *attributes,
        presence: true,
        uniqueness: options[:scope].present? ? options.slice(:scope) : true,
        length: options.slice(:in, :minimum, :maximum, :is),
      )
    end
  end
end

class Item < ActiveRecord::Base
  include NameFormatColumn
  name_format_column :name, scope: :user_id, maximum: 32
end
```

今思い付いて書き下したコードなので、これで動くかはわかんない。