Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

default_value_for gem を Rails 標準機能に置き換えて削除する際の注意点

こんにちは、 クラウド経費開発チームクラウド債務支払開発チーム の 宮村(みやむー) @miyamura.koyo です!

Ruby には default_value_for という gem があります。

これは以下のように属性のデフォルト値を設定できる便利な gem です。

class User < ActiveRecord::Base
  default_value_for :name, "(no name)"
  default_value_for :last_seen { Time.now }
end

u = User.new
u.name       # => "(no name)"
u.last_seen  # => Mon Sep 22 17:28:38 +0200 2008

しかし現在では上記の機能は Rails の標準機能で対応することができます。

本記事では筆者の体験談を基に default_value_for から Rails の標準機能に置き換える際の注意点を紹介します。

とあるリポジトリで

とあるリポジトリの Rails バージョンアップ作業中に default_value_for が Rails 7.1 で動作しないことが発覚しました。

調べてみると Rails の標準機能で置き換えられそうなことがわかりました。

「不要な依存 gem を削除して、システムをスリムにするためにも削除するぞ!」ということで削除に取り組みました。

※ ちなみに2024年8月の執筆時点では Rails 7.2 をサポートしたバージョンがリリースされているので、Rails バージョンアップのためには必ずしも削除する必要はなくなっています。

代替となる Rails の標準機能

代替となる Rails の機能として attribute メソッドに default オプションを渡す方法があります。

class User < ActiveRecord::Base
  attribute :name, default: "(no name)"
  attribute :last_seen, default: -> { Time.now }
end

api.rubyonrails.org

※ インスタンス生成時に評価したい場合は default: -> { Time.now } のように記述して実行時に評価されるようにする必要があります(さもないと、クラス定義時に評価されてしまい意図しない挙動になります)。

ということで単純にこれで置き換えておしまい...とはなりませんでした(泣)。

注意点1 既存の attribute 定義を上書きしないようにする

attribute はデフォルト値を設定するためだけではなく、 cast_type を設定するなど様々な使い方があります。

また当然ですがメソッドなので、子クラスなどで override してしまった場合は上書きされてしまいます。

例えば、以下のようなクラス構造の SubUser クラスの default_value_for を置き換えることを考えてみます。

class User < ActiveRecord::Base
  attribute :name, :custom_type_column
end

class SubUser < User
  default_value_for :name, "Sub User"
end

この時 SubUser の default_value_for を以下のように置き換えてしまうと、 name 属性に設定された :custom_type_column が上書きされて未指定になってしまいます。

行数の多いクラスや、継承・ミックスインの多いクラスだとうっかり上書きしてしまうので気をつけましょう。修正前にテストを書いておくとより安心して修正できるためオススメです。

class SubUser < User
  attribute :name, default: "Sub User"
end

注意点2 default 値をセットするタイミングで、インスタンスのリレーションを辿った値を参照したい場合は after_initialize と new_record? を使う必要がある

以下のように default_value_for のタイミングで何かしらのリレーションを辿ってデフォルト値をセットするとします。

class User < ActiveRecord::Base
  has_one :role

  default_value_for :role_name { |user| user.role.name }
end

この場合、単純に attribute :role_name, default: -> { self.role.name } としても動作しません。

なぜかというと default オプションに渡す処理はインスタンス化が完了する前に呼ばれるため、リレーションを辿ることができないのです。

そのため、 after_initialize で置き換える必要があります。

この時 if: :new_record? をつけて、インスタンス生成時のみ動作するよう制御する必要があることに注意してください。

また、値をセットする際に nil (未指定) の場合のみセットするようにしないと、User.new(role_name: "name") のように生成時に引数で明示的に値を入れた場合に、引数ではなくデフォルト値が優先されてしまい意図しない挙動になります。そのため、 ||= でセットするようにしましょう。

class User < ActiveRecord::Base
  has_one :role

  after_initialize :set_role_name, if: :new_record?

  private

  def set_role_name
    self.role_name ||= role.name
  end
end

GitLab が default_value_for を Rails 標準機能に置き換えた変更でもそのようにされています。

gitlab.com

ちなみにdefault_value_for の実装を見てみると、確かに内部的には after_initialize で実装されていることがわかります(new_record?の判定も行われています)。

デフォルト値を無視して明示的に nil をセットしたい場合は非互換の挙動になる

after_initialize に置き換える場合、インスタンス生成時に nil をセットしている箇所がないかをチェックし、もしあればインスタンス生成後に nil をセットするように変更する必要があります。

というのも、default_value_for はインスタンス生成時の引数に nil が明示的に渡された場合は上書きしない挙動になっているためです。

これは default_value_for のテストにも書かれています。

# default_value_for を使用している場合
user = User.new(role_name: nil)
# 引数の nil が優先される
user.role_name.nil?
=> true

しかし先述した方法で after_initialize に置き換えた場合、引数が明示的にセットされたかを確認することは困難です(メタプログラミングを駆使すれば可能かもしれないですが実装が煩雑になります)。

そのため、引数で明示的に nil を渡した場合と、単に引数を未指定の場合の区別ができないため、同じ挙動になってしまいます。

# after_initialize に置き換えた場合
user = User.new(role_name: nil)
# 引数の nil よりもデフォルト値が優先される
user.role_name.nil?
=> false

この非互換性で困るケースはレアだと思いますが、困った場合は以下のようにインスタンス生成後に明示的に nil を入れるように実装を修正することで回避可能です。

user = User.new
user.role_name = nil

まとめ

...ということで、先ほど紹介した注意点を乗り越え、無事にリリースまでたどり着けました!徹底的に調査したおかげで特にバグもなく、システムの複雑性を下げる変更を行うことができました!

本記事では筆者の体験談を基に default_value_for gem から Rails の標準機能に置き換える際の注意点を紹介しました。

本記事では紹介しませんでしたが、 default_value_for ではセットしたデフォルト値の dirty フラグを削除するケースがあるなど、複雑な挙動があるため、置き換えの際はテストを十分に行って実施してください。

他の事例を調べてみて、疑問点があれば手元で検証してみることの大事さを痛感した経験になりました。

また一見難しいように見える gem も Ruby で実装されている OSS なので、コードを読んでみることで理解が深まります。

default_value_for に関わる場合はぜひこの記事を思い出してみてください。


マネーフォワード福岡開発拠点では、エンジニアを募集しています!

Rails と Ruby の知的難問を仲間と議論しながら解きたい方はぜひ!

hrmos.co

福岡開発拠点のサイトもあるのでぜひみてください!

fukuoka.moneyforward.com