From c2f6c05c4cc73e831b7e852eb58bd6d7a83fa46c Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Fri, 28 May 2021 23:56:23 +0100 Subject: [PATCH] Refs #32943 -- Added support for covering exclusion constraints using SP-GiST indexes on PostgreSQL 14+. --- django/contrib/postgres/constraints.py | 22 ++-- docs/ref/contrib/postgres/constraints.txt | 8 +- docs/releases/4.1.txt | 4 + tests/postgres_tests/test_constraints.py | 126 ++++++++++++++++++---- 4 files changed, 131 insertions(+), 29 deletions(-) diff --git a/django/contrib/postgres/constraints.py b/django/contrib/postgres/constraints.py index a670628d8af..3b12098fe9c 100644 --- a/django/contrib/postgres/constraints.py +++ b/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 require PostgreSQL 12+.' + '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 using an SP-GiST index ' + 'require PostgreSQL 14+.' ) def deconstruct(self): diff --git a/docs/ref/contrib/postgres/constraints.txt b/docs/ref/contrib/postgres/constraints.txt index cb4347dd956..be06b907ffa 100644 --- a/docs/ref/contrib/postgres/constraints.txt +++ b/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`` ------------- diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index da805df3153..e74e3e07f10 100644 --- a/docs/releases/4.1.txt +++ b/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` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/postgres_tests/test_constraints.py b/tests/postgres_tests/test_constraints.py index dfe63be61b0..bf0b57488ab 100644 --- a/tests/postgres_tests/test_constraints.py +++ b/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,13 @@ 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, ) @@ -755,14 +781,33 @@ 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' + @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, + ) + with connection.schema_editor() as editor: + editor.add_constraint(RangesModel, constraint) + self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) + + 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'], )