Rails 定义自己的 Migration method

xjz19901211 · April 05, 2015 · Last by googya replied at April 05, 2015 · 3353 hits

文章转自我的 Blog http://xjz.pw,转载请注明出处

PS: 文笔不好,大家见谅:)


瞎扯

在团队中,大家是否出现,DB 中的字段只有少数人知道这个字段是在做什么。或是折腾数据的同学,拿到我们的数据后,很多数据字段完全不知道是做啥的。

为了让大家更好的理解数据库各字段的意义,通常的做法是为各个字段加上注释。而加注释的方式也很多,不同的方式可以达到不同的效果。比如:

  • 直接使用文档,这样加了字段还要去另一个地方修改文档,很可能造成不同步
  • 直接 migration 上加注释,这样只能方便自己团队内使用,如果其它不会 Ruby Rails 的同学来看,不够灵活
  • 直接把注释放到 DB 中去,在 rails 中,有一个很好的 gem MigrationComments,但是,新加的字段还好,如果是老的字段,要增加或是修改 comments 的话,会进行 alter table

准备折腾

我是喜欢能不修改或是影响原有功能就尽量不去动。所以我通过了创建一张表,专门来存放 comments,表的结构比较简单,如下

create_table(:database_comments) do |t|
  t.string :table_name, null: false
  t.string :column_name
  t.string :comment, null: false
  t.tags # split by ','
  t.index [:table_name, column_name], unique: true
end

当 column_name 为空时,表示是一个 table 的 comment,否则就是对应 column 的 comment,顺便为每个列打上 tag,比如可以为 password/token 等标记为"secret"

嗯,数据结构就这样定好了,接下来开始折腾了

如何写 comment

既然有 MigrationComments 的例子在先,那么,我们也可以使用同样的方式,通过扩展 ActiveRecord 的 migration 来做这件事了。

def change
  set_table_coment :table_name, "A table comment"
  set_column_comment :table_name, :column_name, "A column comment"
end

Or

def change
  change_table :table_name do |t|
    t.comment "A table comment"
    t.change_comment :column_name, "A column comment"
  end
end

那么我们先来了解,ActiveRecord Migration 的实现,显然,我们通过现有的 MigrationComments + Google + ActiveRecord 源代码来了解是最快的

通过 MigrationComment 代码和 Google 结果,了解到,想要扩展这些功能,需要修改 ActiveRecord 的代码的,那么接下来,就先了解下下 migration 是如何工作的吧

ActiveRecord::Migration 是如何工作的

以 Rails 4.1.5 activerecord 为例

up and down

大家应该都知道运行 migrate 的入口是ActiveRecord::Migrator.migrate('./db/migrations'),如果不知道的话,现在也知道了:)

这里判断了是要 down 还是 up,也就是执行新的 migration 还是 rollback。

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?

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

def down(migrations_paths, target_version = nil, &block)
  migrations = migrations(migrations_paths)
  migrations.select! { |m| yield m } if block_given?

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

Initialize migration table

而在initialize 做了准备工作,初始了 schema_migrations 表

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

initialize_schema_migrations_table 方法最终指向了 SchemaMigration.create_table

def create_table(limit=nil)
  unless table_exists?
    version_options = {null: false}
    version_options[:limit] = limit if limit

    connection.create_table(table_name, id: false) do |t|
      t.column :version, :string, version_options
    end
    connection.add_index table_name, :version, unique: true, name: index_name
  end
end

Run migrate

之后就是执行每一个Migration#migrate,告诉它是要 up 还是 down

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

change? up? down?

exec_migration

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

我们先来看change method,它直接执行了,而在 Migration 类中并没有定义像create_table/change_table这里 method,继续可以看到 Migration 定义了 method_missing

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] = proper_table_name(arguments.first, table_name_options)
        arguments[1] = proper_table_name(arguments.second, table_name_options) if method == :rename_table
      end
    end
    return super unless connection.respond_to?(method)
    connection.send(method, *arguments, &block)
  end
end

都直接转发给 connettion(ActiveRecord::Base.connection) 了,而所有的 connection 都是由继承自AbstractAdapter的类的实例

ActiveRecord::Base.connection.method(:create_table).source_location
ActiveRecord::Base.connection.method(:change_table).source_location
ActiveRecord::Base.connection.method(:add_column).source_location
ActiveRecord::Base.connection.method(:change_column).source_location

由上可知道 migration 中的这些 method 都是来自SchemaStatements,或是在各 db 的 adapter 中做了一些修改

同样,由原代码可以看出,像create_table / change_table block 中的 method 都是由一个单独的类来定义的

def create_table(table_name, options = {})
  td = create_table_definition table_name, options[:temporary], options[:options], options[:as]

  if !options[:as]
    unless options[:id] == false
      pk = options.fetch(:primary_key) {
        Base.get_primary_key table_name.to_s.singularize
      }

      td.primary_key pk, options.fetch(:id, :primary_key), options
    end

    yield td if block_given?
  end

  if options[:force] && table_exists?(table_name)
    drop_table(table_name, options)
  end

  execute schema_creation.accept td
  td.indexes.each_pair { |c,o| add_index table_name, c, o }
end

# ...

private
def create_table_definition(name, temporary, options, as = nil)
  TableDefinition.new native_database_types, name, temporary, options, as
end

Migration revert

到这里我们已经了解到了,如何去写一个自己的 migration method 了,但是还没完呢,在 migrate 时可以看到,如果以 down 方式运行会 revert { change },那么这个 revert 是怎么实现的呢?继续看代码

def revert(*migration_classes)
  run(*migration_classes.reverse, revert: true) unless migration_classes.empty?
  if block_given?
    if @connection.respond_to? :revert
      @connection.revert { yield }
    else
      recorder = CommandRecorder.new(@connection)
      @connection = recorder
      suppress_messages do
        @connection.revert { yield }
      end
      @connection = recorder.delegate
      recorder.commands.each do |cmd, args, block|
        send(cmd, *args, &block)
      end
    end
  end
end

这里又出现新玩意了:CommandRecorder

这里比较迷惑的地方是@connection.revert { yield },外面传入的明明是revert { change }, change 中的 methods 作用域还是当前 Migration 实例,这样传进来可以执行?,看了好一会才回想起, method_missing时会 call @connection.xxx,而上次的代码,在执行前做了@connection = recorder了,写成一坨了。。。

然后看了 CommandRecorder 中的代码,相信已经没人会对 change 可以同时支持 migrate 与 rollback 感到神奇了。

# record +command+. +command+ should be a method name and arguments.
# For example:
#
# recorder.record(:method_name, [:arg1, :arg2])
def record(*command, &block)
  if @reverting
    @commands << inverse_of(*command, &block)
  else
    @commands << (command << block)
  end
end

# Returns the inverse of the given command. For example:
#
# recorder.inverse_of(:rename_table, [:old, :new])
# # => [:rename_table, [:new, :old]]
#
# This method will raise an +IrreversibleMigration+ exception if it cannot
# invert the +command+.
def inverse_of(command, args, &block)
  method = :"invert_#{command}"
  raise IrreversibleMigration unless respond_to?(method, true)
  send(method, args, &block)
end


[:create_table, :create_join_table, :rename_table, :add_column, :remove_column,
  :rename_index, :rename_column, :add_index, :remove_index, :add_timestamps, :remove_timestamps,
  :change_column_default, :add_reference, :remove_reference, :transaction,
  :drop_join_table, :drop_table, :execute_block, :enable_extension,
  :change_column, :execute, :remove_columns, :change_column_null # irreversible methods need to be here too
].each do |method|
  class_eval <<-EOV, __FILE__, __LINE__ + 1
    def #{method}(*args, &block) # def create_table(*args, &block)
      record(:"#{method}", args, &block) # record(:create_table, args, &block)
    end # end
  EOV
end

{ transaction: :transaction,
  execute_block: :execute_block,
  create_table: :drop_table,
  create_join_table: :drop_join_table,
  add_column: :remove_column,
  add_timestamps: :remove_timestamps,
  add_reference: :remove_reference,
  enable_extension: :disable_extension
}.each do |cmd, inv|
  [[inv, cmd], [cmd, inv]].uniq.each do |method, inverse|
    class_eval <<-EOV, __FILE__, __LINE__ + 1
      def invert_#{method}(args, &block) # def invert_create_table(args, &block)
        [:#{inverse}, args, block] # [:drop_table, args, block]
      end # end
    EOV
  end
end

神秘的面纱揭开后,我想大家都不会对真像感到惊奇了...

通过重定义所有的 migration 中的方法,这里方法执行的只是record(:xxx, args, &block), 再定义好每个 methods 对应的 invert 操作,在 invert 操作中可以根据正常执行的参数得出反向操作的参数, 最后在Migration#revert中再把这些command执行

开始折腾

def change
  add_column_comment :users, :mobile, "user mobile'"
  add_table_comment :users, "user table'"

  add_comments :table_name do |c|
    c.table 'table comments'
    c.column :a, 'column comment'
    c.column :s, 'comment', sensitive: true
  end
end

相信到这里时,上面的的实现方式大家应该已经会了,接下来,就可以随意折腾 ActiveRecord Migration

本来只是想讲讲写一个 migratino commnet 的 gem,想到什么写什么,然后就把 Migration 的实现都讲了, 那么,折腾这事,就交给大家了,我折腾好的代码在公司项目上,就不拉出来了:)

Reference

Rails ActiveRecord

You need to Sign in before reply, if you don't have an account, please Sign up first.