LoginSignup
114

More than 5 years have passed since last update.

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

Last updated at Posted at 2014-02-06

Rails 4.0.2 です。

(2016/02/04 追記)
最近のバージョンだと、ここで紹介する方法は使えなくなったと情報をいただきました。
コメントで教えていただいた方法か、Concern を使うのが良さそうです。

発端

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

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

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

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 にしたとしても、その手間の割に楽になっている気がしない。

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

理想はこういう形。

class User < ActiveRecord::Base
  validates :username, name_format: { maximum: 16 }
end
class Item < ActiveRecord::Base
  validates :name, name_format: { scope: :user_id, maximum: 32 }
end

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

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

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

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

調べた

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

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

ActiveModel::ValidationsActiveModel::Modelinclude されている。
参考 ⇒ http://apidock.com/rails/ActiveModel/Validations

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

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 に渡している。

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 にあったので、どんな感じか見てみる。

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 に対してあらかじめ何か操作をしたい時に使えるらしい。
いくつかコードを確認して、 attributesoptions が使えることがわかったので、以下のように書ける。

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

これで

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

これが実現できる!

Concern でも良かったかも

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

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

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

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
114