migration の中で model を触ったら必ず reset_column_information する

治安の悪い Rails アプリケーションでは、migrate 中に model の不整合で怒られることがあります。

class AddAgeToUsers < ActiveRecord::Migration[5.1]
  def up
    p User.first                       # 1
    add_column :users, :age, :integer  # 2
    User.create(name: "Taro", age: 16) # 3
  end
end

1 で User model を触ってしまっているので add_column 前の DB の状態がキャッシュされて
2 で追加した add_column は別にキャッシュをリセットしないので
3 で ActiveModel::UnknownAttributeError: unknown attribute 'age' for User. と怒られます。

これが 1 ファイルだったら別にどうということは無いんですが、migration を跨いだときにも発生するのが厄介なポイントで、

# 001_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
  def up
    create_table :users do |t|
      t.string :name
    end
    p User.first # 1
  end
end

# 002_add_age_to_users.rb
class AddAgeToUsers < ActiveRecord::Migration[5.1]
  def up
    add_column :users, :age, :integer  # 2
    User.create(name: "Taro", age: 16) # 3
  end
end

も同様に

1 で User model を触ってしまったせいでこの時点での DB の状態がキャッシュされて
3 で ActiveModel::UnknownAttributeError: unknown attribute 'age' for User. と怒られます。

さらに厄介なことに、それぞれの migration は単体だと動作してしまうんですよね。 001, 002 の両方を一度に migrate したときだけエラーになります。

この動きが罠っぽいので、model を触ったら必ずリセットしましょうというのが主題です。

# 001_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.1]
  def up
    # ...
    p User.first # 1
    User.reset_column_information # <- この行を追加
  end
end

# 002_add_age_to_users.rb
class AddAgeToUsers < ActiveRecord::Migration[5.1]
  def up
    add_column :users, :age, :integer
    User.create(name: "Taro", age: 16)
    User.reset_column_information # <- この行を追加
  end
end

主題ではあるんですが、そもそも使わなくて済むならそれが一番良いんですよ!!

reset_column_information とは

ActiveRecord は table の column 情報を読んで、 model を定義するだけで良い感じに DB と同期してくれるような仕組みです。

class User < ActiveRecord::Base
end

とするだけで勝手に User#name みたいなメソッドが生えたりなんだりしますよね。

毎回 DB に column 情報を問い合わせてメソッド定義をやっていると重いので、model ごとにキャッシュしています。

reset_column_information はそのキャッシュを消して、 次に使うときにもう一度 DB から column 情報を取得するようにします。

そもそも migration の中で model に触るのってどうなの

まぁ普通は無いというか、無いのが健全な Rails アプリケーションだと思います。

マスタデータの生成

考え直しましょう。 そのために db:seed (db/seeds.rb) があります。

migrate 中に model の定数を参照する

論争ポイントですね。

class User < ApplicationRecord
  ACTIVE = 0
end

class AddStatusToUsers < ActiveRecord::Migration[5.1]
  def up
    add_column :users, :status, :integer, null: false, default: User::ACTIVE
    # model の定数を使わない場合はリテラルのハードコードになる
    # add_column :users, :status, :integer, null: false, default: 0
  end
end

後にこのように変更すると、以前の migration は通らなくなります。

class User < ApplicationRecord
  enum { active: 0, retired: 1 }
end

なので定数を使わない方が望ましいと一般的には言われています。

ただ定数を使う場合はハードコードから解放されて、読みやすい migrate ファイルになるんだなぁ。 強い意志を持って「ハードコードする方を選ぶ」と言われると悩んじゃう。

定数だけなら DB に何も問い合わせないので、この記事の主題である reset_column_information も必要ありませんし。

めんどくさいデータ移行を migration 中でやる

データ移行を db:migrate でやるべきかどうか、という話に関わってくるので 一概に無いとも言い切れないんですが。(僕はやるべきではない派です)

例えば運用に入った後に以下のような slug カラムの追加はあり得るストーリーかなと。

class AddSlugToArticles < ActiveRecord::Migration[5.1]
  # slug は title によって決まるので「デフォルト値の無い NOT NULL」にしたい
  # MySQL の場合は NOT NULL で add_column できるが、sqlite3 では
  # `Cannot add a NOT NULL column with default value NULL`
  # となるので nullable でカラムを作ってデータ移行後 null: false に変更する。
  def up
    add_column :articles, :slug, :string
    # migrate 中のデータ更新はなるべく SQL でやりたいけれど
    # ruby のメソッドを使う方が楽なので model を使ってしまう。
    Article.find_each do |article|
      article.slug = Slug.for(article.title)
      article.save!
    end
    change_column_null :articles, :slug, false
  end
end

リリース手順を

とすることで「migrate 内では触らない」を実現できますが、

という状況だと 1 ファイルでデータ移行まで含めてやると言うのもあり得る判断なんじゃないでしょうか。

書こうと思ったキッカケ

世の中に「リセットしてから model に触りましょう」という記事が多いので (まぁ他人の migration を直さなくても自分のがそれで動作するようになるからな。。) どこかにちゃんと書き残しておきたいと以前から思っていたのでした。

記事はだいたいこう書いてある。

# 002_add_age_to_users.rb
class AddAgeToUsers < AR::Migration
  def up
    add_column :users, :age, :integer
    User.reset_column_information # <- model を触る前に念のためにリセットしましょう
    User.create(name: "Taro", age: 16)
  end
end

触る前にリセットが必要って完全におかしいでしょ!!

正解は「model を触ったので後始末としてリセットする」の徹底です。

# 001_create_users.rb
class CreateUsers < AR::Migration
  def up
    create_table :users do |t|
      t.string :name
    end
    p User.first
    User.reset_column_information # model を触ったので後始末としてリセットする
  end
end

# 002_add_age_to_users.rb
class AddAgeToUsers < AR::Migration
  def up
    add_column :users, :age, :integer
    User.create(name: "Taro", age: 16) # 001 でリセットしているので正常に動作する
    User.reset_column_information # model を触ったので後始末としてリセットする
  end
end

徹底が正解なんですが、面倒な方のために gem を作りました。

https://github.com/onk/activerecord-always_reset_column_information

実装はそれぞれの migration 実行後に ActiveRecord::Base の子孫を全部 reset_column_information して回っているだけです。富豪的ですね。

module Activerecord::AlwaysResetColumnInformation::Migration
  def exec_migration(conn, direction)
    super
    ActiveRecord::Base.descendants.each(&:reset_column_information)
  end
end

ActiveRecord::Migration.prepend Activerecord::AlwaysResetColumnInformation::Migration

「migration を直した方が良くない?」って感じの gem ですが、どうぞご利用ください。

Rails 本体はどう考えてるの?

https://github.com/rails/rails/blob/v5.1.4/activerecord/lib/active_record/migration.rb#L409-L424

Using a model after changing its table

Sometimes you'll want to add a column in a migration and populate it immediately after. In that case, you'll need to make a call to Base#reset_column_information in order to ensure that the model has the latest column data from after the new column was added. Example:

class AddPeopleSalary < ActiveRecord::Migration[5.0]
  def up
    add_column :people, :salary, :integer
    Person.reset_column_information
    Person.all.each do |p|
      p.update_attribute :salary, SalaryCalculator.compute(p)
    end
  end
end

確実に使えるようにするために「使う前にリセットしろ」とのこと。ウーン……。