Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

Railsのvalidationの実行過程を調べる

More than 1 year has passed since last update.

環境

Ruby 2.6.5
Rails 6.0.1

事の発端

ある時、継承したモデルに対して、 validation を書こうとして問題は起きました…

例えば、下記のような User モデルが存在したとします。

user.rb
class User < ApplicationRecord
  validates :name, presence: true
  validates :email, presence: true
  validates :password, presence: true
  validates :card_num, presence: true
end

クレカの番号をDBに生で持つなんて恐ろしいですが、サンプルなので今回は置いておきます。

元々、課金ユーザーのみが対象でしたが、ある時、無課金ユーザーを対象とするために、新しく無課金ユーザーのモデルを作成したとします。無課金ユーザーはクレカの番号を登録しなくても使えるように :card_num に対する validation を上書きしようと考えて下記のように記述してみました。

non_paying_user.rb
class NonPayingUser < User
  validates :card_num, presence: false
end

では、実際はどうなるかと言うと、詳しい方はご存知のように :card_num に対する presence: true は効いてしまう。

console
irb(main):001:0> user = NonPayingUser.new(name: "hoge", email: 'hoge@example.com', password: 'passwor
d')
=> #<NonPayingUser id: nil, name: "hoge", email: "hoge@example.com", password: [FILTERED], card_num: nil, created_at: nil, updated_at: nil>

irb(main):002:0> user.valid?
=> false

irb(main):003:0> user.errors.full_messages
=> ["Card num can't be blank"]

自分みたいな初心者には、親クラスと同じような定義を子クラスでした場合に、上書きされそうなイメージがありました…しかし実際は違う。

なぜだろうと思って、Railsの validation の実行過程を調べてみることにしました。

ActiveModel::Validations#valid?

まずは valid? から追っていきます。
ActiveModl::Validations#valid? メソッドは下記のような実装になっています。
参考:activemodel/validations.rb

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

ここでは、validation_context をセットした後に、エラーを clear し、その後に、 run_validations! を呼んでいます。

その run_validations! メソッドの中では、 _run_validate_callbacks を呼んだ後に、 errors.empty?valid? メソッドの返り値である真偽値を返しています。そのため、実態は、 _run_validate_callbacks メソッドになります。

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

では、 _run_validate_callbacks はどこに定義されているかと言うと、 ActiveModel からは離れて、 ActiveSupport の方になります。 define_callbacks メソッドの中で、動的に定義されていて、内部的には、 run_callbacks を呼んでいます。

参考:activesupport/callbacks.rb

callbacks.rb
def _run_#{name}_callbacks(&block)
  run_callbacks #{name.inspect}, &block
end

今回は、 _run_validate_callbacks メソッドが呼ばれているので、 namevalidate になります。

run_callbacks メソッドは同じファイル内にあります。結構長いメソッドで、全部読むのは大変だったので、重要そうなポイントだけコメントすると下記のようになります。

callbacks.rb
def run_callbacks(kind)
  # callbacksの内、:validateのcallbacksを取り出す。
  callbacks = __callbacks[kind.to_sym]

  # 今回の場合はcallbacksは存在するのでelseに入る
  if callbacks.empty?
    yield if block_given?
  else
    # 環境情報をセット
    # 中身は、Struct.new(:target, :halted, :value)
    env = Filters::Environment.new(self, false, nil)
    # callbacksは、ActiveSupport::Callbacks::CallbackChainクラスのインスタンスだが、
    # そのインスタンスの@callbacksメソッドに値がなければ詰める(初回のみ呼び出し)
    # 返り値は、ActiveSupport::Callbacks::CallbackSequenceのインスタンス
    next_sequence = callbacks.compile

    # invoke_sequenceにProcインスタンスを代入
    # 場合によってはあとで下記がcallされて、実行される
    invoke_sequence = Proc.new do
      skipped = nil
      while true
        current = next_sequence
        current.invoke_before(env)
        if current.final?
          env.value = !env.halted && (!block_given? || yield)
        elsif current.skip?(env)
          (skipped ||= []) << current
          next_sequence = next_sequence.nested
          next
        else
          next_sequence = next_sequence.nested
          begin
            target, block, method, *arguments = current.expand_call_template(env, invoke_sequence)
            target.send(method, *arguments, &block)
          ensure
            next_sequence = current
          end
        end
        current.invoke_after(env)
        skipped.pop.invoke_after(env) while skipped && skipped.first
        break env.value
      end
    end

    # Common case: no 'around' callbacks defined
    if next_sequence.final?
      # 今回は、next_sequence.final?はtrueだったのでこちらに入る
      # invoke_beforeで、ActiveSupport::Callbacks::CallbackSequenceのインスタンスの、
      # @beforeに含まれるcallbackのProcインスタンスを一つ一つcallして実行する
      # @before.each { |b| b.call(env) }
      # validationのコールバックが、ここで実行されていく。
      next_sequence.invoke_before(env)
      env.value = !env.halted && (!block_given? || yield)
      # afterがあればここで実行される
      next_sequence.invoke_after(env)
      env.value
    else
      invoke_sequence.call
    end
  end
end

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

それでは、今回のサンプルであった、 NonPayingUser モデルのインスタンスの callbacks はどうなっているのでしょうか?

console
[1] pry(main)> user = NonPayingUser.new(name: "hoge", email: 'hoge@example.com', password: 'password')
=> #<NonPayingUser:0x00007ffeb0271df0...省略

[2] pry(main)> user.__callbacks[:validate].instance_variable_get('@chain')
=> [#<ActiveSupport::Callbacks::Callback:0x00007ffeb5f6d298
  @chain_config=
   {:scope=>:name,
    :terminator=>
     #<Proc:0x00007ffeaf24eea0@/Users/ty/rails/private_project/testapp/vendor/bundle/gems/activesupport-6.0.1/lib/active_support/callbacks.rb:604>},
  @filter=
   #<ActiveRecord::Validations::PresenceValidator:0x00007ffeb5f6d590
    @attributes=[:name],
    @options={}>,
  @if=[],
  @key=
   #<ActiveRecord::Validations::PresenceValidator:0x00007ffeb5f6d590
    @attributes=[:name],
    @options={}>,
  @kind=:before,
  @name=:validate,
  @unless=[]>,
 #<ActiveSupport::Callbacks::Callback:0x00007ffeb5f6c9b0
...以下略

このように、 validator のcallbackが並んでいることがわかります。

ちなみに当該モデルの validator 一覧は、 _validators メソッドで取得できます。

console
[3] pry(main)> user._validators
=> {:name=>
  [#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb04e6978
    @attributes=[:name],
    @options={}>],
 :email=>
  [#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb2e0b448
    @attributes=[:email],
    @options={}>],
 :password=>
  [#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb2e09ad0
    @attributes=[:password],
    @options={}>],
 :card_num=>
  [#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb04fcd18
    @attributes=[:card_num],
    @options={}>]}

一番最後の部分で、 :card_numPresenceValidator が存在してしまっていますね…

そのため、この validatorcallbacks の一つとして呼ばれて、結果的に、 :card_num に対する presence: truevalidation が効いてしまったと言うことになります。

新たな疑問

ここで新たな疑問が生まれます。それじゃあ、 presence: false って何だったの?と。

それを解き明かすためには、 presence: false を設定した時に何が起こるかを見ていく必要があります。それでは validates メソッドを見ていきます。こちらも長かったので、重要そうな部分だけコメントしています。

active_model/validations/validates.rb

validates.rb
def validates(*attributes)
  # attributesの内、オプションだけ取り出す
  defaults = attributes.extract_options!.dup
 # オプションの内、validator以外のオプションを取り出す
  # _validates_default_keysは、
  # [:if, :unless, :on, :allow_blank, :allow_nil, :strict]
  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?

  # attribute名たちをここで詰める
  defaults[:attributes] = attributes

  # validation毎にvalidatorをセットする
  validations.each do |key, options|
    # もしオプションがnil or falseならセットしない
    next unless options
    key = "#{key.to_s.camelize}Validator"

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

    # validatorを設定する
    validates_with(validator, defaults.merge(_parse_validates_options(options)))
  end
end

少しコメントだけだとわかりにくいと思うので、具体例で見ていきます。

例えば、 User モデルに下記のように validation をセットしたとします。

user.rb
class User < ApplicationRecord
  validates :name, :email, presence: true, length: { maximum: 500 }, allow_nil: true
end

この場合、引数の attributes は下記のような形になります。

[:name, :email, {:presence=>true, :length=>{:maximum=>500}, :allow_nil=>true}]

ここで、 defaults = attributes.extract_options!.dup すると option 部分、すなわち、 {:presence=>true, :length=>{:maximum=>500}, :allow_nil=>true} が取り出されます。

次に、 validations = defaults.slice!(*_validates_default_keys) で、バリデーションオプション([:if, :unless, :on, :allow_blank, :allow_nil, :strict])以外が取り出されます。この時点で、各変数の中身は下記のようになります、

attributes = [:name, :email]
defaults = {:allow_nil=>true}
validations = {:presence=>true, :length=>{:maximum=>500}}

そして、 attributes の中身を defaults[:attributes] にセットし、各 validation 毎に、 attributes に対して、 validator を作っていくという感じです。

さて、ここで冒頭の presence: false は何をしているかということなのですが、 validations.each の次の行で、 next unless options とあります。 presence: false の場合の、 optionsfalse です。そのため、 presence: false がやっていることは、 validator を作らないということだけで、既存の validator を上書きしたりはしないのです。

だから、子クラスで presence: false をしても全く意味がなかったということです。

ようやく腑に落ちました!

副次的なtips

ちなみに、同じ validator かどうかチェックしていないので、下記のように二重に validates すると、二重に validator が設定されてしまいます。

user.rb
class User < ApplicationRecord
  validates :name, presence: true
  validates :name, presence: true
end
console
[2] pry(main)> user._validators
=> {:name=>
  [#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb66c4398
    @attributes=[:name],
    @options={}>,
   #<ActiveRecord::Validations::PresenceValidator:0x00007ffeb66cf9a0
    @attributes=[:name],
    @options={}>]}

最後に

以上になります!

普段、何気なく使っている validation の仕組みの一端を知れて面白かったです。調べながら書いたので、もし間違っている点等ありましたら、ご指摘いただけると幸いです。

最後までお読みいただきありがとうございました!

wakasa51
2015年に卒業後、メーカーで事業管理をしていましたが、2018年1月からRailsエンジニアに転身しました!17年11月から勉強開始した初心者ですが、どうぞよろしくお願い致します。
smarthr
社会の非合理を、ハックする。
https://smarthr.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away