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