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
メソッドも読まなくては