Rails でカラムを追加する際、既存レコードに影響を与えずにデフォルト値を設定したい

株式会社iCAREの[西口](https://twitter.com/NishiguchiNaoya)です。
今回は、デフォルト値付きでカラムを追加する要件のレアケースに遭遇したので記事に残します。

## 実現したかったこと
デフォルト値を指定してカラムを追加する、という実装はよくあると思いますが、今回は下記要件を満たす必要がありました。

:::note info
- 既に存在するレコードにはデフォルト値を適用せず、 `nil` を割り当てたい
- 今後作成するレコードにはデフォルト値が入るようにしたい
:::

## 結論から 💁‍♂️
### うまくいったコード
```ruby
class AddColumnToHogeTables < ActiveRecord::Migration[5.0]
  def change
    # デフォルト値を nil でカラムを追加した後、 100.0 に設定する
    add_column :hoge_tables, :some_item, :float, default: nil
    change_column_default :hoge_tables, :some_item, from: nil, to: 100.0
  end
end
```
一旦デフォルト値を `nil` で `add_column` した直後に、`change_column_default` でデフォルト値を指定する。

### マイグレーション後、 Rails コンソールでデータを確認
```ruby
# レコードが1つ存在する想定
HogeTable.count 
=> 1

# 既存レコードを取得して値を確認する
HogeTable.first
=>
# ....
some_item: nil

# レコードを新規作成して値を確認する
HogeTable.create!
HogeTable.last
=>
# ....
some_item: 100.0
```
元々存在したレコードの `some_item` は `nil` にり、マイグレーション後に作成したレコードの `some_item` にはデフォルト値が入っていることを確認。

## うまくいかなかったコード

```ruby
class AddColumnToHogeTables < ActiveRecord::Migration[5.0]
  def change
    # 普通に書いたらこう
    add_column :hoge_tables, :some_item, :float, default: 100.0
  end
end
```

今回の要件である「今後作成するレコードにはデフォルト値が入るようにしたい」　は満たせるのですが、「既に存在するレコードにはデフォルト値を適用せず、 `nil` を割り当てたい」は満たせませんでした。

```ruby
HogeTable.first
=>
some_item: 100.0 # 既存レコードには、 nil ではなくデフォルト値(100.0)が入ってしまう

HogeTable.create!
HogeTable.last
=>
some_item: 100.0 # 新規レコードには、意図通りデフォルト値(100.0)が入る
```
そのため、前項の通り、一旦デフォルト値を `nil` で `add_column` した直後に、`change_column_default` でデフォルト値を指定する方法で要件を満たせることがわかりました。

## 今回試したすべてのコード
他にもいくつか試したので、記録しておきます。
### マイグレーションファイル
```ruby
class AddColumnsToHogeTables < ActiveRecord::Migration[5.0]
  def change
    # 何も指定しない
    add_column :hoge_tables, :case_1_column, :float

    # デフォルト値のみ設定
    add_column :hoge_tables, :case_2_column, :float, default: 100.0

    # NOT NULL制約とデフォルト値を設定
    add_column :hoge_tables, :case_3_column, :float, default: 100.0, null: false

    # デフォルト値を NULL でカラムを追加した後、 100.0 に設定する
    add_column :hoge_tables, :case_4_column, :float, default: nil
    change_column_default :hoge_tables, :case_4_column, from: nil, to: 100.0
  end
end
```
### コンソールでのデータ確認
```ruby
# 既存レコードのレコードを取得して値を確認する
HogeTable.first
=>
case_1_column: nil,
case_2_column: 100.0,
case_3_column: 100.0,
case_4_column: nil # 既存レコードには、意図通り nil が入る

# レコードを新規作成して値を確認する
HogeTable.create!
HogeTable.last
=>
case_1_column: nil,
case_2_column: 100.0,
case_3_column: 100.0,
case_4_column: 100.0 # 新規レコードには、意図通りデフォルト値(100.0)が入る
```

## Rubocop の BulkChangeTable に引っかかる場合
カラムを複数追加する際に `add_column` メソッドで書いた結果、 Rubocop に bulk オプションを使うように指摘されました。
そのため、以下のように `bulk: true` オプションを使って対応しました。
```ruby
class AddColumnsToHogeTables < ActiveRecord::Migration[5.0]
  def change
    # カラム追加
    change_table :hoge_tables, bulk: true do |t|
      t.float :first_item, default: nil
      t.float :second_item, default: nil
      t.float :third_item, default: nil
      t.float :fourth_item, default: nil
    end

    # デフォルト値設定
    change_table :hoge_tables, bulk: true do |t|
      t.change_default :first_item, from: nil, to: 5.0
      t.change_default :second_item, from: nil, to: 10.0
      t.change_default :third_item, from: nil, to: 50.0
      t.change_default :fourth_item, from: nil, to: 100.0
    end
  end
end
```
`bluk: true` オプションを使うことで、追加するカラムの数に関わらず発行される ALTER 文が2つで済みます。
```sql
ALTER TABLE "hoge_tables" ADD "first_item" float DEFAULT NULL, ADD "second_item" float DEFAULT NULL, ADD "third_item" float DEFAULT NULL, ADD "fourth_item" float DEFAULT NULL;
ALTER TABLE "hoge_tables" ALTER COLUMN "first_item" SET DEFAULT 5.0, ALTER COLUMN "second_item" SET DEFAULT 10.0, ALTER COLUMN "third_item" SET DEFAULT 50.0, ALTER COLUMN "fourth_item" SET DEFAULT 100.0;
```
`bluk: true` オプションを使わないと、追加するカラムの数 × 2 の ALTER 文が発行され DB への負荷が高くなってしまいます。

```ruby
class AddColumnToHogeTables < ActiveRecord::Migration[5.0]
  def change
    add_column :hoge_tables, :first_item, :float, default: nil
    change_column_default :hoge_tables, :first_item, from: nil, to: 5.0

    add_column :hoge_tables, :second_item, :float, default: nil
    change_column_default :hoge_tables, :second_item, from: nil, to: 10.0

    add_column :hoge_tables, :third_item, :float, default: nil
    change_column_default :hoge_tables, :third_item, from: nil, to: 50.0
  
    add_column :hoge_tables, :fourth_item, :float, default: nil
    change_column_default :hoge_tables, :fourth_item, from: nil, to: 100.0
  end
end
```
```sql
ALTER TABLE "hoge_tables" ADD "first_item" float DEFAULT NULL;
ALTER TABLE "hoge_tables" ALTER COLUMN "first_item" SET DEFAULT 5.0;

ALTER TABLE "hoge_tables" ADD "second_item" float DEFAULT NULL;
ALTER TABLE "hoge_tables" ALTER COLUMN "second_item" SET DEFAULT 10.0;

ALTER TABLE "hoge_tables" ADD "third_item" float DEFAULT NULL;
ALTER TABLE "hoge_tables" ALTER COLUMN "third_item" SET DEFAULT 50.0;

ALTER TABLE "hoge_tables" ADD "fourth_item" float DEFAULT NULL;
ALTER TABLE "hoge_tables" ALTER COLUMN "fourth_item" SET DEFAULT 100.0;
```


参考: https://www.rubydoc.info/gems/rubocop/0.61.1/RuboCop/Cop/Rails/BulkChangeTable

## 要件の背景（おまけ）
詳細は記載できませんが…

1️⃣ とあるテーブルに、デフォルト値付きでカラムを追加したい。
2️⃣ ただ、新しいカラムといえど既に提供している顧客データには変更を加えたくない。
3️⃣ よって、既に存在するレコードの新カラムには nil を割り当て、今後作成するレコードの新カラムにはデフォルト値が入るようにしたい。
4️⃣ しかし、Rails でデフォルト値を指定してマイグレーションすると既存のデータにもデフォルト値が入ってしまうため、要件を満たせない。


「どのようにマイグレーションすれば実現できるんだ…？」

と、パッと浮かばなかったので色々試しました、というのが背景です。

## +α
その他のユースケースとして、enum でステータスを設定する際にも今回の方法が使えると思います。

```ruby
class AddColumnToPiyoTables < ActiveRecord::Migration[5.0]
  def change
    add_column :piyo_tables, :status, :integer, default: 0 # 「対象外」のステータス
    change_column_default :piyo_tables, :status, from: 0, to: 10
  end
end
```
今後作成されるレコードには特定のステータスを割り当て、既存のレコードには「対象外」のステータスを割り当てたい（既存レコードは要件のスコープ外としたい）、といった要件があるかもしれません。

## 参考記事
- https://api.rubyonrails.org/v7.1/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_column_default
- https://api.rubyonrails.org/v7.1/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-change_table

## 〆
今回は、Railsを使用してデフォルト値付きのカラムを追加する際のイレギュラーな対応事例を紹介しました。
この方法で、既存データに影響を与えずにデフォルト値を設定することができます。
どなたかの実装に役立つ機会があれば幸いです。

## スペシャルサンクス
本記事中の実装及び執筆中に、弊社フェローの [igaiga](https://x.com/igaiga555)さん に相談させていただきました。