railsのwith_optionsについて
railsのwith_optionsについて、以下の記事で取り上げられているようにオプションが上書きされてしまうということを知らずに結構時間を溶かしてしまったので、勉強がてらrailsのコードを読んでいこうと思います。(rubyのProcとかもわかっていないので一緒に勉強していきます。)
環境
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
そしてそのoptions
をActiveSupport::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_merger
をself
としてそれに続くブロックの評価を行います。これにより、ブロック内でインスタンス変数にアクセスすることができます。
これら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メソッドによって引数として渡されるcontext
とoptions
を同名のインスタンス変数に格納しています。
今回の例で行くと、@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' }
したことと同じになります。
感想
上記のように一つづつ追っていくと勉強になりますね。あんまり理解できていなかったブロックに関しても学ぶことができてよかったなと思います。