Skip to content

Commit 6c63b9b

Browse files
authored
Merge pull request rails#49187 from ccutrer/defer-constraints
Add `set_constraints` helper for PostgreSQL
2 parents 680f503 + f9fee1a commit 6c63b9b

File tree

6 files changed

+98
-6
lines changed

6 files changed

+98
-6
lines changed

activerecord/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
* Add `set_constraints` helper for PostgreSQL
2+
3+
```ruby
4+
Post.create!(user_id: -1) # => ActiveRecord::InvalidForeignKey
5+
6+
Post.transaction do
7+
Post.connection.set_constraints(:deferred)
8+
p = Post.create!(user_id: -1)
9+
u = User.create!
10+
p.user = u
11+
p.save!
12+
end
13+
```
14+
15+
*Cody Cutrer*
16+
117
* Include `ActiveModel::API` in `ActiveRecord::Base`
218

319
*Sean Doyle*

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,27 @@ def build_explain_clause(options = [])
132132
"EXPLAIN (#{options.join(", ").upcase})"
133133
end
134134

135+
# Set when constraints will be checked for the current transaction.
136+
#
137+
# Not passing any specific constraint names will set the value for all deferrable constraints.
138+
#
139+
# [<tt>deferred</tt>]
140+
# Valid values are +:deferred+ or +:immediate+.
141+
#
142+
# See https://www.postgresql.org/docs/current/sql-set-constraints.html
143+
def set_constraints(deferred, *constraints)
144+
unless %i[deferred immediate].include?(deferred)
145+
raise ArgumentError, "deferred must be :deferred or :immediate"
146+
end
147+
148+
constraints = if constraints.empty?
149+
"ALL"
150+
else
151+
constraints.map { |c| quote_table_name(c) }.join(", ")
152+
end
153+
execute("SET CONSTRAINTS #{constraints} #{deferred.to_s.upcase}")
154+
end
155+
135156
private
136157
IDLE_TRANSACTION_STATUSES = [PG::PQTRANS_IDLE, PG::PQTRANS_INTRANS, PG::PQTRANS_INERROR]
137158
private_constant :IDLE_TRANSACTION_STATUSES
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/author"
5+
6+
class PostgresqlDeferredConstraintsTest < ActiveRecord::PostgreSQLTestCase
7+
def setup
8+
@connection = ActiveRecord::Base.connection
9+
@fk = @connection.foreign_keys("authors").first.name
10+
@other_fk = @connection.foreign_keys("lessons_students").first.name
11+
end
12+
13+
def test_defer_constraints
14+
assert_raises ActiveRecord::InvalidForeignKey do
15+
@connection.set_constraints(:deferred)
16+
assert_nothing_raised do
17+
Author.create!(author_address_id: -1, name: "John Doe")
18+
end
19+
@connection.set_constraints(:immediate)
20+
end
21+
end
22+
23+
def test_defer_constraints_with_specific_fk
24+
assert_raises ActiveRecord::InvalidForeignKey do
25+
@connection.set_constraints(:deferred, @fk)
26+
assert_nothing_raised do
27+
Author.create!(author_address_id: -1, name: "John Doe")
28+
end
29+
@connection.set_constraints(:immediate, @fk)
30+
end
31+
end
32+
33+
def test_defer_constraints_with_multiple_fks
34+
assert_raises ActiveRecord::InvalidForeignKey do
35+
@connection.set_constraints(:deferred, @other_fk, @fk)
36+
assert_nothing_raised do
37+
Author.create!(author_address_id: -1, name: "John Doe")
38+
end
39+
@connection.set_constraints(:immediate, @other_fk, @fk)
40+
end
41+
end
42+
43+
def test_defer_constraints_only_defers_single_fk
44+
@connection.set_constraints(:deferred, @other_fk)
45+
assert_raises ActiveRecord::InvalidForeignKey do
46+
Author.create!(author_address_id: -1, name: "John Doe")
47+
end
48+
end
49+
50+
def test_set_constraints_requires_valid_value
51+
assert_raises ArgumentError do
52+
@connection.set_constraints(:invalid)
53+
end
54+
end
55+
end

activerecord/test/cases/migration/exclusion_constraint_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def test_added_deferrable_initially_immediate_exclusion_constraint
162162

163163
assert_nothing_raised do
164164
Invoice.transaction(requires_new: true) do
165-
Invoice.connection.exec_query("SET CONSTRAINTS invoices_date_overlap DEFERRED")
165+
Invoice.connection.set_constraints(:deferred, "invoices_date_overlap")
166166
Invoice.create!(start_date: "2020-12-31", end_date: "2021-01-01")
167167
invoice.update!(end_date: "2020-12-31")
168168

activerecord/test/schema/schema.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@
9797
create_table :author_addresses, force: true do |t|
9898
end
9999

100-
add_foreign_key :authors, :author_addresses
100+
add_foreign_key :authors, :author_addresses, deferrable: :immediate
101101

102102
create_table :author_favorites, force: true do |t|
103103
t.column :author_id, :integer
@@ -706,7 +706,7 @@
706706
t.integer :college_id
707707
end
708708

709-
add_foreign_key :lessons_students, :students, on_delete: :cascade
709+
add_foreign_key :lessons_students, :students, on_delete: :cascade, deferrable: :immediate
710710

711711
create_table :lint_models, force: true
712712

guides/source/active_record_postgresql.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,11 @@ ActiveRecord::Base.connection.transaction do
633633
end
634634
```
635635

636-
When the `:deferrable` option is set to `:immediate`, let the foreign keys keep the default behavior of checking the constraint immediately, but allow manually deferring the checks using `SET CONSTRAINTS ALL DEFERRED` within a transaction. This will cause the foreign keys to be checked when the transaction is committed:
636+
When the `:deferrable` option is set to `:immediate`, let the foreign keys keep the default behavior of checking the constraint immediately, but allow manually deferring the checks using `set_constraints` within a transaction. This will cause the foreign keys to be checked when the transaction is committed:
637637

638638
```ruby
639-
ActiveRecord::Base.transaction do
640-
ActiveRecord::Base.connection.execute("SET CONSTRAINTS ALL DEFERRED")
639+
ActiveRecord::Base.connection.transaction do
640+
ActiveRecord::Base.connection.set_constraints(:deferred)
641641
person = Person.create(alias_id: SecureRandom.uuid, name: "John Doe")
642642
Alias.create(id: person.alias_id, person_id: person.id, name: "jaydee")
643643
end

0 commit comments

Comments
 (0)