環境
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
の仕組みの一端を知れて面白かったです。調べながら書いたので、もし間違っている点等ありましたら、ご指摘いただけると幸いです。
最後までお読みいただきありがとうございました!