Refs #33342 -- Deprecated ExclusionConstraint.opclasses.

This commit is contained in:
Hannes Ljungberg 2021-12-08 21:28:08 +01:00 committed by Mariusz Felisiak
parent ff225fac1d
commit 59a66f0512
5 changed files with 247 additions and 82 deletions

View File

@ -1,3 +1,5 @@
import warnings
from django.contrib.postgres.indexes import OpClass from django.contrib.postgres.indexes import OpClass
from django.db import NotSupportedError from django.db import NotSupportedError
from django.db.backends.ddl_references import Expressions, Statement, Table 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.expressions import ExpressionList
from django.db.models.indexes import IndexExpression from django.db.models.indexes import IndexExpression
from django.db.models.sql import Query from django.db.models.sql import Query
from django.utils.deprecation import RemovedInDjango50Warning
__all__ = ['ExclusionConstraint'] __all__ = ['ExclusionConstraint']
@ -67,6 +70,14 @@ class ExclusionConstraint(BaseConstraint):
self.deferrable = deferrable self.deferrable = deferrable
self.include = tuple(include) if include else () self.include = tuple(include) if include else ()
self.opclasses = opclasses 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) super().__init__(name=name)
def _get_expressions(self, schema_editor, query): def _get_expressions(self, schema_editor, query):

View File

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

View File

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

View File

@ -353,6 +353,38 @@ Miscellaneous
* The ``name`` argument of :func:`django.utils.functional.cached_property` is * The ``name`` argument of :func:`django.utils.functional.cached_property` is
deprecated as it's unnecessary as of Python 3.6. 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 Features removed in 4.1
======================= =======================

View File

@ -10,8 +10,9 @@ from django.db.models import (
) )
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Cast, Left, Lower 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 import timezone
from django.utils.deprecation import RemovedInDjango50Warning
from . import PostgreSQLTestCase from . import PostgreSQLTestCase
from .models import ( from .models import (
@ -272,6 +273,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
include='invalid', include='invalid',
) )
@ignore_warnings(category=RemovedInDjango50Warning)
def test_invalid_opclasses_type(self): def test_invalid_opclasses_type(self):
msg = 'ExclusionConstraint.opclasses must be a list or tuple.' msg = 'ExclusionConstraint.opclasses must be a list or tuple.'
with self.assertRaisesMessage(ValueError, msg): with self.assertRaisesMessage(ValueError, msg):
@ -281,6 +283,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
opclasses='invalid', opclasses='invalid',
) )
@ignore_warnings(category=RemovedInDjango50Warning)
def test_opclasses_and_expressions_same_length(self): def test_opclasses_and_expressions_same_length(self):
msg = ( msg = (
'ExclusionConstraint.expressions and ' 'ExclusionConstraint.expressions and '
@ -343,14 +346,15 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
) )
constraint = ExclusionConstraint( constraint = ExclusionConstraint(
name='exclude_overlapping', name='exclude_overlapping',
expressions=[(F('datespan'), RangeOperators.ADJACENT_TO)], expressions=[
opclasses=['range_ops'], (OpClass('datespan', name='range_ops'), RangeOperators.ADJACENT_TO),
],
) )
self.assertEqual( self.assertEqual(
repr(constraint), repr(constraint),
"<ExclusionConstraint: index_type='GIST' expressions=[" "<ExclusionConstraint: index_type='GIST' expressions=["
"(F(datespan), '-|-')] name='exclude_overlapping' " "(OpClass(F(datespan), name=range_ops), '-|-')] "
"opclasses=['range_ops']>", "name='exclude_overlapping'>",
) )
def test_eq(self): def test_eq(self):
@ -407,6 +411,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
], ],
include=['cancelled'], include=['cancelled'],
) )
with ignore_warnings(category=RemovedInDjango50Warning):
constraint_8 = ExclusionConstraint( constraint_8 = ExclusionConstraint(
name='exclude_overlapping', name='exclude_overlapping',
expressions=[ expressions=[
@ -424,6 +429,8 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
], ],
opclasses=['range_ops', 'range_ops'] 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, 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)
@ -432,10 +439,8 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
self.assertNotEqual(constraint_2, constraint_3) self.assertNotEqual(constraint_2, constraint_3)
self.assertNotEqual(constraint_2, constraint_4) self.assertNotEqual(constraint_2, constraint_4)
self.assertNotEqual(constraint_2, constraint_7) self.assertNotEqual(constraint_2, constraint_7)
self.assertNotEqual(constraint_2, constraint_9)
self.assertNotEqual(constraint_4, constraint_5) self.assertNotEqual(constraint_4, constraint_5)
self.assertNotEqual(constraint_5, constraint_6) self.assertNotEqual(constraint_5, constraint_6)
self.assertNotEqual(constraint_7, constraint_8)
self.assertNotEqual(constraint_1, object()) self.assertNotEqual(constraint_1, object())
def test_deconstruct(self): def test_deconstruct(self):
@ -511,6 +516,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
'include': ('cancelled', 'room'), 'include': ('cancelled', 'room'),
}) })
@ignore_warnings(category=RemovedInDjango50Warning)
def test_deconstruct_opclasses(self): def test_deconstruct_opclasses(self):
constraint = ExclusionConstraint( constraint = ExclusionConstraint(
name='exclude_overlapping', 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): class TsTzRange(Func):
function = 'TSTZRANGE' function = 'TSTZRANGE'
output_field = DateTimeRangeField() output_field = DateTimeRangeField()
@ -605,7 +612,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
) )
self._test_range_overlaps(constraint) self._test_range_overlaps(constraint)
def test_range_overlaps_custom_opclass_expression(self): def test_range_overlaps_custom(self):
class TsTzRange(Func): class TsTzRange(Func):
function = 'TSTZRANGE' function = 'TSTZRANGE'
output_field = DateTimeRangeField() output_field = DateTimeRangeField()
@ -856,17 +863,25 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
with self.assertRaisesMessage(NotSupportedError, msg): with self.assertRaisesMessage(NotSupportedError, msg):
editor.add_constraint(RangesModel, constraint) editor.add_constraint(RangesModel, constraint)
def test_range_adjacent_opclasses(self): def test_range_adjacent_opclass(self):
constraint_name = 'ints_adjacent_opclasses' constraint_name = 'ints_adjacent_opclass'
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
constraint = ExclusionConstraint( constraint = ExclusionConstraint(
name=constraint_name, name=constraint_name,
expressions=[('ints', RangeOperators.ADJACENT_TO)], expressions=[
opclasses=['range_ops'], (OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
],
) )
with connection.schema_editor() as editor: with connection.schema_editor() as editor:
editor.add_constraint(RangesModel, constraint) 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)) RangesModel.objects.create(ints=(20, 50))
with self.assertRaises(IntegrityError), transaction.atomic(): with self.assertRaises(IntegrityError), transaction.atomic():
RangesModel.objects.create(ints=(10, 20)) RangesModel.objects.create(ints=(10, 20))
@ -877,6 +892,142 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
editor.remove_constraint(RangesModel, constraint) 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))
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=[
(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_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=[
(OpClass('ints', name='range_ops'), 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))
@skipUnlessDBFeature('supports_covering_gist_indexes')
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=[
(OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
],
index_type='gist',
include=['decimals'],
)
with connection.schema_editor() as editor:
editor.add_constraint(RangesModel, constraint)
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@skipUnlessDBFeature('supports_covering_spgist_indexes')
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=[
(OpClass('ints', name='range_ops'), RangeOperators.ADJACENT_TO),
],
index_type='spgist',
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_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=[('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])
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))
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))
@ignore_warnings(category=RemovedInDjango50Warning)
def test_range_adjacent_opclasses_condition(self): def test_range_adjacent_opclasses_condition(self):
constraint_name = 'ints_adjacent_opclasses_condition' constraint_name = 'ints_adjacent_opclasses_condition'
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@ -890,6 +1041,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
editor.add_constraint(RangesModel, constraint) editor.add_constraint(RangesModel, constraint)
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@ignore_warnings(category=RemovedInDjango50Warning)
def test_range_adjacent_opclasses_deferrable(self): def test_range_adjacent_opclasses_deferrable(self):
constraint_name = 'ints_adjacent_opclasses_deferrable' constraint_name = 'ints_adjacent_opclasses_deferrable'
self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) self.assertNotIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@ -903,6 +1055,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
editor.add_constraint(RangesModel, constraint) editor.add_constraint(RangesModel, constraint)
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@ignore_warnings(category=RemovedInDjango50Warning)
@skipUnlessDBFeature('supports_covering_gist_indexes') @skipUnlessDBFeature('supports_covering_gist_indexes')
def test_range_adjacent_gist_opclasses_include(self): def test_range_adjacent_gist_opclasses_include(self):
constraint_name = 'ints_adjacent_gist_opclasses_include' constraint_name = 'ints_adjacent_gist_opclasses_include'
@ -918,6 +1071,7 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
editor.add_constraint(RangesModel, constraint) editor.add_constraint(RangesModel, constraint)
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table))
@ignore_warnings(category=RemovedInDjango50Warning)
@skipUnlessDBFeature('supports_covering_spgist_indexes') @skipUnlessDBFeature('supports_covering_spgist_indexes')
def test_range_adjacent_spgist_opclasses_include(self): def test_range_adjacent_spgist_opclasses_include(self):
constraint_name = 'ints_adjacent_spgist_opclasses_include' constraint_name = 'ints_adjacent_spgist_opclasses_include'
@ -932,42 +1086,3 @@ class ExclusionConstraintTests(PostgreSQLTestCase):
with connection.schema_editor() as editor: with connection.schema_editor() as editor:
editor.add_constraint(RangesModel, constraint) editor.add_constraint(RangesModel, constraint)
self.assertIn(constraint_name, self.get_constraints(RangesModel._meta.db_table)) 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),
)
constraint = ExclusionConstraint(
name=constraint_name,
expressions=[(OpClass('ints', 'range_ops'), RangeOperators.ADJACENT_TO)],
)
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])
self.assertEqual(
cursor.fetchall(),
[('range_ops', constraint_name)],
)
# 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),
)
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))