Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails深読み: ActiveRecord::PendingMigrationErrorの発生条件からMigrationの挙動を追う

morimorihoge です。最近ゲームらしいゲームをやれてない。

おかげさまで社内有志で週間Railsウォッチを始めてぼちぼち1年が経とうとしています。普段は利用者としてRailsを使っているとRailsの内部挙動まで追いかけないといけないケースは少ないのですが、最新のRails commit履歴やバグフィックス履歴を見ていくことで色々と「あーこの使い方でバグるのか」とか「次のバージョンからこれできるようになるのか」といった発見があります。

僕がRailsをまともに使い始めたのは3.0.0rcの頃でしたので、GitHubのリリース履歴を見ると少なくともその時代からももう7年が経ち、歴史あるソフトウェアになってきたなあというのを感じます。それに合わせてコードベースも大きくなり、新たに学習しようとした場合のハードルはどうしても上がっていっている気はしますね。

数年前社内有志でCrafting Rails 4 Applications読み会を行いましたが、あれもRailsの全体を網羅する内容というよりは、一つ一つ絞った内容を深掘りするような内容でした。

参考: Amazon: Crafting Rails 4 Applications: Expert Practices for Everyday Rails Development (The Facets of Ruby)

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にまとめてしまったとしても問題ない(はず)ということですね(ABのファイルを消してもPendingMigrationErrorにはならない)。
※この手のことをやるGemを捜すとSquasher が見つかりますが、僕は古いMigration履歴も開発の時系列履歴として残しておきたい派なので使ったことはないです

まとめ

そんなわけで、今回はざざっとActiveRecord::PendingMigrationErrorを追いかけてみました。
普段あまり意識しないで使えていますが、こうやって内部実装を追いかけることで、Rails的に何が許されていて何が許されていないのかという境目を見ることができます。Migrationファイルのリネームなんかは何も知らないとやってしまう人がいそうな部分ではありますし、Migrationファイルとschema_migrationsテーブルの相互関係についても「多分こうかな〜」というのはあっても確信を得るにはソースコードを読むのが一番ですね。

またそのうち気が向いたら書いていこうと思います。ではでは。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。