Repository: django/django

Issue:
# Fixed #36827 -- Added support for exclusion constraints using Hash indexes on PostgreSQL.

#### Trac ticket number

ticket-36827

#### Branch description

Add support for Hash index type for `ExclusionConstraint` in PostgreSQL.

#### Checklist
- [x] This PR targets the `main` branch.
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [x] I have added or updated relevant docs, including release notes if applicable.
- [x] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/contrib/postgres/constraints.py b/django/contrib/postgres/constraints.py
index 13a90e4b7f66..dea77c2f1458 100644
--- a/django/contrib/postgres/constraints.py
+++ b/django/contrib/postgres/constraints.py
@@ -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"]
@@ -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(
@@ -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


Test patch:
diff --git a/tests/postgres_tests/test_constraints.py b/tests/postgres_tests/test_constraints.py
index 331b23980d6d..39c2f9b1f160 100644
--- a/tests/postgres_tests/test_constraints.py
+++ b/tests/postgres_tests/test_constraints.py
@@ -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
@@ -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",
@@ -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(
@@ -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)
@@ -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(
@@ -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"
@@ -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",
+            )