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

Add support for if_exists/if_not_exists on remove_column/add_column #38352

Merged
merged 1 commit into from
Jan 30, 2020
Merged
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
24 changes: 24 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
* Adds support for `if_not_exists` to `add_column` and `if_exists` to `remove_column.

Applications can set their migrations to ignore exceptions raised when adding a column that already exists or when removing a column that does not exist.

Example Usage:

```
class AddColumnTitle < ActiveRecord::Migration[6.1]
def change
add_column :posts, :title, :string, if_not_exists: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do something in the case where title exists, but it happens to be a text, then rolling back the existing column's removed and then migrating again, the column would now be a string? Though I suppose that's what you want and later migrations is the newest source of truth. Though it could be potentially confusing and happen silently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Turns out column_exists? takes a type. I've updated this to still raise if you pass a different type and added a test for that case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woot 🙌

end
end
```

```
class RemoveColumnTitle < ActiveRecord::Migration[6.1]
def change
remove_column :posts, :title, if_exists: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice that both these options correspond to their SQL counterparts, but the sorta tailing if yet just an option reads odd to me. I find that I have to read both add_column and remove_column multiple times and sort of migrate/rollback the migration in my head. I wonder if there's something like this could work:

add_column    :posts, :title, :string, skip_when_migrated: true
remove_column :posts, :title, skip_when_migrated: true

I don't care so much that add_column requires if_not_exists while if_exists is only for remove_column, it's more about describing what happens in case the migration is needless. Maybe only_when_required: true or only_when_needed: true. Maybe suppress_error: true as another option.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm following the existing if_exists/if_not_exists that is used by create and drop table and I don't think they should be different because that's really confusing for users.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love if_exists / if_not_exists but I definitely don't want create and drop to have one set of options and add/remove column to have totally different options. We could change the create/drop table but that creates a whole deprecation cycle.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah doh, I tried searching for if_not_exists on api.rubyonrails.org and came back blank. Sorry about the derailing, all good from here 🙏

end
end
```

*Eileen M. Uchitelle*

* Regexp-escape table name for MS SQL

Add `Regexp.escape` to one method in ActiveRecord, so that table names with regular expression characters in them work as expected. Since MS SQL Server uses "[" and "]" to quote table and column names, and those characters are regular expression characters, methods like `pluck` and `select` fail in certain cases when used with the MS SQL Server adapter.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,9 @@ def drop_table(table_name, **options)
# column will have the same collation as the table.
# * <tt>:comment</tt> -
# Specifies the comment for the column. This option is ignored by some backends.
# * <tt>:if_not_exists</tt> -
# Specifies if the column already exists to not try to re-add it. This will avoid
# duplicate column errors.
#
# Note: The precision is the total number of significant digits,
# and the scale is the number of digits that can be stored following
Expand Down Expand Up @@ -587,7 +590,12 @@ def drop_table(table_name, **options)
# # Defines a column with a database-specific type.
# add_column(:shapes, :triangle, 'polygon')
# # ALTER TABLE "shapes" ADD "triangle" polygon
#
# # Ignores the method call if the column exists
# add_column(:shapes, :triangle, 'polygon', if_not_exists: true)
def add_column(table_name, column_name, type, **options)
return if options[:if_not_exists] == true && column_exists?(table_name, column_name, type)

at = create_alter_table table_name
at.add_column(column_name, type, **options)
execute schema_creation.accept at
Expand Down Expand Up @@ -616,7 +624,15 @@ def remove_columns(table_name, *column_names, **options)
# to provide these in a migration's +change+ method so it can be reverted.
# In that case, +type+ and +options+ will be used by #add_column.
# Indexes on the column are automatically removed.
#
# If the options provided include an +if_exists+ key, it will be used to check if the
# column does not exist. This will silently ignore the migration rather than raising
# if the column was already used.
#
# remove_column(:suppliers, :qualification, if_exists: true)
def remove_column(table_name, column_name, type = nil, **options)
return if options[:if_exists] == true && !column_exists?(table_name, column_name)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we pass the type into the exists check here too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this and tried it out and there's an issue with passing type. For add_column the type is required, you have to pass it so we can use that to check if the column_exists?.

However, in remove_column it's allowed but completely ignored by the actual column dropper. For remove_column since type is ignored by the execute we don't need to pass type there because the column will be removed regardless of type so we don't want to protect on nil or type. Does that make sense? I feel like I'm rambling...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically doesn't matter what you pass for type, remove column will always remove the column because type is ignored.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, perfect 👌


execute "ALTER TABLE #{quote_table_name(table_name)} #{remove_column_for_alter(table_name, column_name, type, **options)}"
end

Expand Down
174 changes: 174 additions & 0 deletions activerecord/test/cases/migration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,180 @@ def test_create_table_with_force_true_does_not_drop_nonexisting_table
Person.connection.drop_table :testings2, if_exists: true
end

def test_remove_column_with_if_not_exists_not_set
migration_a = Class.new(ActiveRecord::Migration::Current) {
def version; 100 end
def migrate(x)
add_column "people", "last_name", :string
end
}.new

migration_b = Class.new(ActiveRecord::Migration::Current) {
def version; 101 end
def migrate(x)
remove_column "people", "last_name"
end
}.new

migration_c = Class.new(ActiveRecord::Migration::Current) {
def version; 102 end
def migrate(x)
remove_column "people", "last_name"
end
}.new

ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
assert_column Person, :last_name, "migration_a should have added the last_name column on people"

ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
assert_no_column Person, :last_name, "migration_b should have dropped the last_name column on people"

migrator = ActiveRecord::Migrator.new(:up, [migration_c], @schema_migration, 102)

if current_adapter?(:SQLite3Adapter)
assert_nothing_raised do
migrator.migrate
end
else
error = assert_raises do
migrator.migrate
end

if current_adapter?(:Mysql2Adapter)
if ActiveRecord::Base.connection.mariadb?
assert_match(/Can't DROP COLUMN `last_name`; check that it exists/, error.message)
else
assert_match(/check that column\/key exists/, error.message)
end
elsif
assert_match(/column \"last_name\" of relation \"people\" does not exist/, error.message)
end
end
ensure
Person.reset_column_information
end

def test_remove_column_with_if_exists_set
migration_a = Class.new(ActiveRecord::Migration::Current) {
def version; 100 end
def migrate(x)
add_column "people", "last_name", :string
end
}.new

migration_b = Class.new(ActiveRecord::Migration::Current) {
def version; 101 end
def migrate(x)
remove_column "people", "last_name"
end
}.new

migration_c = Class.new(ActiveRecord::Migration::Current) {
def version; 102 end
def migrate(x)
remove_column "people", "last_name", if_exists: true
end
}.new

ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
assert_column Person, :last_name, "migration_a should have added the last_name column on people"

ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
assert_no_column Person, :last_name, "migration_b should have dropped the last_name column on people"

migrator = ActiveRecord::Migrator.new(:up, [migration_c], @schema_migration, 102)

assert_nothing_raised do
migrator.migrate
end
ensure
Person.reset_column_information
end

def test_add_column_with_if_not_exists_not_set
migration_a = Class.new(ActiveRecord::Migration::Current) {
def version; 100 end
def migrate(x)
add_column "people", "last_name", :string
end
}.new

migration_b = Class.new(ActiveRecord::Migration::Current) {
def version; 101 end
def migrate(x)
add_column "people", "last_name", :string
end
}.new

ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
assert_column Person, :last_name, "migration_a should have created the last_name column on people"

assert_raises do
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
end
ensure
Person.reset_column_information
if Person.column_names.include?("last_name")
Person.connection.remove_column("people", "last_name")
end
end

def test_add_column_with_if_not_exists_set_to_true
migration_a = Class.new(ActiveRecord::Migration::Current) {
def version; 100 end
def migrate(x)
add_column "people", "last_name", :string
end
}.new

migration_b = Class.new(ActiveRecord::Migration::Current) {
def version; 101 end
def migrate(x)
add_column "people", "last_name", :string, if_not_exists: true
end
}.new

ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
assert_column Person, :last_name, "migration_a should have created the last_name column on people"

assert_nothing_raised do
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
end
ensure
Person.reset_column_information
if Person.column_names.include?("last_name")
Person.connection.remove_column("people", "last_name")
end
end

def test_add_column_with_if_not_exists_set_to_true_still_raises_if_type_is_different
migration_a = Class.new(ActiveRecord::Migration::Current) {
def version; 100 end
def migrate(x)
add_column "people", "last_name", :string
end
}.new

migration_b = Class.new(ActiveRecord::Migration::Current) {
def version; 101 end
def migrate(x)
add_column "people", "last_name", :boolean, if_not_exists: true
end
}.new

ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
assert_column Person, :last_name, "migration_a should have created the last_name column on people"

assert_raises do
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
end
ensure
Person.reset_column_information
if Person.column_names.include?("last_name")
Person.connection.remove_column("people", "last_name")
end
end

def test_migration_instance_has_connection
migration = Class.new(ActiveRecord::Migration::Current).new
assert_equal ActiveRecord::Base.connection, migration.connection
Expand Down