✈️

has_oneからhas_manyへの安全な変更方法

2023/12/22に公開

こちらはRuby on Rails Advent Calendar 2023の22日目の記事です

はじめに

Railsアプリケーションでのデータモデルの関連性をhas_oneからhas_manyに変更する際の手順と考慮点を詳細に説明します。この変更は、データ整合性やアプリケーションの安定性を保つために重要です。

背景

新卒配属されて初めての事業課題としてリファイメント(工数見積もりのMTG)にて....

先輩PdM「今まで単数で登録していたものを複数登録して欲しいです!!」

僕「つまりどうすれば良いのですか?」
先輩エンジニア「has_oneをhas_manyに変えればいけると思うよ」
僕「なるほど...grepして該当箇所を変更すれば良い感じですかね..そんなに難しくなさそう」
案件としては、1対1の関連付けを1対多に変更するというものでした

そもそも1対1と1対多とは?


かなり基本的な話になりますが、DBのテーブルのリレーションの話をします。

1対1のリレーション

初めに1対1とは上記の例で考えて欲しいですが、1つのContentsに対して1つのFormsの情報を持っているような関係になります。Railsではhas_oneというメソッドを使えば別のモデル間でリレーションの定義ができます。
1対1のリレーションを実現するには、初めに上記の例で言うとContentモデルに以下のように記述します

class Content < ApplicationRecord
  has_one :form
end

逆に従属するformモデルには以下のように宣言します。

class Form < ApplicationRecord
  belongs_to :content
end

これで1対1のリレーションができました

1対多のリレーション

次に1対多ですが、1つのBooksが複数のAuthorsの情報を持つようなケースのことを指します。(書籍には複数著者がいる感じで想定していただけると良いと思います)。Railsではhas_manyというメソッドを使って別のモデル間で1対多のリレーションの定義ができます。
ここで注意なのが、has_manyでの関連付けを宣言する際、相手のモデル名は複数形で指定する必要があります。

class Book < ApplicationRecord
  has_many :authors
end
class Author < ApplicationRecord
  belongs_to :book
end

これで一対多のリレーションができました

変更時の罠

実装当初の見積もりで、has_oneメソッドをhas_manyメソッドに変更するだけなので、grepして該当箇所を変更してメソッド名を複数形に変えるだけだと思いそこまでかからないだろうと思っていたのですが、変数の複数形に変更になるので、Frontendのコードまで影響が出ることに実装途中で気づきました。

Frontendのコードやコンポーネントの変更も必要になるのでまとめてリリースしたほうが良いと思った結果.....

実装時の影響範囲が増え、リリースを大分遅らせてしまう結果に.....
また、デプロイ時にBackendとFrontendにわずかな時間差分が出てしまうため、画面を開いたままのユーザー(今回は管理画面の変更だったので、社員の方々)がいた場合エラーになってしまう可能性があり、事前にリリースタイミングを使わない時間にしたり、変更後リロードして使っていただくように周知をしたりしました。

サービスを止めないようにリリースするためには

同じような実装の案件が再びあり、開発スピードとサービスを止めないためにどうするべきかチーム内で議論しました。その結果既存のコードを残した状態でリリースすればBackendとFrontendで新規開発するように実装できるのでそれができないか調査しました。要するにhas_oneメソッドとhas_manyメソッドを両方同時に定義できるか調査しました

has_oneメソッドとhas_manyメソッドを同時に定義する

結論から話すと、has_oneメソッドとhas_manyメソッドは両方同時に定義できます。おそらく両方使うケースはあまりなさそうですが、考えられるケースとしては、記事一覧用のhas_manyの関連付けと新着記事を一つだけpickupして表示するhas_oneの関連付けとして使うようなケースでしょうか。
ContentモデルとFormモデルの関係で言うと以下のような実装になります

class Content < ApplicationRecord
  has_one :form
  has_many :forms
end
class Form < ApplicationRecord
  belongs_to :content
end

これでContentモデルはFormモデルと1対1でもあり1対多としても関連付けを行うことができました。

formとformsは別の定義として扱われるので、既存の関連付けを残しつつ変更後の関連付けの定義を行うことができます。これにより、Frontendとの依存関係をなくすことでBackendの開発とFrontendの開発を分離して行うことができました!
また、has_oneのデータは1つしかデータを持たないためhas_manyのデータとして定義しても特に問題がないためfrontendのリリース時にも両方定義していてもサービスを止めることなくリリースすることができました!

既存のhas_oneメソッドを削除

古い関連付けである、has_oneメソッドをfrontendの実装が終わった段階で削除していきます。これで安全に置き換えすることができました!

補足

先程has_oneとhas_manyを同時に定義可能という話でしたが、has_manyのリレーションを持ったデータをhas_oneに変更した場合、デフォルトでは、最も若いidのレコードの情報が出力されます。
仮に、最新のデータをhas_oneのデータとして持たせたい場合は、ラムダ式を使って定義できます
例えば、User モデルにおいて、最も新しく作成された Profile を取得したい場合、以下のように記述します:

class User < ApplicationRecord
  has_one :profile, -> { order(created_at: :desc) }
end

またラムダ式内でwhere メソッドを使用して条件を指定できるので、特定の条件に合わせてフィルタリングも可能です。

class User < ApplicationRecord
  has_one :profile, -> { where(active: true).order(created_at: :desc) }
end

複雑な条件でなければメソッドを定義することなく簡単に条件を定義できるのでよさそうです。

まとめ

has_oneからhas_manyにリレーションを変更した際はメソッド名の変更が必要なこと、has_oneからhas_manyへリレーションを変える際は既存のリレーションを残しつつ、has_oneとhas_manyを両方定義してからFrontendリリース後にhas_oneメソッドを削除するとサービスを止めることなく安全にリリースできると言う話でした!
既存のBackendのリレーションを変更しようと考えている方のお役に立てると幸いです。

Discussion