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:
parent
e1a09e6e80
commit
25aa5c4beb
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user