1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

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_callbackActiveRecord::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::Validationsvalid?がコールされますが、そこでは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::ClassMethodsdefine_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?'

参考

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?