Skip to content

Commit 6d42731

Browse files
committed
Merge PR rails#41487
2 parents aa449a8 + 25aa5c4 commit 6d42731

File tree

9 files changed

+146
-2
lines changed

9 files changed

+146
-2
lines changed

activerecord/CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,43 @@
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,
4+
but becomes a major limitation when creating related records before the parent record is inserted into the database.
5+
One example of this is looking up / creating a person via one or more unique alias.
6+
7+
```ruby
8+
Person.transaction do
9+
alias = Alias
10+
.create_with(user_id: SecureRandom.uuid)
11+
.create_or_find_by(name: "DHH")
12+
13+
person = Person
14+
.create_with(name: "David Heinemeier Hansson")
15+
.create_or_find_by(id: alias.user_id)
16+
end
17+
```
18+
19+
Using the default behavior, the transaction would fail when executing the first `INSERT` statement.
20+
21+
By passing the `:deferrable` option to the `add_foreign_key` statement in migrations, it's possible to defer this
22+
check.
23+
24+
```ruby
25+
add_foreign_key :aliases, :person, deferrable: true
26+
```
27+
28+
Passing `deferrable: true` doesn't change the default behavior, but allows manually deferring the check using
29+
`SET CONSTRAINTS ALL DEFERRED` within a transaction. This will cause the foreign keys to be checked after the
30+
transaction.
31+
32+
It's also possible to adjust the default behavior from an immediate check (after the statement), to a deferred check
33+
(after the transaction):
34+
35+
```ruby
36+
add_foreign_key :aliases, :person, deferrable: :deferred
37+
```
38+
39+
*Benedikt Deicke*
40+
141
* Allow configuring Postgres password through the socket URL.
242
343
For example:

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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,9 @@ 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
1080+
# +:deferred+ or +:immediate+ to specify the default behavior. Defaults to +false+.
10781081
def add_foreign_key(from_table, to_table, **options)
10791082
return unless supports_foreign_keys?
10801083
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)
@@ -716,6 +718,10 @@ def extract_foreign_key_action(specifier)
716718
end
717719
end
718720

721+
def extract_foreign_key_deferrable(deferrable, deferred)
722+
deferrable && (deferred ? :deferred : true)
723+
end
724+
719725
def add_column_for_alter(table_name, column_name, type, **options)
720726
return super unless options.key?(:comment)
721727
[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
@@ -210,6 +210,10 @@ def supports_validate_constraints?
210210
true
211211
end
212212

213+
def supports_deferrable_constraints?
214+
true
215+
end
216+
213217
def supports_views?
214218
true
215219
end

activerecord/lib/active_record/schema_dumper.rb

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

266266
parts << "on_update: #{foreign_key.on_update.inspect}" if foreign_key.on_update
267267
parts << "on_delete: #{foreign_key.on_delete.inspect}" if foreign_key.on_delete
268+
parts << "deferrable: #{foreign_key.deferrable.inspect}" if foreign_key.deferrable
268269

269270
" #{parts.join(', ')}"
270271
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)