This commit is contained in:
Rafael Mendonça França 2021-09-22 18:43:16 -04:00
commit 6d42731ded
No known key found for this signature in database
GPG Key ID: FC23B6D0F1EEE948
9 changed files with 146 additions and 2 deletions

@ -1,3 +1,43 @@
* 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*
* Allow configuring Postgres password through the socket URL.
For example:

@ -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,9 @@ 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)
@ -716,6 +718,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]) }]

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

@ -265,6 +265,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"