読者です 読者をやめる 読者になる 読者になる

woshidan's blog

そんなことよりコードにダイブ。

バージョン指定をしない時、Railsのrake db:migrateでchangeメソッドを実行するMigrationのインスタンスの配列をどうやって取得しているのか

Rails migration

migrationで厄介な対応が必要な場合、SchemaMigrationをいじって整合性を取る必要性がある、と先輩が言っていたのを聞いて、ふむふむと追いかけてみました。

なお、この記事に出てくるコードのRailsのバージョンは、5.0.0.alphaです。

まとめ

バージョン指定をしない時、Railsのrake db:migrateでchangeメソッドを実行するMigrationのインスタンスの配列の取得手順について.

  • migrationファイルが入ったパスでmigrationファイルを探し、それに対応するMigrationクラスのサブクラスのインスタンスの配列を作る
  • 上記をprefixによるバージョンで並び替える
    • upの場合、ActiveRecord::SchemaMigrationに対応したテーブルに入っているレコード数分は飛ばした上記の配列のサブセットを取得
    • downの場合、上記の配列の0番目から指定したバージョンまでのサブセットを取得
  • さらに、上のように得た配列の中身に対して、
    • upの場合、もう一度SchemaMigrationのテーブルに入っている値から、その中にすでにmigrationされているものがないか調べて除外
    • downの場合、指定したバージョンを除外した後、SchemaMigrationのテーブルに入っている値だけを選別している

Migrationをする範囲を指定しているらしきメソッドに行き着くまで

rake db:migrateコマンドで実行されている処理を探したいのですが、rake ...で動くということは、libsとかtasksとかに置いてありそうだな、ということで探すと、activte_record/tasks/database_tasks.rbにそれらしいのがありました。

module ActiveRecord
  module Tasks # :nodoc:
    ...
    module DatabaseTasks
      extend self
      ...

      def migrate
        verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true
        version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil
        scope   = ENV['SCOPE']
        verbose_was, Migration.verbose = Migration.verbose, verbose
        Migrator.migrate(Migrator.migrations_paths, version) do |migration|
          scope.blank? || scope == migration.scope
        end
      ensure
        Migration.verbose = verbose_was
      end

これを読んでいけばいいのでしょうか。

と思ったら、意外に変数を受け渡す感じのメソッドが続きます。

def migrate(migrations_paths, target_version = nil, &block)
  case
  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
end
def up(migrations_paths, target_version = nil)
  migrations = migrations(migrations_paths)
  migrations.select! { |m| yield m } if block_given?

  new(:up, migrations, target_version).migrate
end
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
  @migrations        = migrations

  validate(@migrations)

  Base.connection.initialize_schema_migrations_table
end

Migrationをする範囲を指定しているらしきメソッド

バージョンを指定しなかった場合の挙動に関して知りたい場合、Migration#migrateメソッドの中身以降を読んでいけばいいのかな、という感じでした。

def migrate
  # def target
  #   migrations.detect { |m| m.version == @target_version }
  # end
  # なので、@target_versionがある(=バージョン指定をした)場合は、
  # 指定のバージョンがなかったら、例外を発生して終わる
  if !target && @target_version && @target_version > 0
    raise UnknownMigrationVersionError.new(@target_version)
  end


  runnable.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

Migrationをする範囲を指定しているらしきメソッドの中身1

まず、注目したいのは、Migration#runnableメソッドです。

def runnable  
  runnable = migrations[start..finish]
  if up?
    runnable.reject { |m| ran?(m) }
  else
    # skip the last migration if we're headed down, but not ALL the way down
    runnable.pop if target
    runnable.find_all { |m| ran?(m) }
  end
end

このメソッドの返り値で実行するmigrationが決まっています。

runnable = migrations[start..finish]

の行をもうちょっとじっくり眺めてみます。

startの部分

downの場合は0で、upの場合はActiveRecord::SchemaMigrationのテーブルにはいっているレコードの数か0になっています。

def start
  up? ? 0 : (migrations.index(current) || 0)
end

# 関連コード

def current_migration
  migrations.detect { |m| m.version == current_version }
end
alias :current :current_migration

def current_version
  migrated.max || 0
end

def migrated
  @migrated_versions ||= Set.new(self.class.get_all_versions)
end

get_all_versionsの部分ですが、ここがActiveRecord::SchemaMigrationモデルに対応したテーブルを見に行っています。

def get_all_versions(connection = Base.connection)
  if connection.table_exists?(schema_migrations_table_name)
    SchemaMigration.all.map { |x| x.version.to_i }.sort
  else
    []
  end
end

finishの部分

バージョン指定がなければ、migrationsの配列の最後のindexを返します

def finish
  migrations.index(target) || migrations.size - 1
end

def target
  migrations.detect { |m| m.version == @target_version }
end

migrationsの部分

migrationファイルの入ったパスを探しに行って、ファイルから読み込んで作ったActiveRecord::Migrationのサブクラスの配列を作っているっぽいです。

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 

以上をまとめると、runnable = migrations[start..finish]は何も指定がない場合、

  • migrationファイルが入ったパスでmigrationファイルを探し、それに対応するMigrationクラスのサブクラスのインスタンスの配列を作る
  • 上記をprefixによるバージョンで並び替える
    • upの場合、ActiveRecord::SchemaMigrationに対応したテーブルに入っているレコード数分は飛ばした上記の配列のサブセットを取得
    • downの場合、上記の配列の0番目から指定したバージョンまでのサブセットを取得

しているようです。

Migrationをする範囲を指定しているらしきメソッドの中身2

そうして取得したMigrationのサブクラスのインスタンスについてどうしているかというと、

  if up?
    runnable.reject { |m| ran?(m) }
  else
    # skip the last migration if we're headed down, but not ALL the way down
    runnable.pop if target
    runnable.find_all { |m| ran?(m) }
  end
    def ran?(migration)
      migrated.include?(migration.version.to_i)
    end
  • upの場合、もう一度SchemaMigrationのテーブルに入っている値から、その中にすでにmigrationされているものがないか調べて除外
  • downの場合、指定したバージョンを除外した後、SchemaMigrationのテーブルに入っている値だけを選別している

こういうコードになってたんですね。