Skip to content

Commit b5a8b10

Browse files
authored
Fix #45 correctly reinstate nullable unique constraints (#47)
Ensure that the unique/unique_together constraint is reinstated exactly the same as the one that was dropped earlier in `_alter_field` This fixes both cases described on the bug report, that is when `AlterField` modifies a column which is: 1) individually `unique=True` as well as `null=True` 2) in a `unique_together` where at least 1 column is nullable
1 parent 5eec3e1 commit b5a8b10

8 files changed

+155
-23
lines changed

sql_server/pyodbc/schema.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -454,21 +454,30 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
454454
# True | True | True | False
455455
if (not old_field.db_index or old_field.unique) and new_field.db_index and not new_field.unique:
456456
self.execute(self._create_index_sql(model, [new_field]))
457-
# Restore an index, SQL Server requires explicit restoration
457+
458+
# Restore indexes & unique constraints deleted above, SQL Server requires explicit restoration
458459
if (old_type != new_type or (old_field.null and not new_field.null)) and (
459460
old_field.column == new_field.column
460461
):
461-
unique_columns = []
462+
# Restore unique constraints
463+
# Note: if nullable they are implemented via an explicit filtered UNIQUE INDEX (not CONSTRAINT)
464+
# in order to get ANSI-compliant NULL behaviour (i.e. NULL != NULL, multiple are allowed)
462465
if old_field.unique and new_field.unique:
463-
unique_columns.append([old_field.column])
466+
if new_field.null:
467+
self.execute(
468+
self._create_index_sql(
469+
model, [old_field], sql=self.sql_create_unique_null, suffix="_uniq"
470+
)
471+
)
472+
else:
473+
self.execute(self._create_unique_sql(model, columns=[old_field.column]))
464474
else:
465475
for fields in model._meta.unique_together:
466476
columns = [model._meta.get_field(field).column for field in fields]
467477
if old_field.column in columns:
468-
unique_columns.append(columns)
469-
if unique_columns:
470-
for columns in unique_columns:
471-
self.execute(self._create_unique_sql(model, columns))
478+
condition = ' AND '.join(["[%s] IS NOT NULL" % col for col in columns])
479+
self.execute(self._create_unique_sql(model, columns, condition=condition))
480+
# Restore indexes
472481
index_columns = []
473482
if old_field.db_index and new_field.db_index:
474483
index_columns.append([old_field])

testapp/migrations/0002_test_unique_nullable_part1.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Migration(migrations.Migration):
88
]
99

1010
operations = [
11+
# Issue #38 test prep
1112
# Create with a field that is unique *and* nullable so it is implemented with a filtered unique index.
1213
migrations.CreateModel(
1314
name='TestUniqueNullableModel',

testapp/migrations/0003_test_unique_nullable_part2.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Migration(migrations.Migration):
88
]
99

1010
operations = [
11+
# Issue #38 test
1112
# Now remove the null=True to check this transition is correctly handled.
1213
migrations.AlterField(
1314
model_name='testuniquenullablemodel',
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('testapp', '0003_test_unique_nullable_part2'),
8+
]
9+
10+
# Issue #45 test prep
11+
operations = [
12+
# for case 1:
13+
migrations.AddField(
14+
model_name='testuniquenullablemodel',
15+
name='x',
16+
field=models.CharField(max_length=10, null=True, unique=True),
17+
),
18+
19+
# for case 2:
20+
migrations.CreateModel(
21+
name='TestNullableUniqueTogetherModel',
22+
fields=[
23+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
24+
('a', models.CharField(max_length=50, null=True)),
25+
('b', models.CharField(max_length=50)),
26+
('c', models.CharField(max_length=50)),
27+
],
28+
options={
29+
'unique_together': {('a', 'b')},
30+
},
31+
),
32+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('testapp', '0004_test_issue45_unique_type_change_part1'),
8+
]
9+
10+
# Issue #45 test
11+
operations = [
12+
# Case 1: changing max_length changes the column type - the filtered UNIQUE INDEX which implements
13+
# the nullable unique constraint, should be correctly reinstated after this change of column type
14+
# (see also the specific unit test which checks that multiple rows with NULL are allowed)
15+
migrations.AlterField(
16+
model_name='testuniquenullablemodel',
17+
name='x',
18+
field=models.CharField(max_length=11, null=True, unique=True),
19+
),
20+
21+
# Case 2: the filtered UNIQUE INDEX implementing the partially nullable `unique_together` constraint
22+
# should be correctly reinstated after this column type change
23+
migrations.AlterField(
24+
model_name='testnullableuniquetogethermodel',
25+
name='a',
26+
field=models.CharField(max_length=51, null=True),
27+
),
28+
# ...similarly adding another field to the `unique_together` should preserve the constraint correctly
29+
migrations.AlterUniqueTogether(
30+
name='testnullableuniquetogethermodel',
31+
unique_together={('a', 'b', 'c')},
32+
),
33+
]

testapp/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,23 @@ def __str__(self):
4444

4545

4646
class TestUniqueNullableModel(models.Model):
47+
# Issue #38:
4748
# This field started off as unique=True *and* null=True so it is implemented with a filtered unique index
4849
# Then it is made non-nullable by a subsequent migration, to check this is correctly handled (the index
4950
# should be dropped, then a normal unique constraint should be added, now that the column is not nullable)
5051
test_field = models.CharField(max_length=100, unique=True)
52+
53+
# Issue #45 (case 1)
54+
# Field used for testing changing the 'type' of a field that's both unique & nullable
55+
x = models.CharField(max_length=11, null=True, unique=True)
56+
57+
58+
class TestNullableUniqueTogetherModel(models.Model):
59+
class Meta:
60+
unique_together = (('a', 'b', 'c'),)
61+
62+
# Issue #45 (case 2)
63+
# Fields used for testing changing the 'type of a field that is in a `unique_together`
64+
a = models.CharField(max_length=51, null=True)
65+
b = models.CharField(max_length=50)
66+
c = models.CharField(max_length=50)

testapp/tests/test_constraints.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from django.db.utils import IntegrityError
2+
from django.test import TestCase, skipUnlessDBFeature
3+
4+
from ..models import (
5+
Author, Editor, Post,
6+
TestUniqueNullableModel, TestNullableUniqueTogetherModel,
7+
)
8+
9+
10+
@skipUnlessDBFeature('supports_nullable_unique_constraints')
11+
class TestNullableUniqueColumn(TestCase):
12+
def test_multiple_nulls(self):
13+
# Issue #45 (case 1) - after field `x` has had its type changed, the filtered UNIQUE
14+
# INDEX which is implementing the nullable unique constraint should still be correctly
15+
# in place - i.e. allowing multiple NULLs but still enforcing uniqueness of non-NULLs
16+
17+
# Allowed
18+
TestUniqueNullableModel.objects.create(x=None, test_field='randomness')
19+
TestUniqueNullableModel.objects.create(x=None, test_field='doesntmatter')
20+
21+
# Disallowed
22+
TestUniqueNullableModel.objects.create(x="foo", test_field='irrelevant')
23+
with self.assertRaises(IntegrityError):
24+
TestUniqueNullableModel.objects.create(x="foo", test_field='nonsense')
25+
26+
27+
@skipUnlessDBFeature('supports_partially_nullable_unique_constraints')
28+
class TestPartiallyNullableUniqueTogether(TestCase):
29+
def test_partially_nullable(self):
30+
# Check basic behaviour of `unique_together` where at least 1 of the columns is nullable
31+
32+
# It should be possible to have 2 rows both with NULL `alt_editor`
33+
author = Author.objects.create(name="author")
34+
Post.objects.create(title="foo", author=author)
35+
Post.objects.create(title="foo", author=author)
36+
37+
# But `unique_together` is still enforced for non-NULL values
38+
editor = Editor.objects.create(name="editor")
39+
Post.objects.create(title="foo", author=author, alt_editor=editor)
40+
with self.assertRaises(IntegrityError):
41+
Post.objects.create(title="foo", author=author, alt_editor=editor)
42+
43+
def test_after_type_change(self):
44+
# Issue #45 (case 2) - after one of the fields in the `unique_together` has had its
45+
# type changed in a migration, the constraint should still be correctly enforced
46+
47+
# Multiple rows with a=NULL are considered different
48+
TestNullableUniqueTogetherModel.objects.create(a=None, b='bbb', c='ccc')
49+
TestNullableUniqueTogetherModel.objects.create(a=None, b='bbb', c='ccc')
50+
51+
# Uniqueness still enforced for non-NULL values
52+
TestNullableUniqueTogetherModel.objects.create(a='aaa', b='bbb', c='ccc')
53+
with self.assertRaises(IntegrityError):
54+
TestNullableUniqueTogetherModel.objects.create(a='aaa', b='bbb', c='ccc')

testapp/tests/test_expressions.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
from django import VERSION
44
from django.db.models import IntegerField
55
from django.db.models.expressions import Case, Exists, OuterRef, Subquery, Value, When
6-
from django.db.utils import IntegrityError
7-
from django.test import TestCase, skipUnlessDBFeature
6+
from django.test import TestCase
87

9-
from ..models import Author, Comment, Editor, Post
8+
from ..models import Author, Comment, Post
109

1110
DJANGO3 = VERSION[0] >= 3
1211

@@ -52,16 +51,3 @@ def test_order_by_exists(self):
5251

5352
authors_by_posts = Author.objects.order_by(Exists(Post.objects.filter(author=OuterRef('pk'))).asc())
5453
self.assertSequenceEqual(authors_by_posts, [author_without_posts, self.author])
55-
56-
57-
@skipUnlessDBFeature('supports_partially_nullable_unique_constraints')
58-
class TestPartiallyNullableUniqueTogether(TestCase):
59-
def test_partially_nullable(self):
60-
author = Author.objects.create(name="author")
61-
Post.objects.create(title="foo", author=author)
62-
Post.objects.create(title="foo", author=author)
63-
64-
editor = Editor.objects.create(name="editor")
65-
Post.objects.create(title="foo", author=author, alt_editor=editor)
66-
with self.assertRaises(IntegrityError):
67-
Post.objects.create(title="foo", author=author, alt_editor=editor)

0 commit comments

Comments
 (0)