volpe’s diary

フリーランスじゃなくなったプログラマ volpe が日々便利だなぁと感じたことを中心に綴るブログです

Rails で DBカラム変更と同時に既存データを移行する

既に本番稼働中のサービスでDBのカラムを変更する場合に、デプロイと同時に既存データを移行しないと整合性が取れない場合がある。 rake タスクとしてデータ移行処理を記述しておいて、本番サーバで実行する方法などもあるけど、 migration_data gem を使うとマイグレーションと同期してデータ移行処理を叩いてくれて便利だったので簡単な使い方とハマったポイントをメモしておく。

データ移行の処理を書く

データ移行の処理をどこに書くかというと、 migration ファイルの data メソッドに書く。

公式サイトから。

class CreateUsers < ActiveRecord::Migration
  def change
    # Database schema changes as usual
  end

  def data
    User.create!(name: 'Andrey', email: 'ka8725@gmail.com')
  end

  def rollback
    User.find_by(name: 'Andrey', email: 'ka8725@gmail.com').destroy
  end
end

data メソッドにデータ移行の処理を書いておけば、 rails db:migrate 時に実行してくれるとのこと。 rollbackrails db:rollback 時に実行してくれるのかな。

spec を書く

data rollback に書いた処理は spec でテストを書くことも出来る。 データ移行はとてもセンシティブな処理なのでテストが簡単に書けるのはとても心強い。

再び公式サイトから。

require 'spec_helper'
require 'migration_data/testing'
require_migration 'create_users'

describe CreateUsers do
  describe '#data' do
    it 'works' do
      expect { described_class.new.data }.to_not raise_exception
    end
  end

  describe '#rollback' do
    before do
      described_class.new.data
    end

    it 'works' do
      expect { described_class.new.rollback }.to_not raise_exception
    end
  end
end

ハマったポイント

ただ、spec が通ったからと言って本番環境で通るとは限らない。

特に複数の migration ファイルをまとめて実行する際に以下のような問題に遭遇した。

  • model と カラムの不一致でデータの保存が出来ない
  • 古いカラム情報が残って正しくデータの更新が行えない

1つずつ解説する。

model と カラムの不一致でデータの保存が出来ない

例えば1つ目の migration ファイルで Child テーブルのカラム hoge を追加して、2つ目の migration ファイルで同テーブルにカラム fuga を追加しつつ model では fugavalidates :fuga, presence: true を行ってるとする。

そして、hoge への更新処理を1つ目の migration ファイルの data メソッドに activerecord-import の gem を使って以下のように書いているとする。

以下のようなパターン

  • migration ファイル1
    • children テーブルにカラム hoge を追加
    • data メソッドにデータ移行処理を記述
  def data
    children = Child.includes(:parent).map do |child|
      child.hoge = child.parent.old_hoge
      child
    end

    Child.import children, on_duplicate_key_update: [:hoge]
  end
  • migration ファイル2
    • children テーブルにカラム fuga を追加
    • Child model で validates :fuga, presence: true を実装

ここで、これら2つの migration ファイルをまとめて db:migrate しようとした場合にエラーが発生する。

原因は、上記 data が実行される際に後から追加されるはずの fuga へのバリデーションのコードが実行されてしまうが、まだ fuga はカラムとして追加されていないため未定義な変数へのアクセスエラーとなる。 この data メソッドを書いた時点では後に同テーブルにどんなカラムが追加され、どんなバリデーションが追加されるか分からないため単体の spec が通ったとしても安心できない。

回避方法としては validate: false オプションでバリデーションせずに更新する方法がある。

  def data
    children = Child.includes(:parent).map do |child|
      child.hoge = child.parent.old_hoge
      child
    end

    Child.import children, on_duplicate_key_update: [:hoge], validate: false
  end

バリデーションを通さないので若干不安ではあるが・・・他に回避方法あるかなぁ。

古いカラム情報が残って正しくデータの更新が行えない

複数の migration ファイルで data を定義すると、最初に実行されたメソッド内で where などが呼ばれたテーブルのカラムの情報がキャッシュされてしまい、その後の migration ファイルで追加したカラムが data メソッド内で認識できずに更新などが失敗する事がある。

以下のようなパターン

  • migration ファイル1
    • children テーブルにカラム hoge を追加
    • data メソッドで Child.where(...) などの処理を行う
  def data
    Child.where(...) # ここでカラムの情報がキャッシュされる
  end
  • migration ファイル2
    • children テーブルにカラム fuga を追加
    • data メソッドで同じように Child.where(...) などして、レコードに対して update(fuga: 'hello') を行う
  def data
    Child.where(...).update(fuga: 'hello')
  end

ここで fuga の更新が無視される!!!!

こんな時は、2つ目以降の data メソッド内で、カラム情報をリセットする Class.reset_column_information を呼んであげればいい

  def data
    Child.reset_column_information # これを追加
    Child.where(...).update(fuga: 'hello')
  end

fuga が正常に更新されるようになる。

migration ファイルってデプロイのタイミングによってどんな風にまとめられて実行されるか分からないので、毎回上記のような対策をしておくのが良いのかも。

関連記事を見つけました。さすが onk さん。 blog.onk.ninja