Skip to content

Commit e642926

Browse files
authored
Merge pull request rails#53456 from kamipo/unique_constraint_with_nulls_not_distinct
NULLS NOT DISTINCT works with UNIQUE CONSTRAINT as well as UNIQUE INDEX
2 parents 7af392f + 38489e4 commit e642926

File tree

8 files changed

+36
-4
lines changed

8 files changed

+36
-4
lines changed

activerecord/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* NULLS NOT DISTINCT works with UNIQUE CONSTRAINT as well as UNIQUE INDEX.
2+
3+
*Ryuta Kamizono*
4+
15
* `PG::UnableToSend: no connection to the server` is now retryable as a connection-related exception
26

37
*Kazuma Watanabe*

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def visit_UniqueConstraintDefinition(o)
5252
sql = ["CONSTRAINT"]
5353
sql << quote_column_name(o.name)
5454
sql << "UNIQUE"
55+
sql << "NULLS NOT DISTINCT" if supports_nulls_not_distinct? && o.nulls_not_distinct
5556

5657
if o.using_index
5758
sql << "USING INDEX #{quote_column_name(o.using_index)}"

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ def using_index
224224
options[:using_index]
225225
end
226226

227+
def nulls_not_distinct
228+
options[:nulls_not_distinct]
229+
end
230+
227231
def export_name_on_schema_dump?
228232
!ActiveRecord::SchemaDumper.unique_ignore_pattern.match?(name) if name
229233
end
@@ -317,7 +321,7 @@ def remove_exclusion_constraint(*args)
317321

318322
# Adds a unique constraint.
319323
#
320-
# t.unique_constraint(:position, name: 'unique_position', deferrable: :deferred)
324+
# t.unique_constraint(:position, name: 'unique_position', deferrable: :deferred, nulls_not_distinct: true)
321325
#
322326
# See {connection.add_unique_constraint}[rdoc-ref:SchemaStatements#add_unique_constraint]
323327
def unique_constraint(*args)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def unique_constraints_in_create(table, stream)
6868
"t.unique_constraint #{unique_constraint.column.inspect}"
6969
]
7070

71+
parts << "nulls_not_distinct: #{unique_constraint.nulls_not_distinct.inspect}" if unique_constraint.nulls_not_distinct
7172
parts << "deferrable: #{unique_constraint.deferrable.inspect}" if unique_constraint.deferrable
7273

7374
if unique_constraint.export_name_on_schema_dump?

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ def unique_constraints(table_name)
698698
scope = quoted_scope(table_name)
699699

700700
unique_info = internal_exec_query(<<~SQL, "SCHEMA", allow_retry: true, materialize_transactions: false)
701-
SELECT c.conname, c.conrelid, c.conkey, c.condeferrable, c.condeferred
701+
SELECT c.conname, c.conrelid, c.conkey, c.condeferrable, c.condeferred, pg_get_constraintdef(c.oid) AS constraintdef
702702
FROM pg_constraint c
703703
JOIN pg_class t ON c.conrelid = t.oid
704704
JOIN pg_namespace n ON n.oid = c.connamespace
@@ -711,10 +711,12 @@ def unique_constraints(table_name)
711711
conkey = row["conkey"].delete("{}").split(",").map(&:to_i)
712712
columns = column_names_from_column_numbers(row["conrelid"], conkey)
713713

714+
nulls_not_distinct = row["constraintdef"].start_with?("UNIQUE NULLS NOT DISTINCT")
714715
deferrable = extract_constraint_deferrable(row["condeferrable"], row["condeferred"])
715716

716717
options = {
717718
name: row["conname"],
719+
nulls_not_distinct: nulls_not_distinct,
718720
deferrable: deferrable
719721
}
720722

@@ -771,7 +773,7 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
771773

772774
# Adds a new unique constraint to the table.
773775
#
774-
# add_unique_constraint :sections, [:position], deferrable: :deferred, name: "unique_position"
776+
# add_unique_constraint :sections, [:position], deferrable: :deferred, name: "unique_position", nulls_not_distinct: true
775777
#
776778
# generates:
777779
#
@@ -788,6 +790,9 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
788790
# Specify whether or not the unique constraint should be deferrable. Valid values are +false+ or +:immediate+ or +:deferred+ to specify the default behavior. Defaults to +false+.
789791
# [<tt>:using_index</tt>]
790792
# To specify an existing unique index name. Defaults to +nil+.
793+
# [<tt>:nulls_not_distinct</tt>]
794+
# Create a unique constraint where NULLs are treated equally.
795+
# Note: only supported by PostgreSQL version 15.0.0 and greater.
791796
def add_unique_constraint(table_name, column_name = nil, **options)
792797
options = unique_constraint_options(table_name, column_name, options)
793798
at = create_alter_table(table_name)

activerecord/test/cases/migration/unique_constraint_test.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,32 @@ def test_unique_constraints
3939
name: "test_unique_constraints_position_deferrable_deferred",
4040
deferrable: :deferred,
4141
column: ["position_3"]
42+
}, {
43+
name: "test_unique_constraints_position_nulls_not_distinct",
44+
nulls_not_distinct: true,
45+
column: ["position_4"]
4246
}
4347
]
4448

4549
assert_equal expected_constraints.size, unique_constraints.size
4650

51+
expected_nulls_not_distinct = expected_constraints.pop
52+
4753
expected_constraints.each do |expected_constraint|
4854
constraint = unique_constraints.find { |constraint| constraint.name == expected_constraint[:name] }
4955
assert_equal "test_unique_constraints", constraint.table_name
5056
assert_equal expected_constraint[:name], constraint.name
5157
assert_equal expected_constraint[:column], constraint.column
5258
assert_equal expected_constraint[:deferrable], constraint.deferrable
5359
end
60+
61+
if supports_nulls_not_distinct?
62+
constraint = unique_constraints.find { |constraint| constraint.name == expected_nulls_not_distinct[:name] }
63+
assert_equal "test_unique_constraints", constraint.table_name
64+
assert_equal expected_nulls_not_distinct[:name], constraint.name
65+
assert_equal expected_nulls_not_distinct[:column], constraint.column
66+
assert_equal expected_nulls_not_distinct[:nulls_not_distinct], constraint.nulls_not_distinct
67+
end
5468
end
5569

5670
def test_unique_constraints_scoped_to_schemas

activerecord/test/cases/schema_dumper_test.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,11 @@ def test_schema_dumps_unique_constraints
246246
output = dump_table_schema("test_unique_constraints")
247247
constraint_definitions = output.split(/\n/).grep(/t\.unique_constraint/)
248248

249-
assert_equal 3, constraint_definitions.size
249+
assert_equal 4, constraint_definitions.size
250250
assert_match 't.unique_constraint ["position_1"], name: "test_unique_constraints_position_deferrable_false"', output
251251
assert_match 't.unique_constraint ["position_2"], deferrable: :immediate, name: "test_unique_constraints_position_deferrable_immediate"', output
252252
assert_match 't.unique_constraint ["position_3"], deferrable: :deferred, name: "test_unique_constraints_position_deferrable_deferred"', output
253+
assert_match 't.unique_constraint ["position_4"], nulls_not_distinct: true, name: "test_unique_constraints_position_nulls_not_distinct"', output
253254
end
254255

255256
def test_schema_does_not_dump_unique_constraints_as_indexes

activerecord/test/schema/postgresql_specific_schema.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,12 @@
175175
t.integer :position_1
176176
t.integer :position_2
177177
t.integer :position_3
178+
t.integer :position_4
178179

179180
t.unique_constraint :position_1, name: "test_unique_constraints_position_deferrable_false"
180181
t.unique_constraint :position_2, name: "test_unique_constraints_position_deferrable_immediate", deferrable: :immediate
181182
t.unique_constraint :position_3, name: "test_unique_constraints_position_deferrable_deferred", deferrable: :deferred
183+
t.unique_constraint :position_4, name: "test_unique_constraints_position_nulls_not_distinct", nulls_not_distinct: true
182184
end
183185

184186
if supports_partitioned_indexes?

0 commit comments

Comments
 (0)