Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions django/contrib/postgres/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.db.models.lookups import PostgresOperatorLookup
from django.db.models.sql import Query

from .fields import RangeOperators
from .utils import CheckPostgresInstalledMixin

__all__ = ["ExclusionConstraint"]
Expand Down Expand Up @@ -36,9 +37,9 @@ def __init__(
violation_error_code=None,
violation_error_message=None,
):
if index_type and index_type.lower() not in {"gist", "spgist"}:
if index_type and index_type.lower() not in {"gist", "hash", "spgist"}:
raise ValueError(
"Exclusion constraints only support GiST or SP-GiST indexes."
"Exclusion constraints only support GiST, Hash, or SP-GiST indexes."
)
if not expressions:
raise ValueError(
Expand All @@ -57,6 +58,24 @@ def __init__(
)
if not isinstance(include, (NoneType, list, tuple)):
raise ValueError("ExclusionConstraint.include must be a list or tuple.")
if index_type and index_type.lower() == "hash":
if include:
raise ValueError(
"Covering exclusion constraints using Hash indexes are not "
"supported."
)
if not expressions:
pass
elif len(expressions) > 1:
raise ValueError(
"Composite exclusion constraints using Hash indexes are not "
"supported."
)
elif expressions[0][1] != RangeOperators.EQUAL:
raise ValueError(
"Exclusion constraints using Hash indexes only support the EQUAL "
"operator."
)
self.expressions = expressions
self.index_type = index_type or "GIST"
self.condition = condition
Expand Down
10 changes: 7 additions & 3 deletions docs/ref/contrib/postgres/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,13 @@ creates an exclusion constraint on ``circle`` using ``circle_ops``.

.. attribute:: ExclusionConstraint.index_type

The index type of the constraint. Accepted values are ``GIST`` or ``SPGIST``.
Matching is case insensitive. If not provided, the default index type is
``GIST``.
The index type of the constraint. Accepted values are ``GiST``, ``Hash``, or
``SPGiST``. Matching is case insensitive. If not provided, the default index
type is ``GIST``.

.. versionchanged:: 6.1

Support for exclusion constraints using Hash indexes was added.

``condition``
-------------
Expand Down
3 changes: 3 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ Minor features
:class:`~django.contrib.postgres.fields.HStoreField` when ``psycopg`` 3.2+ is
installed and ``django.contrib.postgres`` is in :setting:`INSTALLED_APPS`.

* :class:`~django.contrib.postgres.constraints.ExclusionConstraint` now
supports the Hash index type.

:mod:`django.contrib.redirects`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
86 changes: 84 additions & 2 deletions tests/postgres_tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@
from django.utils import timezone

from . import PostgreSQLTestCase
from .models import HotelReservation, IntegerArrayModel, RangesModel, Room, Scene
from .models import (
HotelReservation,
IntegerArrayModel,
RangesModel,
Room,
Scene,
)

try:
from django.contrib.postgres.constraints import ExclusionConstraint
Expand Down Expand Up @@ -299,7 +305,7 @@ def test_invalid_condition(self):
)

def test_invalid_index_type(self):
msg = "Exclusion constraints only support GiST or SP-GiST indexes."
msg = "Exclusion constraints only support GiST, Hash, or SP-GiST indexes."
with self.assertRaisesMessage(ValueError, msg):
ExclusionConstraint(
index_type="gin",
Expand Down Expand Up @@ -480,6 +486,18 @@ def test_repr(self):
"(F(datespan), '-|-')] name='exclude_overlapping' "
"violation_error_code='overlapping_must_be_excluded'>",
)
constraint = ExclusionConstraint(
name="exclude_equal_hash",
index_type="hash",
expressions=[(F("room"), RangeOperators.EQUAL)],
violation_error_code="room_must_be_unique",
)
self.assertEqual(
repr(constraint),
"<ExclusionConstraint: index_type='hash' expressions=["
"(F(room), '=')] name='exclude_equal_hash' "
"violation_error_code='room_must_be_unique'>",
)

def test_eq(self):
constraint_1 = ExclusionConstraint(
Expand Down Expand Up @@ -581,6 +599,12 @@ def test_eq(self):
violation_error_code="custom_code",
violation_error_message="other custom error",
)
constraint_13 = ExclusionConstraint(
name="exclude_equal_hash",
index_type="hash",
expressions=[(F("room"), RangeOperators.EQUAL)],
violation_error_code="room_must_be_unique",
)
self.assertEqual(constraint_1, constraint_1)
self.assertEqual(constraint_1, mock.ANY)
self.assertNotEqual(constraint_1, constraint_2)
Expand All @@ -597,8 +621,10 @@ def test_eq(self):
self.assertNotEqual(constraint_1, object())
self.assertNotEqual(constraint_10, constraint_11)
self.assertNotEqual(constraint_11, constraint_12)
self.assertNotEqual(constraint_12, constraint_13)
self.assertEqual(constraint_10, constraint_10)
self.assertEqual(constraint_12, constraint_12)
self.assertEqual(constraint_13, constraint_13)

def test_deconstruct(self):
constraint = ExclusionConstraint(
Expand Down Expand Up @@ -1256,6 +1282,26 @@ def test_range_equal_cast(self):
editor.add_constraint(Room, constraint)
self.assertIn(constraint_name, self.get_constraints(Room._meta.db_table))

def test_hash_uniqueness(self):
constraint_name = "exclusion_equal_room_hash"
self.assertNotIn(constraint_name, self.get_constraints(Room._meta.db_table))
constraint = ExclusionConstraint(
name=constraint_name,
index_type="hash",
expressions=[(F("number"), RangeOperators.EQUAL)],
)
with connection.schema_editor() as editor:
editor.add_constraint(Room, constraint)
self.assertIn(constraint_name, self.get_constraints(Room._meta.db_table))
Room.objects.create(number=101)
with self.assertRaises(IntegrityError), transaction.atomic():
Room.objects.create(number=101)
Room.objects.create(number=102)
# Drop the constraint.
with connection.schema_editor() as editor:
editor.remove_constraint(Room, constraint)
self.assertNotIn(constraint.name, self.get_constraints(Room._meta.db_table))

@isolate_apps("postgres_tests")
def test_table_create(self):
constraint_name = "exclusion_equal_number_tc"
Expand Down Expand Up @@ -1287,3 +1333,39 @@ def test_database_default(self):
msg = "Constraint “ints_equal” is violated."
with self.assertRaisesMessage(ValidationError, msg):
constraint.validate(RangesModel, RangesModel())

def test_covering_hash_index_not_supported(self):
constraint_name = "covering_hash_index_not_supported"
msg = "Covering exclusion constraints using Hash indexes are not supported."
with self.assertRaisesMessage(ValueError, msg):
ExclusionConstraint(
name=constraint_name,
expressions=[("int1", RangeOperators.EQUAL)],
index_type="hash",
include=["int2"],
)

def test_composite_hash_index_not_supported(self):
constraint_name = "composite_hash_index_not_supported"
msg = "Composite exclusion constraints using Hash indexes are not supported."
with self.assertRaisesMessage(ValueError, msg):
ExclusionConstraint(
name=constraint_name,
expressions=[
("int1", RangeOperators.EQUAL),
("int2", RangeOperators.EQUAL),
],
index_type="hash",
)

def test_non_equal_hash_index_not_supported(self):
constraint_name = "none_equal_hash_index_not_supported"
msg = (
"Exclusion constraints using Hash indexes only support the EQUAL operator."
)
with self.assertRaisesMessage(ValueError, msg):
ExclusionConstraint(
name=constraint_name,
expressions=[("int1", RangeOperators.NOT_EQUAL)],
index_type="hash",
)