Description
Problem
Unique constraints which are now implemented using a filtered UNIQUE INDEX
(to get desired behaviour for multiple NULL
s) are not correctly reinstated after an AlterField
e.g. if the column type is changed.
This happens in at least these cases:
- after changing the type of a field which has
null=True
andunique=True
- after changing the type of a field involved in a
unique_together
The constraint is reinstated using ALTER TABLE ... ADD CONSTRAINT
instead of CREATE UNIQUE INDEX ... WHERE x is NOT NULL
This means the unique constraint is no longer getting the ANSI-compliant NULL behaviour which was recently introduced by PR #1 (for single columns) and PR #24 (for unique_together) to allow for multiple NULL
values.
Symptoms
If there are already multiple NULL
values in the table, the migration will fail because reinstating the over-strict constraint fails with an error like this:
django.db.utils.IntegrityError: ('23000', "[23000] [Microsoft][ODBC Driver 13 for SQL Server][SQL Server]The CREATE UNIQUE INDEX statement terminated because a duplicate key was found for the object name 'dbo.myapp_foomodel' and the index name 'myapp_foomodel_field_a_d7c02f64_uniq'. The duplicate key value is (<NULL>). (1505) (SQLExecDirectW); [23000] [Microsoft][ODBC Driver 13 for SQL Server][SQL Server]Could not create constraint or index. See previous errors. (1750); [23000] [Microsoft][ODBC Driver 13 for SQL Server][SQL Server]The statement has been terminated. (3621)")
(note: despite the error message saying CREATE UNIQUE INDEX
this error is thrown during execution of ALTER TABLE ... ADD CONSTRAINT
- presumably because internally in MSSQL this results in running CREATE UNIQUE INDEX
without any conditions/filter)
Or if there aren't multiple NULLs then the migration may 'succeed' but:
- have the wrong runtime behaviour with multiple NULLs
- potentially fail on a future migration step - for example in the 2nd case with
unique_together
, if in future anAlterUniqueTogether
tries to add/remove a field, it will first try to delete the existing constraint as if it were aUNIQUE INDEX
(which it should be) but actually it's aCONSTRAINT
so the later migration will fail like this:
django.db.utils.ProgrammingError: ('42000', "[42000] [Microsoft][ODBC Driver 13 for SQL Server][SQL Server]An explicit DROP INDEX is not allowed on index 'bar_baz_deadbeef_uniq'. It is being used for UNIQUE KEY constraint enforcement. (3723) (SQLExecDirectW)")
Reproduction steps
I can reproduce this in a minimal Django project with using:
Django==2.2.10
django-mssql-backend==2.8.0
pyodbc==4.0.30
pytz==2019.3
sqlparse==0.3.0
- Add the following model and run
makemigrations
and thenmigrate
class FooModel(models.Model):
field_a = models.CharField(max_length=10, null=True, unique=True)
- Create 2 rows which both have
field_a
asNULL
- Edit
max_length
to say15
,makemigrations
again and runmigrate
- This explodes with an error as in the first example above
To repro the 2nd example:
- Add
field_x = models.CharField(max_length=10)
which is in aunique_together
with at least 1 other field e.g.field_y
- Then do
AlterField
onfield_x
to change it tomax_length=15
(this will succeed if there are no rows in the table) - Then do
AlterUniqueTogether
to add a 3rd fieldfield_z
to theunique_together
- This explodes with an error like the second example above
Cause
As far as I can tell the issue is that #24 changed the way such unique indexes were implemented, but did not ensure that _alter_field
reinstates them in the same way, after they are temporarily dropped in order to make changes to the field.
Proposed solution
In _alter_field
under # Restore an index, SQL Server requires explicit restoration
, the handling of unique=True
/unique_together
needs to reinstate filtered UNIQUE INDEX
rather than a CONSTRAINT
in the relevant cases.
I am working on a PR for this.