mirror of
				https://github.com/django/django.git
				synced 2025-10-31 09:41:08 +00:00 
			
		
		
		
	Fixed #31455 -- Added support for deferrable exclusion constraints on PostgreSQL.
This commit is contained in:
		
				
					committed by
					
						 Mariusz Felisiak
						Mariusz Felisiak
					
				
			
			
				
	
			
			
			
						parent
						
							5d2f5dd4cc
						
					
				
				
					commit
					b4068bc656
				
			| @@ -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, | ||||
|         ) | ||||
|   | ||||
| @@ -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 | ||||
| -------- | ||||
|  | ||||
|   | ||||
| @@ -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` | ||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user