記事のサマリー(TL;DR)
- SmartHR の基幹 Rails アプリに
config.active_record.belongs_to_required_by_default = trueを導入するため、既存belongs_to全件にoptional: trueを一括付与し段階的に PR をマージ shoulda-matchersのbelong_toマッチャがbelongs_to_required_by_defaultの値で判定ロジックを切り替える仕様により、設定変更後に CI テストが大量失敗:blankと:requiredの i18n メッセージの差異まで対処が必要で、最終的に 4 ステップ・複数 PR に分割して無事完了
大規模 Rails 移行で直面する「デフォルト設定の借金」問題
Rails アプリを長期運用している国内の SaaS 企業では、config.load_defaults が古いバージョンに固定されたまま Rails 本体だけアップグレードを続けるケースが多い。SmartHR の事例が示すように、load_defaults 5.0 相当の設定一つでも、数百モデルを抱えるアプリでは「既存 belongs_to への一括追記」「テストフレームワークの挙動変化」「i18n 定義の差異」という三重の地雷を踏む。kintone や Salesforce のカスタム Rails 連携基盤、あるいは EC-CUBE から移行した独自 Rails アプリを保守している場合も同様の問題が潜在する。AST パーサーを使った自動置換スクリプトをあらかじめ武器として持っておくことが、大規模移行コストを大幅に圧縮する。
詳細
config.active_record.belongs_to_required_by_default = true の概要
この設定は Rails 5.0 のデフォルト(config.load_defaults 5.0)から有効になるもので、モデルに belongs_to :user と書いた際に validates_presence_of :user, message: :required を自動で追加する挙動をします。新規プロジェクトでは当然 on になっていますが、古くから運用しているアプリでは明示的に有効化されていないことがあります。
移行方針:先に optional: true を全件付与する
「基本機能」は長年の開発で膨大な belongs_to が存在するため、素朴に設定を切り替えると既存の振る舞いが壊れます。そこで次の方針を採りました。
- 設定変更の前に、既存の全
belongs_toにoptional: trueを明示的に付与して既存挙動を保護する - 設定を
trueに変更し、以降に新規追加されるbelongs_toだけに新しいルールを適用する - 最終的に不要になった
optional: trueを削除する
全件付与の確認方法:PresenceValidator インスタンスの増減で検知
optional: true が漏れている belongs_to があれば、設定変更前後で ActiveRecord::Validations::PresenceValidator インスタンス数が増えます。config.eager_load = true を設定した開発環境で以下を実行することで差分を確認します。
ActiveRecord::Base.descendants.sum do |model|
model._validators.values.flatten.count do |v|
v.is_a?(ActiveRecord::Validations::PresenceValidator)
end
end
今回は activerecord-multi-tenant が動的に belongs_to を実行していたケースと Active Storage のモデルが例外として見つかりました。
PR を分割して少しずつ適用する
自動スクリプトを適用した結果、1 PR の Files changed が 352 になりました。コンフリクトリスクを下げるため複数 PR に分割してマージを進め、全件への optional: true 付与を完了させました。
「やったか!?」——shoulda-matchers の罠
全 belongs_to に optional: true を付与したのち belongs_to_required_by_default = true に変更した直後、CI で大量のテストが失敗しました。原因は shoulda-matchers v7.0.1 の belong_to マッチャの仕様です。
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 確認をスキップ |
これまで「基本機能」のテストでは optional も required もチェインしていなかったため、設定変更前は 3-a の振る舞い(バリデーションがないことを期待)でテストが通っていました。設定変更後は 3-b に切り替わり、バリデーションが存在しない belongs_to のテストが全て失敗したのです。
:blank と :required の i18n 差異という追加の壁
「基本機能」には belongs_to :company に加えて validates :company, presence: true を重複して書くパターンが多数存在していました。これを belongs_to :company, optional: false に一本化したい場合、エラーメッセージの差異が問題になります。
validates :company, presence: true→ エラーメッセージキーは:blankbelongs_to :company, optional: false→ エラーメッセージキーは:required
SmartHR の i18n 設定では :blank のみアプリ内で定義されており、:required は rails-i18n のデフォルト値が使われていました。そのため両者のメッセージ文字列が異なる状態で、既存の shoulda-matchers テストがこの差異を前提として通っていたのです。
最終的な 4 ステップの変更計画
これらの前提を踏まえ、次の順序で作業を完了させました。
ステップ 1:belong_to マッチャに .optional を付与
テストコードの is_expected.to belong_to(:company) を is_expected.to belong_to(:company).optional に変更。バリデーション確認が不要なケースは .without_validating_presence を使用。
ステップ 2:validates :company, presence: true の呼び出しを整理
belongs_to :company + validates :company, presence: true のパターンを belongs_to :company, optional: false に一本化し、テスト側を belong_to(:company).required に変更。
あわせて I18n.t('errors.messages.required') を I18n.t('errors.messages.blank') と同一メッセージに揃えます(Files changed: 152)。条件付きバリデーション等の手動対応が必要なエッジケースは個別に対応。
ステップ 3:デフォルト設定の変更
config/application.rb に config.active_record.belongs_to_required_by_default = true を追記。
ステップ 4:一時的に付与した optional: true を削除
ステップ 3 で設定が有効になったことで不要になった optional: true を全件削除。
まとめと教訓
大規模プロジェクトを横断して変更する際は、AST パーサーを使った自動置換スクリプトを作成することが非常に有効です。作業が進むにつれて想定外の落とし穴(マルチテナント gem の動的 belongs_to・shoulda-matchers の判定ロジック・i18n メッセージの差異)が次々と発覚しましたが、内容を精査して一つずつ対処することで、最終的に不具合なく設定変更を完了できました。
今後も Rails のデフォルト設定を一つずつ適用し、最終的に最新 Rails の設定と同等にしていく予定です。