2021-08-28

Railsのvalidateメソッドを読む

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

Rails: 7.0.0.alpha
Ruby: 2.7.3

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

validate メソッド

メソッドはここにある

def validate(*args, &block)
  options = args.extract_options!

  if args.all?(Symbol)
    options.each_key do |k|
      unless VALID_OPTIONS_FOR_VALIDATE.include?(k)
        raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{VALID_OPTIONS_FOR_VALIDATE.map(&:inspect).join(', ')}. Perhaps you meant to call `validates` instead of `validate`?")
      end
    end
  end

  if options.key?(:on)
    options = options.dup
    options[:on] = Array(options[:on])
    options[:if] = [
      ->(o) { !(options[:on] & Array(o.validation_context)).empty? },
      *options[:if]
    ]
  end

  set_callback(:validate, *args, options, &block)
end

まず args には [#<ActiveRecord::Validations::PresenceValidator:0x00007fcfa7ce6110 @attributes=[:name], @options={}>, {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)}] という感じの配列が渡ってくる(block は渡してないので nil)

options = args.extract_options!

前回の記事で処理を見て行ったが、 extract_options! によって args 最後の引数の {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)} の部分が options となる

args.all?(Symbol)

options.key?(:on)

今回の例では、このどちらも false となるので、中の処理は行われないため即座に set_callback(:validate, *args, options, &block) まで到達する
(どこかでここも見ていきたい)

わかりやすくすると、この時点では set_callback(:validate, [#<ActiveRecord::Validations::PresenceValidator:0x00007fcfa7ce6110 @attributes=[:name], @options={}>], {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)}, nil) という呼び出しが行われる

set_callback メソッド

メソッドはここにある

def set_callback(name, *filter_list, &block)
  type, filters, options = normalize_callback_params(filter_list, block)

  self_chain = get_callbacks name
  mapped = filters.map do |filter|
    Callback.build(self_chain, filter, type, options)
  end

  __update_callbacks(name) do |target, chain|
    options[:prepend] ? chain.prepend(*mapped) : chain.append(*mapped)
    target.set_callbacks name, chain
  end
end

渡ってくる引数は ↑ で書いた通り

type, filters, options = normalize_callback_params(filter_list, block)

normalize_callback_params メソッドは同ファイルのここにある

def normalize_callback_params(filters, block)
  # CALLBACK_FILTER_TYPES は [:before, :after, :around]なので :before が typeとなる
  type = CALLBACK_FILTER_TYPES.include?(filters.first) ? filters.shift : :before

  # options はいつもの {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)}
  options = filters.extract_options!

  # blockはないので処理は行われない
  filters.unshift(block) if block

  # type => :before
  # filters => [#<ActiveRecord::Validations::PresenceValidator:0x00007fa03a630948 @attributes=[:name], @options={}>]
  # options.dup => {:class=>Book(id: integer, name: string, created_at: datetime, updated_at: datetime)}
  [type, filters, options.dup]
end

戻り値をそれぞれ変数に展開している

self_chain = get_callbacks name

get_callbacks では __callbacks という class_attribute から ActiveSupport::Callbacks::CallbackChain のインスタンスを取得している
(こいつの定義や、中身については一旦置いておく)

mapped = filters.map do |filter|
  Callback.build(self_chain, filter, type, options)
end

では ActiveSupport::Callbacks::Callback のインスタンスを取得している
(こいつも一旦内部は見にいかないでおく)

__update_callbacks(name) do |target, chain|

__update_callbacks メソッドには block を渡していて、内部で yield が呼ばれている

def __update_callbacks(name)
  # [self] + ActiveSupport::DescendantsTracker.descendants(self) の部分はBookクラスが入った配列 => [Book]
  # reverse_each はRuby組み込みのメソッドで、配列を逆から処理していく
  ([self] + ActiveSupport::DescendantsTracker.descendants(self)).reverse_each do |target|
    # name は :validate なので Book.get_callbacks :validate を呼び出している
    # chain には ActiveSupport::Callbacks::CallbackChain のインスタンスが入る
    chain = target.get_callbacks name

    # target(=Bookクラス)とchain(=ActiveSupport::Callbacks::CallbackChainのインスタンス)
    # を引数に渡されたブロックを実行する
    yield target, chain.dup
  end
end

__update_callbacks に戻って見てみる

# ブロック引数のtargetとchainは↑の通り
__update_callbacks(name) do |target, chain|
  # optionsは { class: Book } というハッシュなので、 options[:prepend] はnil
  # よってここではchainに対してappendされている
  # mappedは ActiveSupport::Callbacks::Callback のインスタンスで、nameに対してPresenceValidatorを処理するなどの情報が入っている
  options[:prepend] ? chain.prepend(*mapped) : chain.append(*mapped)

  # Book.set_callbacks :validate, ActiveSupport::Callbacks::CallbackChainのインスタンス をやっている
  target.set_callbacks name, chain
end

set_callbacks は同ファイルの protected メソッドとして定義されている

protected

def set_callbacks(name, callbacks)
  # 特異クラスに __callbacks メソッドがあるかどうか確認している(
  # ここではfalseとなるので、 self.__callbacks に値がセットされる
  # 第二引数がfalseなので、スーパークラスやincludeしたモジュールに定義されたメソッドは対象としていない
  unless singleton_class.method_defined?(:__callbacks, false)
    self.__callbacks = __callbacks.dup
  end
  # 登録されている__callbacksからvalidateのものを更新されたcallbacksで上書きしている
  self.__callbacks[name.to_sym] = callbacks
  self.__callbacks
end

結局どう言うことか?

残念ながら、Rails のコールバックチェインの部分の知識がないため、詳細まではわからなかった
しかし、以下のような流れがなんとなく見えてきた

  • Book.__callbacks を見ると、 save destroy create validate などを対象とした、 ActiveSupport::Callbacks::CallbackChain のインスタンスが多数格納されている
  • validate メソッドなどを実行すると、それに関係する ActiveSupport::Callbacks::CallbackChain が都度 update されていき、どんどん追加されていく

名前などから察するに、 __callbacks に validation やコールバックの情報をためていき、必要な箇所で必要なコールバックを呼び出している感じだろうか
もう少し内部まで踏み込む必要がありそうだ

ソースコードにメソッドの説明書きなどもあるので、コードを読むばかりではなく説明も読んでいこう
Rails はそう簡単に理解できるものではないので、焦らずやっていこう