From 5a44cf9280f6e98a866042dec5f77b44511749f1 Mon Sep 17 00:00:00 2001 From: David Beitey Date: Fri, 15 Jan 2021 19:03:26 +1000 Subject: [PATCH] Error on unsupported unique constraint conditions CREATE INDEX in SQL Server only supports AND conditions (not OR) as part of its WHERE syntax. This change handles that situation by raising an error from the schema editor class. This change adds unit tests to confirm this happens against a SQL Server database. --- sql_server/pyodbc/schema.py | 9 ++- .../0008_test_unique_constraints.py | 68 +++++++++++++++++++ testapp/models.py | 37 ++++++++++ testapp/tests/test_constraints.py | 62 +++++++++++++++-- 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 testapp/migrations/0008_test_unique_constraints.py diff --git a/sql_server/pyodbc/schema.py b/sql_server/pyodbc/schema.py index b7188228..1928c598 100644 --- a/sql_server/pyodbc/schema.py +++ b/sql_server/pyodbc/schema.py @@ -14,8 +14,9 @@ Statement as DjStatement, Table, ) -from django.db.models import Index +from django.db.models import Index, UniqueConstraint from django.db.models.fields import AutoField, BigAutoField +from django.db.models.sql.where import AND from django.db.transaction import TransactionManagementError from django.utils.encoding import force_str @@ -970,3 +971,9 @@ def remove_field(self, model, field): for sql in list(self.deferred_sql): if isinstance(sql, Statement) and sql.references_column(model._meta.db_table, field.column): self.deferred_sql.remove(sql) + + def add_constraint(self, model, constraint): + if isinstance(constraint, UniqueConstraint) and constraint.condition and constraint.condition.connector != AND: + raise NotImplementedError("The backend does not support %s conditions on unique constraint %s." % + (constraint.condition.connector, constraint.name)) + super().add_constraint(model, constraint) diff --git a/testapp/migrations/0008_test_unique_constraints.py b/testapp/migrations/0008_test_unique_constraints.py new file mode 100644 index 00000000..6becc49e --- /dev/null +++ b/testapp/migrations/0008_test_unique_constraints.py @@ -0,0 +1,68 @@ +# Generated by Django 3.1.5 on 2021-01-18 00:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('testapp', '0007_test_remove_onetoone_field_part2'), + ] + + operations = [ + migrations.CreateModel( + name='TestUnsupportableUniqueConstraint', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('_type', models.CharField(max_length=50)), + ('status', models.CharField(max_length=50)), + ], + options={ + 'managed': False, + }, + ), + migrations.CreateModel( + name='TestSupportableUniqueConstraint', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('_type', models.CharField(max_length=50)), + ('status', models.CharField(max_length=50)), + ], + ), + migrations.AddConstraint( + model_name='testsupportableuniqueconstraint', + constraint=models.UniqueConstraint( + condition=models.Q( + ('status', 'in_progress'), + ('status', 'needs_changes'), + ('status', 'published'), + ), + fields=('_type',), + name='and_constraint', + ), + ), + migrations.AddConstraint( + model_name='testsupportableuniqueconstraint', + constraint=models.UniqueConstraint( + condition=models.Q(status__in=['in_progress', 'needs_changes']), + fields=('_type',), + name='in_constraint', + ), + ), + ] diff --git a/testapp/models.py b/testapp/models.py index c87f797b..5ae20445 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -1,6 +1,7 @@ import uuid from django.db import models +from django.db.models import Q from django.utils import timezone @@ -71,3 +72,39 @@ class TestRemoveOneToOneFieldModel(models.Model): # thats already is removed. # b = models.OneToOneField('self', on_delete=models.SET_NULL, null=True) a = models.CharField(max_length=50) + + +class TestUnsupportableUniqueConstraint(models.Model): + class Meta: + managed = False + constraints = [ + models.UniqueConstraint( + name='or_constraint', + fields=['_type'], + condition=(Q(status='in_progress') | Q(status='needs_changes')), + ), + ] + + _type = models.CharField(max_length=50) + status = models.CharField(max_length=50) + + +class TestSupportableUniqueConstraint(models.Model): + class Meta: + constraints = [ + models.UniqueConstraint( + name='and_constraint', + fields=['_type'], + condition=( + Q(status='in_progress') & Q(status='needs_changes') & Q(status='published') + ), + ), + models.UniqueConstraint( + name='in_constraint', + fields=['_type'], + condition=(Q(status__in=['in_progress', 'needs_changes'])), + ), + ] + + _type = models.CharField(max_length=50) + status = models.CharField(max_length=50) diff --git a/testapp/tests/test_constraints.py b/testapp/tests/test_constraints.py index 523a2c85..bc61586a 100644 --- a/testapp/tests/test_constraints.py +++ b/testapp/tests/test_constraints.py @@ -1,9 +1,14 @@ -from django.db.utils import IntegrityError -from django.test import TestCase, skipUnlessDBFeature +from django.db import IntegrityError, connections, migrations, models +from django.db.migrations.state import ProjectState +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature +from sql_server.pyodbc.base import DatabaseWrapper from ..models import ( - Author, Editor, Post, - TestUniqueNullableModel, TestNullableUniqueTogetherModel, + Author, + Editor, + Post, + TestUniqueNullableModel, + TestNullableUniqueTogetherModel, ) @@ -52,3 +57,52 @@ def test_after_type_change(self): TestNullableUniqueTogetherModel.objects.create(a='aaa', b='bbb', c='ccc') with self.assertRaises(IntegrityError): TestNullableUniqueTogetherModel.objects.create(a='aaa', b='bbb', c='ccc') + + +class TestUniqueConstraints(TransactionTestCase): + def test_unsupportable_unique_constraint(self): + # Only execute tests when running against SQL Server + connection = connections['default'] + if isinstance(connection, DatabaseWrapper): + + class TestMigration(migrations.Migration): + initial = True + + operations = [ + migrations.CreateModel( + name='TestUnsupportableUniqueConstraint', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('_type', models.CharField(max_length=50)), + ('status', models.CharField(max_length=50)), + ], + ), + migrations.AddConstraint( + model_name='testunsupportableuniqueconstraint', + constraint=models.UniqueConstraint( + condition=models.Q( + ('status', 'in_progress'), + ('status', 'needs_changes'), + _connector='OR', + ), + fields=('_type',), + name='or_constraint', + ), + ), + ] + + migration = TestMigration('testapp', 'test_unsupportable_unique_constraint') + + with connection.schema_editor(atomic=True) as editor: + with self.assertRaisesRegex( + NotImplementedError, "does not support OR conditions" + ): + return migration.apply(ProjectState(), editor)