give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

RailsのMigrationでカラムの型を変える時の注意点

先日めでたくリリースしたTwitterボットのBe Vimmerですが、Migrationにバグがありました。

class CreateVimCommands < ActiveRecord::Migration
  def change
    create_table :vim_commands do |t|
      t.string :mode_id #しまった!integerだった!!
      t.string :command
      t.string :description

      t.timestamps
    end
    add_index :vim_commands, [:mode_id, :command], :unique
  end
end


初回リリースバージョンではこれでも一応動いていたんですが、今朝の仕様変更を実装するために以下のコードを加えたところ、Cronの実行時にエラーが発生しました。

VimCommand.where(language: lang).joins(:mode).where("modes.label NOT LIKE 'EX%'").select('vim_commands.id')
PGError: ERROR:  operator does not exist: integer = character varying
LINE 1: ..."vim_commands" INNER JOIN "modes" ON "modes"."id" = "vim_com...
                                                             ^
HINT:  No operator matches the given name and argument type(s). You might need to add explicit type casts.
: SELECT COUNT(vim_commands.id) FROM "vim_commands" INNER JOIN "modes" ON "modes"."id" = "vim_commands"."mode_id" WHERE "vim_commands"."language" = 'en' AND (modes.label NOT LIKE 'EX%')


しかもローカルのSqlite3なら動いていたのに、HerokuのPostgresの時に初めて遭遇するというイヤらしさ!
仕方がないのでMigrationを追加してカラムの型をstringからintegerに変更しました。

class ChangeModeId < ActiveRecord::Migration
  def up
    remove_index :vim_commands, [:language, :mode_id, :command]
    change_column :vim_commands, :mode_id, :integer
    #デフォルトのインデックス名は長すぎるようなのでカスタマイズ
    add_index :vim_commands, [:language, :mode_id, :command], unique: true, name: "idx_l_m_c" 
  end

  def down
    #However, string type is not valid
    remove_index :vim_commands, [:language, :mode_id, :command]
    change_column :vim_commands, :mode_id, :string
    add_index :vim_commands, [:language, :mode_id, :command], unique: true
  end
end


ローカルで動作確認して、HerokuでもMigrationを実行!・・・してみたら、またエラーが(T T)

PGError: ERROR:  column "mode_id" cannot be cast to type "pg_catalog.int4"
: ALTER TABLE "vim_commands" ALTER COLUMN "mode_id" TYPE integer


どうもネットの情報を見ていると、Postgresの場合、特殊なFunctionを追加する必要があるようです。
How to convert a table column to another data type - Postgres OnLine Journal


しかし、動いているのはHerokuだし、どうやってFunctionなんて追加するんだ〜!?と困ってしまったんで、さらにググってみました。
するとこんな情報を発見。
ruby on rails - Altered my database structure in development, so tried to reset Heroku deployed database. Erased it but now I can't migrate my db over - Stack Overflow


この情報を参考にして、一回新しいカラムを追加して新旧のカラムをスワップするように変更しました。

class ChangeModeId < ActiveRecord::Migration
  def up
    #http://stackoverflow.com/questions/8503156/altered-my-database-structure-in-development-so-tried-to-reset-heroku-deployed
    remove_index :vim_commands, [:language, :mode_id, :command]
    rename_column :vim_commands, :mode_id, :old_mode_id
    add_column :vim_commands, :mode_id, :integer
    VimCommand.reset_column_information
    VimCommand.each {|e| e.update_attribute(:mode_id, e.old_mode_id.to_i) }
    remove_column :vim_commands, :old_mode_id
    add_index :vim_commands, [:language, :mode_id, :command], unique: true, name: "idx_l_m_c"
  end

  def down
    raise IrreversibleMigration
  end
end


ロールバックの仕方はよくわからないので、「raise IrreversibleMigration」で済ましてしまいました。(すみません、適当で・・・)

まとめ

Localの次はいきなりProductionではなく、やっぱりHeroku上にStaging環境を用意しておいた方が良いと思いました。
意外とSqlite3とPostgresで挙動に違いがありますね。
Migrationの実行途中でエラーが起きると、手作業でのロールバックが必要になったり、最悪データベース全体をゼロから作り直したりする必要があるので、特に注意です。


以上、ご参考までに。

あわせて読みたい

これとほぼ同じ内容をもう少し詳しく書き直してみました。
Railsでカラムのデータ型を変更する場合の手順 - give IT a try