Rails アプリでオンラインでカラムの削除やリネームを行うには

前提知識

Rails アプリにおいて、テーブルの追加やカラムの追加は簡単なものの、カラムの削除やリネームは慎重に行う必要がある。たとえアプリからそのカラムを参照してないとしても、いきなりカラムを削除するとエラーになる可能性が大いにある。

というのも Rails にはスキーマキャッシュというものがあり、テーブルのカラム情報をモデルがキャッシュしているからだ。このキャッシュはたとえばいわゆる N+1 クエリ問題を避けるために includes (eager_load) するときに参照される。 SELECT 句で t0_r0 のような機械的に別名が振られるようなクエリを見たことがある Rails エンジニアは多いと思う。

機械的に全カラムを取得するためにスキーマキャッシュを利用しているため、このようなクエリが実行されてる中でカラムを削除したりリネームしたりすると、スキーマキャッシュをもとに並べらたカラムの一部が消えてしまうため、不正なクエリとなってエラーになってしまう。 このせいで Rails においてカラムの削除やリネームは面倒なものになっている。

Rails 5.x 以降のやりかた

Rails 5.0 からは ignored_columns というオプションが追加され、ここに追加されたカラムは Rails からは見えなくなる。 このオプションはまさに Rails のオンラインスキーマ変更のやりにくさを解消するために追加された。僕が Rails 5.0 で最も好きな改善の一つだ。

https://github.com/rails/rails/pull/21720

なのでカラム削除の手順としては、

  1. アプリケーションコード内でそのカラムを参照している箇所を直す (これはスキーマキャッシュとは無関係に当然やる必要がある)
  2. Model.ignored_columnsカラム名を追加してデプロイ (1と同時でもよい)
  3. 実際にカラムを削除またはリネーム
  4. Model.ignored_columns を元に戻してデプロイ

という流れになる。

Rails 4.x でのやりかた

ignored_columns は使えないが、DB からカラム情報を取得するところにモンキーパッチをあてて特定のカラムを隠すようにすればなんとかいける。 具体的には MySQL を使っている場合は mysql2 adapter のメソッドをフックする。

module ActiveRecordInvisibleColumn
  INVISIBLE_COLUMNS = {
    'foos' => ['bar'],
  }.freeze
  def columns(table_name)
    super.delete_if { |column| INVISIBLE_COLUMNS.fetch(table_name, []).include?(column.name) }
  end
end

ActiveSupport.on_load :active_record do
  ActiveRecord::ConnectionAdapters::Mysql2Adapter.prepend(ActiveRecordInvisibleColumn)
end

この方法は foos というテーブル名が (たとえ複数の DB を使っていても) グローバルに一意という前提を置いている点に注意する必要がある。 これで Rails 5.x の ignored_columns と同じような効果が得られるので、あとは同じ流れでカラムの削除を進めることができる。 過去に某巨大 Rails アプリで実際に成功したのでまぁたぶんだいたいいけると思う。

とはいえ Rails 5.0 以上にさっさとバージョンアップして ignored_columns を使ったほうがいいことは言うまでもない。