Edited at

db:migrateすると何が起こるか。ActiveRecordコードリーデイング

More than 3 years have passed since last update.

Wantedlyでは、Railsによる開発が行われており、社内で有志のコードリーディングを行っています。


WHY

何故やるのか:コードレベルまで一度追うことで、問題が起こった時の解決までの時間が短縮されます。

また、本質的になにをやっているか理解するとRailsの魔法感を低減できます。


WHAT

なにをするのか:今回はmigrate周りのコードリーディングです。コマンドからSQL文発行までのソースを追います。


HOW

どうやってやるか?実際のコードを読みます。


rake db:migrateを実行するとなにが起こるか

コマンドからSQL文発行までのソースを追いたい。


source file

今回呼んだソースはRails 4.0.13のものです。自分の環境では以下に入っていました。

~/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/activerecord-4.0.13


task :migrate (railties/database.rake)

rakeタスクはこのファイルで定義されています。

ここでは、Migrator.migrateを呼び出すのが主なシゴト。

  desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)."

task :migrate => [:environment, :load_config] do
ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true
ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, ENV["VERSION"] ? ENV["VERSION"].to_i : nil) do |migration|
ENV["SCOPE"].blank? || (ENV["SCOPE"] == migration.scope)
end
db_namespace['_dump'].invoke
end

Migratorは何なのかと思いながら、Migrator.migrateと引数のMigrator.migrations_pathsをみる。


Migrator#migrate (activerecord-4.0.13/lib/active_record/migration.rb)

現在のバージョンによって、バージョンを進めるのか、戻すのか、何も変えないのかを決める。

(このファイル内にdef migrateが3つある)

def migrate(migrations_paths, target_version = nil, &block)

target_versionとcurrent_versionの状態によって、行うアクションを決める。

単純にrake db:migrateとした場合は、普通1番上のupを呼ぶ。

現在よりも古いバージョンを指定した場合はdownを呼ぶ。

when target_version.nil?

up(migrations_paths, target_version, &block)
when current_version == 0 && target_version == 0
[]
when current_version > target_version
down(migrations_paths, target_version, &block)
else
up(migrations_paths, target_version, &block)
end


target_versionとcurrent_versionはどうやって決まるか?

target_versionは指定しなければnil

current_versionは、以下のメソッドにより、繋がっているDBのSchemaMigration.table_nameのversion数値の最大値が用いられる。schme.rbのバージョン値は関係ないんですね。

def current_version

sm_table = schema_migrations_table_name
if Base.connection.table_exists?(sm_table)
get_all_versions.max || 0
else
0
end
end


Migrator.migration_paths

デフォルトでは、['db/migrate']が帰る。Arrayにto_aしてもArrayのまま変化しない。

Wantedlyでは複数のDBをRailsに繋いでいます。

デフォルトではなく、rake mail_db:migrateとした場合は以下。

rakeファイルのload_configでmigration_pathsに"mail_db"が入る。

ActiveRecord::Migrator.migrations_paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths


Migrator.up

現在のmigrationを持ってきて、Migratorオブジェクトを作ってmigrateを呼ぶ。

migrations.select! { |m| yield m } if block_given?

migration.scopeとENV['SCOPE']が違うものを実行しないようにしている。

def up(migrations_paths, target_version = nil)

migrations = migrations(migrations_paths)
migrations.select! { |m| yield m } if block_given?

self.new(:up, migrations, target_version).migrate
end

そもそもmigrationsとは?


Migrator.migrations

migrateディレクトリをみて、マッチするファイルを持ってくる。


  • Dir


    • ワイルドカードの展開を行い、 パターンにマッチするファイル名を文字列の配列として返します。



なので、fileには、指定したmigrateディレクトリ以下のマッチしたファイルが文字列で全部入る。

["db/migrate/20110627144734_create_jobs.rb",

"db/migrate/20110628003714_devise_create_users.rb",

"db/migrate/20110629071604_remove_applicant_from_jobs.rb",

"db/migrate/20110629071620_remove_connector_from_jobs.rb",

...]みたいな感じ。

ここから更に、バージョン数_名前_.rbに一致するものだけを見て、MigrationProxyなるものを作成する。

これは実際のMigrationを呼び出すもの。

MigrationProxy is used to defer loading of the actual migration classes until they are needed

delegate :migrate, :announce, :write, :disable_ddl_transaction, to: :migration

これがmigrationsになる。

def migrations(paths)

paths = Array(paths)

files = Dir[*paths.map { |p| "#{p}/**/[0-9]*_*.rb" }]

migrations = files.map do |file|
version, name, scope = file.scan(/([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/).first

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


Migrator#initialize

Migrationを呼び出す人。

migrationsに文字列が一つでもあったらwarnを出してmigrationsを取得し直す。

その後、validateを呼び出してから、Base.connection.initialize_schema_migrations_table

    def initialize(direction, migrations, target_version = nil)

raise StandardError.new("This database does not yet support migrations") unless Base.connection.supports_migrations?

@direction = direction
@target_version = target_version
@migrated_versions = nil

if Array(migrations).grep(String).empty?
@migrations = migrations
else
ActiveSupport::Deprecation.warn "instantiate this class with a list of migrations"
@migrations = self.class.migrations(migrations)
end

validate(@migrations)

Base.connection.initialize_schema_migrations_table
end


Base.connection.initialize_schema_migrations_table

ActiveRecord::SchemaMigration.create_tableをやると思っている

これは、テーブルが存在しなければ以下のテーブルを作る。

connection.create_table(table_name, id: false) do |t|

t.column :version, :string, version_options
end


Migrator#migrate

各々のmigrationに対して、execute_migration_in_transaction(migration, @direction)を呼び出す。

    def migrate

if !target && @target_version && @target_version > 0
raise UnknownMigrationVersionError.new(@target_version)
end

running = runnable

if block_given?
message = "block argument to migrate is deprecated, please filter migrations before constructing the migrator"
ActiveSupport::Deprecation.warn message
running.select! { |m| yield m }
end

running.each do |migration|
Base.logger.info "Migrating to #{migration.name} (#{migration.version})" if Base.logger

begin
execute_migration_in_transaction(migration, @direction)
rescue => e
canceled_msg = use_transaction?(migration) ? "this and " : ""
raise StandardError, "An error has occurred, #{canceled_msg}all later migrations canceled:\n\n#{e}", e.backtrace
end
end
end


execute_migration_in_transaction

migration.migrate(direction)を呼び出したあとに、それを記録する。

ddl_transaction(migration)はTransaction処理が可能(Base.connection.supports_ddl_transactions?)ならTransaction処理の中で行う。

    def execute_migration_in_transaction(migration, direction)

ddl_transaction(migration) do
migration.migrate(direction)
record_version_state_after_migrating(migration.version)
end
end


Migration#migrate(direction)

ここでは既に、どの変更をどの方向に切り替える、という情報が揃っている。

exec_migration(conn, direction)を実行。

upであれば"migrating"として、終了後に実行時の経過時間を表示。

"migrated (2.4854)"みたいに表示する。

# 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

time = nil
ActiveRecord::Base.connection_pool.with_connection do |conn|
time = Benchmark.measure do
exec_migration(conn, direction)
end
end

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


Migration.exec_migration(connection, direction)

if respond_to?(:change)で changeが定義されていれば、

changeかrevertを実行する。

up, downとかしかなければ、upかdownを実行する。

def exec_migration(conn, direction)

@connection = conn
if respond_to?(:change)
if direction == :down
revert { change }
else
change
end
else
send(direction)
end
ensure
@connection = nil
end


Migration.change

Migrationファイルのchangeメソッドが呼ばれる。我々が書いているあのファイルだ。

ここでは、add_columnが呼ばれることにする。

add_columnは定義されていない。これはmethod_missingによって実行される。

method='add_column'のときは、ActiveRecord::Base.connectionにadd_columnメソッドがあるかを確かめて、

connection.send(method, *arguments, &block)により実行。

実行されたら、say_with_timeというフランクな名前のメソッドにより、実行時間と共に出力される。

def method_missing(method, *arguments, &block)

arg_list = arguments.map{ |a| a.inspect } * ', '

say_with_time "#{method}(#{arg_list})" do
unless @connection.respond_to? :revert
unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
arguments[0] = Migrator.proper_table_name(arguments.first)
arguments[1] = Migrator.proper_table_name(arguments.second) if method == :rename_table
end
end
return super unless connection.respond_to?(method)
connection.send(method, *arguments, &block)
end
end


最後:add_columnはなにをやっているか

ここから先は結構、PostgresSQLとかSQLiteとか固有のものと共通のものがあってごちゃごちゃしている印象。


SchemaStatements.add_column (postgresql/schema_statements.rb)

def add_column(table_name, column_name, type, options = {})

clear_cache!
super
end


SchemaStatements.add_column (abstract/schema_statements.rb)

# Adds a new column to the named table.

# See TableDefinition#column for details of the options you can use.
def add_column(table_name, column_name, type, options = {})
at = create_alter_table table_name
at.add_column(column_name, type, options)
execute schema_creation.accept at
end

alter_tableというものを作っているっぽい。

AlterTable.new create_table_definition(name, false, {})

add_column(name, type, options)だけの情報を持つクラス

def schema_creation

SchemaCreation.new self
end

class SchemaCreation
def accept(o)
m = @cache[o.class] ||= "visit_#{o.class.name.split('::').last}"
send m, o
end
...
end
visit_AlterTableが呼ばれる。

def visit_AlterTable(o)

sql = "ALTER TABLE #{quote_table_name(o.name)} "
sql << o.adds.map { |col| visit_AddColumn col }.join(' ')
end

def visit_AddColumn(o)
sql_type = type_to_sql(o.type.to_sym, o.limit, o.precision, o.scale)
sql = "ADD #{quote_column_name(o.name)} #{sql_type}"
add_column_options!(sql, column_options(o))
end

execute の中で@connection.async_exec(sql)が呼ばれてSQLが実行される。


おわり

実際にコードを読んでみることで、日常的に打っているコマンドが内部で何をやっているかコードレベルで理解しやすくなりました。

特にバージョンの話とか、migrateファイルのadd_columnみたいなメソッドが、実際はmethod_missingで拾われていて、databaseのコネクションにメソッドが追加されたら自動的に使えるので面白い仕組みだなとか。

Wantedlyの場合は、WantedlyのコードベースでRailsを触るのが初めてというメンバーも多く(自分もJavaとPythonの人でした)、こういった読み会は勉強になります。

他のメンバーの読み方とか、考え方とかを共有できるという意味でもお勧めです。