diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index 217763672e..1656b3ecce 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -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
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
index 86ef26cfd8..f6a0085206 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
@@ -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 ON DELETE. Valid values are +:nullify+, +:cascade+ and +:restrict+
# [:on_update]
# Action that happens ON UPDATE. Valid values are +:nullify+, +:cascade+ and +:restrict+
+ # [:if_not_exists]
+ # Specifies if the foreign key already exists to not try to re-add it. This will avoid
+ # duplicate column errors.
# [:validate]
# (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
# [:to_table]
# 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
diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
index 1a6e0dbe53..35c87fd6da 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
@@ -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)
diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb
index 3bdb25650b..d6f4417e43 100644
--- a/activerecord/test/cases/migration/foreign_key_test.rb
+++ b/activerecord/test/cases/migration/foreign_key_test.rb
@@ -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