Add :bulk => true option to change_table

This commit is contained in:
Pratik Naik 2011-01-31 13:21:03 +00:00
parent 9db4c07e0b
commit 30176f28a4
7 changed files with 316 additions and 65 deletions

View File

@ -1,5 +1,18 @@
*Rails 3.1.0 (unreleased)*
* Add :bulk => true option to change_table to make all the schema changes defined in change_table block using a single ALTER statement. [Pratik Naik]
Example:
change_table(:users, :bulk => true) do |t|
t.string :company_name
t.change :birthdate, :datetime
end
This will now result in:
ALTER TABLE `users` ADD COLUMN `company_name` varchar(255), CHANGE `updated_at` `updated_at` datetime DEFAULT NULL
* Removed support for accessing attributes on a has_and_belongs_to_many join table. This has been
documented as deprecated behaviour since April 2006. Please use has_many :through instead.
[Jon Leighton]

View File

@ -176,6 +176,13 @@ module ActiveRecord
# # Other column alterations here
# end
#
# The +options+ hash can include the following keys:
# [<tt>:bulk</tt>]
# Set this to true to make this a bulk alter query, such as
# ALTER TABLE `users` ADD COLUMN age INT(11), ADD COLUMN birthdate DATETIME ...
#
# Defaults to false.
#
# ===== Examples
# ====== Add a column
# change_table(:suppliers) do |t|
@ -224,8 +231,14 @@ module ActiveRecord
#
# See also Table for details on
# all of the various column transformation
def change_table(table_name)
yield Table.new(table_name, self)
def change_table(table_name, options = {})
if supports_bulk_alter? && options[:bulk]
recorder = ActiveRecord::Migration::CommandRecorder.new(self)
yield Table.new(table_name, recorder)
bulk_change_table(table_name, recorder.commands)
else
yield Table.new(table_name, self)
end
end
# Renames a table.
@ -253,10 +266,7 @@ module ActiveRecord
# remove_column(:suppliers, :qualification)
# remove_columns(:suppliers, :qualification, :experience)
def remove_column(table_name, *column_names)
raise ArgumentError.new("You must specify at least one column name. Example: remove_column(:people, :first_name)") if column_names.empty?
column_names.flatten.each do |column_name|
execute "ALTER TABLE #{quote_table_name(table_name)} DROP #{quote_column_name(column_name)}"
end
columns_for_remove(table_name, *column_names).each {|column_name| execute "ALTER TABLE #{quote_table_name(table_name)} DROP #{column_name}" }
end
alias :remove_columns :remove_column
@ -327,25 +337,8 @@ module ActiveRecord
#
# Note: SQLite doesn't support index length
def add_index(table_name, column_name, options = {})
column_names = Array.wrap(column_name)
index_name = index_name(table_name, :column => column_names)
if Hash === options # legacy support, since this param was a string
index_type = options[:unique] ? "UNIQUE" : ""
index_name = options[:name].to_s if options.key?(:name)
else
index_type = options
end
if index_name.length > index_name_length
raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' is too long; the limit is #{index_name_length} characters"
end
if index_name_exists?(table_name, index_name, false)
raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' already exists"
end
quoted_column_names = quoted_columns_for_index(column_names, options).join(", ")
execute "CREATE #{index_type} INDEX #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} (#{quoted_column_names})"
index_name, index_type, index_columns = add_index_options(table_name, column_name, options)
execute "CREATE #{index_type} INDEX #{quote_column_name(index_name)} ON #{quote_table_name(table_name)} (#{index_columns})"
end
# Remove the given index from the table.
@ -359,11 +352,7 @@ module ActiveRecord
# Remove the index named by_branch_party in the accounts table.
# remove_index :accounts, :name => :by_branch_party
def remove_index(table_name, options = {})
index_name = index_name(table_name, options)
unless index_name_exists?(table_name, index_name, true)
raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' does not exist"
end
remove_index!(table_name, index_name)
remove_index!(table_name, index_name_for_remove(table_name, options))
end
def remove_index!(table_name, index_name) #:nodoc:
@ -469,7 +458,7 @@ module ActiveRecord
end
def type_to_sql(type, limit = nil, precision = nil, scale = nil) #:nodoc:
if native = native_database_types[type]
if native = native_database_types[type.to_sym]
column_type_sql = (native.is_a?(Hash) ? native[:name] : native).dup
if type == :decimal # ignore limit, use precision and scale
@ -537,6 +526,45 @@ module ActiveRecord
options.include?(:default) && !(options[:null] == false && options[:default].nil?)
end
def add_index_options(table_name, column_name, options = {})
column_names = Array.wrap(column_name)
index_name = index_name(table_name, :column => column_names)
if Hash === options # legacy support, since this param was a string
index_type = options[:unique] ? "UNIQUE" : ""
index_name = options[:name].to_s if options.key?(:name)
else
index_type = options
end
if index_name.length > index_name_length
raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' is too long; the limit is #{index_name_length} characters"
end
if index_name_exists?(table_name, index_name, false)
raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' already exists"
end
index_columns = quoted_columns_for_index(column_names, options).join(", ")
[index_name, index_type, index_columns]
end
def index_name_for_remove(table_name, options = {})
index_name = index_name(table_name, options)
unless index_name_exists?(table_name, index_name, true)
raise ArgumentError, "Index name '#{index_name}' on table '#{table_name}' does not exist"
end
index_name
end
def columns_for_remove(table_name, *column_names)
column_names = column_names.flatten
raise ArgumentError.new("You must specify at least one column name. Example: remove_column(:people, :first_name)") if column_names.blank?
column_names.map {|column_name| quote_column_name(column_name) }
end
private
def table_definition
TableDefinition.new(self)

View File

@ -77,6 +77,10 @@ module ActiveRecord
false
end
def supports_bulk_alter?
false
end
# Does this adapter support savepoints? PostgreSQL and MySQL do,
# SQLite < 3.6.8 does not.
def supports_savepoints?

View File

@ -203,6 +203,10 @@ module ActiveRecord
ADAPTER_NAME
end
def supports_bulk_alter? #:nodoc:
true
end
# Returns +true+ when the connection adapter supports prepared statement
# caching, otherwise returns +false+
def supports_statement_cache?
@ -547,11 +551,23 @@ module ActiveRecord
execute "RENAME TABLE #{quote_table_name(table_name)} TO #{quote_table_name(new_name)}"
end
def bulk_change_table(table_name, operations) #:nodoc:
sqls = operations.map do |command, args|
table, arguments = args.shift, args
method = :"#{command}_sql"
if respond_to?(method)
send(method, table, *arguments)
else
raise "Unknown method called : #{method}(#{arguments.inspect})"
end
end.flatten.join(", ")
execute("ALTER TABLE #{quote_table_name(table_name)} #{sqls}")
end
def add_column(table_name, column_name, type, options = {})
add_column_sql = "ALTER TABLE #{quote_table_name(table_name)} ADD #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}"
add_column_options!(add_column_sql, options)
add_column_position!(add_column_sql, options)
execute(add_column_sql)
execute("ALTER TABLE #{quote_table_name(table_name)} #{add_column_sql(table_name, column_name, type, options)}")
end
def change_column_default(table_name, column_name, default) #:nodoc:
@ -570,34 +586,11 @@ module ActiveRecord
end
def change_column(table_name, column_name, type, options = {}) #:nodoc:
column = column_for(table_name, column_name)
unless options_include_default?(options)
options[:default] = column.default
end
unless options.has_key?(:null)
options[:null] = column.null
end
change_column_sql = "ALTER TABLE #{quote_table_name(table_name)} CHANGE #{quote_column_name(column_name)} #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}"
add_column_options!(change_column_sql, options)
add_column_position!(change_column_sql, options)
execute(change_column_sql)
execute("ALTER TABLE #{quote_table_name(table_name)} #{change_column_sql(table_name, column_name, type, options)}")
end
def rename_column(table_name, column_name, new_column_name) #:nodoc:
options = {}
if column = columns(table_name).find { |c| c.name == column_name.to_s }
options[:default] = column.default
options[:null] = column.null
else
raise ActiveRecordError, "No such column: #{table_name}.#{column_name}"
end
current_type = select_one("SHOW COLUMNS FROM #{quote_table_name(table_name)} LIKE '#{column_name}'")["Type"]
rename_column_sql = "ALTER TABLE #{quote_table_name(table_name)} CHANGE #{quote_column_name(column_name)} #{quote_column_name(new_column_name)} #{current_type}"
add_column_options!(rename_column_sql, options)
execute(rename_column_sql)
execute("ALTER TABLE #{quote_table_name(table_name)} #{rename_column_sql(table_name, column_name, new_column_name)}")
end
# Maps logical Rails types to MySQL-specific data types.
@ -680,6 +673,69 @@ module ActiveRecord
end
end
def add_column_sql(table_name, column_name, type, options = {})
add_column_sql = "ADD #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}"
add_column_options!(add_column_sql, options)
add_column_position!(add_column_sql, options)
add_column_sql
end
def remove_column_sql(table_name, *column_names)
columns_for_remove(table_name, *column_names).map {|column_name| "DROP #{column_name}" }
end
alias :remove_columns_sql :remove_column
def change_column_sql(table_name, column_name, type, options = {})
column = column_for(table_name, column_name)
unless options_include_default?(options)
options[:default] = column.default
end
unless options.has_key?(:null)
options[:null] = column.null
end
change_column_sql = "CHANGE #{quote_column_name(column_name)} #{quote_column_name(column_name)} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}"
add_column_options!(change_column_sql, options)
add_column_position!(change_column_sql, options)
change_column_sql
end
def rename_column_sql(table_name, column_name, new_column_name)
options = {}
if column = columns(table_name).find { |c| c.name == column_name.to_s }
options[:default] = column.default
options[:null] = column.null
else
raise ActiveRecordError, "No such column: #{table_name}.#{column_name}"
end
current_type = select_one("SHOW COLUMNS FROM #{quote_table_name(table_name)} LIKE '#{column_name}'")["Type"]
rename_column_sql = "CHANGE #{quote_column_name(column_name)} #{quote_column_name(new_column_name)} #{current_type}"
add_column_options!(rename_column_sql, options)
rename_column_sql
end
def add_index_sql(table_name, column_name, options = {})
index_name, index_type, index_columns = add_index_options(table_name, column_name, options)
"ADD #{index_type} INDEX #{index_name} (#{index_columns})"
end
def remove_index_sql(table_name, options = {})
index_name = index_name_for_remove(table_name, options)
"DROP INDEX #{index_name}"
end
def add_timestamps_sql(table_name)
[add_column_sql(table_name, :created_at, :datetime), add_column_sql(table_name, :updated_at, :datetime)]
end
def remove_timestamps_sql(table_name)
[remove_column_sql(table_name, :updated_at), remove_column_sql(table_name, :created_at)]
end
private
def connect
encoding = @config[:encoding]

View File

@ -40,7 +40,7 @@ module ActiveRecord
@commands.reverse.map { |name, args|
method = :"invert_#{name}"
raise IrreversibleMigration unless respond_to?(method, true)
__send__(method, args)
send(method, args)
}
end
@ -48,12 +48,16 @@ module ActiveRecord
super || delegate.respond_to?(*args)
end
def send(method, *args) # :nodoc:
return super unless respond_to?(method)
record(method, args)
[:create_table, :rename_table, :add_column, :remove_column, :rename_index, :rename_column, :add_index, :remove_index, :add_timestamps, :remove_timestamps, :change_column, :change_column_default].each do |method|
class_eval <<-EOV, __FILE__, __LINE__ + 1
def #{method}(*args)
record(:"#{method}", args)
end
EOV
end
private
def invert_create_table(args)
[:drop_table, args]
end
@ -86,6 +90,14 @@ module ActiveRecord
def invert_add_timestamps(args)
[:remove_timestamps, args]
end
# Forwards any missing method call to the \target.
def method_missing(method, *args, &block)
@delegate.send(method, *args, &block)
rescue NoMethodError => e
raise e, e.message.sub(/ for #<.*$/, " via proxy for #{@delegate}")
end
end
end
end

View File

@ -16,7 +16,7 @@ module ActiveRecord
def test_send_calls_super
assert_raises(NoMethodError) do
@recorder.send(:create_table, :horses)
@recorder.send(:non_existing_method, :horses)
end
end

View File

@ -1923,6 +1923,144 @@ if ActiveRecord::Base.connection.supports_migrations?
end
end
class AlterTableMigrationsTest < ActiveRecord::TestCase
def setup
@connection = Person.connection
@connection.create_table(:delete_me, :force => true) {|t| }
end
def teardown
Person.connection.drop_table(:delete_me) rescue nil
end
def test_adding_multiple_columns
assert_queries(1) do
with_bulk_change_table do |t|
t.column :name, :string
t.string :qualification, :experience
t.integer :age, :default => 0
t.date :birthdate
t.timestamps
end
end
assert_equal 8, columns.size
[:name, :qualification, :experience].each {|s| assert_equal :string, column(s).type }
assert_equal 0, column(:age).default
end
def test_removing_columns
with_bulk_change_table do |t|
t.string :qualification, :experience
end
[:qualification, :experience].each {|c| assert column(c) }
assert_queries(1) do
with_bulk_change_table do |t|
t.remove :qualification, :experience
t.string :qualification_experience
end
end
[:qualification, :experience].each {|c| assert ! column(c) }
assert column(:qualification_experience)
end
def test_adding_indexes
with_bulk_change_table do |t|
t.string :username
t.string :name
t.integer :age
end
# Adding an index fires a query everytime to check if an index already exists or not
assert_queries(3) do
with_bulk_change_table do |t|
t.index :username, :unique => true, :name => :awesome_username_index
t.index [:name, :age]
end
end
assert_equal 2, indexes.size
name_age_index = index(:index_delete_me_on_name_and_age)
assert_equal ['name', 'age'].sort, name_age_index.columns.sort
assert ! name_age_index.unique
assert index(:awesome_username_index).unique
end
def test_removing_index
with_bulk_change_table do |t|
t.string :name
t.index :name
end
assert index(:index_delete_me_on_name)
assert_queries(3) do
with_bulk_change_table do |t|
t.remove_index :name
t.index :name, :name => :new_name_index, :unique => true
end
end
assert ! index(:index_delete_me_on_name)
new_name_index = index(:new_name_index)
assert new_name_index.unique
end
def test_changing_columns
with_bulk_change_table do |t|
t.string :name
t.date :birthdate
end
assert ! column(:name).default
assert_equal :date, column(:birthdate).type
assert_queries(1) do
with_bulk_change_table do |t|
t.change :name, :string, :default => 'NONAME'
t.change :birthdate, :datetime
end
end
assert_equal 'NONAME', column(:name).default
assert_equal :datetime, column(:birthdate).type
end
protected
def with_bulk_change_table
# Reset columns/indexes cache as we're changing the table
@columns = @indexes = nil
Person.connection.change_table(:delete_me, :bulk => true) do |t|
yield t
end
end
def column(name)
columns.detect {|c| c.name == name.to_s }
end
def columns
@columns ||= Person.connection.columns('delete_me')
end
def index(name)
indexes.detect {|i| i.name == name.to_s }
end
def indexes
@indexes ||= Person.connection.indexes('delete_me')
end
end
class CopyMigrationsTest < ActiveRecord::TestCase
def setup
end