railsのwith_optionsについて

railsのwith_optionsについて、以下の記事で取り上げられているようにオプションが上書きされてしまうということを知らずに結構時間を溶かしてしまったので、勉強がてらrailsのコードを読んでいこうと思います。(rubyのProcとかもわかっていないので一緒に勉強していきます。)

blog.serverworks.co.jp

環境

ruby2.7.0
rails 5.2.0

準備

以下のようなコードを想定して読んでいこうと思います。

class Post < ActiveRecord::Base
  with_options presense: true, if: -> { body.present? } do
    validates :title
    vaildates :phone_number, if -> { country == 'ja' }
  end
end

読んでいく

with_options

まずは本命のwith_optionsの方を見て行きます。 rails/with_options.rb at 5-2-stable · rails/rails · GitHub
こちらはコードとしてはたった6行しかありませんね。メインはOptionMergerのほうなのかなと思いつつ、まずはこちらを見ていこうと思います。

class Object
  def with_options(options, &block)
    option_merger = ActiveSupport::OptionMerger.new(self, options)
    block.arity.zero? ? option_merger.instance_eval(&block) : block.call(option_merger)
  end
end

まず、with_options というのがObjectクラスに対して追加で定義しているインスタンスメソッドであることがわかります。(こういうのをモンキーパッチって言うんですね)

続いて引数として options&blockを受け取ってます。今回のコードは以下のような(){}が省略された形になっているので、options{presense: true, if: -> { body.present? }}が入っていて、それに続くProcオブジェクトが&blockに入っている形になります。

with_options({presense: true, if: -> { body.present? }}) do
  ...
end

そしてそのoptionsActiveSupport::OptionMergerに渡してインスタンスoption_mergerの生成を行っています。
このインスタンスは後で追うことにして先に進むと、Procクラスのインスタンスメソッドであるarityが出てきます。これは以下のようにProc オブジェクトが受け付ける引数の数を返します。

> proc = Proc.new{|a| a}
> proc.arity
# => 1
proc2 = Proc.new{|a,b,c| a}
proc2.arity
# => 3
> l = lambda { 1 }
> l.arity
# => 0

with_optionsではそれが0かどうかで処理を分けています。つまり、Procオブジェクトが引数を取る場合は先程のoption_mergerを引数にとって、そのProcオブジェクトを実行する。一方引数を取らない場合は、option_mergerのコンテキストの中で実行する。という形になります。 今回の例では引数を取らない場合に当たりますね。

それぞれの処理について見ていきます。
まずはoption_merger.instance_eval(&block)についてです。引数の先頭に&をつけることでブロックを引数として渡しています。つまり、blockとして渡ってきたProcオブジェクトを再度instance_evalに引数として渡しているということになります。 次にinstance_evalですが、これはeval族の一つで、今回でいうとOptionMergerインスタンスであるoption_mergerselfとしてそれに続くブロックの評価を行います。これにより、ブロック内でインスタンス変数にアクセスすることができます。
これら2つのことからinstance_eval(&block)option_mergerのコンテキストにおいて、渡したブロックを実行しているということになります。以下のような感じです。

class Foo
  def initialize
    @foo = 'This is a instance of Foo'
  end  
end

def hoge(&block)
  f = Foo.new()
  f.instance_eval(&block)
end

hoge do
  p @foo
end  
"This is a instance of Foo"
# => "This is a instance of Foo"

続いて、ブロックが引数を取る場合の処理であるblock.call(option_merger)についてですが、こちらはoption_mergerを引数としてこのブロックを実行しています。今回はこちらのロジックは通過しません。ちなみにブロックが引数を取る場合というと以下のような場合です。

> (0..5).each do |n|
>   p n  
> end  
0
1
2
3
4
5
# => 0..5

> def hoge(&block)
>   yield 'a', 'b', 'c'
> end  
> hoge do |x, y, z|
>   p x, y, z
> end  
"a"
"b"
"c"
# => ["a", "b", "c"]

以上がwith_optionsとなります。わかったこととしてはoption_mergerのコンテキストで渡されたブロックを実行するということです。ですが、これだけではなぜオプションが上書きされてしまうのかというのがよくわかりませんね。。。 なのでOptionMergerの方について見ていこうと思います。option_merger.instance_eval(&block)を想定して見ていきます。

ActiveSupport::OptionMerger

OptionMergerの実装もかなり小さめでした。 rails/option_merger.rb at 5-2-stable · rails/rails · GitHub

# frozen_string_literal: true

require "active_support/core_ext/hash/deep_merge"

module ActiveSupport
  class OptionMerger #:nodoc:
    instance_methods.each do |method|
      undef_method(method) if method !~ /^(__|instance_eval|class|object_id)/
    end

    def initialize(context, options)
      @context, @options = context, options
    end

    private
      def method_missing(method, *arguments, &block)
        if arguments.first.is_a?(Proc)
          proc = arguments.pop
          arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) }
        else
          arguments << (arguments.last.respond_to?(:to_hash) ? @options.deep_merge(arguments.pop) : @options.dup)
        end

        @context.__send__(method, *arguments, &block)
      end
  end

まずクラスの定義の最初に以下のようにインスタンスメソッドを消していってます。

instance_methods.each do |method|
  undef_method(method) if method !~ /^(__|instance_eval|class|object_id)/
end

これによって以下のようにインスタンスメソッドが限られる形になります

[1] pry(main)> ActiveSupport::OptionMerger.instance_methods
=> [:html_safe?,
 :pry,
 :`,
 :__binding__,
 :with_options,
 :unloadable,
 :require_or_load,
 :load_dependency,
 :require_dependency,
 :pretty_print_instance_variables,
 :pretty_print,
 :pretty_print_inspect,
 :pretty_print_cycle,
 :class,
 :class_eval,
 :debugger,
 :byebug,
 :pretty_inspect,
 :object_id,
 :__send__,
 :__id__,
 :instance_eval]

(こういうメソッドの定義とか削除とかもeachイテレーションで回せるのか)
そしてinitializeメソッドによって引数として渡されるcontextoptionsを同名のインスタンス変数に格納しています。 今回の例で行くと、@contextにself、つまりwith_optionsを使おうとしてるActiveRecordを継承したクラス(Post)を、@optionsにはwith_optionsに渡しているオプション(presense: true, if: -> { body.present? })を渡しています。
そして最後にprivate methodとして method_missingを定義しています。このメソッドはBasicObjectクラスに定義されているメソッドでNoMethodErrorが発生した際に呼び出されるものになります。ここではこのメソッドをオーバーライドしている形ですね。

class Foo
  def method_missing(name, *args)
    p name
    p "There is no such method."
  end  
end

f = Foo.new()
f.no_method
:no_method
"There is no such method."
# => "There is no such method."

また、引数もBasicObjectの場合はメソッド名とその引数の2つだけなのに対して、今回オーバーライドすることで、ブロックも受け取れるようにもしているようです。
その中で何をやっているのかというと、argumentsの最初の要素がProcであれば、ラムダ式argumentsにpushする。このときのラムダ式の内容としては、ラムダ式の引数をそのProcに渡して実行した結果を@optionsにdeep_mergeするというもの。一方、Procでなければ、argumentsの最後の要素がto_hashメソッドを呼び出せるものかどうかに依って、呼び出せる場合はargumentsの最後の要素を@optionsにdeep_mergeした上でargumentsにpushし、呼び出せない場合は@optionsをduplicateしたものをpushするというもの。(ロジックを文字起こししただけですね(汗))
そして、これらのうちどちらかの操作を終えた後、再度 __send__メソッドによって@contextに対して、つまり今回の例で行くとPostクラスにおいて同じメソッドを呼び出しています。

まとめ

以上をまとめると、まずoption_mergerのcontextにおいて&blockで渡したブロックが実行されます。今回でいうとblockの中身は以下2つのvalidatesメソッドになります。

validates :title
vaildates :phone_number, if -> { country == 'ja' }

このvalidatesメソッドはoption_mergerにおいて定義されていませんので、オーバーライドされたmethod_missingを通ります。
一つ目に関してはmethod:validatesが入り、argumentsには[:title]が入ります。そして、arguments.first.is_a?(Proc)にも(arguments.last.respond_to?(:to_hash)にも当てはまらないため、最終的には

arguments << @options.dup
@context.__send__(method, *arguments, &block)

という処理が実行されます。ここで確認しておくと、@contextには(Postが、@optionsにはpresense: true, if: -> { body.present? }が入っています。 なので結果的には、 Post内で validates :title, presense: true, if: -> { body.present? }したことと同じになります。

続いて、二つ目に関してはmethod:validatesが入り、argumentsには[:phone_number, if -> { country == 'ja' }]が入ります。そして、arguments.first.is_a?(Proc)には当てはまりませんが、(arguments.last.respond_to?(:to_hash)には当てはまるため、最終的には

arguments << @options.deep_merge(arguments.pop)
@context.__send__(method, *arguments, &block)

という処理が実行されます。 そして、ついにここで@options内if: -> { body.present? }if -> { country == 'ja' }がマージされてしまい、前者の条件が消えてしまうということになります。 なので結果的にPost内でvalidates :phone_number, presense: true, if: -> { country == 'ja' }したことと同じになります。

感想

上記のように一つづつ追っていくと勉強になりますね。あんまり理解できていなかったブロックに関しても学ぶことができてよかったなと思います。