はじめに
Railsを使っていると、綺麗に隠蔽されているためvalidates :name, presence: true
などを書くだけで簡単にvalidationが実装できてしまいます。
裏側にはピュアなRubyで実装されたロジックが存在するわけですが、普段あまり意識することがないです。
最近カスタムvalidationを実装する機会があり、「あれ?僕、validationの挙動を理解していないな?」となったのでまとめます。
validates
class Hoge < ApplicationRecord
validates :name, presence: true
end
↑ のように()
を省略して書くことが多いのでわかりにくいですが、実際のところはメソッドコールです。
なので、クラスがロードされる時点でvalidates
メソッドが呼ばれています。
class Hoge < ApplicationRecord
validates(:name, presence: true)
end
このvalidates
メソッドが定義されているのは、ActiveModel::Validations::ClassMethods
です。
pry(main)> show-source Hoge.validates
From: /usr/local/bundle/gems/activemodel-7.2.0/lib/active_model/validations/validates.rb:8:
Owner: ActiveModel::Validations::ClassMethods
Visibility: public
Signature: validates(*attributes)
Number of lines: 23
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 = 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
内部の処理では、特定のvalidationに対して責務を持つValidator(class)とattribute情報をvalidates_with
メソッドに渡しています。
仮にvalidates :name, presence: true
を想定すると、validates_withメソッドは以下のように呼び出されます。
validates_with(ActiveRecord::Validations::PresenceValidator, {:attributes=>[:name]})
Railsのコードリーディングは迷子になってしまうこともかなりあるので、pryでeditしてprintしながら値を確認していくのが便利です。
例
pry(main)> edit Hoge.validates
# railsのソースコードに対してputsなどを仕込む
pry(main)> reload!
pry(main)> Hoge.validates(:name, presence: true)
# 出力の確認
validates_with
メソッドは以下のようになっています。
def validates_with(*args, &block)
options = args.extract_options!
options[:class] = self
args.each do |klass|
validator = klass.new(options.dup, &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
validationの対象のクラスやattributesなどを含むoptionsを引数として対象のValidatorクラスのインスタンスを生成して、validateメソッドに渡します。
先ほどと同様にvalidates :name, presence: true
を想定するとvalidateメソッドは以下のように呼び出されます。
validate(#<ActiveRecord::Validations::PresenceValidator:0x00007fffe27f1f68 @attributes=[:name], @options={}>, {:attributes=>[:name], :class=>Hoge})
validate
メソッドは以下のようになっています。
def validate(*args, &block)
options = args.extract_options!
if args.all?(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.merge(if: [predicate_for_validation_context(options[:on]), *options[:if]])
end
set_callback(:validate, *args, options, &block)
end
ここでは、set_callback
でvalidationを実装する予定のクラスにvalidationを登録しています。
set_callback
はActiveRecord::Transaction
に定義されていて、これはsuper呼び出しによって探索対象のActiveSupport::Callbacks::ClassMethodsのset_callbackメソッドを呼び出します。
このset_callbackメソッドはclass_attributeである__callbacks
に対してvalidation情報を登録します。
実際にvalidatesメソッドの呼び出し前後を確認すると__callbacks
の:validate
が変化していることを確認することができます。(filterが変化している)
validates呼び出し前後の__callbacks[:validate]
pry(main)> Hoge.__callbacks[:validate]
=> #<ActiveSupport::Callbacks::CallbackChain:0x00007fffe1f4a298
@all_callbacks=nil,
@chain=
[#<ActiveSupport::Callbacks::Callback:0x00007fffe1f4a338
@chain_config=
{:scope=>:name,
:terminator=>
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>},
@compiled=
#<ActiveSupport::Callbacks::Filters::Before:0x00007fffe1f4a2e8
@filter=:cant_modify_encrypted_attributes_when_frozen,
@halted_lambda=
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>,
@name=:validate,
@user_callback=
#<Proc:0x00007fffe1f34830 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:361 (lambda)>,
@user_conditions=
[#<Proc:0x00007fffe1f34970 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:406 (lambda)>]>,
@filter=:cant_modify_encrypted_attributes_when_frozen,
@if=
[#<Proc:0x00007fffe1f34fb0 /usr/local/bundle/gems/activerecord-7.2.0/lib/active_record/encryption/encryptable_record.rb:13 (lambda)>],
@kind=:before,
@name=:validate,
@unless=[]>],
@config=
{:scope=>:name,
:terminator=>
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>},
@mutex=#<Thread::Mutex:0x00007fffe1f345d8>,
@name=:validate,
@single_callbacks={}>
pry(main)> Hoge.validates(:name, presence: true)
pry(main)> Hoge.__callbacks[:validate]
=> #<ActiveSupport::Callbacks::CallbackChain:0x00007fffe21df878
@all_callbacks=nil,
@chain=
[#<ActiveSupport::Callbacks::Callback:0x00007fffe1f4a338
@chain_config=
{:scope=>:name,
:terminator=>
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>},
@compiled=
#<ActiveSupport::Callbacks::Filters::Before:0x00007fffe1f4a2e8
@filter=:cant_modify_encrypted_attributes_when_frozen,
@halted_lambda=
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>,
@name=:validate,
@user_callback=
#<Proc:0x00007fffe1f34830 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:361 (lambda)>,
@user_conditions=
[#<Proc:0x00007fffe1f34970 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:406 (lambda)>]>,
@filter=:cant_modify_encrypted_attributes_when_frozen,
@if=
[#<Proc:0x00007fffe1f34fb0 /usr/local/bundle/gems/activerecord-7.2.0/lib/active_record/encryption/encryptable_record.rb:13 (lambda)>],
@kind=:before,
@name=:validate,
@unless=[]>,
#<ActiveSupport::Callbacks::Callback:0x00007fffe21df918
@chain_config=
{:scope=>:name,
:terminator=>
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>},
@compiled=
#<ActiveSupport::Callbacks::Filters::Before:0x00007fffe21df8c8
@filter=#<ActiveRecord::Validations::PresenceValidator:0x00007fffe28673d0 @attributes=[:name], @options={}>,
@halted_lambda=
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>,
@name=:validate,
@user_callback=
#<Proc:0x00007fffe2865dc8 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:384 (lambda)>,
@user_conditions=[]>,
@filter=#<ActiveRecord::Validations::PresenceValidator:0x00007fffe28673d0 @attributes=[:name], @options={}>,
@if=[],
@kind=:before,
@name=:validate,
@unless=[]>],
@config=
{:scope=>:name,
:terminator=>
#<Proc:0x00007fffe2028b60 /usr/local/bundle/gems/activesupport-7.2.0/lib/active_support/callbacks.rb:665>},
@mutex=#<Thread::Mutex:0x00007fffe2865698>,
@name=:validate,
@single_callbacks={}>
valid?
データの正当性を確かめる際にはvalid?
メソッドをコールすると思いますが、valid?
はActiveRecord::Validations
で以下のように定義されています。
def valid?(context = nil)
context ||= default_validation_context
output = super(context)
errors.empty? && output
end
super
によって以下のActiveModel::Validations
のvalid?
がコールされますが、そこではrun_validations
メソッドが呼ばれています。
def valid?(context = nil)
current_context, self.validation_context = validation_context, context
errors.clear
run_validations!
ensure
self.validation_context = current_context
end
# ...
def run_validations!
_run_validate_callbacks
errors.empty?
end
run_validations!
メソッドは_run_validate_callbacks
を呼び出し、errorsに値が入っているかどうかの判定をしています。
これが実質valid?
メソッドの戻り値になります。
RailsのbuiltinのPresenceValidatorを見ると、データが正しくなかったときにインスタンスのerrorsにオブジェクトを入れているので納得です。
module ActiveModel
module Validations
class PresenceValidator < EachValidator # :nodoc:
def validate_each(record, attr_name, value)
record.errors.add(attr_name, :blank, **options) if value.blank?
end
end
# ...
_run_validate_callbacks
はメタプロされているので探しても見つかりませんが、ActiveSupport::Callbacks::ClassMethods
のdefine_callbacks
で定義されるようになっています。
def define_callbacks(*names)
options = names.extract_options!
names.each do |name|
name = name.to_sym
([self] + self.descendants).each do |target|
target.set_callbacks name, CallbackChain.new(name, options)
end
module_eval <<-RUBY, __FILE__, __LINE__ + 1
def _run_#{name}_callbacks(&block)
run_callbacks #{name.inspect}, &block
end
def self._#{name}_callbacks
get_callbacks(#{name.inspect})
end
def self._#{name}_callbacks=(value)
set_callbacks(#{name.inspect}, value)
end
def _#{name}_callbacks
__callbacks[#{name.inspect}]
end
RUBY
end
end
なので_run_validate_callbacks
の実態は、run_callbacks :validate
になります。
def run_callbacks(kind, type = nil)
callbacks = __callbacks[kind.to_sym]
if callbacks.empty?
yield if block_given?
else
env = Filters::Environment.new(self, false, nil)
next_sequence = callbacks.compile(type)
# Common case: no 'around' callbacks defined
if next_sequence.final?
next_sequence.invoke_before(env)
env.value = !env.halted && (!block_given? || yield)
next_sequence.invoke_after(env)
env.value
else
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&.first
break env.value
end
end
invoke_sequence.call
end
end
end
この処理で、class_attributeの__callback
に登録されたcallback処理たちが呼び出されます。
validates
で登録された__callback[:validate]
も同様なのでvalidationが実行されます。
ここから先ももう少し追いたかったのですが、力尽きたのでここまでにしてValidatorのvalidate_each
のcallstackを貼っておきます。
presence: trueを設定し、valid?時に呼び出されるcallstack
activerecord-7.2.0/lib/active_record/validations/presence.rb:10:in `validate_each'
activemodel-7.2.0/lib/active_model/validator.rb:155:in `block in validate'
activemodel-7.2.0/lib/active_model/validator.rb:151:in `each'
activemodel-7.2.0/lib/active_model/validator.rb:151:in `validate'
activesupport-7.2.0/lib/active_support/callbacks.rb:385:in `block in make_lambda'
activesupport-7.2.0/lib/active_support/callbacks.rb:179:in `block in call'
activesupport-7.2.0/lib/active_support/callbacks.rb:670:in `block (2 levels) in default_terminator'
activesupport-7.2.0/lib/active_support/callbacks.rb:669:in `catch'
activesupport-7.2.0/lib/active_support/callbacks.rb:669:in `block in default_terminator'
activesupport-7.2.0/lib/active_support/callbacks.rb:180:in `call'
activesupport-7.2.0/lib/active_support/callbacks.rb:559:in `block in invoke_before'
activesupport-7.2.0/lib/active_support/callbacks.rb:559:in `each'
activesupport-7.2.0/lib/active_support/callbacks.rb:559:in `invoke_before'
activesupport-7.2.0/lib/active_support/callbacks.rb:109:in `run_callbacks'
activesupport-7.2.0/lib/active_support/callbacks.rb:915:in `_run_validate_callbacks'
activemodel-7.2.0/lib/active_model/validations.rb:441:in `run_validations!'
activemodel-7.2.0/lib/active_model/validations/callbacks.rb:115:in `block in run_validations!'
activesupport-7.2.0/lib/active_support/callbacks.rb:110:in `run_callbacks'
activesupport-7.2.0/lib/active_support/callbacks.rb:915:in `_run_validation_callbacks'
activemodel-7.2.0/lib/active_model/validations/callbacks.rb:115:in `run_validations!'
activemodel-7.2.0/lib/active_model/validations.rb:366:in `valid?'
activerecord-7.2.0/lib/active_record/validations.rb:71:in `valid?'
参考