環境
Ruby 2.6.5
Rails 6.0.1
事の発端
ある時、継承したモデルに対して、 validation を書こうとして問題は起きました…
例えば、下記のような User モデルが存在したとします。
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 を上書きしようと考えて下記のように記述してみました。
class NonPayingUser < User
validates :card_num, presence: false
end
では、実際はどうなるかと言うと、詳しい方はご存知のように :card_num に対する presence: true は効いてしまう。
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
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 メソッドになります。
def run_validations!
_run_validate_callbacks
errors.empty?
end
では、 _run_validate_callbacks はどこに定義されているかと言うと、 ActiveModel からは離れて、 ActiveSupport の方になります。 define_callbacks メソッドの中で、動的に定義されていて、内部的には、 run_callbacks を呼んでいます。
def _run_#{name}_callbacks(&block)
run_callbacks #{name.inspect}, &block
end
今回は、 _run_validate_callbacks メソッドが呼ばれているので、 name は validate になります。
run_callbacks メソッドは同じファイル内にあります。結構長いメソッドで、全部読むのは大変だったので、重要そうなポイントだけコメントすると下記のようになります。
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 はどうなっているのでしょうか?
[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 メソッドで取得できます。
[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_num に PresenceValidator が存在してしまっていますね…
そのため、この validator が callbacks の一つとして呼ばれて、結果的に、 :card_num に対する presence: true の validation が効いてしまったと言うことになります。
新たな疑問
ここで新たな疑問が生まれます。それじゃあ、 presence: false って何だったの?と。
それを解き明かすためには、 presence: false を設定した時に何が起こるかを見ていく必要があります。それでは validates メソッドを見ていきます。こちらも長かったので、重要そうな部分だけコメントしています。
active_model/validations/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 をセットしたとします。
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 の場合の、 options は false です。そのため、 presence: false がやっていることは、 validator を作らないということだけで、既存の validator を上書きしたりはしないのです。
だから、子クラスで presence: false をしても全く意味がなかったということです。
ようやく腑に落ちました!
副次的なtips
ちなみに、同じ validator かどうかチェックしていないので、下記のように二重に validates すると、二重に validator が設定されてしまいます。
class User < ApplicationRecord
validates :name, presence: true
validates :name, presence: true
end
[2] pry(main)> user._validators
=> {:name=>
[#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb66c4398
@attributes=[:name],
@options={}>,
#<ActiveRecord::Validations::PresenceValidator:0x00007ffeb66cf9a0
@attributes=[:name],
@options={}>]}
最後に
以上になります!
普段、何気なく使っている validation の仕組みの一端を知れて面白かったです。調べながら書いたので、もし間違っている点等ありましたら、ご指摘いただけると幸いです。
最後までお読みいただきありがとうございました!