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::Baseに Validations (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
まず引数に取る attributes は validates :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 メソッドも読まなくては