2021-08-22

Railsのvalidatesメソッドを読む

今までRailsとかOSSのコードを読むことは結構あったけど、ちゃんと記事にして残すということをしてこなかったので、今後は暇な時に読んだコードについてちゃんと記事を書いていこうと思った
僕はRailsエンジニアなので、Railsについては常々内部をもっと知りたいと思っており、今回はなじみ深い validates メソッドを読むことにした

Rails: 7.0.0.alpha
Ruby: 2.7.3

validatesメソッドはどこにある?

モデルでよく書く validates メソッドは source_location で探索したところ activemodel/lib/active_model/validations/validates.rb:106 にあるようだ

メソッドがあるのはわかったが「なぜモデルでクラスメソッドとしてvalidatesメソッドが呼べるのか?」ということから理解しなければ、真に読んだとは言えまい

  • Railsがvalidatesメソッドが定義されているファイルを認識する(requireされる)までの流れ
  • モデルでvalidatesメソッドが呼べるようになる(include/extendされる)までの流れ
  • validatesメソッドの処理内容確認

に分けて見ていく

Railsがvalidatesメソッドが定義されているファイルを認識する(requireされる)までの流れ

まず、Rubyがライブラリを認識しているかどうかを確認するため $LOAD_PATH を眺める

(略)
 "/Users/hakozaru/products/test_prj/rails/activerecord/lib",
 "/Users/hakozaru/products/test_prj/rails/activemodel/lib",
(略)

validates メソッドが書かれているファイルはともかく、ベースとなる activemodel 自体は認識されていることがわかった

GemはロードするとGem内にある XXX.gemspec に書かれている require_path に置かれたファイルがrequireされるようになっていて
activemodel Gemでは lib指定されているため、lib/active_model.rb がrequireされる
(余談ですが、Gem作成のお作法で、lib配下に置く.rbファイルは1つにした方がよいらしい)

requireされると、 ActiveModel moduleがロードされるわけだが、それと同時に様々なモジュールの自動ロードが走りまくっている↓

module ActiveModel
  extend ActiveSupport::Autoload

  autoload :Attribute # require active_model/attribute
  ...
  autoload :Validations # require active_model/validations
end

Rubyのautoloadは引数を2つ必要としているが、ここでは1つだけで済んでいるのは、見ての通りRailsが ActiveSupport::Autoload によってオーバーライドしているため
Railsの命名規則に従ってファイル探索し、対象となるファイルをいい感じにrequireしてくれている
(ここでは内部までは見にいかない)

さて、 autoload によってactive_model/validations.rbもrequireされるということがわかった
active_model/validations.rb内部では更に

Dir[File.expand_path("validations/*.rb", __dir__)].each { |file| require file }

という処理も実行されており、validationに関する様々なファイルがロードされている
そしてその中に今回着目している validates メソッドが定義されているファイルが含まれており、Railsが認識するところまでたどり着くことができた

モデルでvalidatesメソッドが呼べるようになる(include/extendされる)までの流れ

メソッド定義されているファイルをRailsが認識したところまでは追えたので、次はモデルで実際にメソッドが呼べるようになるまでの流れを追っていく
現時点では.rbファイルがrequireされただけで、自分が作ったモデルでメソッドを呼ぶためにはincludeなりextendなりされる必要があるので、それを探していく

自分のモデルに何一つメソッドを書かなくても validates メソッドは呼べるため、当然継承している ActiveRecord::Base が怪しい。となる

ActiveRecord::BaseValidations (ActiveRecord::Validations)がincludeされていて、さらにその中でinclude ActiveModel::Validationsが行われている
↑で書いた通り、既に active_model/validations.rb をはじめとするvalidationに関するファイルはrequireされていてファイルが認識されているため、ActiveModel::Validations をincludeできる

include ActiveModel::Validations するということはvalidatesメソッドが定義されているmoduleをincludeすることになるということである
そして、includeされた先で extend ActiveSupport::Concern されているため、 module ClassMethods 内部に定義されているvalidatesメソッドは、モデルでクラスメソッドとして呼び出すことができる。ということになる

ということで、モデルで呼べるようになるまでの流れが理解できた

validatesメソッドの処理内容確認

こんな感じの処理になっている

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|
    key = "#{key.to_s.camelize}Validator"

    begin
      validator = key.include?("::") ? key.constantize : const_get(key)
    rescue NameError
      raise ArgumentError, "Unknown validator: '#{key}'"
    end

    next unless options

    validates_with(validator, defaults.merge(_parse_validates_options(options)))
  end
end

まず引数に取る attributesvalidates :hoge, presence: true こんな感じで定義した時に [:hoge, {:presence=>true}] という形で渡ってくる

defaults = attributes.extract_options!.dup

extract_options!で取得されるのは presence: true の部分のハッシュで {:presence=>true}defaults となる

validations = defaults.slice!(*_validates_default_keys)

defaults に対して slice!(*_validates_default_keys) を実行している
_validates_default_keys[:if, :unless, :on, :allow_blank, :allow_nil, :strict] という配列になっている
Rubyに slice! というメソッドは存在しないので、この slice! はRailsによって拡張されているメソッドである

slice!activesupport/lib/active_support/core_ext/hash/slice.rb にあり、「レシーバのハッシュに対して、引数で渡したキーを除外したハッシュを返す(破壊的に)」というもの

defaults{presence: true} であり、それに対して slice!(:if, :unless, :on, :allow_blank, :allow_nil, :strict) を呼び出しても、該当するキーが存在しないため、そのままのハッシュが validations 変数に入るということになる

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?

validates メソッドを引数なしで呼び出すと上の例外が、 validates :hoge のように呼び出すと下の例外となる

defaults[:attributes] = attributes

defaults[:attributes] = attributes{ attbitutes: [:hoge] } というものが defaults に追加される

validations.each do |key, options|
  key = "#{key.to_s.camelize}Validator"

  begin
    validator = key.include?("::") ? key.constantize : const_get(key)
  rescue NameError
    raise ArgumentError, "Unknown validator: '#{key}'"
  end

  next unless options

  validates_with(validator, defaults.merge(_parse_validates_options(options)))
end

その後 validations 変数(中身は { presence: true } )に対してeachが呼び出され、順番に検証が走っていく
まず、 :presence.to_s.camelize によって 'Presence' という文字列が作成され、それに Validator という文字をくっつけた PresenceValidator という文字列を key という変数に入れている

keyに対して :: という名前空間が入っているかどうかを判定し、文字列から定数化を行いvalidatorを取得している
( camelize メソッドは hoge/fuga という文字列は Hoge::Fuga と変換するので :: が含まれる可能性はありうる)

ちなみにここで取得しているValidatorは activemodel/lib/active_model/validations/presence.rb で定義されているもので、上の方で説明した通り active_model/validations.rb がrequireされるとファイルの一番下に書かれている
Dir[File.expand_path("validations/*.rb", __dir__)].each { |file| require file } によってrequireされているため認識できている

そして最後は取得したvalidatorを使って、 validates_with を呼んでいる
よく書く validates メソッドは、実は内部的には validates_with を呼んでいるのだった!
(共通の独自validationを定義したいときに使えるアレと同じことをやっている)

最後 validates_with の第二引数に渡している defaults.merge(_parse_validates_options(options)) は何をやっているのかというと割と簡単で

def _parse_validates_options(options)
  case options
  when TrueClass
    {}
  when Hash
    options
  when Range, Array
    { in: options }
  else
    { with: options }
  end
end

みたいなことをやっている

defaults = { attbitutes: [:hoge] }
options = true

なので

{ attbitutes: [:hoge] }.merge({})

をやっていることになる まとめると

validates_with PresenceValidator, { attbitutes: [:hoge] }

というのを実行していることになる

最後に

Railsのコードは本当にとんでもない量ありますが、ファイルやメソッドの配置がとても綺麗でわかりやすいので読みやすいです
validates_with メソッドも読まなくては