Skip to content

Commit 95ed7e7

Browse files
committed
Add support for if_exists/if_not_exists on remove_column/add_column
This PR adds support for `if_exists` on `remove_column` and `if_not_exists` on `add_column` to support silently ignoring migrations if the remove tries to remove a non-existent column or an add tries to add an already existing column. We (GitHub) have custom monkey-patched support for these features and would like to upstream this behavior. This matches the same behavior that is supported for `create_table` and `drop_table`. The behavior for sqlite is different from mysql/postgres and sqlite for remove column and that is reflected in the tests.
1 parent 9082609 commit 95ed7e7

File tree

3 files changed

+214
-0
lines changed

3 files changed

+214
-0
lines changed

activerecord/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
* Adds support for `if_not_exists` to `add_column` and `if_exists` to `remove_column.
2+
3+
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.
4+
5+
Example Usage:
6+
7+
```
8+
class AddColumnTitle < ActiveRecord::Migration[6.1]
9+
def change
10+
add_column :posts, :title, :string, if_not_exists: true
11+
end
12+
end
13+
```
14+
15+
```
16+
class RemoveColumnTitle < ActiveRecord::Migration[6.1]
17+
def change
18+
remove_column :posts, :title, if_exists: true
19+
end
20+
end
21+
```
22+
23+
*Eileen M. Uchitelle*
24+
125
* Regexp-escape table name for MS SQL
226
327
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.

activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,9 @@ def drop_table(table_name, **options)
536536
# column will have the same collation as the table.
537537
# * <tt>:comment</tt> -
538538
# Specifies the comment for the column. This option is ignored by some backends.
539+
# * <tt>:if_not_exists</tt> -
540+
# Specifies if the column already exists to not try to re-add it. This will avoid
541+
# duplicate column errors.
539542
#
540543
# Note: The precision is the total number of significant digits,
541544
# and the scale is the number of digits that can be stored following
@@ -587,7 +590,12 @@ def drop_table(table_name, **options)
587590
# # Defines a column with a database-specific type.
588591
# add_column(:shapes, :triangle, 'polygon')
589592
# # ALTER TABLE "shapes" ADD "triangle" polygon
593+
#
594+
# # Ignores the method call if the column exists
595+
# add_column(:shapes, :triangle, 'polygon', if_not_exists: true)
590596
def add_column(table_name, column_name, type, **options)
597+
return if options[:if_not_exists] == true && column_exists?(table_name, column_name, type)
598+
591599
at = create_alter_table table_name
592600
at.add_column(column_name, type, **options)
593601
execute schema_creation.accept at
@@ -616,7 +624,15 @@ def remove_columns(table_name, *column_names, **options)
616624
# to provide these in a migration's +change+ method so it can be reverted.
617625
# In that case, +type+ and +options+ will be used by #add_column.
618626
# Indexes on the column are automatically removed.
627+
#
628+
# If the options provided include an +if_exists+ key, it will be used to check if the
629+
# column does not exist. This will silently ignore the migration rather than raising
630+
# if the column was already used.
631+
#
632+
# remove_column(:suppliers, :qualification, if_exists: true)
619633
def remove_column(table_name, column_name, type = nil, **options)
634+
return if options[:if_exists] == true && !column_exists?(table_name, column_name)
635+
620636
execute "ALTER TABLE #{quote_table_name(table_name)} #{remove_column_for_alter(table_name, column_name, type, **options)}"
621637
end
622638

activerecord/test/cases/migration_test.rb

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,180 @@ def test_create_table_with_force_true_does_not_drop_nonexisting_table
169169
Person.connection.drop_table :testings2, if_exists: true
170170
end
171171

172+
def test_remove_column_with_if_not_exists_not_set
173+
migration_a = Class.new(ActiveRecord::Migration::Current) {
174+
def version; 100 end
175+
def migrate(x)
176+
add_column "people", "last_name", :string
177+
end
178+
}.new
179+
180+
migration_b = Class.new(ActiveRecord::Migration::Current) {
181+
def version; 101 end
182+
def migrate(x)
183+
remove_column "people", "last_name"
184+
end
185+
}.new
186+
187+
migration_c = Class.new(ActiveRecord::Migration::Current) {
188+
def version; 102 end
189+
def migrate(x)
190+
remove_column "people", "last_name"
191+
end
192+
}.new
193+
194+
ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
195+
assert_column Person, :last_name, "migration_a should have added the last_name column on people"
196+
197+
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
198+
assert_no_column Person, :last_name, "migration_b should have dropped the last_name column on people"
199+
200+
migrator = ActiveRecord::Migrator.new(:up, [migration_c], @schema_migration, 102)
201+
202+
if current_adapter?(:SQLite3Adapter)
203+
assert_nothing_raised do
204+
migrator.migrate
205+
end
206+
else
207+
error = assert_raises do
208+
migrator.migrate
209+
end
210+
211+
if current_adapter?(:Mysql2Adapter)
212+
if ActiveRecord::Base.connection.mariadb?
213+
assert_match(/Can't DROP COLUMN `last_name`; check that it exists/, error.message)
214+
else
215+
assert_match(/check that column\/key exists/, error.message)
216+
end
217+
elsif
218+
assert_match(/column \"last_name\" of relation \"people\" does not exist/, error.message)
219+
end
220+
end
221+
ensure
222+
Person.reset_column_information
223+
end
224+
225+
def test_remove_column_with_if_exists_set
226+
migration_a = Class.new(ActiveRecord::Migration::Current) {
227+
def version; 100 end
228+
def migrate(x)
229+
add_column "people", "last_name", :string
230+
end
231+
}.new
232+
233+
migration_b = Class.new(ActiveRecord::Migration::Current) {
234+
def version; 101 end
235+
def migrate(x)
236+
remove_column "people", "last_name"
237+
end
238+
}.new
239+
240+
migration_c = Class.new(ActiveRecord::Migration::Current) {
241+
def version; 102 end
242+
def migrate(x)
243+
remove_column "people", "last_name", if_exists: true
244+
end
245+
}.new
246+
247+
ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
248+
assert_column Person, :last_name, "migration_a should have added the last_name column on people"
249+
250+
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
251+
assert_no_column Person, :last_name, "migration_b should have dropped the last_name column on people"
252+
253+
migrator = ActiveRecord::Migrator.new(:up, [migration_c], @schema_migration, 102)
254+
255+
assert_nothing_raised do
256+
migrator.migrate
257+
end
258+
ensure
259+
Person.reset_column_information
260+
end
261+
262+
def test_add_column_with_if_not_exists_not_set
263+
migration_a = Class.new(ActiveRecord::Migration::Current) {
264+
def version; 100 end
265+
def migrate(x)
266+
add_column "people", "last_name", :string
267+
end
268+
}.new
269+
270+
migration_b = Class.new(ActiveRecord::Migration::Current) {
271+
def version; 101 end
272+
def migrate(x)
273+
add_column "people", "last_name", :string
274+
end
275+
}.new
276+
277+
ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
278+
assert_column Person, :last_name, "migration_a should have created the last_name column on people"
279+
280+
assert_raises do
281+
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
282+
end
283+
ensure
284+
Person.reset_column_information
285+
if Person.column_names.include?("last_name")
286+
Person.connection.remove_column("people", "last_name")
287+
end
288+
end
289+
290+
def test_add_column_with_if_not_exists_set_to_true
291+
migration_a = Class.new(ActiveRecord::Migration::Current) {
292+
def version; 100 end
293+
def migrate(x)
294+
add_column "people", "last_name", :string
295+
end
296+
}.new
297+
298+
migration_b = Class.new(ActiveRecord::Migration::Current) {
299+
def version; 101 end
300+
def migrate(x)
301+
add_column "people", "last_name", :string, if_not_exists: true
302+
end
303+
}.new
304+
305+
ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
306+
assert_column Person, :last_name, "migration_a should have created the last_name column on people"
307+
308+
assert_nothing_raised do
309+
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
310+
end
311+
ensure
312+
Person.reset_column_information
313+
if Person.column_names.include?("last_name")
314+
Person.connection.remove_column("people", "last_name")
315+
end
316+
end
317+
318+
def test_add_column_with_if_not_exists_set_to_true_still_raises_if_type_is_different
319+
migration_a = Class.new(ActiveRecord::Migration::Current) {
320+
def version; 100 end
321+
def migrate(x)
322+
add_column "people", "last_name", :string
323+
end
324+
}.new
325+
326+
migration_b = Class.new(ActiveRecord::Migration::Current) {
327+
def version; 101 end
328+
def migrate(x)
329+
add_column "people", "last_name", :boolean, if_not_exists: true
330+
end
331+
}.new
332+
333+
ActiveRecord::Migrator.new(:up, [migration_a], @schema_migration, 100).migrate
334+
assert_column Person, :last_name, "migration_a should have created the last_name column on people"
335+
336+
assert_raises do
337+
ActiveRecord::Migrator.new(:up, [migration_b], @schema_migration, 101).migrate
338+
end
339+
ensure
340+
Person.reset_column_information
341+
if Person.column_names.include?("last_name")
342+
Person.connection.remove_column("people", "last_name")
343+
end
344+
end
345+
172346
def test_migration_instance_has_connection
173347
migration = Class.new(ActiveRecord::Migration::Current).new
174348
assert_equal ActiveRecord::Base.connection, migration.connection

0 commit comments

Comments
 (0)