Skip to content

Commit d4ef733

Browse files
committed
Add support for nulls_distinct=False in unique constraints
1 parent 063134e commit d4ef733

File tree

3 files changed

+171
-1
lines changed

3 files changed

+171
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
88

99
## [Unreleased]
1010

11-
_No notable unreleased changes_
11+
- Fixed a bug where a unique constraint with `nulls_distinct=False` was created
12+
without "NULLS NOT DISTINCT" by `SaferAddUniqueConstraint`.
13+
- Fixed a bug where a unique constraint with `nulls_distinct=False` resulted in
14+
invalid SQL when `SaferAddUniqueConstraint` was used with PostgreSQL 14.x or
15+
earlier (which does not support "NULLS NOT DISTINCT"). The operation will now
16+
raise a `ConstraintNotSupported` exception instead.
1217

1318
## [0.1.25] - 2026-02-24
1419

src/django_pg_migration_tools/operations.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def safer_create_index(
267267
index: models.Index | IndexSQLBuilder,
268268
unique: bool,
269269
model: type[models.Model],
270+
nulls_distinct: bool = True,
270271
) -> None:
271272
self._ensure_not_in_transaction(schema_editor)
272273

@@ -283,6 +284,7 @@ def safer_create_index(
283284
model=model,
284285
schema_editor=schema_editor,
285286
index=index,
287+
nulls_distinct=nulls_distinct,
286288
)
287289
schema_editor.execute(index_sql)
288290

@@ -374,6 +376,7 @@ def _get_create_index_sql(
374376
model: type[models.Model],
375377
schema_editor: base_schema.BaseDatabaseSchemaEditor,
376378
index: models.Index | IndexSQLBuilder,
379+
nulls_distinct: bool,
377380
) -> str:
378381
if isinstance(index, IndexSQLBuilder):
379382
assert model._meta.db_table == index.table_name
@@ -388,6 +391,9 @@ def _get_create_index_sql(
388391
if unique:
389392
index_sql = index_sql.replace("CREATE INDEX", "CREATE UNIQUE INDEX")
390393

394+
if not nulls_distinct:
395+
index_sql += " NULLS NOT DISTINCT"
396+
391397
return index_sql
392398

393399

@@ -399,6 +405,10 @@ class ConstraintAlreadyExists(ConstraintOperationError):
399405
pass
400406

401407

408+
class ConstraintNotSupported(ConstraintOperationError):
409+
pass
410+
411+
402412
class SafeConstraintOperationManager(base_operations.Operation):
403413
def create_unique_constraint(
404414
self,
@@ -419,6 +429,20 @@ def create_unique_constraint(
419429

420430
index = self._get_index_for_constraint(constraint)
421431

432+
if constraint.nulls_distinct is None or constraint.nulls_distinct is True:
433+
nulls_distinct = True
434+
else:
435+
nulls_distinct = False
436+
437+
if (
438+
not nulls_distinct
439+
and not schema_editor.connection.features.supports_nulls_distinct_unique_constraints
440+
):
441+
# Postgres versions <= 14 do not support NULLS NOT DISTINCT
442+
raise ConstraintNotSupported(
443+
"Constraints with NULLS NOT DISTINCT are not supported with this PostgreSQL version"
444+
)
445+
422446
if (constraint.condition is not None) or constraint.expressions:
423447
"""
424448
Unique constraints with conditions/expressions do not exist in postgres.
@@ -434,6 +458,7 @@ def create_unique_constraint(
434458
index=index,
435459
model=model,
436460
unique=True,
461+
nulls_distinct=nulls_distinct,
437462
)
438463
return
439464

@@ -448,6 +473,7 @@ def create_unique_constraint(
448473
index=index,
449474
model=model,
450475
unique=True,
476+
nulls_distinct=nulls_distinct,
451477
)
452478

453479
# Django doesn't have a handy flag "using=..." so we need to alter the

tests/django_pg_migration_tools/test_operations.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from textwrap import dedent
22
from typing import Any
3+
from unittest.mock import patch
34

45
import pytest
56
from django.db import (
@@ -1436,6 +1437,144 @@ def test_when_expression_on_constraint_only_creates_index(self):
14361437
)
14371438
assert not cursor.fetchone()
14381439

1440+
# Disable the overall test transaction because a unique concurrent index
1441+
# cannot be triggered/tested inside of a transaction.
1442+
@pytest.mark.django_db(transaction=True)
1443+
def test_when_nulls_not_distinct(self):
1444+
# Prove that:
1445+
# - An invalid index doesn't exist.
1446+
# - The constraint doesn't exist yet.
1447+
with connection.cursor() as cursor:
1448+
cursor.execute(
1449+
psycopg_sql.SQL(operations.IndexQueries.CHECK_INVALID_INDEX)
1450+
.format(index_name=psycopg_sql.Literal("unique_null_int_field"))
1451+
.as_string(cursor.connection)
1452+
)
1453+
assert not cursor.fetchone()
1454+
cursor.execute(
1455+
psycopg_sql.SQL(operations.ConstraintQueries.CHECK_EXISTING_CONSTRAINT)
1456+
.format(constraint_name=psycopg_sql.Literal("unique_null_int_field"))
1457+
.as_string(cursor.connection)
1458+
)
1459+
assert not cursor.fetchone()
1460+
# Also, set the lock_timeout to check it has been returned to
1461+
# its original value once the unique index creation is completed.
1462+
cursor.execute(_SET_LOCK_TIMEOUT)
1463+
1464+
project_state = ProjectState()
1465+
project_state.add_model(ModelState.from_model(NullIntFieldModel))
1466+
new_state = project_state.clone()
1467+
1468+
operation = operations.SaferAddUniqueConstraint(
1469+
model_name="nullintfieldmodel",
1470+
constraint=UniqueConstraint(
1471+
fields=("int_field",),
1472+
name="unique_null_int_field",
1473+
nulls_distinct=False,
1474+
),
1475+
)
1476+
operation.state_forwards(self.app_label, new_state)
1477+
# Proceed to add the unique index followed by the constraint:
1478+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
1479+
if not connection.features.supports_nulls_distinct_unique_constraints:
1480+
# Postgres versions <= 14 do not support NULLS NOT DISTINCT
1481+
with pytest.raises(operations.ConstraintNotSupported):
1482+
operation.database_forwards(
1483+
self.app_label,
1484+
editor,
1485+
from_state=project_state,
1486+
to_state=new_state,
1487+
)
1488+
return
1489+
1490+
with utils.CaptureQueriesContext(connection) as queries:
1491+
operation.database_forwards(
1492+
self.app_label, editor, from_state=project_state, to_state=new_state
1493+
)
1494+
1495+
with connection.cursor() as cursor:
1496+
cursor.execute(
1497+
_CHECK_INDEX_EXISTS_QUERY,
1498+
{
1499+
"table_name": "example_app_nullintfieldmodel",
1500+
"index_name": "unique_null_int_field",
1501+
},
1502+
)
1503+
assert cursor.fetchone()
1504+
cursor.execute(
1505+
_CHECK_CONSTRAINT_EXISTS_QUERY,
1506+
{
1507+
"table_name": "example_app_nullintfieldmodel",
1508+
"constraint_name": "unique_null_int_field",
1509+
},
1510+
)
1511+
assert cursor.fetchone()
1512+
1513+
# Assert on the sequence of expected SQL queries:
1514+
#
1515+
# 1. Check if the constraint already exists.
1516+
assert queries[0]["sql"] == dedent("""
1517+
SELECT con.conname
1518+
FROM pg_catalog.pg_constraint con
1519+
INNER JOIN pg_catalog.pg_namespace nsp ON nsp.oid = con.connamespace
1520+
WHERE con.conname = 'unique_null_int_field'
1521+
AND nsp.nspname = current_schema();
1522+
""")
1523+
# 2. Check the original lock_timeout value to be able to restore it
1524+
# later.
1525+
assert queries[1]["sql"] == "SHOW lock_timeout;"
1526+
# 3. Remove the timeout.
1527+
assert queries[2]["sql"] == "SET lock_timeout = '0';"
1528+
# 4. Verify if the index is invalid.
1529+
assert queries[3]["sql"] == dedent("""
1530+
SELECT relname
1531+
FROM pg_class, pg_index
1532+
WHERE (
1533+
pg_index.indisvalid = false
1534+
AND pg_index.indexrelid = pg_class.oid
1535+
AND relname = 'unique_null_int_field'
1536+
);
1537+
""")
1538+
# 5. Finally create the index concurrently.
1539+
assert (
1540+
queries[4]["sql"]
1541+
== 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "unique_null_int_field" ON "example_app_nullintfieldmodel" ("int_field") NULLS NOT DISTINCT'
1542+
)
1543+
# 6. Set the timeout back to what it was originally.
1544+
assert queries[5]["sql"] == "SET lock_timeout = '1s';"
1545+
1546+
# 7. Add the table constraint.
1547+
assert (
1548+
queries[6]["sql"]
1549+
== 'ALTER TABLE "example_app_nullintfieldmodel" ADD CONSTRAINT "unique_null_int_field" UNIQUE USING INDEX "unique_null_int_field"'
1550+
)
1551+
1552+
def test_when_nulls_not_distinct_and_not_supported(self):
1553+
project_state = ProjectState()
1554+
project_state.add_model(ModelState.from_model(NullIntFieldModel))
1555+
new_state = project_state.clone()
1556+
1557+
operation = operations.SaferAddUniqueConstraint(
1558+
model_name="nullintfieldmodel",
1559+
constraint=UniqueConstraint(
1560+
fields=("int_field",),
1561+
name="unique_null_int_field",
1562+
nulls_distinct=False,
1563+
),
1564+
)
1565+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
1566+
with patch.object(
1567+
connection, "features", supports_nulls_distinct_unique_constraints=False
1568+
):
1569+
# Postgres versions <= 14 do not support NULLS NOT DISTINCT
1570+
with pytest.raises(operations.ConstraintNotSupported):
1571+
operation.database_forwards(
1572+
self.app_label,
1573+
editor,
1574+
from_state=project_state,
1575+
to_state=new_state,
1576+
)
1577+
14391578

14401579
class TestBuildPostgresIdentifier:
14411580
def test_happy_path(self):

0 commit comments

Comments
 (0)