Skip to content

Commit

Permalink
Merge pull request rails#53456 from kamipo/unique_constraint_with_nul…
Browse files Browse the repository at this point in the history
…ls_not_distinct

NULLS NOT DISTINCT works with UNIQUE CONSTRAINT as well as UNIQUE INDEX
  • Loading branch information
kamipo authored Oct 27, 2024
2 parents 7af392f + 38489e4 commit e642926
Show file tree
Hide file tree
Showing 8 changed files with 36 additions and 4 deletions.
4 changes: 4 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
* NULLS NOT DISTINCT works with UNIQUE CONSTRAINT as well as UNIQUE INDEX.

*Ryuta Kamizono*

* `PG::UnableToSend: no connection to the server` is now retryable as a connection-related exception

*Kazuma Watanabe*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def visit_UniqueConstraintDefinition(o)
sql = ["CONSTRAINT"]
sql << quote_column_name(o.name)
sql << "UNIQUE"
sql << "NULLS NOT DISTINCT" if supports_nulls_not_distinct? && o.nulls_not_distinct

if o.using_index
sql << "USING INDEX #{quote_column_name(o.using_index)}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ def using_index
options[:using_index]
end

def nulls_not_distinct
options[:nulls_not_distinct]
end

def export_name_on_schema_dump?
!ActiveRecord::SchemaDumper.unique_ignore_pattern.match?(name) if name
end
Expand Down Expand Up @@ -317,7 +321,7 @@ def remove_exclusion_constraint(*args)

# Adds a unique constraint.
#
# t.unique_constraint(:position, name: 'unique_position', deferrable: :deferred)
# t.unique_constraint(:position, name: 'unique_position', deferrable: :deferred, nulls_not_distinct: true)
#
# See {connection.add_unique_constraint}[rdoc-ref:SchemaStatements#add_unique_constraint]
def unique_constraint(*args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def unique_constraints_in_create(table, stream)
"t.unique_constraint #{unique_constraint.column.inspect}"
]

parts << "nulls_not_distinct: #{unique_constraint.nulls_not_distinct.inspect}" if unique_constraint.nulls_not_distinct
parts << "deferrable: #{unique_constraint.deferrable.inspect}" if unique_constraint.deferrable

if unique_constraint.export_name_on_schema_dump?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ def unique_constraints(table_name)
scope = quoted_scope(table_name)

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

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

options = {
name: row["conname"],
nulls_not_distinct: nulls_not_distinct,
deferrable: deferrable
}

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

# Adds a new unique constraint to the table.
#
# add_unique_constraint :sections, [:position], deferrable: :deferred, name: "unique_position"
# add_unique_constraint :sections, [:position], deferrable: :deferred, name: "unique_position", nulls_not_distinct: true
#
# generates:
#
Expand All @@ -788,6 +790,9 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
# 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+.
# [<tt>:using_index</tt>]
# To specify an existing unique index name. Defaults to +nil+.
# [<tt>:nulls_not_distinct</tt>]
# Create a unique constraint where NULLs are treated equally.
# Note: only supported by PostgreSQL version 15.0.0 and greater.
def add_unique_constraint(table_name, column_name = nil, **options)
options = unique_constraint_options(table_name, column_name, options)
at = create_alter_table(table_name)
Expand Down
14 changes: 14 additions & 0 deletions activerecord/test/cases/migration/unique_constraint_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,32 @@ def test_unique_constraints
name: "test_unique_constraints_position_deferrable_deferred",
deferrable: :deferred,
column: ["position_3"]
}, {
name: "test_unique_constraints_position_nulls_not_distinct",
nulls_not_distinct: true,
column: ["position_4"]
}
]

assert_equal expected_constraints.size, unique_constraints.size

expected_nulls_not_distinct = expected_constraints.pop

expected_constraints.each do |expected_constraint|
constraint = unique_constraints.find { |constraint| constraint.name == expected_constraint[:name] }
assert_equal "test_unique_constraints", constraint.table_name
assert_equal expected_constraint[:name], constraint.name
assert_equal expected_constraint[:column], constraint.column
assert_equal expected_constraint[:deferrable], constraint.deferrable
end

if supports_nulls_not_distinct?
constraint = unique_constraints.find { |constraint| constraint.name == expected_nulls_not_distinct[:name] }
assert_equal "test_unique_constraints", constraint.table_name
assert_equal expected_nulls_not_distinct[:name], constraint.name
assert_equal expected_nulls_not_distinct[:column], constraint.column
assert_equal expected_nulls_not_distinct[:nulls_not_distinct], constraint.nulls_not_distinct
end
end

def test_unique_constraints_scoped_to_schemas
Expand Down
3 changes: 2 additions & 1 deletion activerecord/test/cases/schema_dumper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,11 @@ def test_schema_dumps_unique_constraints
output = dump_table_schema("test_unique_constraints")
constraint_definitions = output.split(/\n/).grep(/t\.unique_constraint/)

assert_equal 3, constraint_definitions.size
assert_equal 4, constraint_definitions.size
assert_match 't.unique_constraint ["position_1"], name: "test_unique_constraints_position_deferrable_false"', output
assert_match 't.unique_constraint ["position_2"], deferrable: :immediate, name: "test_unique_constraints_position_deferrable_immediate"', output
assert_match 't.unique_constraint ["position_3"], deferrable: :deferred, name: "test_unique_constraints_position_deferrable_deferred"', output
assert_match 't.unique_constraint ["position_4"], nulls_not_distinct: true, name: "test_unique_constraints_position_nulls_not_distinct"', output
end

def test_schema_does_not_dump_unique_constraints_as_indexes
Expand Down
2 changes: 2 additions & 0 deletions activerecord/test/schema/postgresql_specific_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,12 @@
t.integer :position_1
t.integer :position_2
t.integer :position_3
t.integer :position_4

t.unique_constraint :position_1, name: "test_unique_constraints_position_deferrable_false"
t.unique_constraint :position_2, name: "test_unique_constraints_position_deferrable_immediate", deferrable: :immediate
t.unique_constraint :position_3, name: "test_unique_constraints_position_deferrable_deferred", deferrable: :deferred
t.unique_constraint :position_4, name: "test_unique_constraints_position_nulls_not_distinct", nulls_not_distinct: true
end

if supports_partitioned_indexes?
Expand Down

0 comments on commit e642926

Please sign in to comment.