Browse Source

Refs #33342 -- Deprecated ExclusionConstraint.opclasses.

Hannes Ljungberg 3 years ago
parent
commit
59a66f0512

+ 11 - 0
django/contrib/postgres/constraints.py

@@ -1,3 +1,5 @@
+import warnings
+
 from django.contrib.postgres.indexes import OpClass
 from django.db import NotSupportedError
 from django.db.backends.ddl_references import Expressions, Statement, Table
@@ -6,6 +8,7 @@ from django.db.models.constraints import BaseConstraint
 from django.db.models.expressions import ExpressionList
 from django.db.models.indexes import IndexExpression
 from django.db.models.sql import Query
+from django.utils.deprecation import RemovedInDjango50Warning
 
 __all__ = ['ExclusionConstraint']
 
@@ -67,6 +70,14 @@ class ExclusionConstraint(BaseConstraint):
         self.deferrable = deferrable
         self.include = tuple(include) if include else ()
         self.opclasses = opclasses
+        if self.opclasses:
+            warnings.warn(
+                'The opclasses argument is deprecated in favor of using '
+                'django.contrib.postgres.indexes.OpClass in '
+                'ExclusionConstraint.expressions.',
+                category=RemovedInDjango50Warning,
+                stacklevel=2,
+            )
         super().__init__(name=name)
 
     def _get_expressions(self, schema_editor, query):

+ 3 - 0
docs/internals/deprecation.txt

@@ -72,6 +72,9 @@ details on these changes.
 * The ``name`` argument of ``django.utils.functional.cached_property()`` will
   be removed.
 
+* The ``opclasses`` argument of
+  ``django.contrib.postgres.constraints.ExclusionConstraint`` will be removed.
+
 .. _deprecation-removed-in-4.1:
 
 4.1

+ 16 - 12
docs/ref/contrib/postgres/constraints.txt

@@ -53,10 +53,22 @@ operators with strings. For example::
 
     Only commutative operators can be used in exclusion constraints.
 
+The :class:`OpClass() <django.contrib.postgres.indexes.OpClass>` expression can
+be used to specify a custom `operator class`_ for the constraint expressions.
+For example::
+
+    expressions=[
+        (OpClass('circle', name='circle_ops'), RangeOperators.OVERLAPS),
+    ]
+
+creates an exclusion constraint on ``circle`` using ``circle_ops``.
+
 .. versionchanged:: 4.1
 
     Support for the ``OpClass()`` expression was added.
 
+.. _operator class: https://www.postgresql.org/docs/current/indexes-opclass.html
+
 ``index_type``
 --------------
 
@@ -147,19 +159,11 @@ For example::
 
 creates an exclusion constraint on ``circle`` using ``circle_ops``.
 
-Alternatively, you can use
-:class:`OpClass() <django.contrib.postgres.indexes.OpClass>` in
-:attr:`~ExclusionConstraint.expressions`::
-
-    ExclusionConstraint(
-        name='exclude_overlapping_opclasses',
-        expressions=[(OpClass('circle', 'circle_ops'), RangeOperators.OVERLAPS)],
-    )
-
-.. versionchanged:: 4.1
+.. deprecated:: 4.1
 
-    Support for specifying operator classes with the ``OpClass()`` expression
-    was added.
+    The ``opclasses`` parameter is deprecated in favor of using
+    :class:`OpClass() <django.contrib.postgres.indexes.OpClass>` in
+    :attr:`~ExclusionConstraint.expressions`.
 
 Examples
 --------

+ 32 - 0
docs/releases/4.1.txt

@@ -353,6 +353,38 @@ Miscellaneous
 * The ``name`` argument of :func:`django.utils.functional.cached_property` is
   deprecated as it's unnecessary as of Python 3.6.
 
+* The ``opclasses`` argument of
+  ``django.contrib.postgres.constraints.ExclusionConstraint`` is deprecated in
+  favor of using :class:`OpClass() <django.contrib.postgres.indexes.OpClass>`
+  in :attr:`.ExclusionConstraint.expressions`. To use it, you need to add
+  ``'django.contrib.postgres'`` in your :setting:`INSTALLED_APPS`.
+
+  After making this change, :djadmin:`makemigrations` will generate a new
+  migration with two operations: ``RemoveConstraint`` and ``AddConstraint``.
+  Since this change has no effect on the database schema,
+  the :class:`~django.db.migrations.operations.SeparateDatabaseAndState`
+  operation can be used to only update the migration state without running any
+  SQL. Move the generated operations into the ``state_operations`` argument of
+  :class:`~django.db.migrations.operations.SeparateDatabaseAndState`. For
+  example::
+
+    class Migration(migrations.Migration):
+        ...
+
+        operations = [
+            migrations.SeparateDatabaseAndState(
+                database_operations=[],
+                state_operations=[
+                    migrations.RemoveConstraint(
+                        ...
+                    ),
+                    migrations.AddConstraint(
+                        ...
+                    ),
+                ],
+            ),
+        ]
+
 Features removed in 4.1
 =======================
 

+ 179 - 64
tests/postgres_tests/test_constraints.py

@@ -10,8 +10,9 @@ from django.db.models import (
 )
 from django.db.models.fields.json import KeyTextTransform
 from django.db.models.functions import Cast, Left, Lower
-from django.test import modify_settings, skipUnlessDBFeature
+from django.test import ignore_warnings, modify_settings, skipUnlessDBFeature
 from django.utils import timezone
+from django.utils.deprecation import RemovedInDjango50Warning
 
 from . import PostgreSQLTestCase
 from .models import (
@@ -272,6 +273,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
                 include='invalid',
             )
 
+    @ignore_warnings(category=RemovedInDjango50Warning)
     def test_invalid_opclasses_type(self):
         msg = 'ExclusionConstraint.opclasses must be a list or tuple.'
         with self.assertRaisesMessage(ValueError, msg):
@@ -281,6 +283,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
                 opclasses='invalid',
             )
 
+    @ignore_warnings(category=RemovedInDjango50Warning)
     def test_opclasses_and_expressions_same_length(self):
         msg = (
             'ExclusionConstraint.expressions and '
@@ -343,14 +346,15 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         )
         constraint = ExclusionConstraint(
             name='exclude_overlapping',
-            expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)],
-            opclasses=['range_ops'],
+            expressions=[
+                (OpClass('datespan', name='range_ops'), RangeOperators.ADJACENT_TO),
+            ],
         )
         self.assertEqual(
             repr(constraint),
             "<ExclusionConstraint: index_type='GIST' expressions=["
-            "(F(datespan), '-|-')] name='exclude_overlapping' "
-            "opclasses=['range_ops']>",
+            "(OpClass(F(datespan), name=range_ops), '-|-')] "
+            "name='exclude_overlapping'>",
         )
 
     def test_eq(self):
@@ -407,23 +411,26 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
             ],
             include=['cancelled'],
         )
-        constraint_8 = ExclusionConstraint(
-            name='exclude_overlapping',
-            expressions=[
-                ('datespan', RangeOperators.OVERLAPS),
-                ('room', RangeOperators.EQUAL),
-            ],
-            include=['cancelled'],
-            opclasses=['range_ops', 'range_ops']
-        )
-        constraint_9 = ExclusionConstraint(
-            name='exclude_overlapping',
-            expressions=[
-                ('datespan', RangeOperators.OVERLAPS),
-                ('room', RangeOperators.EQUAL),
-            ],
-            opclasses=['range_ops', 'range_ops']
-        )
+        with ignore_warnings(category=RemovedInDjango50Warning):
+            constraint_8 = ExclusionConstraint(
+                name='exclude_overlapping',
+                expressions=[
+                    ('datespan', RangeOperators.OVERLAPS),
+                    ('room', RangeOperators.EQUAL),
+                ],
+                include=['cancelled'],
+                opclasses=['range_ops', 'range_ops']
+            )
+            constraint_9 = ExclusionConstraint(
+                name='exclude_overlapping',
+                expressions=[
+                    ('datespan', RangeOperators.OVERLAPS),
+                    ('room', RangeOperators.EQUAL),
+                ],
+                opclasses=['range_ops', 'range_ops']
+            )
+            self.assertNotEqual(constraint_2, constraint_9)
+            self.assertNotEqual(constraint_7, constraint_8)
         self.assertEqual(constraint_1, constraint_1)
         self.assertEqual(constraint_1, mock.ANY)
         self.assertNotEqual(constraint_1, constraint_2)
@@ -432,10 +439,8 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         self.assertNotEqual(constraint_2, constraint_3)
         self.assertNotEqual(constraint_2, constraint_4)
         self.assertNotEqual(constraint_2, constraint_7)
-        self.assertNotEqual(constraint_2, constraint_9)
         self.assertNotEqual(constraint_4, constraint_5)
         self.assertNotEqual(constraint_5, constraint_6)
-        self.assertNotEqual(constraint_7, constraint_8)
         self.assertNotEqual(constraint_1, object())
 
     def test_deconstruct(self):
@@ -511,6 +516,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
             'include': ('cancelled', 'room'),
         })
 
+    @ignore_warnings(category=RemovedInDjango50Warning)
     def test_deconstruct_opclasses(self):
         constraint = ExclusionConstraint(
             name='exclude_overlapping',
@@ -589,7 +595,8 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
             ),
         ])
 
-    def test_range_overlaps_custom(self):
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    def test_range_overlaps_custom_opclasses(self):
         class TsTzRange(Func):
             function = 'TSTZRANGE'
             output_field = DateTimeRangeField()
@@ -605,7 +612,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         )
         self._test_range_overlaps(constraint)
 
-    def test_range_overlaps_custom_opclass_expression(self):
+    def test_range_overlaps_custom(self):
         class TsTzRange(Func):
             function = 'TSTZRANGE'
             output_field = DateTimeRangeField()
@@ -856,17 +863,25 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
                 with self.assertRaisesMessage(NotSupportedError, msg):
                     editor.add_constraint(RangesModel, constraint)
 
-    def test_range_adjacent_opclasses(self):
-        constraint_name = 'ints_adjacent_opclasses'
+    def test_range_adjacent_opclass(self):
+        constraint_name = 'ints_adjacent_opclass'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[('ints', RangeOperators.ADJACENT_TO)],
-            opclasses=['range_ops'],
+            expressions=[
+                (OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
+            ],
         )
         with connection.schema_editor() as editor:
             editor.add_constraint(RangesModel, constraint)
-        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraints = self.get_constraints(RangesModel._meta.db_table)
+        self.assertIn(constraint_name, constraints)
+        with editor.connection.cursor() as cursor:
+            cursor.execute(SchemaTests.get_opclass_query, [constraint_name])
+            self.assertEqual(
+                cursor.fetchall(),
+                [('range_ops', constraint_name)],
+            )
         RangesModel.objects.create(ints=(20, 50))
         with self.assertRaises(IntegrityError), transaction.atomic():
             RangesModel.objects.create(ints=(10, 20))
@@ -877,26 +892,28 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
             editor.remove_constraint(RangesModel, constraint)
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
-    def test_range_adjacent_opclasses_condition(self):
-        constraint_name = 'ints_adjacent_opclasses_condition'
+    def test_range_adjacent_opclass_condition(self):
+        constraint_name = 'ints_adjacent_opclass_condition'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[('ints', RangeOperators.ADJACENT_TO)],
-            opclasses=['range_ops'],
+            expressions=[
+                (OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
+            ],
             condition=Q(id__gte=100),
         )
         with connection.schema_editor() as editor:
             editor.add_constraint(RangesModel, constraint)
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
-    def test_range_adjacent_opclasses_deferrable(self):
-        constraint_name = 'ints_adjacent_opclasses_deferrable'
+    def test_range_adjacent_opclass_deferrable(self):
+        constraint_name = 'ints_adjacent_opclass_deferrable'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[('ints', RangeOperators.ADJACENT_TO)],
-            opclasses=['range_ops'],
+            expressions=[
+                (OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
+            ],
             deferrable=Deferrable.DEFERRED,
         )
         with connection.schema_editor() as editor:
@@ -904,14 +921,15 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
     @skipUnlessDBFeature('supports_covering_gist_indexes')
-    def test_range_adjacent_gist_opclasses_include(self):
-        constraint_name = 'ints_adjacent_gist_opclasses_include'
+    def test_range_adjacent_gist_opclass_include(self):
+        constraint_name = 'ints_adjacent_gist_opclass_include'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            expressions=[
+                (OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
+            ],
             index_type='gist',
-            opclasses=['range_ops'],
             include=['decimals'],
         )
         with connection.schema_editor() as editor:
@@ -919,55 +937,152 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
     @skipUnlessDBFeature('supports_covering_spgist_indexes')
-    def test_range_adjacent_spgist_opclasses_include(self):
-        constraint_name = 'ints_adjacent_spgist_opclasses_include'
+    def test_range_adjacent_spgist_opclass_include(self):
+        constraint_name = 'ints_adjacent_spgist_opclass_include'
         self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            expressions=[
+                (OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
+            ],
             index_type='spgist',
-            opclasses=['range_ops'],
             include=['decimals'],
         )
         with connection.schema_editor() as editor:
             editor.add_constraint(RangesModel, constraint)
         self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
-    def test_opclass_expression(self):
-        constraint_name = 'ints_adjacent_opclass_expression'
-        self.assertNotIn(
-            constraint_name,
-            self.get_constraints(RangesModel._meta.db_table),
+    def test_range_equal_cast(self):
+        constraint_name = 'exclusion_equal_room_cast'
+        self.assertNotIn(constraint_name, self.get_constraints(Room._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[(Cast('number', IntegerField()), RangeOperators.EQUAL)],
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(Room, constraint)
+        self.assertIn(constraint_name, self.get_constraints(Room._meta.db_table))
+
+
+@modify_settings(INSTALLED_APPS={'append': 'django.contrib.postgres'})
+class ExclusionConstraintOpclassesDepracationTests(PostgreSQLTestCase):
+    def get_constraints(self, table):
+        """Get the constraints on the table using a new cursor."""
+        with connection.cursor() as cursor:
+            return connection.introspection.get_constraints(cursor, table)
+
+    def test_warning(self):
+        msg = (
+            'The opclasses argument is deprecated in favor of using '
+            'django.contrib.postgres.indexes.OpClass in '
+            'ExclusionConstraint.expressions.'
+        )
+        with self.assertWarnsMessage(RemovedInDjango50Warning, msg):
+            ExclusionConstraint(
+                name='exclude_overlapping',
+                expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)],
+                opclasses=['range_ops'],
+            )
+
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    def test_repr(self):
+        constraint = ExclusionConstraint(
+            name='exclude_overlapping',
+            expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)],
+            opclasses=['range_ops'],
         )
+        self.assertEqual(
+            repr(constraint),
+            "<ExclusionConstraint: index_type='GIST' expressions=["
+            "(F(datespan), '-|-')] name='exclude_overlapping' "
+            "opclasses=['range_ops']>",
+        )
+
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    def test_range_adjacent_opclasses(self):
+        constraint_name = 'ints_adjacent_opclasses'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[(OpClass('ints', 'range_ops'), RangeOperators.ADJACENT_TO)],
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            opclasses=['range_ops'],
         )
         with connection.schema_editor() as editor:
             editor.add_constraint(RangesModel, constraint)
         constraints = self.get_constraints(RangesModel._meta.db_table)
         self.assertIn(constraint_name, constraints)
         with editor.connection.cursor() as cursor:
-            cursor.execute(SchemaTests.get_opclass_query, [constraint_name])
+            cursor.execute(SchemaTests.get_opclass_query, [constraint.name])
             self.assertEqual(
                 cursor.fetchall(),
-                [('range_ops', constraint_name)],
+                [('range_ops', constraint.name)],
             )
+        RangesModel.objects.create(ints=(20, 50))
+        with self.assertRaises(IntegrityError), transaction.atomic():
+            RangesModel.objects.create(ints=(10, 20))
+        RangesModel.objects.create(ints=(10, 19))
+        RangesModel.objects.create(ints=(51, 60))
         # Drop the constraint.
         with connection.schema_editor() as editor:
             editor.remove_constraint(RangesModel, constraint)
-        self.assertNotIn(
-            constraint_name,
-            self.get_constraints(RangesModel._meta.db_table),
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    def test_range_adjacent_opclasses_condition(self):
+        constraint_name = 'ints_adjacent_opclasses_condition'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            opclasses=['range_ops'],
+            condition=Q(id__gte=100),
         )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
 
-    def test_range_equal_cast(self):
-        constraint_name = 'exclusion_equal_room_cast'
-        self.assertNotIn(constraint_name, self.get_constraints(Room._meta.db_table))
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    def test_range_adjacent_opclasses_deferrable(self):
+        constraint_name = 'ints_adjacent_opclasses_deferrable'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
         constraint = ExclusionConstraint(
             name=constraint_name,
-            expressions=[(Cast('number', IntegerField()), RangeOperators.EQUAL)],
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            opclasses=['range_ops'],
+            deferrable=Deferrable.DEFERRED,
         )
         with connection.schema_editor() as editor:
-            editor.add_constraint(Room, constraint)
-        self.assertIn(constraint_name, self.get_constraints(Room._meta.db_table))
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    @skipUnlessDBFeature('supports_covering_gist_indexes')
+    def test_range_adjacent_gist_opclasses_include(self):
+        constraint_name = 'ints_adjacent_gist_opclasses_include'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='gist',
+            opclasses=['range_ops'],
+            include=['decimals'],
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+
+    @ignore_warnings(category=RemovedInDjango50Warning)
+    @skipUnlessDBFeature('supports_covering_spgist_indexes')
+    def test_range_adjacent_spgist_opclasses_include(self):
+        constraint_name = 'ints_adjacent_spgist_opclasses_include'
+        self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
+        constraint = ExclusionConstraint(
+            name=constraint_name,
+            expressions=[('ints', RangeOperators.ADJACENT_TO)],
+            index_type='spgist',
+            opclasses=['range_ops'],
+            include=['decimals'],
+        )
+        with connection.schema_editor() as editor:
+            editor.add_constraint(RangesModel, constraint)
+        self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))