13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Linc'wellAdvent Calendar 2019

Day 14

Railsのバリデーションの仕組みを知らなかったので調べてみました

Last updated at Posted at 2019-12-15

こちらはLinc'well Advent Calendar 2019の14日目の記事となります。

タイトルの通りですが、これまでRailsを数カ月間使用してきて、

Modelで何十回もバリデーションを定義したり、
create!したらバリデーションに引っかかってActiveRecord::RecordInvalid例外を起こしたり、
何十回もvalid?メソッドを呼び出したりとしてきたのに、

そういえばvalidationの仕組みを何も知らないな。と思ったので、
バリデーション機能をコードを追いながら調べてみて、その過程をまとめてみました。

間違い等見つけていただけましたら、コメント欄でご指摘いただけたら嬉しいです!

また、文中のRailsのコードはtag v6.0.0のものです。

調べた中での一番の学び

いきなりですが、今回調べてみての一番の学びを先に述べておくと、下記の事柄でした。

  • Railsのバリデーション機能は、コールバックを利用して実現されている。

バリデーションと呼ばれる機能の構成

今回調べてみるに当たって、分けたほうが調べやすいと思ったので、
Railsのバリデーション機能を構成しているものを大きく以下の2つに分けてみました。

  1. Modelにvalidates :name, presence: trueのような文言を記述すると、バリデーションを定義できる。1
  2. 1で定義されたバリデーションに応じて、オブジェクトがデータベースに保存されるかが決まる。
    (savecreateをするときに、バリデーションに引っかかると保存されない。)

便宜上、以下では1を『バリデーション定義』、2を『バリデーション実行』という言葉で表現しています。

『バリデーション定義』のざっくり概要

そもそもバリデーションとしてModelに書いているvalidatesは何なのかというところですが、
これはクラスメソッドにあたります。
(カスタムバリデーションの定義で使うvalidatevalidate_withも同様です。)

私自身、正直validatesがクラスメソッドであることは意識することはあまりなく、なんとなく使っていますが、
Modelの定義でよく目にする、attr_accessorhas_manyも同様にクラスメソッドです。
(参考: Railsによるメタプログラミング入門 前田 修吾さん)

また、このようなclass定義の直下で呼び出されるクラスメソッドを、書籍『メタプログラミングRuby 第2版』では『クラスマクロ』と紹介しています。

ということで、validatesというクラスメソッドを探して、内容を確認すればどのようにバリデーションが定義されるかが分かるということになります。

『バリデーション実行』のざっくり概要

実際に、createのコードを確認してみると、以下の様にsaveが呼ばれ、そのsaveの中でperform_validationsというバリデーションらしきものが行われていることが分かります。

(ActiveRecordのどこに何が定義されているのかなどは、下記の書籍を参考にさせていただいて探しました。
参考: ActiveRecord完全に理解した kinoppydさん)

active_record/persistence.rb
    def create(attributes = nil, &block)
      if attributes.is_a?(Array)
        attributes.collect { |attr| create(attr, &block) }
      else
        object = new(attributes, &block)
        object.save
        object
      end
    end
active_record/validations.rb
    # The regular {ActiveRecord::Base#save}[rdoc-ref:Persistence#save] method is replaced
    # with this when the validations module is mixed in, which it is by default.
    def save(**options)
      perform_validations(options) ? super : false
    end

    ~省略~

    def perform_validations(options = {})
      options[:validate] == false || valid?(options[:context])
    end

そして、このperform_validationsではvalidateを実行しないというオプションがない場合に、valid?を呼びだしており、(Railsガイドでは『バリデーションをトリガする』と表現されていました。)
バリデーション実行の起点となるものはvalid?であることが窺えます。

上記を踏まえると、超ざっくりですがバリデーション実行とは、valid?がtrueを返すか、falseを返すかによって、データベースにオブジェクトを保存するかどうかを決めることだと言い換えることができます。

順番が前後してしまいますが、先にバリデーション実行の詳細を追ってみた過程を記述していきます。

valid?から始まる 『バリデーション実行』の処理の詳細を追ってみる

上で出てきたactive_record/validations.rbvalid?を探して見てみると下記のようになっています。

context ||= default_validation_contextは、このvalid?の呼び出し元の処理がcreateとupdateのどちらによるものかという情報であるcontextを定義しているもので、

contextを引数に取るsuperを呼び出していることから、valid?をオーバーライドしていることが分かります。

active_record/validations.rb
    def valid?(context = nil)
      context ||= default_validation_context
      output = super(context)
      errors.empty? && output
    end

オーバーライドされているvalid?を探してみると、ActiveRecord::ValidationsがincludeしているActiveModel::Validationsというモジュールに、valid?が定義されているのが見つかります。

active_model/validations.rb
    def valid?(context = nil)
      current_context, self.validation_context = validation_context, context
      errors.clear
      run_validations!
    ensure
      self.validation_context = current_context
    end

(どうして2つのValidationsというモジュールが存在するのかに関して、書籍『メタプログラミングRuby 第2版』の『9.2.3 Validations モジュール』において言及されていました。
元々はActiveRecord::Validationsのみしか存在せず、後から機能がActiveModel::Validationsに分割された経緯や理由が書かれており面白かったです。)

そして、このvalid?を見ると、明らかにバリデーションを実行していそうなrun_validations!というメソッドの呼び出し箇所があります。

run_validations!は以下のようになっており、中身を確認してきます。
_run_validate_callbacksはひとまず置いて、
errors.empty?を見ると、インスタンス変数の@errorsの中身の存在確認をしているだけのメソッドであることから、
_run_validate_callbacksがバリデーションっぽいことをして、@errorsに値を入れるのだなと予測できました。

active_model/validations.rb
    def run_validations!
      _run_validate_callbacks
      errors.empty?
    end

そこで、この_run_validate_callbacksを見に行こうと思ったのですが、
コードジャンプしても全文検索しても見つからなかったため、
動的に定義されるメソッドであると推測されました。

が、それらしきものを発見することができず、ググったところ下記の記事が見つかりました。
(参考: Railsのvalidationの実行過程を調べる2

上記の記事を引用させていただくと、この_run_validate_callbacksは、

ざっくりまとめると、当該モデルに設定されたcallbacksの内、 :validateのキーに引っかかるcallbacksを実行していっていると言うことになります。

とのことでした。

この時点では、『:validateのキーに引っかかるcallbacks』というものが一体何なのか知らなかったのですが、
ここまでのバリデーション実行の流れをざっくりまとめると、下のようになりました。

  1. オブジェクトをsaveする際に、valid?が呼ばれる。
  2. valid?から始まる処理の中で、_run_validate_callbacksが実行されることで『:validateのキーに引っかかるcallbacks』というものが実行され、@errorsに何かしらの値が入る(これはこの時点では予想ですが、カスタムバリデーションを定義する時に、record.errors.addのように@errorsに値を入れることからも予想ができます。)
  3. @errorsの中身が空であるかを確認し、空であればtrueを、そうでなければfalseを返し、それによってvalid?の戻り値が決まる。
  4. valid?の戻り値がtrueなら、オブジェクトを保存する処理が続けられ、falseなら保存する処理が中止される。

(『:validateのキーに引っかかるcallbacks』が、Modelに定義されているコールバックの中で、:validateキーに対応するActiveSupport::Callbacks::CallbackChainを指しているということが後で分かりました。)

『バリデーション定義』の処理の詳細を追ってみる

バリデーション実行の詳細がある程度分かったことから、
あとは、バリデーション定義の処理の中で、『:validateのキーに引っかかるcallbacks』が定義されているところを見つけることができれば、
バリデーションの機能の全体感を把握することができるので、そこをゴールとして進んでいきます。

validatesがクラスの読み込み時に実行されるところから定義の処理は始まります。

処理の中身としては、受け取った引数を([:name, :presence: true]みたいな)、
良い感じに加工し、最終的にvalidates_withを呼び出すものです。

active_model/validations/validates.rb
    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

軽く流れを追うと、ブロック引数のkeyには:presence:lengthなどが入り、key = "#{key.to_s.camelize}Validator"では、"PresenceValidator"といった文字列が作られます。

そして、その文字列からvalidator = key.include?("::") ? key.constantize : const_get(key)でクラス名の定数を取得し、validates_withに引数で渡しています。
(activemodel/lib/active_model/validations配下に、LengthValidatorInclusionValidatorなどのバリデーションの種類に対応したValidatorクラスがそれぞれ用意されています。)

最後のvalidates_withの呼び出しは、カスタムバリデータを使ったバリデーションの定義と同じ形になります。

そして、呼び出されたvalidates_withが何をしているかというと、引数として渡されたValidatorクラス名を使って、インスタンスを作り、最後にvalidateメソッド呼び出しの引数に渡しています。

このvalidateはカスタムメソッドを使ったバリデーションを定義するときに使うものと同じです。
(validate :expiration_date_cannot_be_in_the_pastみたいに使うやつです。)

active_model/validations/with.rb
    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
  end

続けて、validateを見ていきます。

すると、set_callback(:validate, *args, options, &block)という、いかにも、『:validateのキーに引っかかるcallbacks』に関係していそうなメソッドを呼び出しているのが発見でき、

さらに、同じファイルにdefine_callbacks :validate, scope: :nameという、これまた関係していそうなメソッドがincludedの中で呼ばれているのが見つかります。

active_model/validations.rb
   define_callbacks :validate, scope: :name_badge: 
   
   ~省略~   

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

      if args.all? { |arg| arg.is_a?(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] = Array(options[:if])
        options[:if].unshift ->(o) {
          !(options[:on] & Array(o.validation_context)).empty?
        }
      end

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

見つけたは良いものの、define_callbacksset_callbackが何なのか知らなかったので、ググってみると下記の記事が見つかりとても勉強になりました。

(参考: ActiveModelでコールバックを使いたい時のまとめ - Qiita | Issus(イシューズ)
https://issus.me/projects/34/issues/71

(参考: railsのbefore_actionはどうやってうごいているか

上記記事を参考にざっくりまとめると、
define_callbacks :validate:validateというnameを持つ一連のコールバックをまとめるCallbackChainインスタンスを作成し、
(コード中のコメントでは、sets of events in the object life cycle that support callbacksと表現されてました。)

set_callback(:validate, *args, options, &block)で、
先ほど定義した:validateという一連のコールバックの一つとして実行する処理として、引数で渡されたものを設定しています。

これで _run_validate_callbacksが具体的に何をするのかに繋がりました。
set_callback(:validate, *args, options, &block):validateというキーに引っかかる様にセットされた処理を実行していくということになります。
(また、複数形であることからも、複数のバリデーションがある場合は、複数回set_callbackが呼ばれることもあるだろうと予想できます。)

set_callback(:validate, *args, options, &block)でコールバックの一つとして定義された処理こそがバリデーションの中身ということなります。

ということで次は、set_callback(:validate, *args, options, &block)がどのようなcallbackを定義するのかを追っていきます。

active_support/callbacks.rb
    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

Callback.buildで作られるActiveSupport::Callbacks::Callbackインスタンスの一例としては、下記のようなものです。

@kindには:beforeや:afterなどの実行のタイミングを表す値が入り、指定がなかった場合は:beforeが入ります。
@filterには、引数で渡されたValidatorインスタンスが入り、これがコールバックで実行する処理の元となるっぽいです。

そして、それらを最後に__update_callbacksで一連のコールバック処理(chain)に追加しています。

 #<ActiveSupport::Callbacks::Callback:0x0000565553a47960
  @chain_config={:scope=>:name, :terminator=>#<Proc:0x0000565549744b50@/usr/local/bundle/gems/activesupport-5.2.2.1/lib/active_support/callbacks.rb:603>},
  @filter=#<ActiveRecord::Validations::PresenceValidator:0x0000565553a47cd0 @attributes=[:user], @options={:message=>:required}>,
  @if=[],
  @key=#<ActiveRecord::Validations::PresenceValidator:0x0000565553a47cd0 @attributes=[:user], @options={:message=>:required}>,
  @kind=:before,
  @name=:validate,
  @unless=[]>

あとは、ActiveSupport::Callbacks::が、どのようにActiveModel::Validations::配下のValidatorクラスのインスタンスを元にして、コールバックの処理を定義・実行しているのかを追えば、恐らく終わりというところまで来ました。

ところが、そのコールバックのActiveSupport::Callbacksの仕組みを追っていったところ、
とても難しく、本記事にうまくまとめられなそうだったため、また別の記事として調べようと思います。(もしくはこの記事に追記します。)

(完全に実力不足でした。ここが一番気になるところなのにすみません。。)

ということで、一旦ここまでのバリデーション定義の流れをざっくりまとめると、下記のようになりました。

  1. クラスの読み込み時に、validatesといったバリデーション定義のクラスメソッドが実行される。
  2. validatesvalidates_with(カスタムバリデータを用いたバリデーション定義で使うやつ)→validate(カスタムメソッドを用いたバリデーション定義で使うやつ)の順でメソッドが呼ばれる。
  3. validateでは、バリデーションの種類(:presenceとか:length)に応じて、対応するValidatorクラスのインスタンスを生成し、それを引数に含めてset_callbackメソッドを呼び出す。
  4. set_callbackメソッドでは、引数で受け取ったValidatorを使用しながら、バリデーションを実行する処理を含むコールバックを定義する。(そして、このコールバックはvalid?が呼ばれると実行されることになる。)

感想

色々と知らなかったことを知ることができ、とても勉強になりました。

余談として、attr_accessorといったアクセサや、has_manyといったアソシエーションも同じクラスマクロとして調べる対象の候補だったのですが、
ActiveRecord完全に理解した』にて、前者は『すごく長い』、後者は『難しい』と紹介されていたので、特に何も書かれていなかったValidationsを今回は選択してみました。

勉強になったので、今度は他のクラスマクロも同様に調べてみようと思います。
(その前にCallbackとValidatorをきちんと調べてから。)

参考文献

下記の文献を見て色々と勉強させていただきながら、この記事を書いてみました。
どれも勉強になりました。

  1. ここでModelと表している・想像しているものは主にApplicationRecordを継承しているクラスになります。ApplicationRecordはActiveRecord::Baseを継承しており、ActiveRecord::Baseにバリデーションの機能が組み込まれています。

  2. この記事にたどり着くのが書いてる中で後半の方になってしまったのですが、知りたいことがたくさん書いてあって、とても参考にさせていただきました。 2

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?