Railsでテーブル定義を安全に変更するための手順

スキーマ変更によるトラブルを防ぐ

Soudai Sasada

Soudai Sasada

Railsではmodelとテーブルを1対1として扱い様々な便利機能を提供してくれます。ただこの特性によりテーブル定義を変更する際に予期しないエラーが起きてしまうことがあります。この記事ではなぜそうしたことが起きるのか、また、どのようにすれば安全にテーブル定義を変更することができるかについて詳しく解説しようと思います。

ActiveRecordの便利な機能

ActiveRecordでは「テーブルをクラス、カラムをメソッド、レコードをクラスのインスタンス」とし継承したクラス名の複数形と同じ名前のテーブルを自動で読み取ってくれます。

app/models/user.rb
# == Schema Info
#
# Table name: users
#
#  id                  :bigint         not null, primary key
#  name                :string(255)    not null
#  address             :string(255)    not null
#

class User < ApplicationRecord
end
irb
# 自動でカラムに関連するメソッドを用意してくれる
irb(main):001:0> u = User.new
irb(main):002:0> u.methods.grep(/name/)
=> [:name_before_type_cast, :name_for_database, :name_came_from_user?, :name?, :name_changed?, :name_change, :name_will_change!, :name_was, :name_previously_changed?, :name_previous_change, :name_previously_was, :restore_name!, :clear_name_change, :saved_change_to_name?, :saved_change_to_name, :name_before_last_save, :will_save_change_to_name?, :name_change_to_be_saved, :name_in_database, :name, :name=, :table_name_prefix, :pluralize_table_names, :table_name_suffix, :table_name_prefix?, :table_name_suffix?, :pluralize_table_names?, :store_full_class_name, :store_full_class_name?, :model_name, :changed_attribute_names_to_save, :attribute_names, :instance_variable_names]

# クラスメソッドも然り、直感的にSQLを発行し結果を取得できる
irb(main):003:0> User.all.first(2)
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 2]]
=>  [#<User id: 1, name: "Taro Yamada", address: "100-0000\t東京都\t渋谷区\t架空町2-28-1\t架空マンション 005号室", created_at: "2021-02-25 23:43:30.405930000 +0000", updated_at: "2021-02-25 23:43:30.405930000 +0000">, #<User id: 2, name: "Hanako Yamada", address: "100-0000\t東京都\t渋谷区\t架空町2-28-2", created_at: "2021-02-25 23:44:14.355908000 +0000", updated_at: "2021-02-25 23:44:14.355908000 +0000">]

開発者はActiveRecordを継承するだけでこれらの機能が使えます。
仕組み的にはクラス名からテーブルを推測しテーブルへアクセスのうえテーブルの情報をキャッシュしつつそれらから動的に各種のメソッドを追加しているため、カラムに変更がある場合にもこれらのメソッドが影響を受けることを意味してます。そのためテーブル定義の変更を伴う場合はこれを念頭に置いて修正しなければなりません。極端な例ですがクラスを削除する前にテーブルを削除してしまえばクラスの読み込み時にエラーが発生しますし、カラム名を変更する場合にも何も考えなしにマイグレーションファイルを書きカラム名に関する処理を変更するだけだとリリース時にトラブルに見舞われるかどうかはDBの変更が先かソースコードが修正されたアプリケーションのリリースが先かに依存してしまいます。

この記事ではカラムを変更する場合を考えてみましょう。

安全にカラムを変更する

せっかくなので例示したusersテーブルをそのまま使おうと思います。このような状態ですね。

app/models/user.rb
# == Schema Info
#
# Table name: users
#
#  id                  :bigint         not null, primary key
#  name                :string(255)    not null
#  address             :string(255)    not null
#

class User < ApplicationRecord
end

このテーブルのaddressカラムをzipcodeprefecturecitystreetapartment_numberに分割する例を考えてみましょう。

大きく分けて7つのステップに分かれます。

  1. 新しいカラムを追加する
  2. 新しいカラムに値を入れる

・古いカラムと新しいカラムの両方に書き込む
・古いカラムのデータを新しいカラムに移す 3. 新しいカラムに制約を追加する 4. 新しいカラムを参照する 5. 古いカラムの制約を変更する 6. 古いカラムを削除する準備をする
・書き込み先を新しいカラムのみにする
・古いカラムを認識しないように定義する 7. 古いカラムを削除する

リリースは各ステップで行うため合計で7回となります。
1から3までは新しいカラムの準備をします。この間はアプリケーション側では古いカラムを参照し続けます。そして4でカラムをリプレース、5から7で古いカラムを削除しリプレースが完了、という流れです。

それでは順番に見ていきましょう。

新しいカラムを追加する

ここでやることは単純です。それはカラムを追加することです。
カラムの追加をリリースすることにリスクはありません。新しく処理が追加されるわけでもなければ新しいカラムへの参照もこの時点では発生しないからです。

db/migrate/002_add_zipcode_and_prefecture_and_city_and_street_and_apartment_number_to_users.rb
class AddZipcodeAndPrefectureAndCityAndStreetAndApartmentNumberToUsers < ActiveRecord::Migration[6.1]
  def change
    add_column :users, :zipcode, :string
    add_column :users, :prefecture, :string
    add_column :users, :city, :string
    add_column :users, :street, :string
    add_column :users, :apartment_number, :string
  end
end

特に何も考える必要はないでしょう、と言いたいところですがもし新しく追加するカラムにNOT NULL制約を追加したいと考えているならここではあえて制約を追加せずグッとこらえましょう。 横着してはいけません。制約を付けてしまうとその制約がキッカケで思わぬエラーが発生することになりますし急ぐ理由はないかと思います。

カラム追加の準備ができたら早速リリースしましょう。

新しいカラムに値を入れる

ここでは新しく追加したカラムを使うための準備をしましょう。これから新しく追加されるレコードも含めて全てのレコードで新しいカラムの中身が正しい状態を作ります。そのためにここでは「新しく追加されるレコード」と「これまで格納されているレコード」のそれぞれに必要な処理を追加します。

まずは古いカラムへの書き込み時に新しいカラムへも値を書き込む処理を追加しましょう。
これをしないとレコードの更新時に古いカラムへ新しくデータが書き込まれるたびに新しいカラムのデータが古くなってしまいますしこれから追加されるレコードに値が設定されません。もし一括で過去データを移行しその後にユーザー操作によって古いカラムへの書き込みがありそれに気が付かないまま古いカラムを削除してしまうとユーザー操作によって更新された値は失われてしまいます。
移行に際しシステムを停止する場合にはこの限りではないですが無停止で安全に移行を行うためには必須の作業と言えるでしょう。

新旧両方のカラムへデータが格納できるようになった段階で過去のデータを全て移行し全てのレコードで古いカラムと新しいカラムの両方に値が存在する状態にします。

古いカラムと新しいカラムの両方に書き込む

今回の例ではただ単に別のカラムに同じ内容を書き込むのではなくそれぞれのカラムに適切な値を切り出し格納する必要があります。ここではActiveRecord::Aggregations#composed_ofを使いますがこのあたりはプロジェクトの設計方針に合わせるのが良いでしょう。

app/models/full_address.rb
class FullAddress
  DELIMITER = "\t"

  attr_reader :zipcode, :prefecture, :city, :street, :apartment_number

  def self.convert(address)
    parts = address.split(DELIMITER)
    parts_full_filled = parts.fill(parts.size..4) { '' }
    new(*parts_full_filled)
  end

  def initialize(zipcode, prefecture, city, street, apartment_number)
    @zipcode = zipcode
    @prefecture = prefecture
    @city = city
    @street = street
    @apartment_number = apartment_number
  end
end
app/models/user.rb
class User
  composed_of :full_address,
    mapping: [
      %w(zipcode zipcode),
      %w(prefecture prefecture),
      %w(city city),
      %w(street street),
      %w(apartment_number apartment_number)
    ],
    converter: :convert

  def address=(value)
    super
    self.full_address = value
  end
end

Userではcomposed_ofでバリューオブジェクトを使うように指定します。また、#address=メソッドをオーバーライドし#full_address=へ書き込みを行うように変更しました。これにより書き込み側の他の処理を変えることなく新しいカラムへの書き込みも実現しています。

irb
irb(main):001:0> User.new(address: "100-0000\t東京都\t渋谷区\t架空町1-1-1\t架空ビル5F")
=> #<User id: nil, name: nil, address: "100-0000\t東京都\t渋谷区\t架空町1-1-1\t架空ビル5F", created_at: nil, updated_at: nil, zipcode: "100-0000", prefecture: "東京都", city: "渋谷区", street: "架空町1-1-1", apartment_number: "架空ビル5F">
irb(main):002:0> User.create(name: 'John Doe', address: "100-0000\t東京都\t渋谷区\t架空町1-1-1\t架空ビル5F")
=> #<User id: 3, name: "John Doe", address: "100-0000\t東京都\t渋谷区\t架空町1-1-1\t架空ビル5F", created_at: "2021-02-27 06:44:38.292144000 +0000", updated_at: "2021-02-27 06:44:38.292144000 +0000", zipcode: "100-0000", prefecture: "東京都", city: "渋谷区", street: "架空町1-1-1", apartment_number: "架空ビル5F">
irb(main):007:0> User.where(full_address: nil)
=> #<ActiveRecord::Relation [#<User id: 1, name: "Taro Yamada", address: "100-0000\t東京都\t渋谷区\t架空町2-28-1\t架空マンション 005号室", created_at: "2021-02-25 23:43:30.405930000 +0000", updated_at: "2021-02-25 23:43:30.405930000 +0000", zipcode: nil, prefecture: nil, city: nil, street: nil, apartment_number: nil>, #<User id: 2, name: "Hanako Yamada", address: "100-0000\t東京都\t渋谷区\t架空町2-28-2", created_at: "2021-02-25 23:44:14.355908000 +0000", updated_at: "2021-02-25 23:44:14.355908000 +0000", zipcode: nil, prefecture: nil, city: nil, street: nil, apartment_number: nil>]>

ActiveRecord::Aggregations#composed_ofについてわからない点があればググるよりもソースコードを読むことをおすすめします。非常にコンパクトですし別の処理への依存も最小限なので基本的なActiveRecordの知識があればすぐに理解できる内容です。

古いカラムのデータを新しいカラムに移す

古いカラムへの書き込み時に新しいカラムへも値が書き込まれるようになり新しく作成されるデータや更新されるデータは全て新旧どちらのカラムにも値が存在する状態となりました。すでに存在するレコードも同じ状態としましょう。

本番環境のデータ移行は大抵の場合ミスが許されない状況です。チームレビューの仕組みが整っているようであればどんなに単純な処理でもコード化しレビューを通す方が良いかと思います。

lib/tasks/user.rake
namespace :user do
  desc 'Convert address column to each parts of an address'
  task convert_address: :environment do
    errors = []
    User.where(full_address: nil).find_each do |user|
      unless user.update(address: user.address, updated_at: user.updated_at)
        errors.push([user.id, user.errors.full_messages])
      end
    end
    puts errors.empty? ? 'Success!!' : "Failure (errors: #{errors})"
  end
end

ここではUser#update!メソッドの引数に自身のaddressupdated_atを渡し最終更新日時を変えずに#address=メソッド経由で新しいカラムへの書き込みをしています。今回は新しいカラムへの書き込み状態に一貫性がなくても問題ないため処理全体を一つのトランザクション内で実行していないですがこのあたりは追加したカラムの性質によって異なるでしょう。

ここまででリリースしリリース後にrakeタスクを実行してください。

$ bundle exec rails user:convert_address
Success!!

新しいカラムに制約を追加する

前のステップまでで全てのレコードが正しい状態とすることができました。これで制約を追加できます。今回の例で追加するのはNOT NULL制約です。

db/migrate/003_add_not_null_constraint_to_zipcode_and_prefecture_and_city_and_street_and_apartment_number_on_users.rb
class AddNotNullConstraintToZipcodeAndPrefectureAndCityAndStreetAndApartmentNumberOnUsers < ActiveRecord::Migration[6.1]
  def change
    change_column_null :users, :zipcode, false
    change_column_null :users, :prefecture, false
    change_column_null :users, :city, false
    change_column_null :users, :street, false
  end
end

それではここでリリースしましょう。
万が一ここで何らかのカラムがNULLになっているといった不整合があればこのリリースで気付くことができます。また、その場合においてもリスクはなにもありません。なぜ不整合が起きたのか調査をし前のステップに戻りましょう。

新しいカラムを参照する

ここまでくればあとはもう一息です。 ここでは参照先を新しいカラムへ変更します。今回はカラムが分割されているのでそれらを元のカラムに格納されている値と同じ形に戻す処理を実装します。

app/models/full_address.rb
class FullAddress
  DELIMITER = "\t"

  attr_reader :zipcode, :prefecture, :city, :street, :apartment_number

  def self.convert(address)
    parts = address.split(DELIMITER)
    parts_full_filled = parts.fill(parts.size..4) { '' }
    new(*parts_full_filled)
  end

  def initialize(zipcode, prefecture, city, street, apartment_number)
    @zipcode = zipcode
    @prefecture = prefecture
    @city = city
    @street = street
    @apartment_number = apartment_number
  end

  def to_s
    [zipcode, prefecture, city, street, apartment_number].join(DELIMITER)
  end
end
app/models/user.rb
class User < ApplicationRecord
  composed_of :full_address,
    mapping: [
      %w(zipcode zipcode),
      %w(prefecture prefecture),
      %w(city city),
      %w(street street),
      %w(apartment_number apartment_number)
    ],
    converter: :convert

  def address
    full_address.to_s
  end

  def address=(value)
    super
    self.full_address = value
  end
end

これで参照先が新しいカラムとなりました。実際にはSQLでも古いカラムは参照されていることがほとんどかと思いますのでその場合にはそれらも修正が必要になるかと思います。ここでは表現されていませんが実際に稼働しているアプリケーションでは古いカラムへの全ての依存を新しいカラムへ変更する必要があるので最も骨が折れる作業かもしれません。が、前のステップまででデータの完全性が保証されているので急ぐ必要はありません。落ち着いて作業しましょう。

準備が整ったらリリースです。

irb
irb(main):001:0> u = User.last
=> #<User id: 3, name: "John Doe", address: "100-0000\t東京都\t渋谷区\t架空町1-1-1\t架空ビル5F", created_at: "2021-02-27 06:44:38.292144000 +0000", updated_at: "2021-02-27 0...
irb(main):002:0> u.write_attribute(:address, "hoge")
=> "hoge"
irb(main):003:0> u.address
=> "100-0000\t東京都\t渋谷区\t架空町1-1-1\t架空ビル5F"
irb(main):004:0> u.read_attribute(:address)
=> "hoge"

古いカラムの制約を変更する

ここまでですでに古いカラムが参照されることはなくなりました。ロジックの移行は済んだものと捉えて良いでしょう。ここからは古いカラムを削除するための準備をしていきます。

まずは古いカラムの制約を変更し次のステップへ備えます。

db/migrate/004_remove_not_null_constraint_to_address_on_users.rb
class RemoveNotNullConstraintToAddressOnUsers < ActiveRecord::Migration[6.1]
  def change
    change_column_null :users, :address, true
  end
end

これでリリースしマイグレーションを実行します。

古いカラムを削除する準備をする

前のステップで古いカラムのスキーマが変更されたことで古いカラムへ書き込まなくても問題ない状態となりました。ここからはカラムの削除に備えてアプリケーション側を修正していきます。

書き込み先を新しいカラムのみにする

まずは書き込み先を新しいカラムのみにします。

app/models/user.rb
class User < ApplicationRecord
  composed_of :full_address,
    mapping: [
      %w(zipcode zipcode),
      %w(prefecture prefecture),
      %w(city city),
      %w(street street),
      %w(apartment_number apartment_number)
    ],
    converter: :convert

  def address
    full_address.to_s
  end

  def address=(value)
    super
    self.full_address = value
  end
end

もしvalidationが存在していればそれも削除しましょう。

古いカラムを認識しないように定義する

次に古いカラムをActiveRecordが認識しないようにします。これをしないで削除すると致命的なエラーが発生する可能性があります。必ず削除より前にこの作業を行いカラム削除のリリースより前にリリースしましょう。[こちらの記事]が詳しいです。

app/models/user.rb
class User < ApplicationRecord
  self.ignored_columns = %w(address)
  composed_of :full_address,
    mapping: [
      %w(zipcode zipcode),
      %w(prefecture prefecture),
      %w(city city),
      %w(street street),
      %w(apartment_number apartment_number)
    ],
    converter: :convert

  def address
    full_address.to_s
  end

  def address=(value)
    self.full_address = value
  end
end

これでActiveRecordは古いカラムを認識することができなくなり削除の準備が整いました。リリースをし削除に備えましょう。

古いカラムを削除する

最後に古いカラムを削除します。

db/migrate/005_remove_address_from_users.rb
class RemoveAddressFromUsers < ActiveRecord::Migration[6.1]
  def change
    remove_column :users, :address, :string
  end
end

お疲れ様でした。これをリリースしマイグレーションが実行されたらカラムの移行が完了です。

補足

カラム追加時にNOT NULL制約を追加してはいけないのか

カラム追加時、厳密にはカラム追加のマイグレーションファイル内でNOT NULL制約を最初から付加したいとなった場合、取れる選択肢は2つあるかと思います。

  1. デフォルト値を設定しNULLを防ぐ
  2. マイグレーションファイル内でデータを移しその後にNOT NULL制約を付加する

デフォルト値を設定する場合、気を付けなければならないのは「それが本当に必要なのかどうか」です。このためだけに設定するのだとすればデータ移行が済んだタイミングで「デフォルト値を外す」というスキーマの変更が必要となるでしょう。文脈に矛盾しますがこのパターンは特に問題ないと思っています。
デフォルト値を入れてしまう問題点を挙げるとすればそれが原因で「実質的に意味のある値が入っていないことに気が付かなかった」ということになりかねないことだと思います。NOT NULL制約はNULLな値が一つでも入っていれば例外が発生し異常に気付くことが可能です。が、デフォルト値の場合にはアプリケーションレベルでそれをチェックしなければなりません。NOT NULL制約はRDB側で不正な値が混入するのを防ぐための機能だと解釈すると、NULLだろうと意味のない値だろうとどちらにせよアプリケーション上では誤ったデータだとするならばRDB側でもそうした値の混入を防げるようにしておく方が良い、という意見です。そしてそのためにNOT NULL制約を付加するのであればデータ移行完了後にNOT NULL制約を付加する段階でも既存データの異常チェックとして使うことは理に適っていると考えています。

マイグレーションファイル内でデータを移してしまうのはアウトです。
なぜならデータは常に新しく書き込まれるものでありマイグレーション処理時点からすぐに新しいカラムのデータを完全な状態にするためにはその時点から新たに書き込まれるデータも全て新しいカラムに同期しなければならないからです。
これはマイグレーションより前にソースコードの反映が先に実行されなければ完全な同期は成立しないことを意味します。
まず、マイグレーションより前にソースコードは反映することができません。先にソースコードを反映してしまうと存在しないカラムに対するメソッドに依存してしまうことになるためです。そしてそれは実行時エラーにつながります。
なので必然的にマイグレーションのあとにソースコードを反映する順序となります。しかしこれでは同期がうまくいかない可能性が残ります。なぜならマイグレーションとソースコードの反映にラグがあった場合、カラムが追加されたにも関わらず古いロジックにより新しいカラムへの同期処理が動かない状態で稼働してしまう恐れがあるためです。 また、データを移行するためにはマイグレーションファイル内でモデルを触るか生SQLを実行する必要があり、余計な配慮が必要となります。そうなるとActiveRecord::ModelSchema.reset_column_information の実行はもちろんのことモデルがなくなることで実行時エラーが起きる場合に備えるとsquasherを使うなりしてマイグレーションファイルをまとめマイグレーション実行時にモデルの呼び出していた箇所をどこかで削除する必要があるでしょう。このあたりはこちらの記事が詳しいです。
ではマイグレーションファイル内では既存のデータ移行に留め新しく書き込まれるデータはバックグラウンドジョブで同期を取る戦略を取るのはいかがでしょうか?
僕はこれもおすすめはしません。これを正しく動作させようとしたら連続で書き込まれる場合に書き込み順序を管理する必要が生じます。マイグレーションファイル内で同期をせずリリースを分割するだけでこのような複雑性を作らずに済むのであればそれに越したことはないというのが僕の意見です。

まとめ

Railsは様々な便利な仕組みが用意されています。その中でもActiveRecordではテーブル情報をキャッシュする仕組みが備わっており、それによりテーブルの定義を変更する場合にはキャッシュにより思わぬ振る舞いをしてしまうことがあることも事実です。
障害はユーザーにとってだけではなく開発者にとっても嫌なものです。気持ち良く開発を進めるためにもスキーマ変更時は特に慎重なリリースを心掛け事故を防ぎましょう。また、そのためにも普段から自動テストを書いておき、CIで振る舞いが正しいことを保証できるようにしておくことが重要です。特に今回のようなスキーマ変更の場合は関連する箇所の自動テストがあることで修正を安心してリリースすることができるようになります。横着をせずひとつひとつ着実に進めて楽しく開発しましょう。

この記事がお役に立てたら一杯のコーヒーを恵んで頂けると励みになります 😊

Buy Me A Coffee
© 2020, Soudai Sasada