Skip to content

Commit 25aa5c4

Browse files
committed
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 ```
1 parent e1a09e6 commit 25aa5c4

File tree

9 files changed

+139
-2
lines changed

9 files changed

+139
-2
lines changed

activerecord/CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
1+
* Adds support for deferrable foreign key constraints in PostgreSQL.
2+
3+
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.
4+
5+
```ruby
6+
Person.transaction do
7+
alias = Alias
8+
.create_with(user_id: SecureRandom.uuid)
9+
.create_or_find_by(name: "DHH")
10+
11+
person = Person
12+
.create_with(name: "David Heinemeier Hansson")
13+
.create_or_find_by(id: alias.user_id)
14+
end
15+
```
16+
17+
Using the default behavior, the transaction would fail when executing the first `INSERT` statement.
18+
19+
By passing the `:deferrable` option to the `add_foreign_key` statement in migrations, it's possible to defer this check.
20+
21+
```ruby
22+
add_foreign_key :aliases, :person, deferrable: true
23+
```
24+
25+
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.
26+
27+
It's also possible to adjust the default behavior from an immediate check (after the statement), to a deferred check (after the transaction):
28+
29+
```ruby
30+
add_foreign_key :aliases, :person, deferrable: :deferred
31+
```
32+
33+
*Benedikt Deicke*
34+
135
* Add support for generated columns in PostgreSQL adapter
236
337
Generated columns are supported since version 12.0 of PostgreSQL. This adds

activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ def on_update
106106
options[:on_update]
107107
end
108108

109+
def deferrable
110+
options[:deferrable]
111+
end
112+
109113
def custom_primary_key?
110114
options[:primary_key] != default_primary_key
111115
end

activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,8 @@ def foreign_keys(table_name)
10751075
# duplicate column errors.
10761076
# [<tt>:validate</tt>]
10771077
# (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+.
1078+
# [<tt>:deferrable</tt>]
1079+
# (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+.
10781080
def add_foreign_key(from_table, to_table, **options)
10791081
return unless supports_foreign_keys?
10801082
return if options[:if_not_exists] == true && foreign_key_exists?(from_table, to_table)

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,11 @@ def supports_validate_constraints?
363363
false
364364
end
365365

366+
# Does this adapter support creating deferrable constraints?
367+
def supports_deferrable_constraints?
368+
false
369+
end
370+
366371
# Does this adapter support creating check constraints?
367372
def supports_check_constraints?
368373
false

activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ def visit_AlterTable(o)
1010
end
1111

1212
def visit_AddForeignKey(o)
13-
super.dup.tap { |sql| sql << " NOT VALID" unless o.validate? }
13+
super.dup.tap do |sql|
14+
if o.deferrable
15+
sql << " DEFERRABLE"
16+
sql << " INITIALLY #{o.deferrable.to_s.upcase}" unless o.deferrable == true
17+
end
18+
19+
sql << " NOT VALID" unless o.validate?
20+
end
1421
end
1522

1623
def visit_CheckConstraintDefinition(o)

activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ def rename_index(table_name, old_name, new_name)
483483
def foreign_keys(table_name)
484484
scope = quoted_scope(table_name)
485485
fk_info = exec_query(<<~SQL, "SCHEMA")
486-
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
486+
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
487487
FROM pg_constraint c
488488
JOIN pg_class t1 ON c.conrelid = t1.oid
489489
JOIN pg_class t2 ON c.confrelid = t2.oid
@@ -505,6 +505,8 @@ def foreign_keys(table_name)
505505

506506
options[:on_delete] = extract_foreign_key_action(row["on_delete"])
507507
options[:on_update] = extract_foreign_key_action(row["on_update"])
508+
options[:deferrable] = extract_foreign_key_deferrable(row["deferrable"], row["deferred"])
509+
508510
options[:validate] = row["valid"]
509511

510512
ForeignKeyDefinition.new(table_name, row["to_table"], options)
@@ -712,6 +714,10 @@ def extract_foreign_key_action(specifier)
712714
end
713715
end
714716

717+
def extract_foreign_key_deferrable(deferrable, deferred)
718+
deferrable && (deferred ? :deferred : true)
719+
end
720+
715721
def add_column_for_alter(table_name, column_name, type, **options)
716722
return super unless options.key?(:comment)
717723
[super, Proc.new { change_column_comment(table_name, column_name, options[:comment]) }]

activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ def supports_validate_constraints?
208208
true
209209
end
210210

211+
def supports_deferrable_constraints?
212+
true
213+
end
214+
211215
def supports_views?
212216
true
213217
end

activerecord/lib/active_record/schema_dumper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ def foreign_keys(table, stream)
259259

260260
parts << "on_update: #{foreign_key.on_update.inspect}" if foreign_key.on_update
261261
parts << "on_delete: #{foreign_key.on_delete.inspect}" if foreign_key.on_delete
262+
parts << "deferrable: #{foreign_key.deferrable.inspect}" if foreign_key.deferrable
262263

263264
" #{parts.join(', ')}"
264265
end

activerecord/test/cases/migration/foreign_key_test.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,80 @@ def test_add_invalid_foreign_key
480480
end
481481
end
482482

483+
if ActiveRecord::Base.connection.supports_deferrable_constraints?
484+
def test_deferrable_foreign_key
485+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: true
486+
487+
foreign_keys = @connection.foreign_keys("astronauts")
488+
assert_equal 1, foreign_keys.size
489+
490+
fk = foreign_keys.first
491+
assert_equal true, fk.options[:deferrable]
492+
end
493+
494+
def test_not_deferrable_foreign_key
495+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: false
496+
497+
foreign_keys = @connection.foreign_keys("astronauts")
498+
assert_equal 1, foreign_keys.size
499+
500+
fk = foreign_keys.first
501+
assert_equal false, fk.options[:deferrable]
502+
end
503+
504+
def test_deferrable_initially_deferred_foreign_key
505+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :deferred
506+
507+
foreign_keys = @connection.foreign_keys("astronauts")
508+
assert_equal 1, foreign_keys.size
509+
510+
fk = foreign_keys.first
511+
assert_equal :deferred, fk.options[:deferrable]
512+
end
513+
514+
def test_deferrable_initially_immediate_foreign_key
515+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :immediate
516+
517+
foreign_keys = @connection.foreign_keys("astronauts")
518+
assert_equal 1, foreign_keys.size
519+
520+
fk = foreign_keys.first
521+
assert_equal true, fk.options[:deferrable]
522+
end
523+
524+
def test_schema_dumping_with_defferable
525+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: true
526+
527+
output = dump_table_schema "astronauts"
528+
529+
assert_match %r{\s+add_foreign_key "astronauts", "rockets", deferrable: true$}, output
530+
end
531+
532+
def test_schema_dumping_with_disabled_defferable
533+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: false
534+
535+
output = dump_table_schema "astronauts"
536+
537+
assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output
538+
end
539+
540+
def test_schema_dumping_with_defferable_initially_deferred
541+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :deferred
542+
543+
output = dump_table_schema "astronauts"
544+
545+
assert_match %r{\s+add_foreign_key "astronauts", "rockets", deferrable: :deferred$}, output
546+
end
547+
548+
def test_schema_dumping_with_defferable_initially_immediate
549+
@connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", deferrable: :immediate
550+
551+
output = dump_table_schema "astronauts"
552+
553+
assert_match %r{\s+add_foreign_key "astronauts", "rockets", deferrable: true$}, output
554+
end
555+
end
556+
483557
def test_schema_dumping
484558
@connection.add_foreign_key :astronauts, :rockets
485559
output = dump_table_schema "astronauts"

0 commit comments

Comments
 (0)