Fixed #31455 -- Added support for deferrable exclusion constraints on PostgreSQL.

This commit is contained in:
Ian Foote 2020-04-12 11:43:16 +01:00 committed by Mariusz Felisiak
parent 5d2f5dd4cc
commit b4068bc656
4 changed files with 145 additions and 7 deletions

View File

@ -1,5 +1,5 @@
from django.db.backends.ddl_references import Statement, Table from django.db.backends.ddl_references import Statement, Table
from django.db.models import F, Q from django.db.models import Deferrable, F, Q
from django.db.models.constraints import BaseConstraint from django.db.models.constraints import BaseConstraint
from django.db.models.sql import Query from django.db.models.sql import Query
@ -7,9 +7,12 @@ __all__ = ['ExclusionConstraint']
class ExclusionConstraint(BaseConstraint): class ExclusionConstraint(BaseConstraint):
template = 'CONSTRAINT %(name)s EXCLUDE USING %(index_type)s (%(expressions)s)%(where)s' template = 'CONSTRAINT %(name)s EXCLUDE USING %(index_type)s (%(expressions)s)%(where)s%(deferrable)s'
def __init__(self, *, name, expressions, index_type=None, condition=None): def __init__(
self, *, name, expressions, index_type=None, condition=None,
deferrable=None,
):
if index_type and index_type.lower() not in {'gist', 'spgist'}: if index_type and index_type.lower() not in {'gist', 'spgist'}:
raise ValueError( raise ValueError(
'Exclusion constraints only support GiST or SP-GiST indexes.' 'Exclusion constraints only support GiST or SP-GiST indexes.'
@ -28,9 +31,18 @@ class ExclusionConstraint(BaseConstraint):
raise ValueError( raise ValueError(
'ExclusionConstraint.condition must be a Q instance.' 'ExclusionConstraint.condition must be a Q instance.'
) )
if condition and deferrable:
raise ValueError(
'ExclusionConstraint with conditions cannot be deferred.'
)
if not isinstance(deferrable, (type(None), Deferrable)):
raise ValueError(
'ExclusionConstraint.deferrable must be a Deferrable instance.'
)
self.expressions = expressions self.expressions = expressions
self.index_type = index_type or 'GIST' self.index_type = index_type or 'GIST'
self.condition = condition self.condition = condition
self.deferrable = deferrable
super().__init__(name=name) super().__init__(name=name)
def _get_expression_sql(self, compiler, connection, query): def _get_expression_sql(self, compiler, connection, query):
@ -60,6 +72,7 @@ class ExclusionConstraint(BaseConstraint):
'index_type': self.index_type, 'index_type': self.index_type,
'expressions': ', '.join(expressions), 'expressions': ', '.join(expressions),
'where': ' WHERE (%s)' % condition if condition else '', 'where': ' WHERE (%s)' % condition if condition else '',
'deferrable': schema_editor._deferrable_constraint_sql(self.deferrable),
} }
def create_sql(self, model, schema_editor): def create_sql(self, model, schema_editor):
@ -83,6 +96,8 @@ class ExclusionConstraint(BaseConstraint):
kwargs['condition'] = self.condition kwargs['condition'] = self.condition
if self.index_type.lower() != 'gist': if self.index_type.lower() != 'gist':
kwargs['index_type'] = self.index_type kwargs['index_type'] = self.index_type
if self.deferrable:
kwargs['deferrable'] = self.deferrable
return path, args, kwargs return path, args, kwargs
def __eq__(self, other): def __eq__(self, other):
@ -91,14 +106,16 @@ class ExclusionConstraint(BaseConstraint):
self.name == other.name and self.name == other.name and
self.index_type == other.index_type and self.index_type == other.index_type and
self.expressions == other.expressions and self.expressions == other.expressions and
self.condition == other.condition self.condition == other.condition and
self.deferrable == other.deferrable
) )
return super().__eq__(other) return super().__eq__(other)
def __repr__(self): def __repr__(self):
return '<%s: index_type=%s, expressions=%s%s>' % ( return '<%s: index_type=%s, expressions=%s%s%s>' % (
self.__class__.__qualname__, self.__class__.__qualname__,
self.index_type, self.index_type,
self.expressions, self.expressions,
'' if self.condition is None else ', condition=%s' % self.condition, '' if self.condition is None else ', condition=%s' % self.condition,
'' if self.deferrable is None else ', deferrable=%s' % self.deferrable,
) )

View File

@ -14,7 +14,7 @@ PostgreSQL supports additional data integrity constraints available from the
.. versionadded:: 3.0 .. versionadded:: 3.0
.. class:: ExclusionConstraint(*, name, expressions, index_type=None, condition=None) .. class:: ExclusionConstraint(*, name, expressions, index_type=None, condition=None, deferrable=None)
Creates an exclusion constraint in the database. Internally, PostgreSQL Creates an exclusion constraint in the database. Internally, PostgreSQL
implements exclusion constraints using indexes. The default index type is implements exclusion constraints using indexes. The default index type is
@ -76,6 +76,38 @@ a constraint to a subset of rows. For example,
These conditions have the same database restrictions as These conditions have the same database restrictions as
:attr:`django.db.models.Index.condition`. :attr:`django.db.models.Index.condition`.
``deferrable``
--------------
.. attribute:: ExclusionConstraint.deferrable
.. versionadded:: 3.1
Set this parameter to create a deferrable exclusion constraint. Accepted values
are ``Deferrable.DEFERRED`` or ``Deferrable.IMMEDIATE``. For example::
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import RangeOperators
from django.db.models import Deferrable
ExclusionConstraint(
name='exclude_overlapping_deferred',
expressions=[
('timespan', RangeOperators.OVERLAPS),
],
deferrable=Deferrable.DEFERRED,
)
By default constraints are not deferred. A deferred constraint will not be
enforced until the end of the transaction. An immediate constraint will be
enforced immediately after every command.
.. warning::
Deferred exclusion constraints may lead to a `performance penalty
<https://www.postgresql.org/docs/current/sql-createtable.html#id-1.9.3.85.9.4>`_.
Examples Examples
-------- --------

View File

@ -176,6 +176,9 @@ Minor features
:class:`~django.contrib.postgres.search.SearchRank` allows rank :class:`~django.contrib.postgres.search.SearchRank` allows rank
normalization. normalization.
* The new :attr:`.ExclusionConstraint.deferrable` attribute allows creating
deferrable exclusion constraints.
:mod:`django.contrib.redirects` :mod:`django.contrib.redirects`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -2,7 +2,7 @@ import datetime
from unittest import mock from unittest import mock
from django.db import IntegrityError, connection, transaction from django.db import IntegrityError, connection, transaction
from django.db.models import CheckConstraint, F, Func, Q from django.db.models import CheckConstraint, Deferrable, F, Func, Q
from django.utils import timezone from django.utils import timezone
from . import PostgreSQLTestCase from . import PostgreSQLTestCase
@ -127,6 +127,25 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
expressions=empty_expressions, expressions=empty_expressions,
) )
def test_invalid_deferrable(self):
msg = 'ExclusionConstraint.deferrable must be a Deferrable instance.'
with self.assertRaisesMessage(ValueError, msg):
ExclusionConstraint(
name='exclude_invalid_deferrable',
expressions=[(F('datespan'), RangeOperators.OVERLAPS)],
deferrable='invalid',
)
def test_deferrable_with_condition(self):
msg = 'ExclusionConstraint with conditions cannot be deferred.'
with self.assertRaisesMessage(ValueError, msg):
ExclusionConstraint(
name='exclude_invalid_condition',
expressions=[(F('datespan'), RangeOperators.OVERLAPS)],
condition=Q(cancelled=False),
deferrable=Deferrable.DEFERRED,
)
def test_repr(self): def test_repr(self):
constraint = ExclusionConstraint( constraint = ExclusionConstraint(
name='exclude_overlapping', name='exclude_overlapping',
@ -151,6 +170,16 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
"<ExclusionConstraint: index_type=SPGiST, expressions=[" "<ExclusionConstraint: index_type=SPGiST, expressions=["
"(F(datespan), '-|-')], condition=(AND: ('cancelled', False))>", "(F(datespan), '-|-')], condition=(AND: ('cancelled', False))>",
) )
constraint = ExclusionConstraint(
name='exclude_overlapping',
expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)],
deferrable=Deferrable.IMMEDIATE,
)
self.assertEqual(
repr(constraint),
"<ExclusionConstraint: index_type=GIST, expressions=["
"(F(datespan), '-|-')], deferrable=Deferrable.IMMEDIATE>",
)
def test_eq(self): def test_eq(self):
constraint_1 = ExclusionConstraint( constraint_1 = ExclusionConstraint(
@ -173,11 +202,30 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
expressions=[('datespan', RangeOperators.OVERLAPS)], expressions=[('datespan', RangeOperators.OVERLAPS)],
condition=Q(cancelled=False), condition=Q(cancelled=False),
) )
constraint_4 = ExclusionConstraint(
name='exclude_overlapping',
expressions=[
('datespan', RangeOperators.OVERLAPS),
('room', RangeOperators.EQUAL),
],
deferrable=Deferrable.DEFERRED,
)
constraint_5 = ExclusionConstraint(
name='exclude_overlapping',
expressions=[
('datespan', RangeOperators.OVERLAPS),
('room', RangeOperators.EQUAL),
],
deferrable=Deferrable.IMMEDIATE,
)
self.assertEqual(constraint_1, constraint_1) self.assertEqual(constraint_1, constraint_1)
self.assertEqual(constraint_1, mock.ANY) self.assertEqual(constraint_1, mock.ANY)
self.assertNotEqual(constraint_1, constraint_2) self.assertNotEqual(constraint_1, constraint_2)
self.assertNotEqual(constraint_1, constraint_3) self.assertNotEqual(constraint_1, constraint_3)
self.assertNotEqual(constraint_1, constraint_4)
self.assertNotEqual(constraint_2, constraint_3) self.assertNotEqual(constraint_2, constraint_3)
self.assertNotEqual(constraint_2, constraint_4)
self.assertNotEqual(constraint_4, constraint_5)
self.assertNotEqual(constraint_1, object()) self.assertNotEqual(constraint_1, object())
def test_deconstruct(self): def test_deconstruct(self):
@ -223,6 +271,21 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
'condition': Q(cancelled=False), 'condition': Q(cancelled=False),
}) })
def test_deconstruct_deferrable(self):
constraint = ExclusionConstraint(
name='exclude_overlapping',
expressions=[('datespan', RangeOperators.OVERLAPS)],
deferrable=Deferrable.DEFERRED,
)
path, args, kwargs = constraint.deconstruct()
self.assertEqual(path, 'django.contrib.postgres.constraints.ExclusionConstraint')
self.assertEqual(args, ())
self.assertEqual(kwargs, {
'name': 'exclude_overlapping',
'expressions': [('datespan', RangeOperators.OVERLAPS)],
'deferrable': Deferrable.DEFERRED,
})
def _test_range_overlaps(self, constraint): def _test_range_overlaps(self, constraint):
# Create exclusion constraint. # Create exclusion constraint.
self.assertNotIn(constraint.name, self.get_constraints(HotelReservation._meta.db_table)) self.assertNotIn(constraint.name, self.get_constraints(HotelReservation._meta.db_table))
@ -327,3 +390,26 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
RangesModel.objects.create(ints=(10, 20)) RangesModel.objects.create(ints=(10, 20))
RangesModel.objects.create(ints=(10, 19)) RangesModel.objects.create(ints=(10, 19))
RangesModel.objects.create(ints=(51, 60)) RangesModel.objects.create(ints=(51, 60))
def test_range_adjacent_initially_deferred(self):
constraint_name = 'ints_adjacent_deferred'
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
constraint = ExclusionConstraint(
name=constraint_name,
expressions=[('ints', RangeOperators.ADJACENT_TO)],
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))
RangesModel.objects.create(ints=(20, 50))
adjacent_range = RangesModel.objects.create(ints=(10, 20))
# Constraint behavior can be changed with SET CONSTRAINTS.
with self.assertRaises(IntegrityError):
with transaction.atomic(), connection.cursor() as cursor:
quoted_name = connection.ops.quote_name(constraint_name)
cursor.execute('SET CONSTRAINTS %s IMMEDIATE' % quoted_name)
# Remove adjacent range before the end of transaction.
adjacent_range.delete()
RangesModel.objects.create(ints=(10, 19))
RangesModel.objects.create(ints=(51, 60))