Explorar o código

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

Ian Foote %!s(int64=5) %!d(string=hai) anos
pai
achega
b4068bc656

+ 22 - 5
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,
         )

+ 33 - 1
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
+    <https://www.postgresql.org/docs/current/sql-createtable.html#id-1.9.3.85.9.4>`_.
+
 Examples
 --------
 

+ 3 - 0
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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 87 - 1
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):
             "<ExclusionConstraint: index_type=SPGiST, expressions=["
             "(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):
         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))