Skip to content

Commit 3025eaa

Browse files
authored
Merge pull request rails#46192 from alpaca-tc/support_unique_constraints
Add support for unique constraints (PostgreSQL-only).
2 parents 1137851 + d849ee0 commit 3025eaa

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)