Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migration revert #7280

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 23 additions & 0 deletions activerecord/CHANGELOG.md
@@ -1,5 +1,28 @@
## Rails 4.0.0 (unreleased) ##

* Add Migration#revert

Can be used to revert migration commands or a whole migration.

For example, to create a revertible migration removing the table 'horses' and
creating the table 'apples':

class FixTenderloveMigration < ActiveRecord::Migration
def change
revert do
create_table(:horses) do |t|
t.text :content
t.datetime :remind_at
end
end
create_table(:apples) do |t|
t.string :variety
end
end
end

*Marc-André Lafortune*

* Fix AR#create to return an unsaved record when AR::RecordInvalid is
raised. Fixes #3217.

Expand Down
121 changes: 91 additions & 30 deletions activerecord/lib/active_record/migration.rb
Expand Up @@ -368,22 +368,88 @@ def initialize(name = self.class.name, version = nil)
@name = name
@version = version
@connection = nil
@reverting = false
end

# instantiate the delegate object after initialize is defined
self.verbose = true
self.delegate = new

def revert
@reverting = true
yield
ensure
@reverting = false
# Reverses the migration commands for the given block and
# the given migrations.
#
# The following migration will remove the table 'horses'
# and create the table 'apples' on the way up, and the reverse
# on the way down.
#
# class FixTLMigration < ActiveRecord::Migration
# def change
# revert do
# create_table(:horses) do |t|
# t.text :content
# t.datetime :remind_at
# end
# end
# create_table(:apples) do |t|
# t.string :variety
# end
# end
# end
#
# Or equivalently, if +TenderloveMigration+ is defined as in the
# documentation for Migration:
#
# class FixupTLMigration < ActiveRecord::Migration
# def change
# revert TenderloveMigration
#
# create_table(:apples) do |t|
# t.string :variety
# end
# end
# end
#
# This command can be nested.
#
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

def reverting?
@reverting
@connection.respond_to?(:reverting) && @connection.reverting
end

# Runs the given migration classes.
# Last argument can specify options:
# - :direction (default is :up)
# - :revert (default is false)
#
def run(*migration_classes)
opts = migration_classes.extract_options!
dir = opts[:direction] || :up
dir = (dir == :down ? :up : :down) if opts[:revert]
if reverting?
# If in revert and going :up, say, we want to execute :down without reverting, so
revert { run(*migration_classes, direction: dir, revert: true) }
else
migration_classes.each do |migration_class|
migration_class.new.exec_migration(@connection, dir)
end
end
end

def up
Expand All @@ -409,29 +475,9 @@ def migrate(direction)

time = nil
ActiveRecord::Base.connection_pool.with_connection do |conn|
@connection = conn
if respond_to?(:change)
if direction == :down
recorder = CommandRecorder.new(@connection)
suppress_messages do
@connection = recorder
change
end
@connection = conn
time = Benchmark.measure {
self.revert {
recorder.inverse.each do |cmd, args|
send(cmd, *args)
end
}
}
else
time = Benchmark.measure { change }
end
else
time = Benchmark.measure { send(direction) }
time = Benchmark.measure do
exec_migration(conn, direction)
end
@connection = nil
end

case direction
Expand All @@ -440,6 +486,21 @@ def migrate(direction)
end
end

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

def write(text="")
puts(text) if verbose
end
Expand Down Expand Up @@ -478,7 +539,7 @@ def method_missing(method, *arguments, &block)
arg_list = arguments.map{ |a| a.inspect } * ', '

say_with_time "#{method}(#{arg_list})" do
unless reverting?
unless @connection.respond_to? :revert
unless arguments.empty? || method == :execute
arguments[0] = Migrator.proper_table_name(arguments.first)
arguments[1] = Migrator.proper_table_name(arguments.second) if method == :rename_table
Expand Down
48 changes: 31 additions & 17 deletions activerecord/lib/active_record/migration/command_recorder.rb
Expand Up @@ -16,35 +16,49 @@ class Migration
class CommandRecorder
include JoinTable

attr_accessor :commands, :delegate
attr_accessor :commands, :delegate, :reverting

def initialize(delegate = nil)
@commands = []
@delegate = delegate
@reverting = false
end

def revert
@reverting = !@reverting
previous = @commands
@commands = []
yield
ensure
@commands = previous.concat(@commands.reverse)
@reverting = !@reverting
end

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

# Returns a list that represents commands that are the inverse of the
# commands stored in +commands+. For example:
# Returns the inverse of the given command
#
# recorder.record(:rename_table, [:old, :new])
# recorder.inverse # => [:rename_table, [:new, :old]]
# recorder.inverse_of(:rename_table, [:old, :new])
# # => [:rename_table, [:new, :old]]
#
# This method will raise an +IrreversibleMigration+ exception if it cannot
# invert the +commands+.
def inverse
@commands.reverse.map { |name, args|
method = :"invert_#{name}"
raise IrreversibleMigration unless respond_to?(method, true)
send(method, args)
}
# invert the +command+.
#
def inverse_of(command, args)
method = :"invert_#{command}"
raise IrreversibleMigration unless respond_to?(method, true)
send(method, args)
end

def respond_to?(*args) # :nodoc:
Expand All @@ -53,9 +67,9 @@ def respond_to?(*args) # :nodoc:

[:create_table, :create_join_table, :change_table, :rename_table, :add_column, :remove_column, :rename_index, :rename_column, :add_index, :remove_index, :add_timestamps, :remove_timestamps, :change_column, :change_column_default, :add_reference, :remove_reference].each do |method|
class_eval <<-EOV, __FILE__, __LINE__ + 1
def #{method}(*args) # def create_table(*args)
record(:"#{method}", args) # record(:create_table, args)
end # end
def #{method}(*args, &block) # def create_table(*args, &block)
record(:"#{method}", args, &block) # record(:create_table, args, &block)
end # end
EOV
end
alias :add_belongs_to :add_reference
Expand Down
86 changes: 86 additions & 0 deletions activerecord/test/cases/invertible_migration_test.rb
Expand Up @@ -17,6 +17,17 @@ def change
end
end

class InvertibleRevertMigration < SilentMigration
def change
revert do
create_table("horses") do |t|
t.column :content, :text
t.column :remind_at, :datetime
end
end
end
end

class NonInvertibleMigration < SilentMigration
def change
create_table("horses") do |t|
Expand All @@ -40,6 +51,23 @@ def self.down
end
end

class RevertWholeMigration < SilentMigration
def initialize(name = self.class.name, version = nil, migration)
@migration = migration
super(name, version)
end

def change
revert @migration
end
end

class NestedRevertWholeMigration < RevertWholeMigration
def change
revert { super }
end
end

def teardown
if ActiveRecord::Base.connection.table_exists?("horses")
ActiveRecord::Base.connection.drop_table("horses")
Expand Down Expand Up @@ -67,6 +95,64 @@ def test_migrate_down
assert !migration.connection.table_exists?("horses")
end

def test_migrate_revert
migration = InvertibleMigration.new
revert = InvertibleRevertMigration.new
migration.migrate :up
revert.migrate :up
assert !migration.connection.table_exists?("horses")
revert.migrate :down
assert migration.connection.table_exists?("horses")
migration.migrate :down
assert !migration.connection.table_exists?("horses")
end

def test_migrate_revert_whole_migration
migration = InvertibleMigration.new
[LegacyMigration, InvertibleMigration].each do |klass|
revert = RevertWholeMigration.new(klass)
migration.migrate :up
revert.migrate :up
assert !migration.connection.table_exists?("horses")
revert.migrate :down
assert migration.connection.table_exists?("horses")
migration.migrate :down
assert !migration.connection.table_exists?("horses")
end
end

def test_migrate_nested_revert_whole_migration
revert = NestedRevertWholeMigration.new(InvertibleRevertMigration)
revert.migrate :down
assert revert.connection.table_exists?("horses")
revert.migrate :up
assert !revert.connection.table_exists?("horses")
end

def test_revert_order
block = Proc.new{|t| t.string :name }
recorder = ActiveRecord::Migration::CommandRecorder.new(ActiveRecord::Base.connection)
recorder.instance_eval do
create_table("apples", &block)
revert do
create_table("bananas", &block)
revert do
create_table("clementines")
create_table("dates")
end
create_table("elderberries")
end
revert do
create_table("figs")
create_table("grapes")
end
end
assert_equal [[:create_table, ["apples"], block], [:drop_table, ["elderberries"]],
[:create_table, ["clementines"], nil], [:create_table, ["dates"], nil],
[:drop_table, ["bananas"]], [:drop_table, ["grapes"]],
[:drop_table, ["figs"]]], recorder.commands
end

def test_legacy_up
LegacyMigration.migrate :up
assert ActiveRecord::Base.connection.table_exists?("horses"), "horses should exist"
Expand Down