2021-08-23

Railsのvalidates_withメソッドを読む

前回 validates メソッドは内部的には validates_with を呼び出しているだけということがわかったので、当然 validates_with メソッドも読んでいく
ファイルがrequireされたり、include/extendされる点については前回書いたので省略する

Rails: 7.0.0.alpha
Ruby: 2.7.3

validates :name, presence: true をBookクラスに書いた時の処理を追っているものとする

validates_withメソッド

メソッドはここにある

def validates_with(*args, &block)
  options = args.extract_options!
  options[:class] = self

  args.each do |klass|
    validator = klass.new(options, &block)

    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

まず、引数のargsには [ActiveRecord::Validations::PresenceValidator, {:attributes=>[:name]}] こんな感じの配列が渡ってくる
blockは渡していないのでnil

options = args.extract_options!

extract_options! メソッドは前回も使われていたが、RailsがRubyのArrayクラスを拡張して追加されているメソッド

def extract_options!
  if last.is_a?(Hash) && last.extractable_options?
    pop
  else
    {}
  end
end

# 同ファイルでHashクラスに追加されているメソッド
def extractable_options?
  instance_of?(Hash)
end

argsのlastは { attributes: [:name] } というハッシュなので、{ attributes: [:name] } がoptionsとなる

options[:class] = self

validates を呼び出したのは自分が用意したモデルのクラスコンテキストなので、当然self(=レシーバ)は自分が作成したモデルクラスとなる
なのでoptionsは { class: Book, attributes: [:name] } となる

その後、eachでargsを回していくが、argsは破壊的に変更されているため、 [ActiveRecord::Validations::PresenceValidator] へのeachとなり
ActiveRecord::Validations::PresenceValidator.new({ class: Book, attributes: [:name] }, nil) をvalidatorとして取得している
validatorの中身は↓

class PresenceValidator < EachValidator
  def validate_each(record, attr_name, value)
    record.errors.add(attr_name, :blank, **options) if value.blank?
  end
end

initialize メソッドは親クラスのEachValidatorとその親クラスのValidatorに定義されており
それぞれ以下にコメントしたような処理を行なっている

class EachValidator < Validator
  attr_reader :attributes

  # optionsとして {:attributes=>[:name], :class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)} が渡ってくる
  def initialize(options)
    @attributes = Array(options.delete(:attributes)) # [:name] が @attributes に入る
    raise ArgumentError, ":attributes cannot be blank" if @attributes.empty?
    super # Validatorクラスのinitializeを呼んでいる

    # 初期化時に呼び出されるものの、Railsとしては何もしない(?)メソッド
    # 自分でオーバーライドして、独自の検証を行なったりするためのもの?
    check_validity!
  end

  def check_validity!
  end
end
class Validator
  attr_reader :options

  # optionsとして {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)} が渡ってくる
  # attributesはこの前の処理でdeleteで消えている
  def initialize(options = {})
    # exceptメソッドはRailsによって拡張(active_support/core_ext/hash/except.rb)されているメソッド
    # optionsから:classキーを除外したものを返す
    # よって @options はfreezeされた空のハッシュ({})となる
    @options = options.except(:class).freeze
  end
end

初期化部分を確認したので、その後の処理を見ていく

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)

validator.respond_to?(:attributes) && !validator.attributes.empty?

validator.attributes[:name] となるので、この部分はtrueとなり、 [:name] に対してeachが回っていく
ここで利用されている _validatorsactive_model/validations.rb:52 で定義されている class_attribute によるもので

class_attribute :_validators, instance_writer: false, default: Hash.new { |h, k| h[k] = [] }

こんな感じで定義されている
class_attributeを読むだけで記事になりそうなので、ここでは内部の細かい処理は省略する
インスタンスからは書き換えられないようになっていて、空のハッシュがセットされているということがわかる

とりあえずRailsガイド置いておきますね

あと、定数やクラス変数との違いを解説しているこの記事が利点などを理解できてよかったので置いておく

忘れそうになるが、ここはBookクラスのコンテキストなので Book._validators を呼べるということである

_validators[attribute.to_sym] << validator

_validators は空のハッシュなので、 { name: ActiveRecord::Validations::PresenceValidatorのインスタンス(@attributesに[:name]が、@optionsに{}が入っている) } となる

最後に

  • validator = ActiveRecord::Validations::PresenceValidatorのインスタンス(@attributesに[:name]が、@optionsに{}が入っているもの)
  • options = {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)}

を引数に validate メソッドを呼び出している

なんと今度は自身で独自のvalidationを行いたい時に使う validate メソッドが呼び出されていた!

長くなってきたので今回はここまでにして、次回 validate メソッドの内部処理を見ていく

最後に

validatesvalidates_withvalidate もそれぞれ独立したメソッドだと思っていたので、再利用具合が凄くて美しく無駄がないな...
こんなコード書けるようになりたい