Help us understand the problem. What is going on with this article?

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

More than 3 years have passed since last update.

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

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

kano-e
feedforce
『「働く」を豊かにする。』というミッションを掲げ、企業向けネットサービスを開発・提供しています。
https://www.feedforce.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした