From b4068bc65636cca6c2905aa8c40bea69bb0e4245 Mon Sep 17 00:00:00 2001 From: Ian Foote Date: Sun, 12 Apr 2020 11:43:16 +0100 Subject: [PATCH] Fixed #31455 -- Added support for deferrable exclusion constraints on PostgreSQL. --- django/contrib/postgres/constraints.py | 27 +++++-- docs/ref/contrib/postgres/constraints.txt | 34 ++++++++- docs/releases/3.1.txt | 3 + tests/postgres_tests/test_constraints.py | 88 ++++++++++++++++++++++- 4 files changed, 145 insertions(+), 7 deletions(-) diff --git a/django/contrib/postgres/constraints.py b/django/contrib/postgres/constraints.py index 8cc1f58a100..aea1f9bb3dd 100644 --- a/django/contrib/postgres/constraints.py +++ b/django/contrib/postgres/constraints.py @@ -1,5 +1,5 @@ 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.sql import Query @@ -7,9 +7,12 @@ __all__ = ['ExclusionConstraint'] 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'}: raise ValueError( 'Exclusion constraints only support GiST or SP-GiST indexes.' @@ -28,9 +31,18 @@ class ExclusionConstraint(BaseConstraint): raise ValueError( '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.index_type = index_type or 'GIST' self.condition = condition + self.deferrable = deferrable super().__init__(name=name) def _get_expression_sql(self, compiler, connection, query): @@ -60,6 +72,7 @@ class ExclusionConstraint(BaseConstraint): 'index_type': self.index_type, 'expressions': ', '.join(expressions), 'where': ' WHERE (%s)' % condition if condition else '', + 'deferrable': schema_editor._deferrable_constraint_sql(self.deferrable), } def create_sql(self, model, schema_editor): @@ -83,6 +96,8 @@ class ExclusionConstraint(BaseConstraint): kwargs['condition'] = self.condition if self.index_type.lower() != 'gist': kwargs['index_type'] = self.index_type + if self.deferrable: + kwargs['deferrable'] = self.deferrable return path, args, kwargs def __eq__(self, other): @@ -91,14 +106,16 @@ class ExclusionConstraint(BaseConstraint): self.name == other.name and self.index_type == other.index_type and self.expressions == other.expressions and - self.condition == other.condition + self.condition == other.condition and + self.deferrable == other.deferrable ) return super().__eq__(other) def __repr__(self): - return '<%s: index_type=%s, expressions=%s%s>' % ( + return '<%s: index_type=%s, expressions=%s%s%s>' % ( self.__class__.__qualname__, self.index_type, self.expressions, '' if self.condition is None else ', condition=%s' % self.condition, + '' if self.deferrable is None else ', deferrable=%s' % self.deferrable, ) diff --git a/docs/ref/contrib/postgres/constraints.txt b/docs/ref/contrib/postgres/constraints.txt index fe9e72e6055..acc592fc1e9 100644 --- a/docs/ref/contrib/postgres/constraints.txt +++ b/docs/ref/contrib/postgres/constraints.txt @@ -14,7 +14,7 @@ PostgreSQL supports additional data integrity constraints available from the .. 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 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 :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 + `_. + Examples -------- diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index ae482f01298..3edc8bf6e35 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -176,6 +176,9 @@ Minor features :class:`~django.contrib.postgres.search.SearchRank` allows rank normalization. +* The new :attr:`.ExclusionConstraint.deferrable` attribute allows creating + deferrable exclusion constraints. + :mod:`django.contrib.redirects` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/postgres_tests/test_constraints.py b/tests/postgres_tests/test_constraints.py index ccdd7b818d7..79c24c2d752 100644 --- a/tests/postgres_tests/test_constraints.py +++ b/tests/postgres_tests/test_constraints.py @@ -2,7 +2,7 @@ import datetime from unittest import mock 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 . import PostgreSQLTestCase @@ -127,6 +127,25 @@ class ExclusionConstraintTests(PostgreSQLTestCase): 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): constraint = ExclusionConstraint( name='exclude_overlapping', @@ -151,6 +170,16 @@ class ExclusionConstraintTests(PostgreSQLTestCase): "", ) + constraint = ExclusionConstraint( + name='exclude_overlapping', + expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)], + deferrable=Deferrable.IMMEDIATE, + ) + self.assertEqual( + repr(constraint), + "", + ) def test_eq(self): constraint_1 = ExclusionConstraint( @@ -173,11 +202,30 @@ class ExclusionConstraintTests(PostgreSQLTestCase): expressions=[('datespan', RangeOperators.OVERLAPS)], 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, mock.ANY) self.assertNotEqual(constraint_1, constraint_2) self.assertNotEqual(constraint_1, constraint_3) + self.assertNotEqual(constraint_1, constraint_4) self.assertNotEqual(constraint_2, constraint_3) + self.assertNotEqual(constraint_2, constraint_4) + self.assertNotEqual(constraint_4, constraint_5) self.assertNotEqual(constraint_1, object()) def test_deconstruct(self): @@ -223,6 +271,21 @@ class ExclusionConstraintTests(PostgreSQLTestCase): '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): # Create exclusion constraint. 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, 19)) 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))