1. kano-e

    No comment

    kano-e
Changes in body
Source | HTML | Preview

Rails 4.0.2 です。

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

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

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

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

app/validators/name_format_validator.rb
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
app/model/user.rb
class User < ActiveRecord::Base
  # ちっとも楽になった気がしない……
  validates :username,
    presence: true,
    uniqueness: true,
    length: { maximum: 16 },
    name_format: true
end

理想はこういう形。

app/models/user.rb
class User < ActiveRecord::Base
  validates :username, name_format: { maximum: 16 }
end
app/models/item.rb
class Item < ActiveRecord::Base
  validates :name, name_format: { scope: :user_id, maximum: 32 }
end

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

app/validators/name_format_validator.rb
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 が使えることがわかったので、以下のように書ける。

app/validators/name_format_validator.rb
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

これで

app/models/item.rb
class Item < ActiveRecord::Base
  validates :name, name_format: { scope: :user_id, maximum: 32 }
end

これが実現できる!
と思ったけど Concern でも良かったかなーって、書きながら思い付いた。

app/models/concerns/name_format_column.rb
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

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