diff options
| author | Hannes Ljungberg <hannes@5monkeys.se> | 2019-10-31 13:33:53 +0100 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2020-06-04 12:26:22 +0200 |
| commit | 8c7992f658e1125f8dc20b19206e8bbbe4187372 (patch) | |
| tree | ba09524a9a3d27bb33f22dc92dec4b60a80f37e1 /tests | |
| parent | f997b5e6ae85e2df2342b1a7812fe8130206c957 (diff) | |
Fixed #30913 -- Added support for covering indexes on PostgreSQL 11+.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/constraints/models.py | 17 | ||||
| -rw-r--r-- | tests/constraints/tests.py | 61 | ||||
| -rw-r--r-- | tests/indexes/tests.py | 121 | ||||
| -rw-r--r-- | tests/invalid_models_tests/test_models.py | 159 | ||||
| -rw-r--r-- | tests/migrations/test_operations.py | 124 | ||||
| -rw-r--r-- | tests/model_indexes/tests.py | 38 | ||||
| -rw-r--r-- | tests/postgres_tests/test_indexes.py | 29 | ||||
| -rw-r--r-- | tests/schema/tests.py | 1 |
8 files changed, 548 insertions, 2 deletions
diff --git a/tests/constraints/models.py b/tests/constraints/models.py index 3d091f6ccf..947d1b4ff1 100644 --- a/tests/constraints/models.py +++ b/tests/constraints/models.py @@ -81,6 +81,23 @@ class UniqueConstraintDeferrable(models.Model): ] +class UniqueConstraintInclude(models.Model): + name = models.CharField(max_length=255) + color = models.CharField(max_length=32, null=True) + + class Meta: + required_db_features = { + 'supports_table_check_constraints', + } + constraints = [ + models.UniqueConstraint( + fields=['name'], + name='name_include_color_uniq', + include=['color'], + ), + ] + + class AbstractModel(models.Model): age = models.IntegerField() diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py index 8eb62a940d..02320e30b1 100644 --- a/tests/constraints/tests.py +++ b/tests/constraints/tests.py @@ -8,7 +8,8 @@ from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature from .models import ( ChildModel, Product, UniqueConstraintConditionProduct, - UniqueConstraintDeferrable, UniqueConstraintProduct, + UniqueConstraintDeferrable, UniqueConstraintInclude, + UniqueConstraintProduct, ) @@ -181,6 +182,20 @@ class UniqueConstraintTests(TestCase): self.assertEqual(constraint_1, constraint_1) self.assertNotEqual(constraint_1, constraint_2) + def test_eq_with_include(self): + constraint_1 = models.UniqueConstraint( + fields=['foo', 'bar'], + name='include', + include=['baz_1'], + ) + constraint_2 = models.UniqueConstraint( + fields=['foo', 'bar'], + name='include', + include=['baz_2'], + ) + self.assertEqual(constraint_1, constraint_1) + self.assertNotEqual(constraint_1, constraint_2) + def test_repr(self): fields = ['foo', 'bar'] name = 'unique_fields' @@ -214,6 +229,18 @@ class UniqueConstraintTests(TestCase): "deferrable=Deferrable.IMMEDIATE>", ) + def test_repr_with_include(self): + constraint = models.UniqueConstraint( + fields=['foo', 'bar'], + name='include_fields', + include=['baz_1', 'baz_2'], + ) + self.assertEqual( + repr(constraint), + "<UniqueConstraint: fields=('foo', 'bar') name='include_fields' " + "include=('baz_1', 'baz_2')>", + ) + def test_deconstruction(self): fields = ['foo', 'bar'] name = 'unique_fields' @@ -250,6 +277,20 @@ class UniqueConstraintTests(TestCase): 'deferrable': models.Deferrable.DEFERRED, }) + def test_deconstruction_with_include(self): + fields = ['foo', 'bar'] + name = 'unique_fields' + include = ['baz_1', 'baz_2'] + constraint = models.UniqueConstraint(fields=fields, name=name, include=include) + path, args, kwargs = constraint.deconstruct() + self.assertEqual(path, 'django.db.models.UniqueConstraint') + self.assertEqual(args, ()) + self.assertEqual(kwargs, { + 'fields': tuple(fields), + 'name': name, + 'include': tuple(include), + }) + def test_database_constraint(self): with self.assertRaises(IntegrityError): UniqueConstraintProduct.objects.create(name=self.p1.name, color=self.p1.color) @@ -333,3 +374,21 @@ class UniqueConstraintTests(TestCase): name='name_invalid', deferrable='invalid', ) + + @skipUnlessDBFeature( + 'supports_table_check_constraints', + 'supports_covering_indexes', + ) + def test_include_database_constraint(self): + UniqueConstraintInclude.objects.create(name='p1', color='red') + with self.assertRaises(IntegrityError): + UniqueConstraintInclude.objects.create(name='p1', color='blue') + + def test_invalid_include_argument(self): + msg = 'UniqueConstraint.include must be a list or tuple.' + with self.assertRaisesMessage(ValueError, msg): + models.UniqueConstraint( + name='uniq_include', + fields=['field'], + include='other', + ) diff --git a/tests/indexes/tests.py b/tests/indexes/tests.py index 274ee54a37..3c4541f684 100644 --- a/tests/indexes/tests.py +++ b/tests/indexes/tests.py @@ -236,6 +236,41 @@ class SchemaIndexesPostgreSQLTests(TransactionTestCase): cursor.execute(self.get_opclass_query % indexname) self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', indexname)]) + @skipUnlessDBFeature('supports_covering_indexes') + def test_ops_class_include(self): + index_name = 'test_ops_class_include' + index = Index( + name=index_name, + fields=['body'], + opclasses=['text_pattern_ops'], + include=['headline'], + ) + with connection.schema_editor() as editor: + editor.add_index(IndexedArticle2, index) + with editor.connection.cursor() as cursor: + cursor.execute(self.get_opclass_query % index_name) + self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)]) + + @skipUnlessDBFeature('supports_covering_indexes') + def test_ops_class_include_tablespace(self): + index_name = 'test_ops_class_include_tblspace' + index = Index( + name=index_name, + fields=['body'], + opclasses=['text_pattern_ops'], + include=['headline'], + db_tablespace='pg_default', + ) + with connection.schema_editor() as editor: + editor.add_index(IndexedArticle2, index) + self.assertIn( + 'TABLESPACE "pg_default"', + str(index.create_sql(IndexedArticle2, editor)), + ) + with editor.connection.cursor() as cursor: + cursor.execute(self.get_opclass_query % index_name) + self.assertCountEqual(cursor.fetchall(), [('text_pattern_ops', index_name)]) + def test_ops_class_columns_lists_sql(self): index = Index( fields=['headline'], @@ -417,3 +452,89 @@ class PartialIndexTests(TransactionTestCase): cursor=cursor, table_name=Article._meta.db_table, )) editor.remove_index(index=index, model=Article) + + +@skipUnlessDBFeature('supports_covering_indexes') +class CoveringIndexTests(TransactionTestCase): + available_apps = ['indexes'] + + def test_covering_index(self): + index = Index( + name='covering_headline_idx', + fields=['headline'], + include=['pub_date', 'published'], + ) + with connection.schema_editor() as editor: + self.assertIn( + '(%s) INCLUDE (%s, %s)' % ( + editor.quote_name('headline'), + editor.quote_name('pub_date'), + editor.quote_name('published'), + ), + str(index.create_sql(Article, editor)), + ) + editor.add_index(Article, index) + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints( + cursor=cursor, table_name=Article._meta.db_table, + ) + self.assertIn(index.name, constraints) + self.assertEqual( + constraints[index.name]['columns'], + ['headline', 'pub_date', 'published'], + ) + editor.remove_index(Article, index) + with connection.cursor() as cursor: + self.assertNotIn(index.name, connection.introspection.get_constraints( + cursor=cursor, table_name=Article._meta.db_table, + )) + + def test_covering_partial_index(self): + index = Index( + name='covering_partial_headline_idx', + fields=['headline'], + include=['pub_date'], + condition=Q(pub_date__isnull=False), + ) + with connection.schema_editor() as editor: + self.assertIn( + '(%s) INCLUDE (%s) WHERE %s ' % ( + editor.quote_name('headline'), + editor.quote_name('pub_date'), + editor.quote_name('pub_date'), + ), + str(index.create_sql(Article, editor)), + ) + editor.add_index(Article, index) + with connection.cursor() as cursor: + constraints = connection.introspection.get_constraints( + cursor=cursor, table_name=Article._meta.db_table, + ) + self.assertIn(index.name, constraints) + self.assertEqual( + constraints[index.name]['columns'], + ['headline', 'pub_date'], + ) + editor.remove_index(Article, index) + with connection.cursor() as cursor: + self.assertNotIn(index.name, connection.introspection.get_constraints( + cursor=cursor, table_name=Article._meta.db_table, + )) + + +@skipIfDBFeature('supports_covering_indexes') +class CoveringIndexIgnoredTests(TransactionTestCase): + available_apps = ['indexes'] + + def test_covering_ignored(self): + index = Index( + name='test_covering_ignored', + fields=['headline'], + include=['pub_date'], + ) + with connection.schema_editor() as editor: + editor.add_index(Article, index) + self.assertNotIn( + 'INCLUDE (%s)' % editor.quote_name('headline'), + str(index.create_sql(Article, editor)), + ) diff --git a/tests/invalid_models_tests/test_models.py b/tests/invalid_models_tests/test_models.py index 7847ee8486..374eff6028 100644 --- a/tests/invalid_models_tests/test_models.py +++ b/tests/invalid_models_tests/test_models.py @@ -375,6 +375,78 @@ class IndexesTests(TestCase): self.assertEqual(Model.check(databases=self.databases), []) + def test_index_include_pointing_to_missing_field(self): + class Model(models.Model): + class Meta: + indexes = [ + models.Index(fields=['id'], include=['missing_field'], name='name'), + ] + + self.assertEqual(Model.check(databases=self.databases), [ + Error( + "'indexes' refers to the nonexistent field 'missing_field'.", + obj=Model, + id='models.E012', + ), + ]) + + def test_index_include_pointing_to_m2m_field(self): + class Model(models.Model): + m2m = models.ManyToManyField('self') + + class Meta: + indexes = [models.Index(fields=['id'], include=['m2m'], name='name')] + + self.assertEqual(Model.check(databases=self.databases), [ + Error( + "'indexes' refers to a ManyToManyField 'm2m', but " + "ManyToManyFields are not permitted in 'indexes'.", + obj=Model, + id='models.E013', + ), + ]) + + def test_index_include_pointing_to_non_local_field(self): + class Parent(models.Model): + field1 = models.IntegerField() + + class Child(Parent): + field2 = models.IntegerField() + + class Meta: + indexes = [ + models.Index(fields=['field2'], include=['field1'], name='name'), + ] + + self.assertEqual(Child.check(databases=self.databases), [ + Error( + "'indexes' refers to field 'field1' which is not local to " + "model 'Child'.", + hint='This issue may be caused by multi-table inheritance.', + obj=Child, + id='models.E016', + ), + ]) + + def test_index_include_pointing_to_fk(self): + class Target(models.Model): + pass + + class Model(models.Model): + fk_1 = models.ForeignKey(Target, models.CASCADE, related_name='target_1') + fk_2 = models.ForeignKey(Target, models.CASCADE, related_name='target_2') + + class Meta: + constraints = [ + models.Index( + fields=['id'], + include=['fk_1_id', 'fk_2'], + name='name', + ), + ] + + self.assertEqual(Model.check(databases=self.databases), []) + @isolate_apps('invalid_models_tests') class FieldNamesTests(TestCase): @@ -1568,3 +1640,90 @@ class ConstraintsTests(TestCase): ] self.assertEqual(Model.check(databases=self.databases), []) + + def test_unique_constraint_include_pointing_to_missing_field(self): + class Model(models.Model): + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['id'], + include=['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', + ), + ]) + + def test_unique_constraint_include_pointing_to_m2m_field(self): + class Model(models.Model): + m2m = models.ManyToManyField('self') + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['id'], + include=['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', + ), + ]) + + def test_unique_constraint_include_pointing_to_non_local_field(self): + class Parent(models.Model): + field1 = models.IntegerField() + + class Child(Parent): + field2 = models.IntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['field2'], + include=['field1'], + name='name', + ), + ] + + self.assertEqual(Child.check(databases=self.databases), [ + Error( + "'constraints' refers to field 'field1' which is not local to " + "model 'Child'.", + hint='This issue may be caused by multi-table inheritance.', + obj=Child, + id='models.E016', + ), + ]) + + def test_unique_constraint_include_pointing_to_fk(self): + class Target(models.Model): + pass + + class Model(models.Model): + fk_1 = models.ForeignKey(Target, models.CASCADE, related_name='target_1') + fk_2 = models.ForeignKey(Target, models.CASCADE, related_name='target_2') + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['id'], + include=['fk_1_id', 'fk_2'], + name='name', + ), + ] + + self.assertEqual(Model.check(databases=self.databases), []) diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 855a052058..588186fae4 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -448,6 +448,48 @@ class OperationTests(OperationTestBase): [deferred_unique_constraint], ) + @skipUnlessDBFeature('supports_covering_indexes') + def test_create_model_with_covering_unique_constraint(self): + covering_unique_constraint = models.UniqueConstraint( + fields=['pink'], + include=['weight'], + name='test_constraint_pony_pink_covering_weight', + ) + operation = migrations.CreateModel( + 'Pony', + [ + ('id', models.AutoField(primary_key=True)), + ('pink', models.IntegerField(default=3)), + ('weight', models.FloatField()), + ], + options={'constraints': [covering_unique_constraint]}, + ) + project_state = ProjectState() + new_state = project_state.clone() + operation.state_forwards('test_crmo', new_state) + self.assertEqual(len(new_state.models['test_crmo', 'pony'].options['constraints']), 1) + self.assertTableNotExists('test_crmo_pony') + # Create table. + with connection.schema_editor() as editor: + operation.database_forwards('test_crmo', editor, project_state, new_state) + self.assertTableExists('test_crmo_pony') + Pony = new_state.apps.get_model('test_crmo', 'Pony') + Pony.objects.create(pink=1, weight=4.0) + with self.assertRaises(IntegrityError): + Pony.objects.create(pink=1, weight=7.0) + # Reversal. + with connection.schema_editor() as editor: + operation.database_backwards('test_crmo', editor, new_state, project_state) + self.assertTableNotExists('test_crmo_pony') + # Deconstruction. + definition = operation.deconstruct() + self.assertEqual(definition[0], 'CreateModel') + self.assertEqual(definition[1], []) + self.assertEqual( + definition[2]['options']['constraints'], + [covering_unique_constraint], + ) + def test_create_model_managers(self): """ The managers on a model are set. @@ -2236,6 +2278,88 @@ class OperationTests(OperationTestBase): 'name': 'deferred_pink_constraint_rm', }) + def test_add_covering_unique_constraint(self): + app_label = 'test_addcovering_uc' + project_state = self.set_up_test_model(app_label) + covering_unique_constraint = models.UniqueConstraint( + fields=['pink'], + name='covering_pink_constraint_add', + include=['weight'], + ) + operation = migrations.AddConstraint('Pony', covering_unique_constraint) + self.assertEqual( + operation.describe(), + 'Create constraint covering_pink_constraint_add on model Pony', + ) + # Add constraint. + new_state = project_state.clone() + operation.state_forwards(app_label, new_state) + self.assertEqual(len(new_state.models[app_label, 'pony'].options['constraints']), 1) + Pony = new_state.apps.get_model(app_label, 'Pony') + self.assertEqual(len(Pony._meta.constraints), 1) + with connection.schema_editor() as editor: + operation.database_forwards(app_label, editor, project_state, new_state) + Pony.objects.create(pink=1, weight=4.0) + if connection.features.supports_covering_indexes: + with self.assertRaises(IntegrityError): + Pony.objects.create(pink=1, weight=4.0) + else: + Pony.objects.create(pink=1, weight=4.0) + # Reversal. + with connection.schema_editor() as editor: + operation.database_backwards(app_label, editor, new_state, project_state) + # Constraint doesn't work. + Pony.objects.create(pink=1, weight=4.0) + # Deconstruction. + definition = operation.deconstruct() + self.assertEqual(definition[0], 'AddConstraint') + self.assertEqual(definition[1], []) + self.assertEqual( + definition[2], + {'model_name': 'Pony', 'constraint': covering_unique_constraint}, + ) + + def test_remove_covering_unique_constraint(self): + app_label = 'test_removecovering_uc' + covering_unique_constraint = models.UniqueConstraint( + fields=['pink'], + name='covering_pink_constraint_rm', + include=['weight'], + ) + project_state = self.set_up_test_model(app_label, constraints=[covering_unique_constraint]) + operation = migrations.RemoveConstraint('Pony', covering_unique_constraint.name) + self.assertEqual( + operation.describe(), + 'Remove constraint covering_pink_constraint_rm from model Pony', + ) + # Remove constraint. + 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) + with connection.schema_editor() as editor: + operation.database_forwards(app_label, editor, project_state, new_state) + # 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_covering_indexes: + with self.assertRaises(IntegrityError): + Pony.objects.create(pink=1, weight=4.0) + else: + Pony.objects.create(pink=1, weight=4.0) + # Deconstruction. + definition = operation.deconstruct() + self.assertEqual(definition[0], 'RemoveConstraint') + self.assertEqual(definition[1], []) + self.assertEqual(definition[2], { + 'model_name': 'Pony', + 'name': 'covering_pink_constraint_rm', + }) + def test_alter_model_options(self): """ Tests the AlterModelOptions operation. diff --git a/tests/model_indexes/tests.py b/tests/model_indexes/tests.py index 15ec702937..ff3c6c73c7 100644 --- a/tests/model_indexes/tests.py +++ b/tests/model_indexes/tests.py @@ -17,9 +17,18 @@ class SimpleIndexesTests(SimpleTestCase): index = models.Index(fields=['title']) multi_col_index = models.Index(fields=['title', 'author']) partial_index = models.Index(fields=['title'], name='long_books_idx', condition=models.Q(pages__gt=400)) + covering_index = models.Index( + fields=['title'], + name='include_idx', + include=['author', 'pages'], + ) self.assertEqual(repr(index), "<Index: fields='title'>") self.assertEqual(repr(multi_col_index), "<Index: fields='title, author'>") self.assertEqual(repr(partial_index), "<Index: fields='title', condition=(AND: ('pages__gt', 400))>") + self.assertEqual( + repr(covering_index), + "<Index: fields='title', include='author, pages'>", + ) def test_eq(self): index = models.Index(fields=['title']) @@ -65,6 +74,16 @@ class SimpleIndexesTests(SimpleTestCase): with self.assertRaisesMessage(ValueError, 'Index.condition must be a Q instance.'): models.Index(condition='invalid', name='long_book_idx') + def test_include_requires_list_or_tuple(self): + msg = 'Index.include must be a list or tuple.' + with self.assertRaisesMessage(ValueError, msg): + models.Index(name='test_include', fields=['field'], include='other') + + def test_include_requires_index_name(self): + msg = 'A covering index must be named.' + with self.assertRaisesMessage(ValueError, msg): + models.Index(fields=['field'], include=['other']) + def test_name_auto_generation(self): index = models.Index(fields=['author']) index.set_name_with_model(Book) @@ -128,6 +147,25 @@ class SimpleIndexesTests(SimpleTestCase): } ) + def test_deconstruct_with_include(self): + index = models.Index( + name='book_include_idx', + fields=['title'], + include=['author'], + ) + index.set_name_with_model(Book) + path, args, kwargs = index.deconstruct() + self.assertEqual(path, 'django.db.models.Index') + self.assertEqual(args, ()) + self.assertEqual( + kwargs, + { + 'fields': ['title'], + 'name': 'model_index_title_196f42_idx', + 'include': ('author',), + }, + ) + def test_clone(self): index = models.Index(fields=['title']) new_index = index.clone() diff --git a/tests/postgres_tests/test_indexes.py b/tests/postgres_tests/test_indexes.py index 0440beda5d..b9888f4843 100644 --- a/tests/postgres_tests/test_indexes.py +++ b/tests/postgres_tests/test_indexes.py @@ -11,7 +11,7 @@ from django.test import skipUnlessDBFeature from django.test.utils import register_lookup from . import PostgreSQLSimpleTestCase, PostgreSQLTestCase -from .models import CharFieldModel, IntegerArrayModel +from .models import CharFieldModel, IntegerArrayModel, Scene class IndexTestMixin: @@ -373,6 +373,33 @@ class SchemaTests(PostgreSQLTestCase): editor.remove_index(CharFieldModel, index) self.assertNotIn(index_name, self.get_constraints(CharFieldModel._meta.db_table)) + @skipUnlessDBFeature('supports_covering_gist_indexes') + def test_gist_include(self): + index_name = 'scene_gist_include_setting' + index = GistIndex(name=index_name, fields=['scene'], include=['setting']) + with connection.schema_editor() as editor: + editor.add_index(Scene, index) + constraints = self.get_constraints(Scene._meta.db_table) + self.assertIn(index_name, constraints) + self.assertEqual(constraints[index_name]['type'], GistIndex.suffix) + self.assertEqual(constraints[index_name]['columns'], ['scene', 'setting']) + with connection.schema_editor() as editor: + editor.remove_index(Scene, index) + self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table)) + + def test_gist_include_not_supported(self): + index_name = 'gist_include_exception' + index = GistIndex(fields=['scene'], name=index_name, include=['setting']) + msg = 'Covering GiST indexes requires PostgreSQL 12+.' + with self.assertRaisesMessage(NotSupportedError, msg): + with mock.patch( + 'django.db.backends.postgresql.features.DatabaseFeatures.supports_covering_gist_indexes', + False, + ): + with connection.schema_editor() as editor: + editor.add_index(Scene, index) + self.assertNotIn(index_name, self.get_constraints(Scene._meta.db_table)) + def test_hash_index(self): # Ensure the table is there and doesn't have an index. self.assertNotIn('field', self.get_constraints(CharFieldModel._meta.db_table)) diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 2942b90130..5868d520e8 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -2587,6 +2587,7 @@ class SchemaTests(TransactionTestCase): "columns": editor.quote_name(column), "extra": "", "condition": "", + "include": "", } ) self.assertIn(expected_constraint_name, self.get_constraints(model._meta.db_table)) |
