Add support for `if_exists/if_not_exists` on `remove_foreign_key/add_foreign_key`

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

Add test cases

💇‍♀️
This commit is contained in:
Roberto Miranda 2021-06-27 11:23:12 +01:00
parent 36aee3f544
commit 63c2efaa16
4 changed files with 102 additions and 0 deletions

View File

@ -1,3 +1,28 @@
* Adds support for `if_not_exists` to `add_foreign_key` and `if_exists` to `remove_foreign_key`.
Applications can set their migrations to ignore exceptions raised when adding a foreign key
that already exists or when removing a foreign key that does not exist.
Example Usage:
```ruby
class AddAuthorsForeignKeyToArticles < ActiveRecord::Migration[7.0]
def change
add_foreign_key :articles, :authors, if_not_exists: true
end
end
```
```ruby
class RemoveAuthorsForeignKeyFromArticles < ActiveRecord::Migration[7.0]
def change
remove_foreign_key :articles, :authors, if_exists: true
end
end
```
*Roberto Miranda*
* Prevent polluting ENV during postgresql structure dump/load
Some configuration parameters were provided to pg_dump / psql via

View File

@ -1039,6 +1039,10 @@ module ActiveRecord
#
# ALTER TABLE "articles" ADD CONSTRAINT fk_rails_e74ce85cbc FOREIGN KEY ("author_id") REFERENCES "authors" ("id")
#
# ====== Creating a foreign key, ignoring method call if the foreign key exists
#
# add_foreign_key(:articles, :authors, if_not_exists: true)
#
# ====== Creating a foreign key on a specific column
#
# add_foreign_key :articles, :users, column: :author_id, primary_key: "lng_id"
@ -1066,10 +1070,14 @@ module ActiveRecord
# Action that happens <tt>ON DELETE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+
# [<tt>:on_update</tt>]
# Action that happens <tt>ON UPDATE</tt>. Valid values are +:nullify+, +:cascade+ and +:restrict+
# [<tt>:if_not_exists</tt>]
# Specifies if the foreign key already exists to not try to re-add it. This will avoid
# duplicate column errors.
# [<tt>:validate</tt>]
# (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+.
def add_foreign_key(from_table, to_table, **options)
return unless supports_foreign_keys?
return if options[:if_not_exists] == true && foreign_key_exists?(from_table, to_table)
options = foreign_key_options(from_table, to_table, options)
at = create_alter_table from_table
@ -1099,12 +1107,18 @@ module ActiveRecord
#
# remove_foreign_key :accounts, name: :special_fk_name
#
# Checks if the foreign key exists before trying to remove it. Will silently ignore indexes that
# don't exist.
#
# remove_foreign_key :accounts, :branches, if_exists: true
#
# The +options+ hash accepts the same keys as SchemaStatements#add_foreign_key
# with an addition of
# [<tt>:to_table</tt>]
# The name of the table that contains the referenced primary key.
def remove_foreign_key(from_table, to_table = nil, **options)
return unless supports_foreign_keys?
return if options[:if_exists] == true && !foreign_key_exists?(from_table, to_table)
fk_name_to_delete = foreign_key_for!(from_table, to_table: to_table, **options).name

View File

@ -60,6 +60,8 @@ module ActiveRecord
end
def remove_foreign_key(from_table, to_table = nil, **options)
return if options[:if_exists] == true && !foreign_key_exists?(from_table, to_table)
to_table ||= options[:to_table]
options = options.except(:name, :to_table, :validate)
foreign_keys = foreign_keys(from_table)

View File

@ -575,6 +575,67 @@ if ActiveRecord::Base.connection.supports_foreign_keys?
silence_stream($stdout) { migration.migrate(:down) }
ActiveRecord::Base.table_name_suffix = nil
end
def test_remove_foreign_key_with_if_exists_not_set
@connection.add_foreign_key :astronauts, :rockets
assert_equal 1, @connection.foreign_keys("astronauts").size
@connection.remove_foreign_key :astronauts, :rockets
assert_equal [], @connection.foreign_keys("astronauts")
error = assert_raises do
@connection.remove_foreign_key :astronauts, :rockets
end
assert_equal("Table 'astronauts' has no foreign key for rockets", error.message)
end
def test_remove_foreign_key_with_if_exists_set
@connection.add_foreign_key :astronauts, :rockets
assert_equal 1, @connection.foreign_keys("astronauts").size
@connection.remove_foreign_key :astronauts, :rockets
assert_equal [], @connection.foreign_keys("astronauts")
assert_nothing_raised do
@connection.remove_foreign_key :astronauts, :rockets, if_exists: true
end
end
def test_add_foreign_key_with_if_not_exists_not_set
@connection.add_foreign_key :astronauts, :rockets
assert_equal 1, @connection.foreign_keys("astronauts").size
if current_adapter?(:SQLite3Adapter)
assert_nothing_raised do
@connection.add_foreign_key :astronauts, :rockets
end
else
error = assert_raises do
@connection.add_foreign_key :astronauts, :rockets
end
if current_adapter?(:Mysql2Adapter)
if ActiveRecord::Base.connection.mariadb?
assert_match(/Duplicate key on write or update/, error.message)
else
assert_match(/Duplicate foreign key constraint name/, error.message)
end
else
assert_match(/PG::DuplicateObject: ERROR:.*for relation "astronauts" already exists/, error.message)
end
end
end
def test_add_foreign_key_with_if_not_exists_set
@connection.add_foreign_key :astronauts, :rockets
assert_equal 1, @connection.foreign_keys("astronauts").size
assert_nothing_raised do
@connection.add_foreign_key :astronauts, :rockets, if_not_exists: true
end
end
end
end
end