Browse Source

Refs #32943 -- Added support for covering exclusion constraints using SP-GiST indexes on PostgreSQL 14+.

Nick Pope 3 years ago
parent
commit
c2f6c05c4c

+ 16 - 6
django/contrib/postgres/constraints.py

@@ -45,10 +45,6 @@ class ExclusionConstraint(BaseConstraint):
             raise ValueError(
                 'ExclusionConstraint.include must be a list or tuple.'
             )
-        if include and index_type and index_type.lower() != 'gist':
-            raise ValueError(
-                'Covering exclusion constraints only support GiST indexes.'
-            )
         if not isinstance(opclasses, (list, tuple)):
             raise ValueError(
                 'ExclusionConstraint.opclasses must be a list or tuple.'
@@ -124,9 +120,23 @@ class ExclusionConstraint(BaseConstraint):
         )
 
     def check_supported(self, schema_editor):
-        if self.include and not schema_editor.connection.features.supports_covering_gist_indexes:
+        if (
+            self.include and
+            self.index_type.lower() == 'gist' and
+            not schema_editor.connection.features.supports_covering_gist_indexes
+        ):
+            raise NotSupportedError(
+                'Covering exclusion constraints using a GiST index require '
+                'PostgreSQL 12+.'
+            )
+        if (
+            self.include and
+            self.index_type.lower() == 'spgist' and
+            not schema_editor.connection.features.supports_covering_spgist_indexes
+        ):
             raise NotSupportedError(
-                'Covering exclusion constraints require PostgreSQL 12+.'
+                'Covering exclusion constraints using an SP-GiST index '
+                'require PostgreSQL 14+.'
             )
 
     def deconstruct(self):

+ 7 - 1
docs/ref/contrib/postgres/constraints.txt

@@ -115,7 +115,13 @@ used for queries that select only included fields
 (:attr:`~ExclusionConstraint.include`) and filter only by indexed fields
 (:attr:`~ExclusionConstraint.expressions`).
 
-``include`` is supported only for GiST indexes on PostgreSQL 12+.
+``include`` is supported for GiST indexes on PostgreSQL 12+ and SP-GiST
+indexes on PostgreSQL 14+.
+
+.. versionchanged:: 4.1
+
+    Support for covering exclusion constraints using SP-GiST indexes on
+    PostgreSQL 14+ was added.
 
 ``opclasses``
 -------------

+ 4 - 0
docs/releases/4.1.txt

@@ -71,6 +71,10 @@ Minor features
 * :class:`~django.contrib.postgres.indexes.SpGistIndex` now supports covering
   indexes on PostgreSQL 14+.
 
+* :class:`~django.contrib.postgres.constraints.ExclusionConstraint` now
+  supports covering exclusion constraints using SP-GiST indexes on PostgreSQL
+  14+.
+
 :mod:`django.contrib.redirects`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 104 - 22
tests/postgres_tests/test_constraints.py

@@ -271,16 +271,6 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
                 include='invalid',
             )
 
-    def test_invalid_include_index_type(self):
-        msg = 'Covering exclusion constraints only support GiST indexes.'
-        with self.assertRaisesMessage(ValueError, msg):
-            ExclusionConstraint(
-                name='exclude_invalid_index_type',
-                expressions=[(F('datespan'), RangeOperators.OVERLAPS)],
-                include=['cancelled'],
-                index_type='spgist',
-            )
-
     def test_invalid_opclasses_type(self):
         msg = 'ExclusionConstraint.opclasses must be a list or tuple.'
         with self.assertRaisesMessage(ValueError, msg):
@@ -709,14 +699,33 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         RangesModel.objects.create(ints=(51, 60))
 
     @skipUnlessDBFeature('supports_covering_gist_indexes')
-    def test_range_adjacent_include(self):
-        constraint_name = 'ints_adjacent_include'
+    def test_range_adjacent_gist_include(self):
+        constraint_name = 'ints_adjacent_gist_include'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
             expressions=[('ints', RangeOperators.ADJACENT_TO)],
-            include=['decimals', 'ints'],
             index_type='gist',
+            include=['decimals', 'ints'],
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        RangesModel.objects.create(ints=(20, 50))
+        with self.assertRaises(IntegrityError), transaction.atomic():
+            RangesModel.objects.create(ints=(10, 20))
+        RangesModel.objects.create(ints=(10, 19))
+        RangesModel.objects.create(ints=(51, 60))
+
+    @skipUnlessDBFeature('supports_covering_spgist_indexes')
+    def test_range_adjacent_spgist_include(self):
+        constraint_name = 'ints_adjacent_spgist_include'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='spgist',
+            include=['decimals', 'ints'],
         )
         with connection.schema_editor() as editor:
             editor.add_constraint(RangesModel, constraint)
@@ -728,12 +737,28 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         RangesModel.objects.create(ints=(51, 60))
 
     @skipUnlessDBFeature('supports_covering_gist_indexes')
-    def test_range_adjacent_include_condition(self):
-        constraint_name = 'ints_adjacent_include_condition'
+    def test_range_adjacent_gist_include_condition(self):
+        constraint_name = 'ints_adjacent_gist_include_condition'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
             expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='gist',
+            include=['decimals'],
+            condition=Q(id__gte=100),
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+
+    @skipUnlessDBFeature('supports_covering_spgist_indexes')
+    def test_range_adjacent_spgist_include_condition(self):
+        constraint_name = 'ints_adjacent_spgist_include_condition'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='spgist',
             include=['decimals'],
             condition=Q(id__gte=100),
         )
@@ -742,12 +767,28 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
     @skipUnlessDBFeature('supports_covering_gist_indexes')
-    def test_range_adjacent_include_deferrable(self):
-        constraint_name = 'ints_adjacent_include_deferrable'
+    def test_range_adjacent_gist_include_deferrable(self):
+        constraint_name = 'ints_adjacent_gist_include_deferrable'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='gist',
+            include=['decimals'],
+            deferrable=Deferrable.DEFERRED,
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+
+    @skipUnlessDBFeature('supports_covering_spgist_indexes')
+    def test_range_adjacent_spgist_include_deferrable(self):
+        constraint_name = 'ints_adjacent_spgist_include_deferrable'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
             expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='spgist',
             include=['decimals'],
             deferrable=Deferrable.DEFERRED,
         )
@@ -755,14 +796,18 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
             editor.add_constraint(RangesModel, constraint)
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
-    def test_include_not_supported(self):
-        constraint_name = 'ints_adjacent_include_not_supported'
+    def test_gist_include_not_supported(self):
+        constraint_name = 'ints_adjacent_gist_include_not_supported'
         constraint = ExclusionConstraint(
             name=constraint_name,
             expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='gist',
             include=['id'],
         )
-        msg = 'Covering exclusion constraints require PostgreSQL 12+.'
+        msg = (
+            'Covering exclusion constraints using a GiST index require '
+            'PostgreSQL 12+.'
+        )
         with connection.schema_editor() as editor:
             with mock.patch(
                 'django.db.backends.postgresql.features.DatabaseFeatures.supports_covering_gist_indexes',
@@ -771,6 +816,27 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
                 with self.assertRaisesMessage(NotSupportedError, msg):
                     editor.add_constraint(RangesModel, constraint)
 
+    def test_spgist_include_not_supported(self):
+        constraint_name = 'ints_adjacent_spgist_include_not_supported'
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='spgist',
+            include=['id'],
+        )
+        msg = (
+            'Covering exclusion constraints using an SP-GiST index require '
+            'PostgreSQL 14+.'
+        )
+        with connection.schema_editor() as editor:
+            with mock.patch(
+                'django.db.backends.postgresql.features.DatabaseFeatures.'
+                'supports_covering_spgist_indexes',
+                False,
+            ):
+                with self.assertRaisesMessage(NotSupportedError, msg):
+                    editor.add_constraint(RangesModel, constraint)
+
     def test_range_adjacent_opclasses(self):
         constraint_name = 'ints_adjacent_opclasses'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@@ -819,12 +885,28 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
     @skipUnlessDBFeature('supports_covering_gist_indexes')
-    def test_range_adjacent_opclasses_include(self):
-        constraint_name = 'ints_adjacent_opclasses_include'
+    def test_range_adjacent_gist_opclasses_include(self):
+        constraint_name = 'ints_adjacent_gist_opclasses_include'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='gist',
+            opclasses=['range_ops'],
+            include=['decimals'],
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+
+    @skipUnlessDBFeature('supports_covering_spgist_indexes')
+    def test_range_adjacent_spgist_opclasses_include(self):
+        constraint_name = 'ints_adjacent_spgist_opclasses_include'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
             expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='spgist',
             opclasses=['range_ops'],
             include=['decimals'],
         )