diff options
| author | Hannes Ljungberg <hannes.ljungberg@gmail.com> | 2021-02-06 20:45:54 +0100 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2021-02-23 20:19:53 +0100 |
| commit | 3aa545281e0c0f9fac93753e3769df9e0334dbaa (patch) | |
| tree | 454bec922a78df9ecfe6eab197e7750e8a5bafac /tests | |
| parent | 19ce1d493ae7cbc1e704d338077b1f5f5e5769c9 (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.py | 84 | ||||
| -rw-r--r-- | tests/invalid_models_tests/test_models.py | 141 | ||||
| -rw-r--r-- | tests/migrations/test_operations.py | 93 | ||||
| -rw-r--r-- | tests/postgres_tests/test_constraints.py | 33 | ||||
| -rw-r--r-- | tests/schema/tests.py | 240 |
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. |
