From 25aa5c4bebf977504bd74e0d121b4964fd464cc8 Mon Sep 17 00:00:00 2001 From: Benedikt Deicke Date: Mon, 31 May 2021 17:42:09 +0200 Subject: [PATCH] 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 ``` --- activerecord/CHANGELOG.md | 34 +++++++++ .../abstract/schema_definitions.rb | 4 + .../abstract/schema_statements.rb | 2 + .../connection_adapters/abstract_adapter.rb | 5 ++ .../postgresql/schema_creation.rb | 9 ++- .../postgresql/schema_statements.rb | 8 +- .../connection_adapters/postgresql_adapter.rb | 4 + .../lib/active_record/schema_dumper.rb | 1 + .../test/cases/migration/foreign_key_test.rb | 74 +++++++++++++++++++ 9 files changed, 139 insertions(+), 2 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 0bf058a1b4..3c75b7654f 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -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 diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 3403222aca..c0f01bf5a6 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -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 diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index be3b319e24..65b68ed66e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -1075,6 +1075,8 @@ def foreign_keys(table_name) # duplicate column errors. # [:validate] # (PostgreSQL only) Specify whether or not the constraint should be validated. Defaults to +true+. + # [:deferrable] + # (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) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 84052d4ecd..5f7d3ff638 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -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 diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb index 4601115687..835348db03 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb @@ -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) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb index 757428ed96..cf1a5ec2d7 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -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]) }] diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 354d2eeb97..5506b8ad0d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -208,6 +208,10 @@ def supports_validate_constraints? true end + def supports_deferrable_constraints? + true + end + def supports_views? true end diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index 14883af083..7fb1374e2a 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -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 diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index d6f4417e43..f659e9f953 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -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"