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 としたときの実行過程は次の通り。
- rake db:migrate
- $RAILS_ROOT/Rakefile のロード($RAILS_ROOT は Rails アプリのベースディレクトリ)
- $GEMSHOME/rails-1.2.3/lib/tasks/rails.rb のロード
- 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_sql で SQL 文に変換し(B)、 execute() で実行して、データベースにテーブルを作るわけである。
以上。めでたしめでたし。