Rails
ActiveRecord
勉強会
wantedly

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の人でした)、こういった読み会は勉強になります。
他のメンバーの読み方とか、考え方とかを共有できるという意味でもお勧めです。