Skip to content

Commit 6857589

Browse files
authored
Merge pull request #86 from JamesGriffin/unique-constraint/with-expressions
Handle scenario where unique constraint with expression(s) is provided
2 parents 4d7f470 + 46fc4c6 commit 6857589

File tree

4 files changed

+257
-15
lines changed

4 files changed

+257
-15
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
12+
13+
- Fixed a bug where `SaferAddUniqueConstraint` and `SaferRemoveUniqueConstraint` would
14+
accept unique constraints with expressions on them, but produce invalid SQL. This is
15+
now handled in the same way as unique constraints with conditions, as unique indexes,
16+
as per the equivalent Django `AddConstraint` and `RemoveConstraint` operations.
1217

1318
## [0.1.20] - 2025-06-19
1419

src/django_pg_migration_tools/operations.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -412,12 +412,12 @@ def create_unique_constraint(
412412

413413
index = self._get_index_for_constraint(constraint)
414414

415-
if constraint.condition is not None:
415+
if (constraint.condition is not None) or constraint.expressions:
416416
"""
417-
Unique constraints with conditions do not exist in postgres.
417+
Unique constraints with conditions/expressions do not exist in postgres.
418418
419-
As of writing Django handles these as unique indexes with conditions only
420-
in the auto generated operation, so we only create the index and finish here
419+
As of writing Django handles these as unique indexes with conditions/expressions
420+
only in the auto generated operation, so we only create the index and finish here
421421
"""
422422
SafeIndexOperationManager().safer_create_index(
423423
app_label=app_label,
@@ -491,9 +491,9 @@ def drop_unique_constraint(
491491
if not self.allow_migrate_model(schema_editor.connection.alias, model):
492492
return
493493

494-
if constraint.condition is not None:
495-
# If condition is present on the constraint, it would have been created
496-
# as an index instead, so index is instead removed
494+
if (constraint.condition is not None) or constraint.expressions:
495+
# If condition/expressions are present on the constraint, it would have
496+
# been created as an index instead, so index is instead removed
497497
index = self._get_index_for_constraint(constraint)
498498

499499
SafeIndexOperationManager().safer_drop_index(

tests/django_pg_migration_tools/test_operations.py

Lines changed: 222 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,11 @@
1111
ModelState,
1212
ProjectState,
1313
)
14-
from django.db.models import BaseConstraint, Index, Q, UniqueConstraint
14+
from django.db.models import BaseConstraint, F, Index, Q, UniqueConstraint, functions
1515
from django.test import override_settings, utils
1616

1717
from django_pg_migration_tools import operations
1818
from tests.example_app.models import (
19-
AnotherCharModel,
2019
CharIDModel,
2120
CharModel,
2221
IntModel,
@@ -26,6 +25,8 @@
2625
NotNullIntFieldModel,
2726
NullFKFieldModel,
2827
NullIntFieldModel,
28+
UniqueConditionCharModel,
29+
UniqueExpressionCharModel,
2930
get_check_constraint,
3031
)
3132

@@ -1306,6 +1307,117 @@ def test_when_condition_on_constraint_only_creates_index(self):
13061307
)
13071308
assert not cursor.fetchone()
13081309

1310+
@pytest.mark.django_db(transaction=True)
1311+
def test_when_expression_on_constraint_only_creates_index(self):
1312+
constraint_name = "unique_expression_int_field"
1313+
1314+
# Prove that:
1315+
# - An invalid index doesn't exist.
1316+
# - The constraint/index doesn't exist yet.
1317+
with connection.cursor() as cursor:
1318+
cursor.execute(
1319+
_CHECK_VALID_INDEX_EXISTS_QUERY,
1320+
{"index_name": constraint_name},
1321+
)
1322+
assert not cursor.fetchone()
1323+
# Also, set the lock_timeout to check it has been returned to
1324+
# its original value once the unique index creation is completed.
1325+
cursor.execute(_SET_LOCK_TIMEOUT)
1326+
1327+
project_state = ProjectState()
1328+
project_state.add_model(ModelState.from_model(IntModel))
1329+
new_state = project_state.clone()
1330+
1331+
operation = operations.SaferAddUniqueConstraint(
1332+
model_name="intmodel",
1333+
constraint=UniqueConstraint(
1334+
functions.Greatest(F("int_field"), 2),
1335+
name=constraint_name,
1336+
),
1337+
)
1338+
operation.state_forwards(self.app_label, new_state)
1339+
# Proceed to add the unique index followed by the constraint:
1340+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
1341+
with utils.CaptureQueriesContext(connection) as queries:
1342+
operation.database_forwards(
1343+
self.app_label, editor, from_state=project_state, to_state=new_state
1344+
)
1345+
1346+
# Confirm that exists as index
1347+
with connection.cursor() as cursor:
1348+
cursor.execute(
1349+
_CHECK_INDEX_EXISTS_QUERY,
1350+
{
1351+
"table_name": "example_app_intmodel",
1352+
"index_name": constraint_name,
1353+
},
1354+
)
1355+
assert cursor.fetchone()
1356+
1357+
# Assert on the sequence of expected SQL queries:
1358+
#
1359+
# 1. Check the original lock_timeout value to be able to restore it
1360+
# later.
1361+
assert queries[0]["sql"] == "SHOW lock_timeout;"
1362+
# 2. Remove the timeout.
1363+
assert queries[1]["sql"] == "SET lock_timeout = '0';"
1364+
# 3. Verify if the index is invalid.
1365+
assert queries[2]["sql"] == dedent(
1366+
f"""
1367+
SELECT relname
1368+
FROM pg_class, pg_index
1369+
WHERE (
1370+
pg_index.indisvalid = false
1371+
AND pg_index.indexrelid = pg_class.oid
1372+
AND relname = '{constraint_name}'
1373+
);
1374+
"""
1375+
)
1376+
# 4. Finally create the index concurrently.
1377+
assert (
1378+
queries[3]["sql"]
1379+
== f'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "{constraint_name}" ON "example_app_intmodel" ((GREATEST("int_field", 2)))'
1380+
)
1381+
# 6. Set the timeout back to what it was originally.
1382+
assert queries[4]["sql"] == "SET lock_timeout = '1s';"
1383+
1384+
# There are no additional queries
1385+
assert len(queries) == 5
1386+
1387+
# Reverse the migration to drop the index and constraint, and verify
1388+
# that the lock_timeout queries are correct.
1389+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
1390+
with utils.CaptureQueriesContext(connection) as reverse_queries:
1391+
operation.database_backwards(
1392+
self.app_label, editor, from_state=new_state, to_state=project_state
1393+
)
1394+
1395+
# 2. perform the ALTER TABLE.
1396+
assert reverse_queries[0]["sql"] == "SHOW lock_timeout;"
1397+
1398+
# 3. Remove the timeout.
1399+
assert reverse_queries[1]["sql"] == "SET lock_timeout = '0';"
1400+
# 4. Verify if the index is invalid.
1401+
assert (
1402+
reverse_queries[2]["sql"]
1403+
== f'DROP INDEX CONCURRENTLY IF EXISTS "{constraint_name}"'
1404+
)
1405+
1406+
assert reverse_queries[3]["sql"] == "SET lock_timeout = '1s';"
1407+
1408+
assert len(reverse_queries) == 4
1409+
1410+
# Verify the index representing the constraint doesn't exist any more.
1411+
with connection.cursor() as cursor:
1412+
cursor.execute(
1413+
_CHECK_INDEX_EXISTS_QUERY,
1414+
{
1415+
"table_name": "example_app_intmodel",
1416+
"index_name": constraint_name,
1417+
},
1418+
)
1419+
assert not cursor.fetchone()
1420+
13091421

13101422
class TestBuildPostgresIdentifier:
13111423
def test_happy_path(self):
@@ -1491,11 +1603,116 @@ def test_operation_where_condition_on_unique_constraint(self):
14911603
assert cursor.fetchone()
14921604

14931605
project_state = ProjectState()
1494-
project_state.add_model(ModelState.from_model(AnotherCharModel))
1606+
project_state.add_model(ModelState.from_model(UniqueConditionCharModel))
1607+
new_state = project_state.clone()
1608+
1609+
operation = operations.SaferRemoveUniqueConstraint(
1610+
model_name="uniqueconditioncharmodel",
1611+
name=constraint_name,
1612+
)
1613+
1614+
operation.state_forwards(self.app_label, new_state)
1615+
# Proceed to remove the constraint.
1616+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
1617+
with utils.CaptureQueriesContext(connection) as queries:
1618+
operation.database_forwards(
1619+
self.app_label, editor, from_state=project_state, to_state=new_state
1620+
)
1621+
1622+
# Prove the index is not there any longer.
1623+
with connection.cursor() as cursor:
1624+
cursor.execute(
1625+
_CHECK_VALID_INDEX_EXISTS_QUERY,
1626+
{"index_name": constraint_name},
1627+
)
1628+
assert not cursor.fetchone()
1629+
1630+
# Assert on the sequence of expected SQL queries:
1631+
#
1632+
# 1. Check the original lock_timeout value to be able to restore it
1633+
# later.
1634+
assert queries[0]["sql"] == "SHOW lock_timeout;"
1635+
# 2. Remove the timeout.
1636+
assert queries[1]["sql"] == "SET lock_timeout = '0';"
1637+
1638+
# 3. Drop the index concurrently.
1639+
assert (
1640+
queries[2]["sql"]
1641+
== f'DROP INDEX CONCURRENTLY IF EXISTS "{constraint_name}"'
1642+
)
1643+
# 4. Set the timeout back to what it was originally.
1644+
assert queries[3]["sql"] == "SET lock_timeout = '1s';"
1645+
1646+
assert len(queries) == 4
1647+
1648+
# Before reversing, set the lock_timeout value so we can observe it
1649+
# being re-set.
1650+
with connection.cursor() as cursor:
1651+
cursor.execute(_SET_LOCK_TIMEOUT)
1652+
1653+
# Reverse the migration to recreate the constraint.
1654+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
1655+
with utils.CaptureQueriesContext(connection) as reverse_queries:
1656+
operation.database_backwards(
1657+
self.app_label, editor, from_state=new_state, to_state=project_state
1658+
)
1659+
1660+
# These will be the same as when creating a constraint safely. I.e.,
1661+
# adding the index concurrently without timeouts, and using this index
1662+
# to create the constraint.
1663+
#
1664+
1665+
# 1. Check the original lock_timeout value to be able to restore it
1666+
# later.
1667+
assert reverse_queries[0]["sql"] == "SHOW lock_timeout;"
1668+
# 2. Remove the timeout.
1669+
assert reverse_queries[1]["sql"] == "SET lock_timeout = '0';"
1670+
# 3. Verify if the index is invalid.
1671+
assert reverse_queries[2]["sql"] == dedent(
1672+
f"""
1673+
SELECT relname
1674+
FROM pg_class, pg_index
1675+
WHERE (
1676+
pg_index.indisvalid = false
1677+
AND pg_index.indexrelid = pg_class.oid
1678+
AND relname = '{constraint_name}'
1679+
);
1680+
"""
1681+
)
1682+
# 4. Finally create the index concurrently.
1683+
assert (
1684+
reverse_queries[3]["sql"]
1685+
== f'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "{constraint_name}" ON "example_app_uniqueconditioncharmodel" ("char_field") WHERE "char_field" IN (\'c\', \'something\')'
1686+
)
1687+
# 5. Set the timeout back to what it was originally.
1688+
assert reverse_queries[4]["sql"] == "SET lock_timeout = '1s';"
1689+
1690+
# Nothing else.
1691+
assert len(reverse_queries) == 5
1692+
1693+
@pytest.mark.django_db(transaction=True)
1694+
def test_operation_where_expression_on_unique_constraint(self):
1695+
constraint_name = "unique_char_field_with_expression"
1696+
1697+
with connection.cursor() as cursor:
1698+
# Set the lock_timeout to check it has been returned to
1699+
# its original value once the index creation is completed.
1700+
cursor.execute(_SET_LOCK_TIMEOUT)
1701+
1702+
# Prove that the constraint/index exists before the operation removes it.
1703+
with connection.cursor() as cursor:
1704+
cursor.execute(
1705+
_CHECK_VALID_INDEX_EXISTS_QUERY,
1706+
{"index_name": constraint_name},
1707+
)
1708+
assert cursor.fetchone()
1709+
1710+
project_state = ProjectState()
1711+
project_state.add_model(ModelState.from_model(UniqueExpressionCharModel))
14951712
new_state = project_state.clone()
14961713

14971714
operation = operations.SaferRemoveUniqueConstraint(
1498-
model_name="anothercharmodel",
1715+
model_name="uniqueexpressioncharmodel",
14991716
name=constraint_name,
15001717
)
15011718

@@ -1570,7 +1787,7 @@ def test_operation_where_condition_on_unique_constraint(self):
15701787
# 4. Finally create the index concurrently.
15711788
assert (
15721789
reverse_queries[3]["sql"]
1573-
== f'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "{constraint_name}" ON "example_app_anothercharmodel" ("char_field") WHERE "char_field" IN (\'c\', \'something\')'
1790+
== f'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "{constraint_name}" ON "example_app_uniqueexpressioncharmodel" ((LOWER("char_field")))'
15741791
)
15751792
# 5. Set the timeout back to what it was originally.
15761793
assert reverse_queries[4]["sql"] == "SET lock_timeout = '1s';"

tests/example_app/models.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import django
22
from django.db import models
3+
from django.db.models import functions
34

45

56
def get_check_constraint(condition: models.Q, name: str) -> models.CheckConstraint:
@@ -40,11 +41,13 @@ class Meta:
4041
)
4142

4243

43-
class AnotherCharModel(models.Model):
44+
class UniqueConditionCharModel(models.Model):
4445
char_field = models.CharField(default="char")
4546

4647
class Meta:
47-
indexes = (models.Index(fields=["char_field"], name="another_char_field_idx"),)
48+
indexes = (
49+
models.Index(fields=["char_field"], name="unique_condition_char_field_idx"),
50+
)
4851
constraints = (
4952
models.UniqueConstraint(
5053
fields=["char_field"],
@@ -54,6 +57,23 @@ class Meta:
5457
)
5558

5659

60+
class UniqueExpressionCharModel(models.Model):
61+
char_field = models.CharField(default="char")
62+
63+
class Meta:
64+
indexes = (
65+
models.Index(
66+
fields=["char_field"], name="unique_expression_char_field_idx"
67+
),
68+
)
69+
constraints = (
70+
models.UniqueConstraint(
71+
functions.Lower("char_field"),
72+
name="unique_char_field_with_expression",
73+
),
74+
)
75+
76+
5777
class NullIntFieldModel(models.Model):
5878
int_field = models.IntegerField(null=True)
5979

0 commit comments

Comments
 (0)