SmartHR Tech Blog

SmartHR 開発者ブログ

大きいRailsアプリケーションで belongs_to_required_by_default を安全に有効化するまでの戦い

こんにちは。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_tooptional: trueを付与したかを確認する方法

対象となる全てのbelongs_tooptional: 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_tovalidates_presence_ofを実行しActiveRecord::Validations::PresenceValidatorインスタンスを作成します。

つまりconfig.active_record.belongs_to_required_by_defaultfalseの時とtrueの時でActiveRecord::Validations::PresenceValidatorインスタンスの数に変化がなければ全てのbelongs_tooptional: 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_tooptional: trueをつけることができました。

「やったか!?」

全てのbelongs_tooptional: 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-matchersbelong_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:明示的にoptionalrequiredも指定しない場合

次のように書いたとします。

it { is_expected.to belong_to(:company) }

このときはconfig.active_record.belongs_to_required_by_defaultの値によって振る舞いが変わります。

3-a:config.active_record.belongs_to_required_by_defaultfalseもしくはnilの場合

requiredを設定したときの否定が条件になります。つまり

user.company #=> nil
user.valid?

としたあとに、user.errors[:company]I18n.t('errors.messages.required')の戻り値となる配列要素のいずれかと一致するかどうかをチェックして、一致しなかったらテストはパスします。

これまでの「基本機能」のテストにおけるbelong_toマッチャにはoptionalrequiredもチェインされていないので、すべてのbelong_toはこの挙動になっていました。

3-b:config.active_record.belongs_to_required_by_defaulttrueの場合

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_defaultfalseまたはnil) required判定の否定
3-b なし (belongs_to_required_by_defaulttrue) 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_tooptional: trueをつけた時と同じように、belong_to.optionalをつけていくスクリプトを作って適用させましょう…と思ったのですが、テストやアプリケーションコードを見るとこれまで想定していなかったユースケースが見えてきました。具体的には、バリデーションに対するエラーメッセージの扱いにも大きく手を入れる必要があったのです。

validates ... presence: truebelongs_toが追加するバリデーションの違い

これまで書いてきたように「基本機能」ではconfig.active_record.belongs_to_required_by_defaultが設定されていなかったので、belongs_toを定義しただけでは関連先の存在チェックは行われません。存在チェックをしたいときはbelongs_tooptional: 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_tooptional: 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 を作りあげていく仲間を募集中です!

大きいアプリケーションを改善していく楽しさを一緒に感じていきましょう。

少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!

hello-world.smarthr.co.jp