morimorihoge です。最近ゲームらしいゲームをやれてない。
おかげさまで社内有志で週間Railsウォッチを始めてぼちぼち1年が経とうとしています。普段は利用者としてRailsを使っているとRailsの内部挙動まで追いかけないといけないケースは少ないのですが、最新のRails commit履歴やバグフィックス履歴を見ていくことで色々と「あーこの使い方でバグるのか」とか「次のバージョンからこれできるようになるのか」といった発見があります。
僕がRailsをまともに使い始めたのは3.0.0rcの頃でしたので、GitHubのリリース履歴を見ると少なくともその時代からももう7年が経ち、歴史あるソフトウェアになってきたなあというのを感じます。それに合わせてコードベースも大きくなり、新たに学習しようとした場合のハードルはどうしても上がっていっている気はしますね。
数年前社内有志でCrafting Rails 4 Applications読み会を行いましたが、あれもRailsの全体を網羅する内容というよりは、一つ一つ絞った内容を深掘りするような内容でした。
Railsの内部実装は日々更新されていっている中、既存から大きく実装が差し替わる場合にはChangeLogや解説記事が出たりしますが、昔から変わらない部分については古い記事を信用していいのかよく分からないところも多いので、結局自分で読んでいくしかない感もあるということで、気が向いたときに少しずつ気になった部分の挙動を読んでいったメモを記事にしていこうと思います。
※本記事執筆時点の 5.1-stable
ブランチを参照しています df776aabc45b17dff2cf8edbdd3b1367a1c21167
。
ActiveRecord::PendingMigrationError
とは?
ActiveRecord::PendingMigrationError
はRails開発をそこそこ続けていれば一度くらいは見たことのあるエラーかと思います。RailsのMigrationの仕組みの中で 未実行のMigrationがある場合にraiseされるエラー になります。
開発現場では、db/migrate
の下にあるmigrationファイル(YYYYMMDD#{timestamp}_#{migration_name}.rb
)が追加されたのに rails db:migrate
されていない状態でRails serverにアクセスすると発生します(注: Rails5からはrails
コマンドでもrake
タスクが実行可能)。
とりあえずソースから見ていきましょう。何も考えず PendingMigrationError
で全文検索すると、activerecord/lib/active_record/migration.rbのL127あたり に定義がありました。
class PendingMigrationError < MigrationError#:nodoc:
def initialize(message = nil)
if !message && defined?(Rails.env)
super("Migrations are pending. To resolve this issue, run:\n\n bin/rails db:migrate RAILS_ENV=#{::Rails.env}")
elsif !message
super("Migrations are pending. To resolve this issue, run:\n\n bin/rails db:migrate")
else
super
end
end
end
見覚えのある感じのメッセージですね。
ソースを追ってみる
では実際にPendingMigrationErrorをraiseしている部分はというと、同じく全文検索結果からactiverecord/lib/active_record/migration.rbのL574あたり が出てきます。
少し関係ない部分を整形してクラス階層構造を抜き出すとこんな感じになっています。 ActiveRecord::Migration.check_pending!
ですね。なおここ以外にPendingMigrationErrorをraiseしている部分はありませんでした。
module ActiveRecord
class Migration
class << self
# Raises <tt>ActiveRecord::PendingMigrationError</tt> error if any migrations are pending.
def check_pending!(connection = Base.connection)
raise ActiveRecord::PendingMigrationError if ActiveRecord::Migrator.needs_migration?(connection)
end
end
end
end
上記の通り、ActiveRecord::Migration.check_pending!
ではActiveRecord::Migrator.needs_migration?(connection)
をチェックしているので次はそちらを見てみます。
同じくactiverecord/lib/active_record/migration.rbのL1042あたりです。
def needs_migration?(connection = Base.connection)
(migrations(migrations_paths).collect(&:version) - get_all_versions(connection)).size > 0
end
ソースから挙動を予想する
さて、ここでざっくり挙動を予想してみましょう。実装の雰囲気からmigrations(migrations_paths)
は各migrationを抽象化したオブジェクトのcollectionが入っており、そこからversion
メソッドで取り出したバージョン番号の配列を取り出しています(.collect(&:version)
)。
※このメソッド引数に& + シンボル
を渡す書き方は超一般的なidiomなので、もし知らない人がいたら覚えておきましょう。 .collect{|obj| obj.version}
と同じ意味になります。
参考: Rubyで "&" を使うと幸せになれるらしいよ (*´Д`)ノ
また、migrations(migrations_paths)
はパスを引数に取るため、こちらは恐らくソースコード定義からmigrationに対応するオブジェクトを作成しているのではないかと予想できます。
次に、get_all_versions(connection)
の方はどうでしょうか?migrations
メソッドの方は引数にmigrations_paths
が入っているのに対してこちらにはconnection
が引数になっているので、恐らくDBから取り出した値になるのではないかと予想できます。
予想を検証する
では、予想を確かめてみます。#migrations
はこの辺、#migrations_paths
はこの辺です。抜粋すると
def migrations_paths
@migrations_paths ||= ["db/migrate"]
# just to not break things if someone uses: migrations_path = some_string
Array(@migrations_paths)
end
def migrations(paths)
paths = Array(paths)
migrations = migration_files(paths).map do |file|
version, name, scope = parse_migration_filename(file)
raise IllegalMigrationNameError.new(file) unless version
version = version.to_i
name = name.camelize
MigrationProxy.new(name, version, file, scope)
end
migrations.sort_by(&:version)
end
#parse_migration_filename
周辺は
MigrationFilenameRegexp = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/ # :nodoc:
def parse_migration_filename(filename) # :nodoc:
File.basename(filename).scan(Migration::MigrationFilenameRegexp).first
end
となっています。MigrationFilenameRegexp
の1つめの([0-9]+)
にマッチする部分が文字列で返却されそうな感じですね。
ここから分かるのは、 Migrationファイルのファイル名は意味を持つので勝手に変えてはいけない ということです。
次に、#get_all_versions
の方を見ていきます。activerecord/lib/active_record/migration.rbのL1030あたりです。
def get_all_versions(connection = Base.connection)
if SchemaMigration.table_exists?
SchemaMigration.all_versions.map(&:to_i)
else
[]
end
end
さてここで出てくる SchemaMigration
ですが、これはactiverecord/lib/active_record/schema_migration.rb で、
※以下は一部抜粋
module ActiveRecord
class SchemaMigration < ActiveRecord::Base # :nodoc:
class << self
def primary_key
"version"
end
def all_versions
order(:version).pluck(:version)
end
end
end
end
というversion
カラムをprimary keyに持つ至って普通のActiveRecordオブジェクトです。
Railsの標準Migration機能を使った場合、DBにschema_migrations
というテーブルが自動的にできますが、それに対応するModelクラスですね。
振り返って整理する
ここまで追いかけた上で、改めてneeds_migration?
の実装を見てみましょう。
def needs_migration?(connection = Base.connection)
(migrations(migrations_paths).collect(&:version) - get_all_versions(connection)).size > 0
end
migrations(migrations_paths).collect(&:version)
でファイル名から抽出したバージョン番号文字列配列、get_all_versions(connection)
でschema_migrations
テーブルから取り出したバージョン番号配列を取り出し、Array#-
して差分が無ければOKということになります。
そしてここでもう一つ発見があります。Array#-
で評価しているということは、左辺であるmigrationファイルから取得したリストにあるものは右辺のschema_migrationsにないといけませんが、逆は成り立たなくても良いのです。
すなわち、 migrationファイルに対応するschema_migrationのversion定義は必須だが、schema_migrationsにmigrationsファイルに対応しないversionレコードがあっても問題ない ということになります。
例えば、migrationファイルが大量になりすぎてうざいから昔のmigrationをまとめちゃいたいよー、と思ったときにA -> B -> C
のmigrationをC
にまとめてしまったとしても問題ない(はず)ということですね(A
、B
のファイルを消してもPendingMigrationErrorにはならない)。
※この手のことをやるGemを捜すとSquasher が見つかりますが、僕は古いMigration履歴も開発の時系列履歴として残しておきたい派なので使ったことはないです
まとめ
そんなわけで、今回はざざっとActiveRecord::PendingMigrationError
を追いかけてみました。
普段あまり意識しないで使えていますが、こうやって内部実装を追いかけることで、Rails的に何が許されていて何が許されていないのかという境目を見ることができます。Migrationファイルのリネームなんかは何も知らないとやってしまう人がいそうな部分ではありますし、Migrationファイルとschema_migrations
テーブルの相互関係についても「多分こうかな〜」というのはあっても確信を得るにはソースコードを読むのが一番ですね。
またそのうち気が向いたら書いていこうと思います。ではでは。