こんにちは、 クラウド経費開発チーム ・ クラウド債務支払開発チーム の 宮村(みやむー) @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
※ インスタンス生成時に評価したい場合は 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 標準機能に置き換えた変更でもそのようにされています。
ちなみに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 の知的難問を仲間と議論しながら解きたい方はぜひ!
福岡開発拠点のサイトもあるのでぜひみてください!