From 6a1ea0e94a601279ce2748c38f7dca398120ff7b Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 16 Oct 2026 12:50:30 -0400
Subject: [PATCH v39 8/8] hash: implement amcomparedatums

A hash index stores only the hash code of the value, and every scan, plain
or bitmap, rechecks the heap tuple.  So an updated row gives the same entry
exactly when the new value has the same hash code as the old one.

Updates that become HOT include changing the display scale of a numeric,
which hash_numeric() ignores, and changing a string to one equal under a
nondeterministic collation.

A test checks that an exclusion constraint using a hash index still sees a
change to a value with the same hash code (hashint8(1) = hashint8(2^32)):
the executor does not consult amcomparedatums for exclusion constraints.
---
 src/backend/access/hash/hash.c           |  19 +++-
 src/include/access/hash.h                |   3 +
 src/test/regress/expected/hash_index.out | 120 +++++++++++++++++++++++
 src/test/regress/sql/hash_index.sql      |  55 +++++++++++
 4 files changed, 196 insertions(+), 1 deletion(-)

diff --git a/src/backend/access/hash/hash.c b/src/backend/access/hash/hash.c
index 0b05c4606e1..5feaf48257c 100644
--- a/src/backend/access/hash/hash.c
+++ b/src/backend/access/hash/hash.c
@@ -123,7 +123,7 @@ hashhandler(PG_FUNCTION_ARGS)
 		.amparallelrescan = NULL,
 		.amtranslatestrategy = hashtranslatestrategy,
 		.amtranslatecmptype = hashtranslatecmptype,
-		.amcomparedatums = NULL,
+		.amcomparedatums = hashcomparedatums,
 	};
 
 	PG_RETURN_POINTER(&amroutine);
@@ -1029,3 +1029,20 @@ hashtranslatecmptype(CompareType cmptype, Oid opfamily)
 		return HTEqualStrategyNumber;
 	return InvalidStrategy;
 }
+
+/*
+ * hashcomparedatums() -- would newValue give the same index entry as oldValue?
+ *
+ * A hash index stores only the hash code of the value, and every scan
+ * rechecks the heap tuple, so the entries are the same if the hash codes
+ * are.  Then, for example, numeric 1.0 and 1.00, or strings equal under a
+ * case-insensitive collation, make no new entries.
+ */
+bool
+hashcomparedatums(Relation index, IndexInfo *indexInfo, int attnum,
+				  Oid typid, Datum oldValue, Datum newValue)
+{
+	Assert(attnum == 1);
+	return _hash_datum2hashkey(index, oldValue) ==
+		_hash_datum2hashkey(index, newValue);
+}
diff --git a/src/include/access/hash.h b/src/include/access/hash.h
index a8702f0e5ea..8abb4f23e70 100644
--- a/src/include/access/hash.h
+++ b/src/include/access/hash.h
@@ -389,6 +389,9 @@ extern void hashadjustmembers(Oid opfamilyoid,
 
 extern CompareType hashtranslatestrategy(StrategyNumber strategy, Oid opfamily);
 extern StrategyNumber hashtranslatecmptype(CompareType cmptype, Oid opfamily);
+extern bool hashcomparedatums(Relation index, struct IndexInfo *indexInfo,
+							  int attnum, Oid typid,
+							  Datum oldValue, Datum newValue);
 
 /* private routines */
 
diff --git a/src/test/regress/expected/hash_index.out b/src/test/regress/expected/hash_index.out
index 0b099b9e771..bfa2d563e41 100644
--- a/src/test/regress/expected/hash_index.out
+++ b/src/test/regress/expected/hash_index.out
@@ -375,3 +375,123 @@ CREATE INDEX hash_f8_index2 ON hash_f8_heap USING hash (random float8_ops)
 	WITH (fillfactor=101);
 ERROR:  value 101 out of bounds for option "fillfactor"
 DETAIL:  Valid values are between "10" and "100".
+--
+-- An UPDATE to a value with the same hash code is HOT: the index stores
+-- only the code, and scans recheck the heap tuple.
+--
+CREATE TABLE hash_hot (id int, n numeric) WITH (fillfactor = 10);
+CREATE INDEX hash_hot_n ON hash_hot USING hash (n);
+CREATE INDEX hash_hot_n2 ON hash_hot USING hash ((n * 2));
+INSERT INTO hash_hot SELECT g, g FROM generate_series(1, 50) g;
+CREATE FUNCTION hash_hot_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('hash_hot'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('hash_hot'::regclass) $$;
+BEGIN;
+-- numeric hashes ignore the display scale, in the column and in the
+-- expression: HOT
+UPDATE hash_hot SET n = n + 0.00;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+      50 |  50
+(1 row)
+
+-- a different value: not HOT
+UPDATE hash_hot SET n = n + 100 WHERE id <= 5;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+      55 |  50
+(1 row)
+
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id, n FROM hash_hot WHERE n = 7;
+               QUERY PLAN                
+-----------------------------------------
+ Index Scan using hash_hot_n on hash_hot
+   Index Cond: (n = '7'::numeric)
+(2 rows)
+
+SELECT id, n FROM hash_hot WHERE n = 7;
+ id |  n   
+----+------
+  7 | 7.00
+(1 row)
+
+SELECT id, n FROM hash_hot WHERE n = 7.000;
+ id |  n   
+----+------
+  7 | 7.00
+(1 row)
+
+SELECT id, n FROM hash_hot WHERE n = 3;
+ id | n 
+----+---
+(0 rows)
+
+SELECT id, n FROM hash_hot WHERE n = 103;
+ id |   n    
+----+--------
+  3 | 103.00
+(1 row)
+
+SELECT id, n FROM hash_hot WHERE n * 2 = 14;
+ id |  n   
+----+------
+  7 | 7.00
+(1 row)
+
+RESET enable_bitmapscan;
+SET enable_indexscan = off;
+SELECT count(*) FROM hash_hot h WHERE n = (SELECT n FROM hash_hot WHERE id = h.id);
+ count 
+-------
+    50
+(1 row)
+
+RESET enable_indexscan;
+RESET enable_seqscan;
+DROP TABLE hash_hot;
+-- An exclusion constraint is checked on the values, so a change to a value
+-- with the same hash code must still make a new entry and be checked.
+-- hashint8 folds the high half into the low, so 1 and 2^32 collide.
+SELECT hashint8(1) = hashint8(4294967296) AS same_hash;
+ same_hash 
+-----------
+ t
+(1 row)
+
+CREATE TABLE hash_hot (id int, k int8, EXCLUDE USING hash (k WITH =))
+  WITH (fillfactor = 10);
+INSERT INTO hash_hot VALUES (1, 1), (2, 4294967296);
+UPDATE hash_hot SET k = 4294967296 WHERE id = 1;
+ERROR:  conflicting key value violates exclusion constraint "hash_hot_k_excl"
+DETAIL:  Key (k)=(4294967296) conflicts with existing key (k)=(4294967296).
+-- without a conflict, the update is checked and is not HOT
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+BEGIN;
+DELETE FROM hash_hot WHERE id = 2;
+UPDATE hash_hot SET k = 4294967296 WHERE id = 1;
+SELECT * FROM hash_hot_count();
+ updated | hot 
+---------+-----
+       1 |   0
+(1 row)
+
+COMMIT;
+SELECT * FROM hash_hot;
+ id |     k      
+----+------------
+  1 | 4294967296
+(1 row)
+
+DROP TABLE hash_hot;
+DROP FUNCTION hash_hot_count;
diff --git a/src/test/regress/sql/hash_index.sql b/src/test/regress/sql/hash_index.sql
index 3ebdaeaf172..5020080372c 100644
--- a/src/test/regress/sql/hash_index.sql
+++ b/src/test/regress/sql/hash_index.sql
@@ -351,3 +351,58 @@ CREATE INDEX hash_f8_index2 ON hash_f8_heap USING hash (random float8_ops)
 	WITH (fillfactor=9);
 CREATE INDEX hash_f8_index2 ON hash_f8_heap USING hash (random float8_ops)
 	WITH (fillfactor=101);
+
+--
+-- An UPDATE to a value with the same hash code is HOT: the index stores
+-- only the code, and scans recheck the heap tuple.
+--
+CREATE TABLE hash_hot (id int, n numeric) WITH (fillfactor = 10);
+CREATE INDEX hash_hot_n ON hash_hot USING hash (n);
+CREATE INDEX hash_hot_n2 ON hash_hot USING hash ((n * 2));
+INSERT INTO hash_hot SELECT g, g FROM generate_series(1, 50) g;
+CREATE FUNCTION hash_hot_count(OUT updated bigint, OUT hot bigint)
+LANGUAGE sql AS $$
+  SELECT pg_stat_get_xact_tuples_updated('hash_hot'::regclass),
+         pg_stat_get_xact_tuples_hot_updated('hash_hot'::regclass) $$;
+BEGIN;
+-- numeric hashes ignore the display scale, in the column and in the
+-- expression: HOT
+UPDATE hash_hot SET n = n + 0.00;
+SELECT * FROM hash_hot_count();
+-- a different value: not HOT
+UPDATE hash_hot SET n = n + 100 WHERE id <= 5;
+SELECT * FROM hash_hot_count();
+COMMIT;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+EXPLAIN (COSTS OFF) SELECT id, n FROM hash_hot WHERE n = 7;
+SELECT id, n FROM hash_hot WHERE n = 7;
+SELECT id, n FROM hash_hot WHERE n = 7.000;
+SELECT id, n FROM hash_hot WHERE n = 3;
+SELECT id, n FROM hash_hot WHERE n = 103;
+SELECT id, n FROM hash_hot WHERE n * 2 = 14;
+RESET enable_bitmapscan;
+SET enable_indexscan = off;
+SELECT count(*) FROM hash_hot h WHERE n = (SELECT n FROM hash_hot WHERE id = h.id);
+RESET enable_indexscan;
+RESET enable_seqscan;
+DROP TABLE hash_hot;
+
+-- An exclusion constraint is checked on the values, so a change to a value
+-- with the same hash code must still make a new entry and be checked.
+-- hashint8 folds the high half into the low, so 1 and 2^32 collide.
+SELECT hashint8(1) = hashint8(4294967296) AS same_hash;
+CREATE TABLE hash_hot (id int, k int8, EXCLUDE USING hash (k WITH =))
+  WITH (fillfactor = 10);
+INSERT INTO hash_hot VALUES (1, 1), (2, 4294967296);
+UPDATE hash_hot SET k = 4294967296 WHERE id = 1;
+-- without a conflict, the update is checked and is not HOT
+SELECT pg_stat_force_next_flush();
+BEGIN;
+DELETE FROM hash_hot WHERE id = 2;
+UPDATE hash_hot SET k = 4294967296 WHERE id = 1;
+SELECT * FROM hash_hot_count();
+COMMIT;
+SELECT * FROM hash_hot;
+DROP TABLE hash_hot;
+DROP FUNCTION hash_hot_count;
-- 
2.50.1

