【Rails】with_options ブロックの内側でさらにオプションを指定した場合の動作について

記事タイトルとURLをコピーする

こんにちは。サービス開発課の丸山です。 今回は最近触っていて疑問に思ったwith_optionsについて調べてみました。

with_optionsはRailsでよく使われる、こんな感じのメソッドです。

class Account < ActiveRecord::Base
  with_options presence: true, if: -> { foo? } do
    validates :name
    validates :email
  end
end

これは以下のコードと等価です。
複数のメソッド呼び出しにまとめて同じオプションを指定する時に便利なメソッドですね。
参考: APIドキュメント

class Account < ActiveRecord::Base
  validates :name, presence: true, if: -> { foo? }
  validates :email,  presence: true, if: -> { foo? }
end

ブロックの中でさらにオプションを指定することもできます。

class Account < ActiveRecord::Base
  with_options presence: true, if: -> { foo? } do
    validates :name
    validates :email, unique: ture # presence: true, if: -> { foo? } に加えて unique: true が適用される
  end
end

と便利な使い方ができるメソッドなのですが、こんな使い方をしようとすると自分の想定とは異なる動作をしました。

class Account < ActiveRecord::Base
  with_options presence: true, if: -> { foo? } do
    validates :name
    validates :email, unique: ture
    validates :phone_number, if: -> { bar? }
  end
end

この場合、bar? かつ foo? の場合に phone_number に対するバリデーションが動く想定でしたが、
実際には foo? は無視され、 bar? がtrueであれば バリデーションが動いてしまいました。

調べてみると、これはバグ(不具合)ではなく仕様のようです。
これを回避するにはif オプションを避けて、unlessを使うという方法があります。

# unless を使う方法
class Account < ActiveRecord::Base
  with_options presence: true, if: -> { foo? } do
    validates :name
    validates :email, unique: ture
    validates :phone_number, unless: -> { !bar? }
  end
end

これで foo? かつ bar? の場合に phone_number のバリデーションが動く、想定した動作になります。

なぜこのような動作になるのかというと、「with_optionsはデフォルトのオプションをまとめて指定するためのメソッドであり、制御構文ではないから」ということでした。
頭の中で想定していたwith_optionsの動作イメージ 実際のwith_optionsの動作(イメージ)

パッと見てifが出てくると制御構文のようにコードを読もうとしてしまい、foo?やbar?がその都度評価されると捉えてしまうのですが、
実際にはwith_optionsに渡しているのは「デフォルトのオプション」であり、if: の値もlambdaで作られたProcオブジェクトなので、
ifオプションが評価されるのは実際にメソッドが実行される時だけになります。
また、あくまでwith_optionsに渡すのはデフォルトのオプションなので、ブロックの中で同じキーのオプションを指定するとそこで上書きしてしまうことになります。
これはifではない別のオプションで考えるとわかりやすいです。

class Account < ActiveRecord::Base
  with_options presence: true, if: -> { foo? } do
    validates :phone_number, presence: false
  end
end

ここではwith_optionsに渡している presence: true と矛盾する presence: false をブロックの中で指定しています。
この場合は trueとfalseの両方が適用されるわけではなく、ブロックの中で指定したfalseのみが適応されます。
ifオプションを指定した場合も、これと同じ原理で内側のifオプションだけが実行されることになります。

コードを読んでみる

せっかくなので、with_optionsのコードを読んでみます。

activesupport/lib/active_support/core_ext/object/with_options.rbで定義されていました。
モデル定義でよくみるイメージがありましたが、Objectに対するモンキーパッチなのですね。
そのため、こんな風にレシーバーを明示しても使えるようです。

I18n.with_options locale: user.locale, scope: 'newsletter' do |i18n|
 subject i18n.t :subject
 body    i18n.t :body, user_name: user.name
end

メソッド定義は以下のようにになっています。

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

rails/with_options.rb at 6-1-stable · rails/rails · GitHub

まず引数で渡されたオプションからoptionsとselfからOptionMergerというオブジェクトを作っています。
selfは先ほどの例ではActiveRecord(を継承したモデルクラス)やI18nですね。
その後、blcokの引数がなければOptionMergerオブジェクトのコンテキストでブロックの中身を実行、
blockの引数があればblock.callにOptionMergerオブジェクトを渡して実行しています。

肝心のOptionMergerの定義はこんな感じです。

# frozen_string_literal: true  
  
require "active_support/core_ext/hash/deep_merge"  
require "active_support/core_ext/symbol/starts_ends_with"  
  
module ActiveSupport
  class OptionMerger #:nodoc:
    instance_methods.each do |method|
      undef_method(method) unless method.start_with?("__", "instance_eval", "class", "object_id")
    end
  
    def initialize(context, options)
      @context, @options = context, options
    end
  
    private
      def method_missing(method, *arguments, &block)
        options = nil
        if arguments.first.is_a?(Proc)
          proc = arguments.pop
          arguments << lambda { |*args| @options.deep_merge(proc.call(*args)) }
        elsif arguments.last.respond_to?(:to_hash)
          options = @options.deep_merge(arguments.pop)
        else
          options = @options
        end
  
        invoke_method(method, arguments, options, &block)
      end
  
      if RUBY_VERSION >= "2.7"
        def invoke_method(method, arguments, options, &block)
          if options
            @context.__send__(method, *arguments, **options, &block)
          else
            @context.__send__(method, *arguments, &block)
          end
        end
      else
        def invoke_method(method, arguments, options, &block)
          arguments << options.dup if options
          @context.__send__(method, *arguments, &block)
        end
      end
  end
end

rails/option_merger.rb at 6-1-stable · rails/rails · GitHub

最初のinstance_methods.each do ~ でObjectから継承された多くのメソッドを未定義状態にしています。
残っているのはclass, object, instance_eval, その他__send__などの__から始まるメソッドだけですね。

method_missing(定義されていないメソッドが呼び出された時に実行されるメソッド)が再定義されているので、
OptionMergerオブジェクトのコンテキストで実行されるメソッドは多くがここを通ることになりそうです。
method_missingの中で追加のオプションが与えられていた場合の処理を行って、最終的なオプションを作っています。
with_optionsの内側でさらにvalidatesにオプションをつけた場合などがここに当たりますね。

# ...
elsif arguments.last.respond_to?(:to_hash)
  options = @options.deep_merge(arguments.pop)
else
# ...

ここで追加のオプションとwith_optionsの引数に渡した@optionsdeep_mergeされているのでifを2つ使った場合は最初のifが上書きされて無視されてしまう、ということになります。
最後は整形したoptionsを使って、@context(初期化時に渡したself)に__send__で引数とoptions付きでメソッドを呼び出しています。

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

validateの例なら@contextはActiveRecord(を継承した)クラスなので、無事with_optionsに渡したデフォルトのオプション付きでvalidateなどの実際のメソッドが呼び出されることがわかりました。

終わりに

かなりメタプログラミングな感じのコードでしたが、普段使っているrails のコードの仕組みを知るのは面白いですね。
実際にdeep_mergeでオプションがマージされるところまで確認できたので、もうifなどのオプションを誤って重ねて使ってしまうこともなさそうです。

丸山 礼 (記事一覧)

サービス開発課でCloud Automatorを開発しています。