Adds support for deferrable foreign key constraints in PostgreSQL

By default, foreign key constraints in PostgreSQL are checked after each statement. This works for most use cases, but becomes a major limitation when creating related records before the parent record is inserted into the database.

One example of this is looking up / creating a person via one or more unique alias.

```ruby
Person.transaction do
  alias = Alias
    .create_with(user_id: SecureRandom.uuid)
    .create_or_find_by(name: "DHH")

  person = Person
    .create_with(name: "David Heinemeier Hansson")
    .create_or_find_by(id: alias.user_id)
end
```

Using the default behavior, the transaction would fail when executing the first `INSERT` statement.

This pull request adds support for deferrable foreign key constraints by adding a new option to the `add_foreign_key` statement in migrations:

```ruby
add_foreign_key :aliases, :person, deferrable: true
```

The `deferrable: true` leaves the default behavior, but allows manually deferring the checks using `SET CONSTRAINTS ALL DEFERRED` within a transaction. This will cause the foreign keys to be checked after the transaction.

It's also possible to adjust the default behavior from an immediate check (after the statement), to a deferred check (after the transaction).

```ruby
add_foreign_key :aliases, :person, deferrable: :deferred
```
This commit is contained in:
Benedikt Deicke 2021-05-31 17:42:09 +02:00
parent e1a09e6e80
commit 25aa5c4beb
No known key found for this signature in database
GPG Key ID: 87E593C51B0CA4C6
9 changed files with 139 additions and 2 deletions

@ -1,3 +1,37 @@
* Adds support for deferrable foreign key constraints in PostgreSQL.
By default, foreign key constraints in PostgreSQL are checked after each statement. This works for most use cases, but becomes a major limitation when creating related records before the parent record is inserted into the database. One example of this is looking up / creating a person via one or more unique alias.
```ruby
Person.transaction do
alias = Alias
.create_with(user_id: SecureRandom.uuid)
.create_or_find_by(name: "DHH")
person = Person
.create_with(name: "David Heinemeier Hansson")
.create_or_find_by(id: alias.user_id)
end
```
Using the default behavior, the transaction would fail when executing the first `INSERT` statement.
By passing the `:deferrable` option to the `add_foreign_key` statement in migrations, it's possible to defer this check.
```ruby
add_foreign_key :aliases, :person, deferrable: true
```
Passing `deferrable: true` doesn't change the default behavior, but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED` within a transaction. This will cause the foreign keys to be checked after the transaction.
It's also possible to adjust the default behavior from an immediate check (after the statement), to a deferred check (after the transaction):
```ruby
add_foreign_key :aliases, :person, deferrable: :deferred
```
*Benedikt Deicke*
* Add support for generated columns in PostgreSQL adapter
Generated columns are supported since version 12.0 of PostgreSQL. This adds

@ -106,6 +106,10 @@ def on_update
options[:on_update]
end
def deferrable
options[:deferrable]
end
def custom_primary_key?
options[:primary_key] != default_primary_key
end

@ -1075,6 +1075,8 @@ def foreign_keys(table_name)
# duplicate column errors.
# [<tt>:validate</tt>]
# (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+.
# [<tt>:deferrable</tt>]
# (PostgreSQL only) Specify whether or not the foreign key should be deferrable. Valid values are booleans or +:deferred+ or +:immediate+ to specify the default behavior. Defaults to +false+.
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)

@ -363,6 +363,11 @@ def supports_validate_constraints?
false
end
# Does this adapter support creating deferrable constraints?
def supports_deferrable_constraints?
false
end
# Does this adapter support creating check constraints?
def supports_check_constraints?
false

@ -10,7 +10,14 @@ def visit_AlterTable(o)
end
def visit_AddForeignKey(o)
super.dup.tap { |sql| sql << " NOT VALID" unless o.validate? }
super.dup.tap do |sql|
if o.deferrable
sql << " DEFERRABLE"
sql << " INITIALLY #{o.deferrable.to_s.upcase}" unless o.deferrable == true
end
sql << " NOT VALID" unless o.validate?
end
end
def visit_CheckConstraintDefinition(o)

@ -483,7 +483,7 @@ def rename_index(table_name, old_name, new_name)
def foreign_keys(table_name)
scope = quoted_scope(table_name)
fk_info = exec_query(<<~SQL, "SCHEMA")
SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid
SELECT t2.oid::regclass::text AS to_table, a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid, c.condeferrable AS deferrable, c.condeferred AS deferred
FROM pg_constraint c
JOIN pg_class t1 ON c.conrelid = t1.oid
JOIN pg_class t2 ON c.confrelid = t2.oid
@ -505,6 +505,8 @@ def foreign_keys(table_name)
options[:on_delete] = extract_foreign_key_action(row["on_delete"])
options[:on_update] = extract_foreign_key_action(row["on_update"])
options[:deferrable] = extract_foreign_key_deferrable(row["deferrable"], row["deferred"])
options[:validate] = row["valid"]
ForeignKeyDefinition.new(table_name, row["to_table"], options)
@ -712,6 +714,10 @@ def extract_foreign_key_action(specifier)
end
end
def extract_foreign_key_deferrable(deferrable, deferred)
deferrable && (deferred ? :deferred : true)
end
def add_column_for_alter(table_name, column_name, type, **options)
return super unless options.key?(:comment)
[super, Proc.new { change_column_comment(table_name, column_name, options[:comment]) }]

@ -208,6 +208,10 @@ def supports_validate_constraints?
true
end
def supports_deferrable_constraints?
true
end
def supports_views?
true
end

@ -259,6 +259,7 @@ def foreign_keys(table, stream)
parts << "on_update: #{foreign_key.on_update.inspect}" if foreign_key.on_update
parts << "on_delete: #{foreign_key.on_delete.inspect}" if foreign_key.on_delete
parts << "deferrable: #{foreign_key.deferrable.inspect}" if foreign_key.deferrable
" #{parts.join(', ')}"
end

@ -480,6 +480,80 @@ def test_add_invalid_foreign_key
end
end
if ActiveRecord::Base.connection.supports_deferrable_constraints?
def test_deferrable_foreign_key
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: true
foreign_keys = @connection.foreign_keys("astronauts")
assert_equal 1, foreign_keys.size
fk = foreign_keys.first
assert_equal true, fk.options[:deferrable]
end
def test_not_deferrable_foreign_key
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: false
foreign_keys = @connection.foreign_keys("astronauts")
assert_equal 1, foreign_keys.size
fk = foreign_keys.first
assert_equal false, fk.options[:deferrable]
end
def test_deferrable_initially_deferred_foreign_key
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :deferred
foreign_keys = @connection.foreign_keys("astronauts")
assert_equal 1, foreign_keys.size
fk = foreign_keys.first
assert_equal :deferred, fk.options[:deferrable]
end
def test_deferrable_initially_immediate_foreign_key
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :immediate
foreign_keys = @connection.foreign_keys("astronauts")
assert_equal 1, foreign_keys.size
fk = foreign_keys.first
assert_equal true, fk.options[:deferrable]
end
def test_schema_dumping_with_defferable
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: true
output = dump_table_schema "astronauts"
assert_match %r{\s+add_foreign_key "astronauts", "rockets", deferrable: true$}, output
end
def test_schema_dumping_with_disabled_defferable
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: false
output = dump_table_schema "astronauts"
assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output
end
def test_schema_dumping_with_defferable_initially_deferred
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :deferred
output = dump_table_schema "astronauts"
assert_match %r{\s+add_foreign_key "astronauts", "rockets", deferrable: :deferred$}, output
end
def test_schema_dumping_with_defferable_initially_immediate
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :immediate
output = dump_table_schema "astronauts"
assert_match %r{\s+add_foreign_key "astronauts", "rockets", deferrable: true$}, output
end
end
def test_schema_dumping
@connection.add_foreign_key :astronauts, :rockets
output = dump_table_schema "astronauts"