こんにちは。SmartHRでRails顧問業をしているwillnetです。最近晩ごはんを少しでも早く食べるためにホットクックを導入しました。朝に材料を切ってホットクックに入れておけば、夜の料理時間を短縮できて良い感じです。
さて、以前に「基本機能」と呼ばれるSmartHR 最大の Rails アプリケーションをRuby3.4にアップグレードした時のブログの中で「基本機能」にはconfig.load_defaultsが設定されていないという話がありました。
SmartHR最大のRailsアプリケーションをRuby 3.4(+YJIT)にアップデートしました - SmartHR Tech Blog
load_defaultsが設定されていなかったのは、プロジェクトの歴史的な経緯によるものです。 私たちの開発チームでは、Rails のアップデートに際して、既存の設定値を維持することで影響範囲を最小限に抑える方針を採っています。 とはいえ、load_defaultsが設定されていない状態はあまり望ましいものではありません。現在は、設定値をひとつずつ確認しながら、段階的に Rails 7.2 のデフォルト設定に追いついていく方針で移行作業を進めています。
上記記事の公開から9か月ほど経った現在、「基本機能」にはconfig.load_defaults 5.1が設定されています。今回は、config.load_defaults 5.0への移行に際して特に苦労した設定であるconfig.active_record.belongs_to_required_by_default = trueを導入した話を書きます。
config.active_record.belongs_to_required_by_default = true の概要
この設定は、belongs_to関連を定義したときに関連先の存在を確認するバリデーションを自動で追加するものです(Railsの該当コミット)。config.load_defaults 5.0から有効なので、おそらくこの記事を読んでいるみなさんのRailsアプリケーションはすでに設定済みかと思われます。
具体的にコードで説明すると
belongs_to :user
と書いた時に
validates_presence_of :user, message: :required
を自動的に追記したのと同じ振る舞いをします。
既存のアプリケーションをconfig.active_record.belongs_to_required_by_default = trueに変更する方針
「基本機能」は長い年月を経て作りあげられた巨大なRailsアプリケーションであり、大量のbelongs_toが記述されています。素朴に設定を変更すると間違いなく不具合が起きるので、可能な限り既存の振る舞いを変えないように工夫する必要があります。
具体的にはconfig.active_record.belongs_to_required_by_default = trueとする前に既存のbelongs_to関連全てにoptional: trueを明示的に追記します。optional: trueはバリデーションの自動追加をスキップするためのオプションです。このようにすれば既存の振る舞いはそのまま維持し、新規に作るbelongs_toだけが新しい設定の対象となります。
既存のbelongs_to全てにoptional: trueをつける方法
しかし既存のbelongs_to関連全てに漏れなくoptional: trueを付与していくのは骨が折れます。Pull Request(以下PRと省略します)を作ってもレビューをしている間に新しいbelongs_toが追加されたら、それを探す必要があります。仮にAIエージェントに任せたとしても、それで全て対応できるか自信を持てません。
そこで、モデルのコードをパースしてbelongs_toメソッドの呼び出しを見つけたら自動でoptional: trueを付与するスクリプトを使うことにしました。
以前に別のプロジェクトで同じことをするために作ってMITライセンスで公開していたものです。
しかし、スクリプトで本当に全てのbelongs_to関連に対応できたかは、どのようにして確認すればいいでしょうか。
全てのbelongs_toにoptional: trueを付与したかを確認する方法
対象となる全てのbelongs_toにoptional: trueを付与したかは、アプリケーション全体で定義されているActiveRecord::Validations::PresenceValidatorインスタンスの数を数えることで判定できます。
ActiveRecord::Validations::PresenceValidatorインスタンスは、validates_presence_of(もしくはvalidates :something, presence: true)を実行したときに作られるオブジェクトです。config.active_record.belongs_to_required_by_default = trueとしたとき、optional: trueのないbelongs_toはvalidates_presence_ofを実行しActiveRecord::Validations::PresenceValidatorインスタンスを作成します。
つまりconfig.active_record.belongs_to_required_by_defaultがfalseの時とtrueの時でActiveRecord::Validations::PresenceValidatorインスタンスの数に変化がなければ全てのbelongs_toにoptional: trueを付与したと言えます。
開発環境で試すときは、事前に次のようにconfig/environments/development.rbの設定を変更して全てのコードをロードするようにしておきます。
config.eager_load = true
そのうえでbin/rails cなどを利用して次のコードを実行すると、ActiveRecord::Validations::PresenceValidatorインスタンスの数が返ってきます。
ActiveRecord::Base.descendants.sum do |model| model._validators.values.flatten.count do |v| v.is_a? ActiveRecord::Validations::PresenceValidator end end
設定変更の前後で数が一致していない時はgem内で定義されたモデルが存在するか、もしくは動的にbelongs_toが実行されているはずです。今回のケースではactiverecord-multi-tenantが動的にbelongs_toを実行し、Active Storageが定義するモデルでbelongs_toが存在していました。
PRを分割して少しずつ適用する
方針が整ったのでPRを作ります。すると1つのPR中のFiles changedが352になりました。一度にレビューするには差分が大きすぎるし、レビュー中にコンフリクトする変更が入る可能性が高くなるので、複数のPRに分割して少しずつマージしていくことで、最終的に全てのbelongs_toにoptional: trueをつけることができました。
「やったか!?」
全てのbelongs_toにoptional: trueを付与したら最後にconfig.active_record.belongs_to_required_by_default = trueとして終わり、と思っていたのですが、CIで大量にテストが失敗しました。
調べてみるとshoulda-matchersが用意しているRSpec用のマッチャbelong_toで失敗しています。アプリケーションの振る舞いは変えていないはずなのになぜだろう?と思いshoulda-matchersの実装を調べてみると、そう簡単にこのタスクが終わらないことがわかってきました。
shoulda-matchersが提供しているbelong_toの仕様
shoulda-matchersのバージョンは執筆時点における最新のv7.0.1です。
shoulda-matchersのbelong_toマッチャは対象となるモデルにbelongs_toが定義されているかを確認するマッチャですが、戻り値にメソッドをチェインさせていくことでbelongs_toに渡しているオプションの確認もできます。関連先の存在を確かめるメソッドもあるので、それにより判定条件がどのように変わるかを調べることにしました。
説明のために次のようなモデルを例にします。
class User < ApplicationRecord belongs_to :company end class Company < ApplicationRecord has_many :users end
1: required メソッドをチェーンした場合
次のように書いたとします。
it { is_expected.to belong_to(:company).required }
このときは
user.company #=> nil
user.valid?
としたあとに、user.errors[:company]の戻り値となる配列要素のいずれかがI18n.t('errors.messages.required')と一致するかどうかをチェックしています。一致していたらテストはパスします。
2: optional メソッドをチェーンした場合
次のように書いたとします。
it { is_expected.to belong_to(:company).optional }
このときは
user.company #=> nil
user.valid?
としたあとに、user.errors[:company]が存在するかをチェックしています。エラーメッセージは確認していません。
3:明示的にoptionalもrequiredも指定しない場合
次のように書いたとします。
it { is_expected.to belong_to(:company) }
このときはconfig.active_record.belongs_to_required_by_defaultの値によって振る舞いが変わります。
3-a:config.active_record.belongs_to_required_by_defaultがfalseもしくはnilの場合
requiredを設定したときの否定が条件になります。つまり
user.company #=> nil
user.valid?
としたあとに、user.errors[:company]がI18n.t('errors.messages.required')の戻り値となる配列要素のいずれかと一致するかどうかをチェックして、一致しなかったらテストはパスします。
これまでの「基本機能」のテストにおけるbelong_toマッチャにはoptionalもrequiredもチェインされていないので、すべてのbelong_toはこの挙動になっていました。
3-b:config.active_record.belongs_to_required_by_defaultがtrueの場合
requiredを明示的に書いたのと同じ振る舞いになります。
4:without_validating_presenceメソッドをチェーンした場合
次のように書いたとします。
it { is_expected.to belong_to(:company).without_validating_presence }
このとき、user.companyに関する存在チェックは行いません。例えば次のようなコードのときはshoulda-matcherによる関連先が存在しなかったときのバリデーション確認ができないのでwithout_validating_presenceを付ける必要があります。
class User < ApplicationRecord belongs_to :company before_validation :autoassign_company def autoassign_company self.company = Company.create! end end
belong_toマッチャの挙動のまとめ
ここまでの挙動を表でまとめると次のようになります。
| 番号 | チェインメソッド | 判定条件 |
|---|---|---|
| 1 | required |
user.errors[:company]の戻り値にI18n.t('errors.messages.required')と一致する要素があるかを確認 |
| 2 | optional |
user.errors[:company]の有無を確認(メッセージ内容は見ない) |
| 3-a | なし (belongs_to_required_by_defaultがfalseまたはnil) |
required判定の否定 |
| 3-b | なし (belongs_to_required_by_defaultがtrue) |
requiredと同じ判定 |
| 4 | without_validating_presence |
関連先のpresence確認をスキップ |
つまりどういうことですか?
CIで大量にテストが失敗したのはconfig.active_record.belongs_to_required_by_default = trueにしたことで、belong_toマッチャの振る舞いが3-aから3-bに変わったことが原因です。 3-bではバリデーションがかかっていないモデルのbelong_toマッチャによるテストは全て失敗します。
であれば、該当する全てのbelong_toマッチャに.optionalをつけて、2の振る舞いをさせれば良さそうです。先ほどbelongs_toにoptional: trueをつけた時と同じように、belong_toに.optionalをつけていくスクリプトを作って適用させましょう…と思ったのですが、テストやアプリケーションコードを見るとこれまで想定していなかったユースケースが見えてきました。具体的には、バリデーションに対するエラーメッセージの扱いにも大きく手を入れる必要があったのです。
validates ... presence: trueとbelongs_toが追加するバリデーションの違い
これまで書いてきたように「基本機能」ではconfig.active_record.belongs_to_required_by_defaultが設定されていなかったので、belongs_toを定義しただけでは関連先の存在チェックは行われません。存在チェックをしたいときはbelongs_toにoptional: falseオプションを付ければよいのですが、なぜか次のようなコードが多くの箇所で書かれていました。
class User < ApplicationRecord belongs_to :company validates :company, presence: true end
これは次のように書くのと振る舞いとしてはほぼ同じです。
class User < ApplicationRecord belongs_to :company, optional: false end
config.active_record.belongs_to_required_by_default = trueにすれば次のようにできます。
class User < ApplicationRecord belongs_to :company end
こう書けるようにしたい、と思うかもしれません。僕もそうでした。しかしこれもそう簡単にはいきませんでした。そもそもこれらの記述は完全に同じではありません。
異なるのはエラーメッセージの形式です。belongs_toが設定するバリデーションエラーメッセージはvalidates ... presence: trueデフォルトの:blankではなく:requiredが設定されています。
「基本機能」のi18n設定では、:blankと:requiredの定義が異なっていました。正確には:blankだけが明示的にアプリケーション中に定義されており、:requiredはアプリケーション中に使用されている箇所がないためrails-i18nのデフォルトが定義されている状態でした。
なのでvalidates :company, presence: trueを削除してbelongs_to :company, optional: falseに一本化するためには、「基本機能」のi18n設定の:requiredを:blankと同一にする必要があります。であればすぐにでもi18n設定を変更すればよいと思うでしょうが、そう簡単ではありません。
これまで次のようなテストが通っていました。これが通っているのは:blankと:requiredのi18nの設定が異なるからです。
class User < ApplicationRecord belongs_to :company validates :company, presence: true end it { is_expected.to belong_to(:company) }
このテストは先ほどのshoulda-matcherの仕様で説明した3-aの振る舞いをします。つまり
user.company #=> nil
user.valid?
としたあとに、user.errors[:company]がI18n.t('errors.messages.required')と一致しているかをチェックしています。上記のテストではuser.errors[:company]はI18n.t('errors.messages.blank')を返すためテストが通ります。このとき:blankと:requiredのi18nのメッセージを揃えてしまうとこのテストは失敗するようになってしまいます。
shoulda-matchersの仕様を踏まえた変更計画
ここまでの前提を踏まえて、変更計画を練り直しました。具体的には次の順番で変更を加えていきました。
1:belong_toマッチャにoptionalをつける
belong_toマッチャに明示的にoptionalをつけて問題ないものを全て探し出します。つまりこのような呼び出しを、
it { is_expected.to belong_to(:company) }
次のように変更します。
it { is_expected.to belong_to(:company).optional }
2:validatesの呼び出しをやめる
次のような形になっているコードをすべて見つけて、
class User < ApplicationRecord belongs_to :company validates :company, presence: true end it { is_expected.to belong_to(:company) }
次のように変更します。
class User < ApplicationRecord belongs_to :company, optional: false end it { is_expected.to belong_to(:company).required }
バリデーションに条件が追加されている等、単純に置き換えられない場合はwithout_validating_presenceを利用します。
この変更もbelongs_toにoptional: trueを付与したときと同様に、機械的にコードをパースして自動で書き換えるスクリプトがないと難しいと考え、新しいスクリプトを作って対応しました。スクリプトによる自動修正だけでエッジケースを全て扱うのが費用対効果に見合わなかったので、いくつかのケースは手動でも対応しました。
さらにI18n.t('errors.messages.required')の戻り値をI18n.t('errors.messages.blank')と同じにします。
この工程は差分がかなり大きくなりました(Files changed 152)が、作業内容的に分割することができないためやむなく1つのPRとしました。
3: デフォルトの設定を変更する
config/application.rbに対してconfig.active_record.belongs_to_required_by_default = trueを追記します。
4: 一時的に設定したoptional: trueを削除する
デフォルトの設定を変更したことでbelongs_toに対して明示的に付与したoptional: trueが不要になったので、optional: trueを全て削除します。
戦い終わって
とにかく大変でしたが、特に不具合等なく設定を変更することができました。
今回の件で、大きいプロジェクトを横断して変更したいときはパーサーを利用した自動置換スクリプトを作るのがとても有効だという知見を得ました。全て手作業でやる方針にしていたらと思うとゾッとします。作業が進むにつれ最初には全く想定していなかった落とし穴が見つかりましたが、内容をきちんと精査して一つずつ着実に取り組んでいけば最終的には不具合なく作業を完了させることができます。
これからもRailsのデフォルト設定を一つずつ適用していき、最終的には最新のRailsの設定と同等にしていくつもりです。おそらく今回より大変な設定はないと信じていますが、またなんらかの知見になりそうな作業をしたらブログエントリにしようと思います。
We Are Hiring!
SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!
大きいアプリケーションを改善していく楽しさを一緒に感じていきましょう。
少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!