summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorHannes Ljungberg <hannes.ljungberg@gmail.com>2021-02-06 20:45:54 +0100
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2021-02-23 20:19:53 +0100
commit3aa545281e0c0f9fac93753e3769df9e0334dbaa (patch)
tree454bec922a78df9ecfe6eab197e7750e8a5bafac /tests
parent19ce1d493ae7cbc1e704d338077b1f5f5e5769c9 (diff)
Fixed #30916 -- Added support for functional unique constraints.
Thanks Ian Foote and Mariusz Felisiak for reviews.
Diffstat (limited to 'tests')
-rw-r--r--tests/constraints/tests.py84
-rw-r--r--tests/invalid_models_tests/test_models.py141
-rw-r--r--tests/migrations/test_operations.py93
-rw-r--r--tests/postgres_tests/test_constraints.py33
-rw-r--r--tests/schema/tests.py240
5 files changed, 589 insertions, 2 deletions
diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py
index cb366d8e45..3d59d07b24 100644
--- a/tests/constraints/tests.py
+++ b/tests/constraints/tests.py
@@ -2,7 +2,9 @@ from unittest import mock
from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection, models
+from django.db.models import F
from django.db.models.constraints import BaseConstraint
+from django.db.models.functions import Lower
from django.db.transaction import atomic
from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
@@ -25,6 +27,10 @@ class BaseConstraintTests(SimpleTestCase):
with self.assertRaisesMessage(NotImplementedError, msg):
c.constraint_sql(None, None)
+ def test_contains_expressions(self):
+ c = BaseConstraint('name')
+ self.assertIs(c.contains_expressions, False)
+
def test_create_sql(self):
c = BaseConstraint('name')
msg = 'This method must be implemented by a subclass.'
@@ -218,6 +224,25 @@ class UniqueConstraintTests(TestCase):
self.assertEqual(constraint_1, constraint_1)
self.assertNotEqual(constraint_1, constraint_2)
+ def test_eq_with_expressions(self):
+ constraint = models.UniqueConstraint(
+ Lower('title'),
+ F('author'),
+ name='book_func_uq',
+ )
+ same_constraint = models.UniqueConstraint(
+ Lower('title'),
+ 'author',
+ name='book_func_uq',
+ )
+ another_constraint = models.UniqueConstraint(
+ Lower('title'),
+ name='book_func_uq',
+ )
+ self.assertEqual(constraint, same_constraint)
+ self.assertEqual(constraint, mock.ANY)
+ self.assertNotEqual(constraint, another_constraint)
+
def test_repr(self):
fields = ['foo', 'bar']
name = 'unique_fields'
@@ -275,6 +300,18 @@ class UniqueConstraintTests(TestCase):
"opclasses=['text_pattern_ops', 'varchar_pattern_ops']>",
)
+ def test_repr_with_expressions(self):
+ constraint = models.UniqueConstraint(
+ Lower('title'),
+ F('author'),
+ name='book_func_uq',
+ )
+ self.assertEqual(
+ repr(constraint),
+ "<UniqueConstraint: expressions=(Lower(F(title)), F(author)) "
+ "name='book_func_uq'>",
+ )
+
def test_deconstruction(self):
fields = ['foo', 'bar']
name = 'unique_fields'
@@ -339,6 +376,14 @@ class UniqueConstraintTests(TestCase):
'opclasses': opclasses,
})
+ def test_deconstruction_with_expressions(self):
+ name = 'unique_fields'
+ constraint = models.UniqueConstraint(Lower('title'), name=name)
+ path, args, kwargs = constraint.deconstruct()
+ self.assertEqual(path, 'django.db.models.UniqueConstraint')
+ self.assertEqual(args, (Lower('title'),))
+ self.assertEqual(kwargs, {'name': name})
+
def test_database_constraint(self):
with self.assertRaises(IntegrityError):
UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color)
@@ -434,6 +479,15 @@ class UniqueConstraintTests(TestCase):
deferrable=models.Deferrable.DEFERRED,
)
+ def test_deferrable_with_expressions(self):
+ message = 'UniqueConstraint with expressions cannot be deferred.'
+ with self.assertRaisesMessage(ValueError, message):
+ models.UniqueConstraint(
+ Lower('name'),
+ name='deferred_expression_unique',
+ deferrable=models.Deferrable.DEFERRED,
+ )
+
def test_invalid_defer_argument(self):
message = 'UniqueConstraint.deferrable must be a Deferrable instance.'
with self.assertRaisesMessage(ValueError, message):
@@ -481,3 +535,33 @@ class UniqueConstraintTests(TestCase):
fields=['field'],
opclasses=['foo', 'bar'],
)
+
+ def test_requires_field_or_expression(self):
+ msg = (
+ 'At least one field or expression is required to define a unique '
+ 'constraint.'
+ )
+ with self.assertRaisesMessage(ValueError, msg):
+ models.UniqueConstraint(name='name')
+
+ def test_expressions_and_fields_mutually_exclusive(self):
+ msg = 'UniqueConstraint.fields and expressions are mutually exclusive.'
+ with self.assertRaisesMessage(ValueError, msg):
+ models.UniqueConstraint(Lower('field_1'), fields=['field_2'], name='name')
+
+ def test_expressions_with_opclasses(self):
+ msg = (
+ 'UniqueConstraint.opclasses cannot be used with expressions. Use '
+ 'django.contrib.postgres.indexes.OpClass() instead.'
+ )
+ with self.assertRaisesMessage(ValueError, msg):
+ models.UniqueConstraint(
+ Lower('field'),
+ name='test_func_opclass',
+ opclasses=['jsonb_path_ops'],
+ )
+
+ def test_requires_name(self):
+ msg = 'A unique constraint must be named.'
+ with self.assertRaisesMessage(ValueError, msg):
+ models.UniqueConstraint(fields=['field'])
diff --git a/tests/invalid_models_tests/test_models.py b/tests/invalid_models_tests/test_models.py
index b20eef7159..c79684487d 100644
--- a/tests/invalid_models_tests/test_models.py
+++ b/tests/invalid_models_tests/test_models.py
@@ -2178,3 +2178,144 @@ class ConstraintsTests(TestCase):
]
self.assertEqual(Model.check(databases=self.databases), [])
+
+ def test_func_unique_constraint(self):
+ class Model(models.Model):
+ name = models.CharField(max_length=10)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(Lower('name'), name='lower_name_uq'),
+ ]
+
+ warn = Warning(
+ '%s does not support unique constraints on expressions.'
+ % connection.display_name,
+ hint=(
+ "A constraint won't be created. Silence this warning if you "
+ "don't care about it."
+ ),
+ obj=Model,
+ id='models.W044',
+ )
+ expected = [] if connection.features.supports_expression_indexes else [warn]
+ self.assertEqual(Model.check(databases=self.databases), expected)
+
+ def test_func_unique_constraint_required_db_features(self):
+ class Model(models.Model):
+ name = models.CharField(max_length=10)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(Lower('name'), name='lower_name_unq'),
+ ]
+ required_db_features = {'supports_expression_indexes'}
+
+ self.assertEqual(Model.check(databases=self.databases), [])
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_expression_custom_lookup(self):
+ class Model(models.Model):
+ height = models.IntegerField()
+ weight = models.IntegerField()
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ models.F('height') / (models.F('weight__abs') + models.Value(5)),
+ name='name',
+ ),
+ ]
+
+ with register_lookup(models.IntegerField, Abs):
+ self.assertEqual(Model.check(databases=self.databases), [])
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_pointing_to_missing_field(self):
+ class Model(models.Model):
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(Lower('missing_field').desc(), name='name'),
+ ]
+
+ self.assertEqual(Model.check(databases=self.databases), [
+ Error(
+ "'constraints' refers to the nonexistent field "
+ "'missing_field'.",
+ obj=Model,
+ id='models.E012',
+ ),
+ ])
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_pointing_to_missing_field_nested(self):
+ class Model(models.Model):
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(Abs(Round('missing_field')), name='name'),
+ ]
+
+ self.assertEqual(Model.check(databases=self.databases), [
+ Error(
+ "'constraints' refers to the nonexistent field "
+ "'missing_field'.",
+ obj=Model,
+ id='models.E012',
+ ),
+ ])
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_pointing_to_m2m_field(self):
+ class Model(models.Model):
+ m2m = models.ManyToManyField('self')
+
+ class Meta:
+ constraints = [models.UniqueConstraint(Lower('m2m'), name='name')]
+
+ self.assertEqual(Model.check(databases=self.databases), [
+ Error(
+ "'constraints' refers to a ManyToManyField 'm2m', but "
+ "ManyToManyFields are not permitted in 'constraints'.",
+ obj=Model,
+ id='models.E013',
+ ),
+ ])
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_pointing_to_non_local_field(self):
+ class Foo(models.Model):
+ field1 = models.CharField(max_length=15)
+
+ class Bar(Foo):
+ class Meta:
+ constraints = [models.UniqueConstraint(Lower('field1'), name='name')]
+
+ self.assertEqual(Bar.check(databases=self.databases), [
+ Error(
+ "'constraints' refers to field 'field1' which is not local to "
+ "model 'Bar'.",
+ hint='This issue may be caused by multi-table inheritance.',
+ obj=Bar,
+ id='models.E016',
+ ),
+ ])
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_pointing_to_fk(self):
+ class Foo(models.Model):
+ id = models.CharField(primary_key=True, max_length=255)
+
+ class Bar(models.Model):
+ foo_1 = models.ForeignKey(Foo, models.CASCADE, related_name='bar_1')
+ foo_2 = models.ForeignKey(Foo, models.CASCADE, related_name='bar_2')
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ Lower('foo_1_id'),
+ Lower('foo_2'),
+ name='name',
+ ),
+ ]
+
+ self.assertEqual(Bar.check(databases=self.databases), [])
diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py
index 73a0c63921..cbd65bdb37 100644
--- a/tests/migrations/test_operations.py
+++ b/tests/migrations/test_operations.py
@@ -2562,6 +2562,99 @@ class OperationTests(OperationTestBase):
'name': 'covering_pink_constraint_rm',
})
+ def test_add_func_unique_constraint(self):
+ app_label = 'test_adfuncuc'
+ constraint_name = f'{app_label}_pony_abs_uq'
+ table_name = f'{app_label}_pony'
+ project_state = self.set_up_test_model(app_label)
+ constraint = models.UniqueConstraint(Abs('weight'), name=constraint_name)
+ operation = migrations.AddConstraint('Pony', constraint)
+ self.assertEqual(
+ operation.describe(),
+ 'Create constraint test_adfuncuc_pony_abs_uq on model Pony',
+ )
+ self.assertEqual(
+ operation.migration_name_fragment,
+ 'pony_test_adfuncuc_pony_abs_uq',
+ )
+ new_state = project_state.clone()
+ operation.state_forwards(app_label, new_state)
+ self.assertEqual(len(new_state.models[app_label, 'pony'].options['constraints']), 1)
+ self.assertIndexNameNotExists(table_name, constraint_name)
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ operation.database_forwards(app_label, editor, project_state, new_state)
+ Pony = new_state.apps.get_model(app_label, 'Pony')
+ Pony.objects.create(weight=4.0)
+ if connection.features.supports_expression_indexes:
+ self.assertIndexNameExists(table_name, constraint_name)
+ with self.assertRaises(IntegrityError):
+ Pony.objects.create(weight=-4.0)
+ else:
+ self.assertIndexNameNotExists(table_name, constraint_name)
+ Pony.objects.create(weight=-4.0)
+ # Reversal.
+ with connection.schema_editor() as editor:
+ operation.database_backwards(app_label, editor, new_state, project_state)
+ self.assertIndexNameNotExists(table_name, constraint_name)
+ # Constraint doesn't work.
+ Pony.objects.create(weight=-4.0)
+ # Deconstruction.
+ definition = operation.deconstruct()
+ self.assertEqual(definition[0], 'AddConstraint')
+ self.assertEqual(definition[1], [])
+ self.assertEqual(
+ definition[2],
+ {'model_name': 'Pony', 'constraint': constraint},
+ )
+
+ def test_remove_func_unique_constraint(self):
+ app_label = 'test_rmfuncuc'
+ constraint_name = f'{app_label}_pony_abs_uq'
+ table_name = f'{app_label}_pony'
+ project_state = self.set_up_test_model(app_label, constraints=[
+ models.UniqueConstraint(Abs('weight'), name=constraint_name),
+ ])
+ self.assertTableExists(table_name)
+ if connection.features.supports_expression_indexes:
+ self.assertIndexNameExists(table_name, constraint_name)
+ operation = migrations.RemoveConstraint('Pony', constraint_name)
+ self.assertEqual(
+ operation.describe(),
+ 'Remove constraint test_rmfuncuc_pony_abs_uq from model Pony',
+ )
+ self.assertEqual(
+ operation.migration_name_fragment,
+ 'remove_pony_test_rmfuncuc_pony_abs_uq',
+ )
+ new_state = project_state.clone()
+ operation.state_forwards(app_label, new_state)
+ self.assertEqual(len(new_state.models[app_label, 'pony'].options['constraints']), 0)
+ Pony = new_state.apps.get_model(app_label, 'Pony')
+ self.assertEqual(len(Pony._meta.constraints), 0)
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ operation.database_forwards(app_label, editor, project_state, new_state)
+ self.assertIndexNameNotExists(table_name, constraint_name)
+ # Constraint doesn't work.
+ Pony.objects.create(pink=1, weight=4.0)
+ Pony.objects.create(pink=1, weight=-4.0).delete()
+ # Reversal.
+ with connection.schema_editor() as editor:
+ operation.database_backwards(app_label, editor, new_state, project_state)
+ if connection.features.supports_expression_indexes:
+ self.assertIndexNameExists(table_name, constraint_name)
+ with self.assertRaises(IntegrityError):
+ Pony.objects.create(weight=-4.0)
+ else:
+ self.assertIndexNameNotExists(table_name, constraint_name)
+ Pony.objects.create(weight=-4.0)
+ # Deconstruction.
+ definition = operation.deconstruct()
+ self.assertEqual(definition[0], 'RemoveConstraint')
+ self.assertEqual(definition[1], [])
+ self.assertEqual(definition[2], {'model_name': 'Pony', 'name': constraint_name})
+
def test_alter_model_options(self):
"""
Tests the AlterModelOptions operation.
diff --git a/tests/postgres_tests/test_constraints.py b/tests/postgres_tests/test_constraints.py
index 80c2bb77b6..7915bb226e 100644
--- a/tests/postgres_tests/test_constraints.py
+++ b/tests/postgres_tests/test_constraints.py
@@ -1,6 +1,7 @@
import datetime
from unittest import mock
+from django.contrib.postgres.indexes import OpClass
from django.db import (
IntegrityError, NotSupportedError, connection, transaction,
)
@@ -8,8 +9,8 @@ from django.db.models import (
CheckConstraint, Deferrable, F, Func, IntegerField, Q, UniqueConstraint,
)
from django.db.models.fields.json import KeyTextTransform
-from django.db.models.functions import Cast, Left
-from django.test import skipUnlessDBFeature
+from django.db.models.functions import Cast, Left, Lower
+from django.test import modify_settings, skipUnlessDBFeature
from django.utils import timezone
from . import PostgreSQLTestCase
@@ -26,6 +27,7 @@ except ImportError:
pass
+@modify_settings(INSTALLED_APPS={'append': 'django.contrib.postgres'})
class SchemaTests(PostgreSQLTestCase):
get_opclass_query = '''
SELECT opcname, c.relname FROM pg_opclass AS oc
@@ -166,6 +168,33 @@ class SchemaTests(PostgreSQLTestCase):
[('varchar_pattern_ops', constraint.name)],
)
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_opclass_func(self):
+ constraint = UniqueConstraint(
+ OpClass(Lower('scene'), name='text_pattern_ops'),
+ name='test_opclass_func',
+ )
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Scene, constraint)
+ constraints = self.get_constraints(Scene._meta.db_table)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ self.assertIn(constraint.name, constraints)
+ with editor.connection.cursor() as cursor:
+ cursor.execute(self.get_opclass_query, [constraint.name])
+ self.assertEqual(
+ cursor.fetchall(),
+ [('text_pattern_ops', constraint.name)],
+ )
+ Scene.objects.create(scene='Scene 10', setting='The dark forest of Ewing')
+ with self.assertRaises(IntegrityError), transaction.atomic():
+ Scene.objects.create(scene='ScEnE 10', setting="Sir Bedemir's Castle")
+ Scene.objects.create(scene='Scene 5', setting="Sir Bedemir's Castle")
+ # Drop the constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(Scene, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(Scene._meta.db_table))
+ Scene.objects.create(scene='ScEnE 10', setting="Sir Bedemir's Castle")
+
class ExclusionConstraintTests(PostgreSQLTestCase):
def get_constraints(self, table):
diff --git a/tests/schema/tests.py b/tests/schema/tests.py
index 4522f5faf4..fd8874e157 100644
--- a/tests/schema/tests.py
+++ b/tests/schema/tests.py
@@ -2189,6 +2189,246 @@ class SchemaTests(TransactionTestCase):
AuthorWithUniqueNameAndBirthday._meta.constraints = []
editor.remove_constraint(AuthorWithUniqueNameAndBirthday, constraint)
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ constraint = UniqueConstraint(Upper('name').desc(), name='func_upper_uq')
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Author, constraint)
+ sql = constraint.create_sql(Author, editor)
+ table = Author._meta.db_table
+ constraints = self.get_constraints(table)
+ if connection.features.supports_index_column_ordering:
+ self.assertIndexOrder(table, constraint.name, ['DESC'])
+ self.assertIn(constraint.name, constraints)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ # SQL contains a database function.
+ self.assertIs(sql.references_column(table, 'name'), True)
+ self.assertIn('UPPER(%s)' % editor.quote_name('name'), str(sql))
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(Author, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_composite_func_unique_constraint(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ editor.create_model(BookWithSlug)
+ constraint = UniqueConstraint(
+ Upper('title'),
+ Lower('slug'),
+ name='func_upper_lower_unq',
+ )
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(BookWithSlug, constraint)
+ sql = constraint.create_sql(BookWithSlug, editor)
+ table = BookWithSlug._meta.db_table
+ constraints = self.get_constraints(table)
+ self.assertIn(constraint.name, constraints)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ # SQL contains database functions.
+ self.assertIs(sql.references_column(table, 'title'), True)
+ self.assertIs(sql.references_column(table, 'slug'), True)
+ sql = str(sql)
+ self.assertIn('UPPER(%s)' % editor.quote_name('title'), sql)
+ self.assertIn('LOWER(%s)' % editor.quote_name('slug'), sql)
+ self.assertLess(sql.index('UPPER'), sql.index('LOWER'))
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(BookWithSlug, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_unique_constraint_field_and_expression(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ constraint = UniqueConstraint(
+ F('height').desc(),
+ 'uuid',
+ Lower('name').asc(),
+ name='func_f_lower_field_unq',
+ )
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Author, constraint)
+ sql = constraint.create_sql(Author, editor)
+ table = Author._meta.db_table
+ if connection.features.supports_index_column_ordering:
+ self.assertIndexOrder(table, constraint.name, ['DESC', 'ASC', 'ASC'])
+ constraints = self.get_constraints(table)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ self.assertEqual(len(constraints[constraint.name]['columns']), 3)
+ self.assertEqual(constraints[constraint.name]['columns'][1], 'uuid')
+ # SQL contains database functions and columns.
+ self.assertIs(sql.references_column(table, 'height'), True)
+ self.assertIs(sql.references_column(table, 'name'), True)
+ self.assertIs(sql.references_column(table, 'uuid'), True)
+ self.assertIn('LOWER(%s)' % editor.quote_name('name'), str(sql))
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(Author, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipUnlessDBFeature('supports_expression_indexes', 'supports_partial_indexes')
+ def test_func_unique_constraint_partial(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ constraint = UniqueConstraint(
+ Upper('name'),
+ name='func_upper_cond_weight_uq',
+ condition=Q(weight__isnull=False),
+ )
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Author, constraint)
+ sql = constraint.create_sql(Author, editor)
+ table = Author._meta.db_table
+ constraints = self.get_constraints(table)
+ self.assertIn(constraint.name, constraints)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ self.assertIs(sql.references_column(table, 'name'), True)
+ self.assertIn('UPPER(%s)' % editor.quote_name('name'), str(sql))
+ self.assertIn(
+ 'WHERE %s IS NOT NULL' % editor.quote_name('weight'),
+ str(sql),
+ )
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(Author, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipUnlessDBFeature('supports_expression_indexes', 'supports_covering_indexes')
+ def test_func_unique_constraint_covering(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ constraint = UniqueConstraint(
+ Upper('name'),
+ name='func_upper_covering_uq',
+ include=['weight', 'height'],
+ )
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Author, constraint)
+ sql = constraint.create_sql(Author, editor)
+ table = Author._meta.db_table
+ constraints = self.get_constraints(table)
+ self.assertIn(constraint.name, constraints)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ self.assertEqual(
+ constraints[constraint.name]['columns'],
+ [None, 'weight', 'height'],
+ )
+ self.assertIs(sql.references_column(table, 'name'), True)
+ self.assertIs(sql.references_column(table, 'weight'), True)
+ self.assertIs(sql.references_column(table, 'height'), True)
+ self.assertIn('UPPER(%s)' % editor.quote_name('name'), str(sql))
+ self.assertIn(
+ 'INCLUDE (%s, %s)' % (
+ editor.quote_name('weight'),
+ editor.quote_name('height'),
+ ),
+ str(sql),
+ )
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(Author, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_lookups(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ with register_lookup(CharField, Lower), register_lookup(IntegerField, Abs):
+ constraint = UniqueConstraint(
+ F('name__lower'),
+ F('weight__abs'),
+ name='func_lower_abs_lookup_uq',
+ )
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Author, constraint)
+ sql = constraint.create_sql(Author, editor)
+ table = Author._meta.db_table
+ constraints = self.get_constraints(table)
+ self.assertIn(constraint.name, constraints)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ # SQL contains columns.
+ self.assertIs(sql.references_column(table, 'name'), True)
+ self.assertIs(sql.references_column(table, 'weight'), True)
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(Author, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_collate(self):
+ collation = connection.features.test_collations.get('non_default')
+ if not collation:
+ self.skipTest(
+ 'This backend does not support case-insensitive collations.'
+ )
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ editor.create_model(BookWithSlug)
+ constraint = UniqueConstraint(
+ Collate(F('title'), collation=collation).desc(),
+ Collate('slug', collation=collation),
+ name='func_collate_uq',
+ )
+ # Add constraint.
+ with connection.schema_editor() as editor:
+ editor.add_constraint(BookWithSlug, constraint)
+ sql = constraint.create_sql(BookWithSlug, editor)
+ table = BookWithSlug._meta.db_table
+ constraints = self.get_constraints(table)
+ self.assertIn(constraint.name, constraints)
+ self.assertIs(constraints[constraint.name]['unique'], True)
+ if connection.features.supports_index_column_ordering:
+ self.assertIndexOrder(table, constraint.name, ['DESC', 'ASC'])
+ # SQL contains columns and a collation.
+ self.assertIs(sql.references_column(table, 'title'), True)
+ self.assertIs(sql.references_column(table, 'slug'), True)
+ self.assertIn('COLLATE %s' % editor.quote_name(collation), str(sql))
+ # Remove constraint.
+ with connection.schema_editor() as editor:
+ editor.remove_constraint(BookWithSlug, constraint)
+ self.assertNotIn(constraint.name, self.get_constraints(table))
+
+ @skipIfDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_unsupported(self):
+ # UniqueConstraint is ignored on databases that don't support indexes on
+ # expressions.
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ constraint = UniqueConstraint(F('name'), name='func_name_uq')
+ with connection.schema_editor() as editor, self.assertNumQueries(0):
+ self.assertIsNone(editor.add_constraint(Author, constraint))
+ self.assertIsNone(editor.remove_constraint(Author, constraint))
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_nonexistent_field(self):
+ constraint = UniqueConstraint(Lower('nonexistent'), name='func_nonexistent_uq')
+ msg = (
+ "Cannot resolve keyword 'nonexistent' into field. Choices are: "
+ "height, id, name, uuid, weight"
+ )
+ with self.assertRaisesMessage(FieldError, msg):
+ with connection.schema_editor() as editor:
+ editor.add_constraint(Author, constraint)
+
+ @skipUnlessDBFeature('supports_expression_indexes')
+ def test_func_unique_constraint_nondeterministic(self):
+ with connection.schema_editor() as editor:
+ editor.create_model(Author)
+ constraint = UniqueConstraint(Random(), name='func_random_uq')
+ with connection.schema_editor() as editor:
+ with self.assertRaises(DatabaseError):
+ editor.add_constraint(Author, constraint)
+
def test_index_together(self):
"""
Tests removing and adding index_together constraints on a model.