From d61838761f17d7c934c7be288fadfa14f471b598 Mon Sep 17 00:00:00 2001 From: Haki Benita Date: Thu, 25 Dec 2025 11:42:58 +0200 Subject: [PATCH] Fixed #36827 -- Added support for exclusion constraints using Hash indexes on PostgreSQL. --- django/contrib/postgres/constraints.py | 23 +++++- docs/ref/contrib/postgres/constraints.txt | 10 ++- docs/releases/6.1.txt | 3 + tests/postgres_tests/test_constraints.py | 86 ++++++++++++++++++++++- 4 files changed, 115 insertions(+), 7 deletions(-) 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 diff --git a/docs/ref/contrib/postgres/constraints.txt b/docs/ref/contrib/postgres/constraints.txt index ee8ef02aa212..29083c0b6b91 100644 --- a/docs/ref/contrib/postgres/constraints.txt +++ b/docs/ref/contrib/postgres/constraints.txt @@ -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`` ------------- diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index c4ecefc08a2f..b5b47a11d836 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -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` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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), + "", + ) 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", + )