Skip to content

Commit

Permalink
Add support for if_exists/if_not_exists on `remove_foreign_key/add_…
Browse files Browse the repository at this point in the history
…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

💇‍♀️
  • Loading branch information
robertomiranda committed Jun 27, 2021
1 parent 36aee3f commit 63c2efa
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 0 deletions.
25 changes: 25 additions & 0 deletions 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
Expand Down
Expand Up @@ -1039,6 +1039,10 @@ def foreign_keys(table_name)
#
# 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"
Expand Down Expand Up @@ -1066,10 +1070,14 @@ def foreign_keys(table_name)
# 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
Expand Down Expand Up @@ -1099,12 +1107,18 @@ def add_foreign_key(from_table, to_table, **options)
#
# 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

Expand Down
Expand Up @@ -60,6 +60,8 @@ def add_foreign_key(from_table, to_table, **options)
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)
Expand Down
61 changes: 61 additions & 0 deletions activerecord/test/cases/migration/foreign_key_test.rb
Expand Up @@ -575,6 +575,67 @@ def test_add_foreign_key_with_suffix
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
Expand Down

0 comments on commit 63c2efa

Please sign in to comment.