Skip to content

Commit f9fee1a

Browse files
committed
Add set_constraints helper for PostgreSQL
The docs already talk about how to set up deferrable constraints, but then rely on the user crafting custom SQL to actually use the feature. The helper makes it easier to handle juggling multiple specific constraints and quoting issues.
1 parent 121e0ad commit f9fee1a

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)