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
リリース手順を
- add_column の migrate を当てる
- データ移行する
- change_column_null の migrate を当てる
とすることで「migrate 内では触らない」を実現できますが、
- 「Pull Request をマージしたらデプロイされる」みたいな環境
- Pull Request を機能単位にしたい
という状況だと 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
確実に使えるようにするために「使う前にリセットしろ」とのこと。ウーン……。