Migration の歩き方

Rails ではデータベースのテーブルを作成するのに、db/migrate/ にマイグレーション用のファイル

# $RAILS_ROOT/db/migrate/001_create_entries.rb
class CreateEntries < ActiveRecord::Migration
  def self.up
    create_table :entries do |t|
      t.column :title, :string
      t.column :body, :text
    end
  end
  
  def self.down
    drop_table :entries
  end
end

を作ったあと、

% rake db:migrate

とすることで、"entries" という名前のテーブルがデータベースに作成される。この内部動作をしつこく追いかけてみる。

rake db:migrate

まずは rake コマンドの動きから。
rake はどうやら Rakefile を親ディレクトリ方向に探していくらしい。rake db:migrate としたときの実行過程は次の通り。

  1. rake db:migrate
  2. $RAILS_ROOT/Rakefile のロード($RAILS_ROOT は Rails アプリのベースディレクトリ)
  3. $GEMSHOME/rails-1.2.3/lib/tasks/rails.rb のロード
  4. 3 の rails.rb は $GEMSHOME/rails-1.2.3/lib/tasks/*.rake, $RAILS_ROOT/lib/tasks/**/*.rake, $RAILS_ROOT/vendor/plugins/**/tasks/**/*.rake をロード。

そして db:migrate の定義は、GEMSHOME/rails-1.2.3/lib/tasks/database.rake にある。(これは上の 4 でロードされるファイルの一つである)

 namespace :db do
   desc "Migrate the database through scripts in db/migrate. Target specific version with VERSION=x"
   task :migrate => :environment do
     ActiveRecord::Migrator.migrate("db/migrate/", ENV["VERSION"] ? ENV["VERSION"].to_i : nil)
     Rake::Task["db:schema:dump"].invoke if ActiveRecord::Base.schema_format == :ruby
   end
   ...
 end

ActiveRecord::Migrator, ActiveRecord::Migration

ActiveRecord::Migrator.migrate がマイグレーションのコアな処理部分である。

コードを見る前にあらかじめメソッドの間の呼び出し関係を示すコールグラフを掲げておこう。

Migrator.migrate
 |--SchemaStatements#initialize_schema_information
 |--Migration.up
     |--Migrator#migrate
         |--Migration.migrate
             |--CreateEntries.up
                 |--SchemaStatements#create_table

同じ名前だが実体の異なる migrate というメソッドが3つあることに注意。

# $GEMSHOME/activerecord-1.15.3/lib/active_record/migration.rb
# (以下 GEMSHOME/activerecord-1.15.3/lib/active_record を省略)
# Migrator.migrate
  class Migrator#:nodoc:
    class << self
      def migrate(migrations_path, target_version = nil)
        Base.connection.initialize_schema_information #(A)

        case
          when target_version.nil?, current_version < target_version
            up(migrations_path, target_version)  #(B)
          when current_version > target_version
            down(migrations_path, target_version)
          when current_version == target_version
            return # You're on the right version
        end
      end 
  ..
  end


(A)の initialize_schema_information の定義は active_record/connection_adapters/schema_statements.rb にある。バージョン管理用のテーブル schema_info を実際に作っている。既に作られていたら単に無視。

(B) rake db:migrate というコマンドではバージョンの指定はない。(バージョンを指定するときには、rake db:migrate VERSION=3 のようにする)
target_version は nil になるので、(B) の行が実行される。

# migration.rb
# Migrator.up
def up(migrations_path, target_version = nil)
  self.new(:up, migrations_path, target_version).migrate
end

Migrator のインスタンスを作り、*インスタンスメソッドの* migrate() を実行。

# migration.rb
# Migrator#migrate  
def migrate
  migration_classes.each do |(version, migration_class)|
    Base.logger.info("Reached target version: #{@target_version}") and break if reached_target_version?(version)
    next if irrelevant_migration?(version)

    Base.logger.info "Migrating to #{migration_class} (#{version})"
    migration_class.migrate(@direction) #(A)
    set_schema_version(version)
  end
end

migration_class は CreateEntries のようなクラスオブジェクトが入っている。くどくなるのでソースコードは紹介しないが、migration_classes というメソッドでは、db/migrate/ の下で ([0-9]+)_([_a-z0-9]*).rb という正規表現にマッチするファイルをロードし、ソートした上で配列にして返している。つまり 001_create_entries.rb とかいうおなじみの名前のファイルをロードしているのである。

(A) においては migration_class == CreateEntries である。CreateEntries.migrate は存在しないので、そのスーパークラスである Migration の migrate が呼び出される。

# migration.rb
# Migration.migrate

# Execute this migration in the named direction
def migrate(direction)
  return unless respond_to?(direction)

  case direction
    when :up   then announce "migrating"
    when :down then announce "reverting"
  end

  result = nil
  time = Benchmark.measure { result = send("real_#{direction}") } #(A)

  case direction
    when :up   then announce "migrated (%.4fs)" % time.real; write
    when :down then announce "reverted (%.4fs)" % time.real; write
  end

  result
end

(A) send("real_#{direction}") の部分がわかりずらいが、他の部分でメソッドの別名が定義されているので、real_up => up, real_down => down と考えておけばいい。

# $RAILS_ROOT/db/migrate/001_create_entries.rb
# CreateEntries.up
class CreateEntries < ActiveRecord::Migration
  def self.up
    create_table :entries do |t|
      t.column :title, :string
      t.column :body, :text
    end
  end
  ...
end

create_table がどこで定義されているかと言えば、SchemaStatements においてである。

(データベース固有アダプタ)  <---(継承)--- AbstractAdapter <---(mix-in)--- DatabaseStatements, SchemaStatements

という形で、データベース固有アダプタ(たとえば MysqlAdapter) に SchemaStatements のメソッドが取り込まれている。しかし、Migration と データベース固有アダプタには直接の関係はないはず。どうやって create_table は呼び出されているのか? 鍵は Migration.method_missing にある。

# migration.rb
# Migration.method_missing
def method_missing(method, *arguments, &block)
  say_with_time "#{method}(#{arguments.map { |a| a.inspect }.join(", ")})" do
    arguments[0] = Migrator.proper_table_name(arguments.first) unless arguments.empty? || method == :execute
    ActiveRecord::Base.connection.send(method, *arguments, &block)
  end
end

Migration クラスで未定義のクラスメソッドの呼び出しははすべて上の method_missing に転送される。引数を少し調整した後、すべて ActiveRecord::Base.connection (データベース固有アダプタのインスタンス)へさらに転送される。

したがって CreateEntries.up の中ではデータベースアダプタの任意のインスタンスメソッドが呼び出し可能だ。(たとえば DatabaseStatements#execute や DatabaseStatements#select_all, SchemaStatements#create_table, SchemaStatements#add_index など)

SchemaStatements#create_table

create_table は実際にテーブルをデータベースに作成する。

# connection_adapters/abstract/schema_statements.rb
# SchemaStatements#create_table
def create_table(name, options = {})
  table_definition = TableDefinition.new(self)
  table_definition.primary_key(options[:primary_key] || "id") unless options[:id] == false

  yield table_definition #(A)

  if options[:force]
    drop_table(name, options) rescue nil
  end

  create_sql = "CREATE#{' TEMPORARY' if options[:temporary]} TABLE "
  create_sql << "#{name} ("
  create_sql << table_definition.to_sql #(B)
  create_sql << ") #{options[:options]}" 
  execute create_sql
end

(A) の部分からマイグレーションファイルでおなじみのパタン

    create_table :some_entities do |t|
      ...
    end

のブロック引数 t は TableDefinition インスタンスであることがわかる。ブロックの中でユーザーにスキーマを定義させ、それを table_definition.to_sqlSQL 文に変換し(B)、 execute() で実行して、データベースにテーブルを作るわけである。

以上。めでたしめでたし。