Skip to content

Commit e0cdd0f

Browse files
hannsemanfelixxm
authored andcommitted
Fixed #31649 -- Added support for covering exclusion constraints on PostgreSQL 12+.
1 parent db8268b commit e0cdd0f

File tree

4 files changed

+175
-7
lines changed

4 files changed

+175
-7
lines changed

django/contrib/postgres/constraints.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.db import NotSupportedError
12
from django.db.backends.ddl_references import Statement, Table
23
from django.db.models import Deferrable, F, Q
34
from django.db.models.constraints import BaseConstraint
@@ -7,11 +8,11 @@
78

89

910
class ExclusionConstraint(BaseConstraint):
10-
template = 'CONSTRAINT %(name)s EXCLUDE USING %(index_type)s (%(expressions)s)%(where)s%(deferrable)s'
11+
template = 'CONSTRAINT %(name)s EXCLUDE USING %(index_type)s (%(expressions)s)%(include)s%(where)s%(deferrable)s'
1112

1213
def __init__(
1314
self, *, name, expressions, index_type=None, condition=None,
14-
deferrable=None,
15+
deferrable=None, include=None,
1516
):
1617
if index_type and index_type.lower() not in {'gist', 'spgist'}:
1718
raise ValueError(
@@ -39,10 +40,19 @@ def __init__(
3940
raise ValueError(
4041
'ExclusionConstraint.deferrable must be a Deferrable instance.'
4142
)
43+
if not isinstance(include, (type(None), list, tuple)):
44+
raise ValueError(
45+
'ExclusionConstraint.include must be a list or tuple.'
46+
)
47+
if include and index_type and index_type.lower() != 'gist':
48+
raise ValueError(
49+
'Covering exclusion constraints only support GiST indexes.'
50+
)
4251
self.expressions = expressions
4352
self.index_type = index_type or 'GIST'
4453
self.condition = condition
4554
self.deferrable = deferrable
55+
self.include = tuple(include) if include else ()
4656
super().__init__(name=name)
4757

4858
def _get_expression_sql(self, compiler, connection, query):
@@ -67,15 +77,18 @@ def constraint_sql(self, model, schema_editor):
6777
compiler = query.get_compiler(connection=schema_editor.connection)
6878
expressions = self._get_expression_sql(compiler, schema_editor.connection, query)
6979
condition = self._get_condition_sql(compiler, schema_editor, query)
80+
include = [model._meta.get_field(field_name).column for field_name in self.include]
7081
return self.template % {
7182
'name': schema_editor.quote_name(self.name),
7283
'index_type': self.index_type,
7384
'expressions': ', '.join(expressions),
85+
'include': schema_editor._index_include_sql(model, include),
7486
'where': ' WHERE (%s)' % condition if condition else '',
7587
'deferrable': schema_editor._deferrable_constraint_sql(self.deferrable),
7688
}
7789

7890
def create_sql(self, model, schema_editor):
91+
self.check_supported(schema_editor)
7992
return Statement(
8093
'ALTER TABLE %(table)s ADD %(constraint)s',
8194
table=Table(model._meta.db_table, schema_editor.quote_name),
@@ -89,6 +102,12 @@ def remove_sql(self, model, schema_editor):
89102
schema_editor.quote_name(self.name),
90103
)
91104

105+
def check_supported(self, schema_editor):
106+
if self.include and not schema_editor.connection.features.supports_covering_gist_indexes:
107+
raise NotSupportedError(
108+
'Covering exclusion constraints requires PostgreSQL 12+.'
109+
)
110+
92111
def deconstruct(self):
93112
path, args, kwargs = super().deconstruct()
94113
kwargs['expressions'] = self.expressions
@@ -98,6 +117,8 @@ def deconstruct(self):
98117
kwargs['index_type'] = self.index_type
99118
if self.deferrable:
100119
kwargs['deferrable'] = self.deferrable
120+
if self.include:
121+
kwargs['include'] = self.include
101122
return path, args, kwargs
102123

103124
def __eq__(self, other):
@@ -107,15 +128,17 @@ def __eq__(self, other):
107128
self.index_type == other.index_type and
108129
self.expressions == other.expressions and
109130
self.condition == other.condition and
110-
self.deferrable == other.deferrable
131+
self.deferrable == other.deferrable and
132+
self.include == other.include
111133
)
112134
return super().__eq__(other)
113135

114136
def __repr__(self):
115-
return '<%s: index_type=%s, expressions=%s%s%s>' % (
137+
return '<%s: index_type=%s, expressions=%s%s%s%s>' % (
116138
self.__class__.__qualname__,
117139
self.index_type,
118140
self.expressions,
119141
'' if self.condition is None else ', condition=%s' % self.condition,
120142
'' if self.deferrable is None else ', deferrable=%s' % self.deferrable,
143+
'' if not self.include else ', include=%s' % repr(self.include),
121144
)

docs/ref/contrib/postgres/constraints.txt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ PostgreSQL supports additional data integrity constraints available from the
1212
``ExclusionConstraint``
1313
=======================
1414

15-
.. class:: ExclusionConstraint(*, name, expressions, index_type=None, condition=None, deferrable=None)
15+
.. class:: ExclusionConstraint(*, name, expressions, index_type=None, condition=None, deferrable=None, include=None)
1616

1717
Creates an exclusion constraint in the database. Internally, PostgreSQL
1818
implements exclusion constraints using indexes. The default index type is
@@ -106,6 +106,21 @@ enforced immediately after every command.
106106
Deferred exclusion constraints may lead to a `performance penalty
107107
<https://www.postgresql.org/docs/current/sql-createtable.html#id-1.9.3.85.9.4>`_.
108108

109+
``include``
110+
-----------
111+
112+
.. attribute:: ExclusionConstraint.include
113+
114+
.. versionadded:: 3.2
115+
116+
A list or tuple of the names of the fields to be included in the covering
117+
exclusion constraint as non-key columns. This allows index-only scans to be
118+
used for queries that select only included fields
119+
(:attr:`~ExclusionConstraint.include`) and filter only by indexed fields
120+
(:attr:`~ExclusionConstraint.expressions`).
121+
122+
``include`` is supported only for GiST indexes on PostgreSQL 12+.
123+
109124
Examples
110125
--------
111126

docs/releases/3.2.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ Minor features
7070
:mod:`django.contrib.postgres`
7171
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
7272

73-
* ...
73+
* The new :attr:`.ExclusionConstraint.include` attribute allows creating
74+
covering exclusion constraints on PostgreSQL 12+.
7475

7576
:mod:`django.contrib.redirects`
7677
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

tests/postgres_tests/test_constraints.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import datetime
22
from unittest import mock
33

4-
from django.db import IntegrityError, connection, transaction
4+
from django.db import (
5+
IntegrityError, NotSupportedError, connection, transaction,
6+
)
57
from django.db.models import CheckConstraint, Deferrable, F, Func, Q
8+
from django.test import skipUnlessDBFeature
69
from django.utils import timezone
710

811
from . import PostgreSQLTestCase
@@ -146,6 +149,25 @@ def test_deferrable_with_condition(self):
146149
deferrable=Deferrable.DEFERRED,
147150
)
148151

152+
def test_invalid_include_type(self):
153+
msg = 'ExclusionConstraint.include must be a list or tuple.'
154+
with self.assertRaisesMessage(ValueError, msg):
155+
ExclusionConstraint(
156+
name='exclude_invalid_include',
157+
expressions=[(F('datespan'), RangeOperators.OVERLAPS)],
158+
include='invalid',
159+
)
160+
161+
def test_invalid_include_index_type(self):
162+
msg = 'Covering exclusion constraints only support GiST indexes.'
163+
with self.assertRaisesMessage(ValueError, msg):
164+
ExclusionConstraint(
165+
name='exclude_invalid_index_type',
166+
expressions=[(F('datespan'), RangeOperators.OVERLAPS)],
167+
include=['cancelled'],
168+
index_type='spgist',
169+
)
170+
149171
def test_repr(self):
150172
constraint = ExclusionConstraint(
151173
name='exclude_overlapping',
@@ -180,6 +202,16 @@ def test_repr(self):
180202
"<ExclusionConstraint: index_type=GIST, expressions=["
181203
"(F(datespan), '-|-')], deferrable=Deferrable.IMMEDIATE>",
182204
)
205+
constraint = ExclusionConstraint(
206+
name='exclude_overlapping',
207+
expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)],
208+
include=['cancelled', 'room'],
209+
)
210+
self.assertEqual(
211+
repr(constraint),
212+
"<ExclusionConstraint: index_type=GIST, expressions=["
213+
"(F(datespan), '-|-')], include=('cancelled', 'room')>",
214+
)
183215

184216
def test_eq(self):
185217
constraint_1 = ExclusionConstraint(
@@ -218,14 +250,33 @@ def test_eq(self):
218250
],
219251
deferrable=Deferrable.IMMEDIATE,
220252
)
253+
constraint_6 = ExclusionConstraint(
254+
name='exclude_overlapping',
255+
expressions=[
256+
('datespan', RangeOperators.OVERLAPS),
257+
('room', RangeOperators.EQUAL),
258+
],
259+
deferrable=Deferrable.IMMEDIATE,
260+
include=['cancelled'],
261+
)
262+
constraint_7 = ExclusionConstraint(
263+
name='exclude_overlapping',
264+
expressions=[
265+
('datespan', RangeOperators.OVERLAPS),
266+
('room', RangeOperators.EQUAL),
267+
],
268+
include=['cancelled'],
269+
)
221270
self.assertEqual(constraint_1, constraint_1)
222271
self.assertEqual(constraint_1, mock.ANY)
223272
self.assertNotEqual(constraint_1, constraint_2)
224273
self.assertNotEqual(constraint_1, constraint_3)
225274
self.assertNotEqual(constraint_1, constraint_4)
226275
self.assertNotEqual(constraint_2, constraint_3)
227276
self.assertNotEqual(constraint_2, constraint_4)
277+
self.assertNotEqual(constraint_2, constraint_7)
228278
self.assertNotEqual(constraint_4, constraint_5)
279+
self.assertNotEqual(constraint_5, constraint_6)
229280
self.assertNotEqual(constraint_1, object())
230281

231282
def test_deconstruct(self):
@@ -286,6 +337,21 @@ def test_deconstruct_deferrable(self):
286337
'deferrable': Deferrable.DEFERRED,
287338
})
288339

340+
def test_deconstruct_include(self):
341+
constraint = ExclusionConstraint(
342+
name='exclude_overlapping',
343+
expressions=[('datespan', RangeOperators.OVERLAPS)],
344+
include=['cancelled', 'room'],
345+
)
346+
path, args, kwargs = constraint.deconstruct()
347+
self.assertEqual(path, 'django.contrib.postgres.constraints.ExclusionConstraint')
348+
self.assertEqual(args, ())
349+
self.assertEqual(kwargs, {
350+
'name': 'exclude_overlapping',
351+
'expressions': [('datespan', RangeOperators.OVERLAPS)],
352+
'include': ('cancelled', 'room'),
353+
})
354+
289355
def _test_range_overlaps(self, constraint):
290356
# Create exclusion constraint.
291357
self.assertNotIn(constraint.name, self.get_constraints(HotelReservation._meta.db_table))
@@ -417,3 +483,66 @@ def test_range_adjacent_initially_deferred(self):
417483
adjacent_range.delete()
418484
RangesModel.objects.create(ints=(10, 19))
419485
RangesModel.objects.create(ints=(51, 60))
486+
487+
@skipUnlessDBFeature('supports_covering_gist_indexes')
488+
def test_range_adjacent_include(self):
489+
constraint_name = 'ints_adjacent_include'
490+
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
491+
constraint = ExclusionConstraint(
492+
name=constraint_name,
493+
expressions=[('ints', RangeOperators.ADJACENT_TO)],
494+
include=['decimals', 'ints'],
495+
index_type='gist',
496+
)
497+
with connection.schema_editor() as editor:
498+
editor.add_constraint(RangesModel, constraint)
499+
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
500+
RangesModel.objects.create(ints=(20, 50))
501+
with self.assertRaises(IntegrityError), transaction.atomic():
502+
RangesModel.objects.create(ints=(10, 20))
503+
RangesModel.objects.create(ints=(10, 19))
504+
RangesModel.objects.create(ints=(51, 60))
505+
506+
@skipUnlessDBFeature('supports_covering_gist_indexes')
507+
def test_range_adjacent_include_condition(self):
508+
constraint_name = 'ints_adjacent_include_condition'
509+
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
510+
constraint = ExclusionConstraint(
511+
name=constraint_name,
512+
expressions=[('ints', RangeOperators.ADJACENT_TO)],
513+
include=['decimals'],
514+
condition=Q(id__gte=100),
515+
)
516+
with connection.schema_editor() as editor:
517+
editor.add_constraint(RangesModel, constraint)
518+
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
519+
520+
@skipUnlessDBFeature('supports_covering_gist_indexes')
521+
def test_range_adjacent_include_deferrable(self):
522+
constraint_name = 'ints_adjacent_include_deferrable'
523+
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
524+
constraint = ExclusionConstraint(
525+
name=constraint_name,
526+
expressions=[('ints', RangeOperators.ADJACENT_TO)],
527+
include=['decimals'],
528+
deferrable=Deferrable.DEFERRED,
529+
)
530+
with connection.schema_editor() as editor:
531+
editor.add_constraint(RangesModel, constraint)
532+
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
533+
534+
def test_include_not_supported(self):
535+
constraint_name = 'ints_adjacent_include_not_supported'
536+
constraint = ExclusionConstraint(
537+
name=constraint_name,
538+
expressions=[('ints', RangeOperators.ADJACENT_TO)],
539+
include=['id'],
540+
)
541+
msg = 'Covering exclusion constraints requires PostgreSQL 12+.'
542+
with connection.schema_editor() as editor:
543+
with mock.patch(
544+
'django.db.backends.postgresql.features.DatabaseFeatures.supports_covering_gist_indexes',
545+
False,
546+
):
547+
with self.assertRaisesMessage(NotSupportedError, msg):
548+
editor.add_constraint(RangesModel, constraint)

0 commit comments

Comments
 (0)