Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Active Model オブジェクト初期化時にオプションを渡したいときのおすすめの方法

🔗 前提となる環境

以下の環境で動作確認をしています。

  • Ruby 3.3 + Rails 7.1
  • Ruby 3.2 + Rails 6.1

🔗 課題

Active Model オブジェクトでは、デフォルトでコンストラクタに attribute をキーワード引数で渡すことができますが、それに加えてオプションを渡したいことがあります。特に多いのは、Form Object に行わせようとしている処理の文脈に関する情報(例えば操作しているユーザーの Active Record オブジェクト)を与えたい場合でしょうか。

🔗 TL;DR

おすすめの方法

🔗 初歩的な方法: 位置引数を使う

こういうとき、次のようなコンストラクタを書くと、位置引数で渡すことができます。

class MyForm
  include ActiveModel::Model

  def initialize(user, attributes = {})
    @user = user
    super(attributes)
  end
end

# 使うとき
form = MyForm.new(user, { attr_a: 1 })

ですが、引数は往々にして増えるもので、取り違えが怖いです。Ruby は動的型付け言語なので、本来 MyForm.new(a, b) と呼ぶべきところを MyForm.new(b, a) と書いてしまった場合でも、単に呼び出されてメソッドに入っただけの段階ではエラーになりません。仮に静的型付け言語であったとしても、ab の型に互換性がある場合はコンパイルエラーにできないことがあります。これは位置引数の宿命です。

🔗 キーワード引数にしたい

そこでキーワード引数の出番です。キーワード引数では呼び出し側でそれぞれの引数が何であるかを指定し、その順番は問いません。理想は次のような呼び出し形式です。

form1 = MyForm.new(user:, attr_a: 1) # OK
form2 = MyForm.new(attr_a: 1, user:) # これもOK

この形式には好みもあるようです。あるBPSメンバーは、渡すものが attribute なのか、そうではないオプションであるのかは呼び出し側で明確に区別したいと言っていました。そういう方は一例として次のような方法を取るでしょう。

class MyForm
  include ActiveModel::Model

  def initialize(attributes:, user:)
    @user = user
    super(attributes)
  end
end

form = MyForm.new(user:, attributes: { attr_a: 1 })

ですが、私はメソッドに対してパラメータとして渡すものは、それが何であるべきかだけが公開されるべきで、attribute なのかそうではないのかはインターフェイスから隠蔽されるべきだと思っています。よってこの方法は避けたい。

🔗 attribute にしてはいけない理由

user を attribute とすることで、一見理想が実現できたかに思えます。次のようなコードです。

class MyForm
  include ActiveModel::Model

  def user=(user)
    @user = user
  end
end

しかし、これにも問題があります。

まず、初期化後にもう一度 #user= を呼ぶことができてしまいます。何も対策処理を書かないと、次のようなコードがエラーを起こすことなく実行できてしまいます。

form = MyForm.new(user:)
form.user = another_user
form.assign_attributes(user: some_other_user)

また、attribute の writer は、コンストラクタや #assign_attributes に渡ったハッシュ状のオブジェクト中の値の順番に従って呼び出されます。既存の attribute writer に、ここで言う @user に依存する処理があった場合、次のように呼び出すと前提が崩れます。

form = MyForm.new(attr_a: 1, user:)

つまり、キーワード引数でありながら順番に依存してしまうのです。もちろん、うっかり指定し忘れても、それだけではエラーになりません。これはダメ。

🔗 double splat

Ruby には double splat という機能があります。これを使うと、次のように書くことができます。

class MyForm
  include ActiveModel::Model

  def initialize(user:, **attributes)
    @user = user
    super(attributes)
  end
end

form = MyForm.new(user:, attr_a: 1) #OK
form = MyForm.new(attr_a: 1, user:) #OK

この方法では、attribute にした場合と異なり、MyForm.new({ user:, attr_a: 1 }) と呼ぶことができなくなります。ですが、これは全く許容範囲内です。なぜなら、Form Object では Controller 内で params を操作して取り出したサブセットの ActionController::Parameters に、user をオプションとして加えるという形で使うことになりますが、これは MyForm.new(user:, **my_form_params) と書くことができるからです。attribute なのかそうではないのかはインターフェイスから隠蔽したいとは言ったものの、実際に区分しない書き方をするのは Form Object ではテストコードぐらいのものでしょうか。全パラメータ入りの Hash を渡したい場合には、MyForm.new(**my_form_params) と書くこともできます。

🔗 思わぬ落とし穴: ActionController::Parameters vs Hash

しかし、これにもまだ問題がありました。

ActionController::Parameters ではキーはシンボルではなく文字列になります。Active Model はキーがシンボルであろうと文字列であろうと、attribute の writer を想定通り呼んでくれます。問題はリクエストパラメータのハッシュ形式がネストしているときです。次のコードを見てください。

class MyForm
  # 省略

  def sub_objects_attributes=(attributes)
    attributes.each do |_, h|
       if h[:id].present?
         # 既存オブジェクトの処理
       else
         # 新規オブジェクトの処理
       end
    end
  end
end

#sub_objects_attributes=ActionView::Helpers::FormBuilderfields_for :sub_objects するために必要なよくあるパターンの attribute writer です。個々のパラメータに id があるかどうかを h[:id] で判別しています。これはコンストラクタに渡る attributesActionController::Parameters である場合は、キーがシンボルであろうと文字列であろうと正常に動きます。#sub_objects_attributes= に渡ってくる attributes もまた ActionController::Parameters だからです。
しかし、MyForm.new(user:, **my_form_params) した場合だとこれは動きません。なぜなら、double splat を使って my_form_paramsuser を組み合わせた段階で、コンストラクタに渡る attributesActionController::Parameters ではなく Hash に暗黙に変換され、その段階でオブジェクトに入っている値も Hash になってしまうからです。

解決策: Hash#with_indifferent_access

そこで Hash#with_indifferent_access の出番です。以下のように super するところで使います。

class MyForm
  include ActiveModel::Model

  def initialize(user:, **attributes)
    @user = user
    super(attributes.with_indifferent_access)
  end

  # 省略
end

Hash#with_indifferent_accessActiveSupport::HashWithIndifferentAccess オブジェクトを返します。これは ActionController::Parameters 同様、キーにシンボルを渡しても文字列を渡しても同じように要素にアクセスすることができます。これで目的が達成できました。めでたい!

🔗 ちょっと待った: attribute 性が隠蔽されていない

「attribute なのかそうではないのかはインターフェイスから隠蔽されるべきだ」と言いましたね。その観点からすると、まだ問題があります。my_form_paramsActionController::Parameters である場合、MyForm.new(**my_form_params) と書けないことです。ActionController::Parameters のキーは文字列なので、double splat で展開した場合に user にマッチしないのです。
これを回避するためには MyForm.new(**my_form_params.to_h.symbolize_keys) と書く必要がありますが、これでは「user は attribute ではないのでシンボルで渡してくださいね」と言っているに等しく、要件を満たしているとは言えません。そこで、コンストラクタで工夫をします。

🔗 最終バージョン

以下がこの問題を解決するコードです。

class MyForm
  include ActiveModel::Model

  def initialize(options = {})
    process_options(**options.to_h.symbolize_keys) do |attributes|
      super(attributes.with_indifferent_access)
    end
  end

  private

  def process_options(user:, **attributes)
    @user = user
    yield(attributes)
  end
end

副次的な効果として、MyForm.new(my_form_params) と呼ぶこともできるようになりました。

このコードのキモは、#process_options をオーバライドすることで、サブクラスでもコンストラクタのオプションを増やすことができ、その際にスーパークラスのコンストラクタを呼んだ後にオプションを使った初期化処理を書くこともできる点です。例えば次のようにオーバーライドします。

class MySpecificForm < MyForm
  private

  def process_options(group:, message:, **rest)
    @group = group
    super(**rest)
    puts message
  end
end

#initialize#process_options はモジュールに切り出しておくのも良さそうです。

module ActiveModelWithOptions
  extend ActiveSupport::Concern
  include ActiveModel::Model

  def initialize(options = {})
    process_options(**options.to_h.symbolize_keys) do |attributes|
      super(attributes.with_indifferent_access)
    end
  end

  private

  def process_options(**attributes)
    yield(attributes)
  end
end

include ActiveModel::Model しないほうが取り回しが良いかもしれませんが、その辺はお好みで。

使用例はこちら。

class MyForm
  include ActiveModelWithOptions

  private

  def process_options(user:, **rest)
    @user = user
    super(**rest)
  end
end

かなりシンプルになりました。

🔗 余談: どうしてこうなるの?

double splat 周りで、なぜこうなってしまうのかよく分からないものがありました。以下のように、通常のキーワード引数に加えて double splat がある場合と、double splat のみの場合とで挙動が異なります。

def f(**h)
  puts h.class
end

def g(a:, **h)
  puts h.class
end

h = ActionController::Parameters.new.permit
f(**h) # Hash
g(a: 1, **h) # Hash

h = {}.with_indifferent_access
f(**h) # ActiveSupport::HashWithIndifferentAccess
g(a: 1, **h) # Hash

f(**h) だと ActiveSupport::HashWithIndifferentAccess を渡したとき文字列キーでしかアクセスできない問題が起きないのに、g(a: 1, **h) ではどちらも問題が起きるのが謎です。ActionController::Parameters.new.permit.is_a?(Hash)false を返すのに対して {}.with_indifferent_access.is_a?(Hash)true を返すので、その場合に f(**h) でだけ暗黙の変換が行われなくなるものと予想できます。

レシーバのクラス to_h 後のクラス to_hash 後のクラス
ActiveSupport::HashWithIndifferentAccess Hash Hash
ActionController::Parameters ActiveSupport::HashWithIndifferentAccess Hash

恐らく呼ばれているのは #to_hash でしょう。まぁ、どうでもいいか。



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。