Skip to content

Commit c25bed2

Browse files
authored
Fix #137: handle legacy unique together constraint (#138)
* TDD: regression test for #137 * Fix #137: drop legacy unique_together CONSTRAINT before AlterField This means that a database previously migrated by an older backend (e.g. django-mssql-backend < v2.6.0, or django-pyodbc-azure) will not explode later when an AlterField type/nullability change acts on a field which is in an old unique_together added by the old backend as a table CONSTRAINT (rather than a filtered unique INDEX like the current backend code now does) * Comment improvements Some related to this ticket, others accrued locally while investigating other things, but I figured they would be helpful * Fix argument order in another test Not necessary but also add kwarg names for clarity while we're here
1 parent 5823873 commit c25bed2

File tree

5 files changed

+126
-17
lines changed

5 files changed

+126
-17
lines changed

mssql/functions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ def bulk_update_with_default(self, objs, fields, batch_size=None, default=0):
288288
return rows_updated
289289

290290

291+
# `as_microsoft` called by django.db.models.sql.compiler based on connection.vendor
291292
ATan2.as_microsoft = sqlserver_atan2
292293
# Need copy of old In.split_parameter_list_as_sql for other backends to call
293294
in_split_parameter_list_as_sql = In.split_parameter_list_as_sql

mssql/schema.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,12 @@ def _db_table_constraint_names(self, db_table, column_names=None, column_match_a
234234
unique=None, primary_key=None, index=None, foreign_key=None,
235235
check=None, type_=None, exclude=None, unique_constraint=None):
236236
"""
237-
Return all constraint names matching the columns and conditions. Modified from base `_constraint_names`
238-
`any_column_matches`=False: (default) only return constraints covering exactly `column_names`
239-
`any_column_matches`=True : return any constraints which include at least 1 of `column_names`
237+
Return all constraint names matching the columns and conditions.
238+
Modified from base `_constraint_names` but with the following new arguments:
239+
- `unique_constraint` which explicitly finds unique implemented by CONSTRAINT not by an INDEX
240+
- `column_match_any`:
241+
False: (default) only return constraints covering exactly `column_names`
242+
True : return any constraints which include at least 1 of `column_names`
240243
"""
241244
if column_names is not None:
242245
column_names = [
@@ -533,7 +536,7 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
533536

534537
# Restore indexes & unique constraints deleted above, SQL Server requires explicit restoration
535538
if (old_type != new_type or (old_field.null != new_field.null)) and (
536-
old_field.column == new_field.column
539+
old_field.column == new_field.column # column rename is handled separately above
537540
):
538541
# Restore unique constraints
539542
# Note: if nullable they are implemented via an explicit filtered UNIQUE INDEX (not CONSTRAINT)
@@ -688,8 +691,20 @@ def _delete_indexes(self, model, old_field, new_field):
688691

689692
def _delete_unique_constraints(self, model, old_field, new_field, strict=False):
690693
unique_columns = []
694+
# Considering just this column, we only need to drop unique constraints in advance of altering the field
695+
# *if* it remains unique - if it wasn't unique before there's nothing to drop; if it won't remain unique
696+
# afterwards then that is handled separately in _alter_field
691697
if old_field.unique and new_field.unique:
692698
unique_columns.append([old_field.column])
699+
700+
# Also consider unique_together because, although this is implemented with a filtered unique INDEX now, we
701+
# need to handle the possibility that we're acting on a database previously created by an older version of
702+
# this backend, where unique_together used to be implemented with a CONSTRAINT
703+
for fields in model._meta.unique_together:
704+
columns = [model._meta.get_field(field).column for field in fields]
705+
if old_field.column in columns:
706+
unique_columns.append(columns)
707+
693708
if unique_columns:
694709
for columns in unique_columns:
695710
self._delete_unique_constraint_for_columns(model, columns, strict=strict)
@@ -709,10 +724,12 @@ def _delete_unique_constraint_for_columns(self, model, columns, strict=False, **
709724
len(constraint_names),
710725
repr(columns),
711726
))
727+
# Delete constraints which are implemented as a table CONSTRAINT (this may include some created by an
728+
# older version of this backend, even if the current version would implement it with an INDEX instead)
712729
for constraint_name in constraint_names_normal:
713730
self.execute(self._delete_constraint_sql(self.sql_delete_unique, model, constraint_name))
714-
# Unique indexes which are not table constraints must be deleted using the appropriate SQL.
715-
# These may exist for example to enforce ANSI-compliant unique constraints on nullable columns.
731+
# Delete constraints which are implemented with an explicit index instead (not a table CONSTRAINT)
732+
# These are used for example to enforce ANSI-compliant unique constraints on nullable columns.
716733
for index_name in constraint_names_index:
717734
self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
718735

testapp/tests/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import django.db
2+
3+
4+
def get_constraints(table_name):
5+
connection = django.db.connections[django.db.DEFAULT_DB_ALIAS]
6+
return connection.introspection.get_constraints(
7+
connection.cursor(),
8+
table_name=table_name,
9+
)
10+
11+
12+
def get_constraint_names_where(table_name, **kwargs):
13+
return [
14+
name
15+
for name, details in get_constraints(table_name=table_name).items()
16+
if all(details[k] == v for k, v in kwargs.items())
17+
]

testapp/tests/test_constraints.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the BSD license.
3+
import logging
34

5+
import django.db.utils
46
from django.db import connections, migrations, models
57
from django.db.migrations.state import ProjectState
68
from django.db.utils import IntegrityError
79
from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature
810

911
from mssql.base import DatabaseWrapper
12+
from . import get_constraint_names_where
1013
from ..models import (
1114
Author,
1215
Editor,
@@ -17,6 +20,8 @@
1720
TestRenameManyToManyFieldModel,
1821
)
1922

23+
logger = logging.getLogger('mssql.tests')
24+
2025

2126
@skipUnlessDBFeature('supports_nullable_unique_constraints')
2227
class TestNullableUniqueColumn(TestCase):
@@ -83,6 +88,82 @@ def test_after_type_change(self):
8388
TestNullableUniqueTogetherModel.objects.create(a='aaa', b='bbb', c='ccc')
8489

8590

91+
class TestHandleOldStyleUniqueTogether(TransactionTestCase):
92+
"""
93+
Regression test for https://github.com/microsoft/mssql-django/issues/137
94+
95+
Start with a unique_together which was created by an older version of this backend code, which implemented
96+
it with a table CONSTRAINT instead of a filtered UNIQUE INDEX like the current code does.
97+
e.g. django-mssql-backend < v2.6.0 or (before that) all versions of django-pyodbc-azure
98+
99+
Then alter the type of a column (e.g. max_length of CharField) which is part of that unique_together and
100+
check that the (old-style) CONSTRAINT is dropped before (& a new-style UNIQUE INDEX created afterwards).
101+
"""
102+
def test_drop_old_unique_together_constraint(self):
103+
class TestMigrationA(migrations.Migration):
104+
initial = True
105+
106+
operations = [
107+
migrations.CreateModel(
108+
name='TestHandleOldStyleUniqueTogether',
109+
fields=[
110+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
111+
('foo', models.CharField(max_length=50)),
112+
('bar', models.CharField(max_length=50)),
113+
],
114+
),
115+
# Create the unique_together so that Django knows it exists, however we will deliberately drop
116+
# it (filtered unique INDEX) below & manually replace with the old implementation (CONSTRAINT)
117+
migrations.AlterUniqueTogether(
118+
name='testhandleoldstyleuniquetogether',
119+
unique_together={('foo', 'bar')}
120+
),
121+
]
122+
123+
class TestMigrationB(migrations.Migration):
124+
operations = [
125+
# Alter the type of the field to trigger the _alter_field code which drops/recreats indexes/constraints
126+
migrations.AlterField(
127+
model_name='testhandleoldstyleuniquetogether',
128+
name='foo',
129+
field=models.CharField(max_length=99),
130+
)
131+
]
132+
133+
migration_a = TestMigrationA(name='test_drop_old_unique_together_constraint_a', app_label='testapp')
134+
migration_b = TestMigrationB(name='test_drop_old_unique_together_constraint_b', app_label='testapp')
135+
136+
connection = connections['default']
137+
138+
# Setup
139+
with connection.schema_editor(atomic=True) as editor:
140+
project_state = migration_a.apply(ProjectState(), editor)
141+
142+
# Manually replace the unique_together-enforcing INDEX with the old implementation using a CONSTRAINT instead
143+
# to simulate the state of a database which had been migrated using an older version of this backend
144+
table_name = 'testapp_testhandleoldstyleuniquetogether'
145+
unique_index_names = get_constraint_names_where(table_name=table_name, index=True, unique=True)
146+
assert len(unique_index_names) == 1
147+
unique_together_name = unique_index_names[0]
148+
logger.debug('Replacing UNIQUE INDEX %s with a CONSTRAINT of the same name', unique_together_name)
149+
with connection.schema_editor(atomic=True) as editor:
150+
# Out with the new
151+
editor.execute('DROP INDEX [%s] ON [%s]' % (unique_together_name, table_name))
152+
# In with the old, so that we end up in the state that an old database might be in
153+
editor.execute('ALTER TABLE [%s] ADD CONSTRAINT [%s] UNIQUE ([foo], [bar])' % (table_name, unique_together_name))
154+
155+
# Test by running AlterField
156+
with connection.schema_editor(atomic=True) as editor:
157+
# If this doesn't explode then all is well. Without the bugfix, the CONSTRAINT wasn't dropped before,
158+
# so then re-instating the unique_together using an INDEX of the same name (after altering the field)
159+
# would fail due to the presence of a CONSTRAINT (really still an index under the hood) with that name.
160+
try:
161+
migration_b.apply(project_state, editor)
162+
except django.db.utils.DatabaseError as e:
163+
logger.exception('Failed to AlterField:')
164+
self.fail('Check for regression of issue #137, AlterField failed with exception: %s' % e)
165+
166+
86167
class TestRenameManyToManyField(TestCase):
87168
def test_uniqueness_still_enforced_afterwards(self):
88169
# Issue https://github.com/microsoft/mssql-django/issues/86
@@ -139,7 +220,7 @@ class TestMigration(migrations.Migration):
139220
),
140221
]
141222

142-
migration = TestMigration('testapp', 'test_unsupportable_unique_constraint')
223+
migration = TestMigration(name='test_unsupportable_unique_constraint', app_label='testapp')
143224

144225
with connection.schema_editor(atomic=True) as editor:
145226
with self.assertRaisesRegex(

testapp/tests/test_indexes.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.db.utils import DEFAULT_DB_ALIAS, ConnectionHandler, ProgrammingError
99
from django.test import TestCase
1010

11+
from . import get_constraints
1112
from ..models import (
1213
TestIndexesRetainedRenamed,
1314
Choice,
@@ -26,14 +27,6 @@
2627
logger = logging.getLogger('mssql.tests')
2728

2829

29-
def _get_constraints(table_name):
30-
connection = django.db.connections[django.db.DEFAULT_DB_ALIAS]
31-
return connection.introspection.get_constraints(
32-
connection.cursor(),
33-
table_name=table_name,
34-
)
35-
36-
3730
class TestIndexesRetained(TestCase):
3831
"""
3932
Issue https://github.com/microsoft/mssql-django/issues/14
@@ -46,7 +39,7 @@ def setUpClass(cls):
4639
super().setUpClass()
4740
# Pre-fetch which indexes exist for the relevant test model
4841
# now that all the test migrations have run
49-
cls.constraints = _get_constraints(table_name=TestIndexesRetainedRenamed._meta.db_table)
42+
cls.constraints = get_constraints(table_name=TestIndexesRetainedRenamed._meta.db_table)
5043
cls.indexes = {k: v for k, v in cls.constraints.items() if v['index'] is True}
5144

5245
def _assert_index_exists(self, columns):
@@ -97,7 +90,7 @@ def test_correct_indexes_exist(self):
9790
if not model_cls._meta.managed:
9891
# Models where the table is not managed by Django migrations are irrelevant
9992
continue
100-
model_constraints = _get_constraints(table_name=model_cls._meta.db_table)
93+
model_constraints = get_constraints(table_name=model_cls._meta.db_table)
10194
# Check correct indexes are in place for all fields in model
10295
for field in model_cls._meta.get_fields():
10396
if not hasattr(field, 'column'):

0 commit comments

Comments
 (0)