Skip to content

Commit d849ee0

Browse files
committed
Add support for unique constraints (PostgreSQL-only).
```ruby add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_section_position" remove_unique_key :sections, name: "unique_section_position" ``` See PostgreSQL's [Unique Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS) documentation for more on unique constraints. By default, unique constraints in PostgreSQL are checked after each statement. This works for most use cases, but becomes a major limitation when replacing records with unique column by using multiple statements. An example of swapping unique columns between records. ```ruby old_item = Item.create!(position: 1) new_item = Item.create!(position: 2) Item.transaction do old_item.update!(position: 2) new_item.update!(position: 1) end ``` Using the default behavior, the transaction would fail when executing the first `UPDATE` statement. By passing the `:deferrable` option to the `add_unique_key` statement in migrations, it's possible to defer this check. ```ruby add_unique_key :items, [:position], deferrable: :immediate ``` Passing `deferrable: :immediate` does not change the behaviour of the previous example, but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED` within a transaction. This will cause the unique constraints 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_unique_key :items, [:position], deferrable: :deferred ``` PostgreSQL allows users to create a unique constraints on top of the unique index that cannot be deferred. In this case, even if users creates deferrable unique constraint, the existing unique index does not allow users to violate uniqueness within the transaction. If you want to change existing unique index to deferrable, you need execute `remove_index` before creating deferrable unique constraints. *Hiroyuki Ishii*
1 parent 2eed4dc commit d849ee0

15 files changed

+534
-3
lines changed

activerecord/CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,58 @@
1+
* Add support for unique constraints (PostgreSQL-only).
2+
3+
```ruby
4+
add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_section_position"
5+
remove_unique_key :sections, name: "unique_section_position"
6+
```
7+
8+
See PostgreSQL's [Unique Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS) documentation for more on unique constraints.
9+
10+
By default, unique constraints in PostgreSQL are checked after each statement.
11+
This works for most use cases, but becomes a major limitation when replacing
12+
records with unique column by using multiple statements.
13+
14+
An example of swapping unique columns between records.
15+
16+
```ruby
17+
# position is unique column
18+
old_item = Item.create!(position: 1)
19+
new_item = Item.create!(position: 2)
20+
21+
Item.transaction do
22+
old_item.update!(position: 2)
23+
new_item.update!(position: 1)
24+
end
25+
```
26+
27+
Using the default behavior, the transaction would fail when executing the
28+
first `UPDATE` statement.
29+
30+
By passing the `:deferrable` option to the `add_unique_key` statement in
31+
migrations, it's possible to defer this check.
32+
33+
```ruby
34+
add_unique_key :items, [:position], deferrable: :immediate
35+
```
36+
37+
Passing `deferrable: :immediate` does not change the behaviour of the previous example,
38+
but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED` within a transaction.
39+
This will cause the unique constraints to be checked after the transaction.
40+
41+
It's also possible to adjust the default behavior from an immediate
42+
check (after the statement), to a deferred check (after the transaction):
43+
44+
```ruby
45+
add_unique_key :items, [:position], deferrable: :deferred
46+
```
47+
48+
PostgreSQL allows users to create a unique constraints on top of the unique
49+
index that cannot be deferred. In this case, even if users creates deferrable
50+
unique constraint, the existing unique index does not allow users to violate uniqueness
51+
within the transaction. If you want to change existing unique index to deferrable,
52+
you need execute `remove_index` before creating deferrable unique constraints.
53+
54+
*Hiroyuki Ishii*
55+
156
* Remove deprecated `Tasks::DatabaseTasks.schema_file_type`.
257
358
*Rafael Mendonça França*

activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ def accept(o)
1616
delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql,
1717
:options_include_default?, :supports_indexes_in_create?, :use_foreign_keys?,
1818
:quoted_columns_for_index, :supports_partial_index?, :supports_check_constraints?,
19-
:supports_index_include?, :supports_exclusion_constraints?, to: :@conn, private: true
19+
:supports_index_include?, :supports_exclusion_constraints?, :supports_unique_keys?,
20+
to: :@conn, private: true
2021

2122
private
2223
def visit_AlterTable(o)
@@ -63,6 +64,10 @@ def visit_TableDefinition(o)
6364
statements.concat(o.exclusion_constraints.map { |exc| accept exc })
6465
end
6566

67+
if supports_unique_keys?
68+
statements.concat(o.unique_keys.map { |exc| accept exc })
69+
end
70+
6671
create_sql << "(#{statements.join(', ')})" if statements.present?
6772
add_table_options!(create_sql, o)
6873
create_sql << " AS #{to_sql(o.as)}" if o.as

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,11 @@ def supports_exclusion_constraints?
500500
false
501501
end
502502

503+
# Does this adapter support creating unique constraints?
504+
def supports_unique_keys?
505+
false
506+
end
507+
503508
# Does this adapter support views?
504509
def supports_views?
505510
false

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ def visit_AlterTable(o)
1212
sql << o.constraint_validations.map { |fk| visit_ValidateConstraint fk }.join(" ")
1313
sql << o.exclusion_constraint_adds.map { |con| visit_AddExclusionConstraint con }.join(" ")
1414
sql << o.exclusion_constraint_drops.map { |con| visit_DropExclusionConstraint con }.join(" ")
15+
sql << o.unique_key_adds.map { |con| visit_AddUniqueKey con }.join(" ")
16+
sql << o.unique_key_drops.map { |con| visit_DropUniqueKey con }.join(" ")
1517
end
1618

1719
def visit_AddForeignKey(o)
@@ -44,6 +46,21 @@ def visit_ExclusionConstraintDefinition(o)
4446
sql.join(" ")
4547
end
4648

49+
def visit_UniqueKeyDefinition(o)
50+
column_name = Array(o.columns).map { |column| quote_column_name(column) }.join(", ")
51+
52+
sql = ["CONSTRAINT"]
53+
sql << quote_column_name(o.name)
54+
sql << "UNIQUE"
55+
sql << "(#{column_name})"
56+
57+
if o.deferrable
58+
sql << "DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}"
59+
end
60+
61+
sql.join(" ")
62+
end
63+
4764
def visit_AddExclusionConstraint(o)
4865
"ADD #{accept(o)}"
4966
end
@@ -52,6 +69,14 @@ def visit_DropExclusionConstraint(name)
5269
"DROP CONSTRAINT #{quote_column_name(name)}"
5370
end
5471

72+
def visit_AddUniqueKey(o)
73+
"ADD #{accept(o)}"
74+
end
75+
76+
def visit_DropUniqueKey(name)
77+
"DROP CONSTRAINT #{quote_column_name(name)}"
78+
end
79+
5580
def visit_ChangeColumnDefinition(o)
5681
column = o.column
5782
column.sql_type = type_to_sql(column.type, **column.options)

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

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,26 +207,50 @@ def export_name_on_schema_dump?
207207
end
208208
end
209209

210+
UniqueKeyDefinition = Struct.new(:table_name, :columns, :options) do
211+
def name
212+
options[:name]
213+
end
214+
215+
def deferrable
216+
options[:deferrable]
217+
end
218+
219+
def export_name_on_schema_dump?
220+
!ActiveRecord::SchemaDumper.unique_ignore_pattern.match?(name) if name
221+
end
222+
end
223+
210224
class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
211225
include ColumnMethods
212226

213-
attr_reader :exclusion_constraints, :unlogged
227+
attr_reader :exclusion_constraints, :unique_keys, :unlogged
214228

215229
def initialize(*, **)
216230
super
217231
@exclusion_constraints = []
232+
@unique_keys = []
218233
@unlogged = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables
219234
end
220235

221236
def exclusion_constraint(expression, **options)
222237
exclusion_constraints << new_exclusion_constraint_definition(expression, options)
223238
end
224239

240+
def unique_key(column_name, **options)
241+
unique_keys << new_unique_key_definition(column_name, options)
242+
end
243+
225244
def new_exclusion_constraint_definition(expression, options) # :nodoc:
226245
options = @conn.exclusion_constraint_options(name, expression, options)
227246
ExclusionConstraintDefinition.new(name, expression, options)
228247
end
229248

249+
def new_unique_key_definition(column_name, options) # :nodoc:
250+
options = @conn.unique_key_options(name, column_name, options)
251+
UniqueKeyDefinition.new(name, column_name, options)
252+
end
253+
230254
def new_column_definition(name, type, **options) # :nodoc:
231255
case type
232256
when :virtual
@@ -274,16 +298,36 @@ def exclusion_constraint(*args)
274298
def remove_exclusion_constraint(*args)
275299
@base.remove_exclusion_constraint(name, *args)
276300
end
301+
302+
# Adds an unique constraint.
303+
#
304+
# t.unique_key(:position, name: 'unique_position', deferrable: :deferred)
305+
#
306+
# See {connection.add_unique_key}[rdoc-ref:SchemaStatements#add_unique_key]
307+
def unique_key(*args)
308+
@base.add_unique_key(name, *args)
309+
end
310+
311+
# Removes the given unique constraint from the table.
312+
#
313+
# t.remove_unique_key(name: "unique_position")
314+
#
315+
# See {connection.remove_unique_key}[rdoc-ref:SchemaStatements#remove_unique_key]
316+
def remove_unique_key(*args)
317+
@base.remove_unique_key(name, *args)
318+
end
277319
end
278320

279321
class AlterTable < ActiveRecord::ConnectionAdapters::AlterTable
280-
attr_reader :constraint_validations, :exclusion_constraint_adds, :exclusion_constraint_drops
322+
attr_reader :constraint_validations, :exclusion_constraint_adds, :exclusion_constraint_drops, :unique_key_adds, :unique_key_drops
281323

282324
def initialize(td)
283325
super
284326
@constraint_validations = []
285327
@exclusion_constraint_adds = []
286328
@exclusion_constraint_drops = []
329+
@unique_key_adds = []
330+
@unique_key_drops = []
287331
end
288332

289333
def validate_constraint(name)
@@ -297,6 +341,14 @@ def add_exclusion_constraint(expression, options)
297341
def drop_exclusion_constraint(constraint_name)
298342
@exclusion_constraint_drops << constraint_name
299343
end
344+
345+
def add_unique_key(column_name, options)
346+
@unique_key_adds << @td.new_unique_key_definition(column_name, options)
347+
end
348+
349+
def drop_unique_key(unique_key_name)
350+
@unique_key_drops << unique_key_name
351+
end
300352
end
301353
end
302354
end

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,26 @@ def exclusion_constraints_in_create(table, stream)
4949
end
5050
end
5151

52+
def unique_keys_in_create(table, stream)
53+
if (unique_keys = @connection.unique_keys(table)).any?
54+
add_unique_key_statements = unique_keys.map do |unique_key|
55+
parts = [
56+
"t.unique_key #{unique_key.columns.inspect}"
57+
]
58+
59+
parts << "deferrable: #{unique_key.deferrable.inspect}" if unique_key.deferrable
60+
61+
if unique_key.export_name_on_schema_dump?
62+
parts << "name: #{unique_key.name.inspect}"
63+
end
64+
65+
" #{parts.join(', ')}"
66+
end
67+
68+
stream.puts add_unique_key_statements.sort.join("\n")
69+
end
70+
end
71+
5272
def prepare_column_options(column)
5373
spec = super
5474
spec[:array] = "true" if column.array?

0 commit comments

Comments
 (0)