On Wed, 14 Oct 2026 16:25:33 +0200, Matthias van de Meent 
<[email protected]> wrote:
> On Fri, 2 Oct 2026 at 01:34, Alberto Piai <[email protected]> wrote:
> >
> > I wonder what would be the downsides of exposing this function: the
> > notion of image equality is already user-visible through pg_catalog.*=
> > (record_image_eq).
> 
> I've thought about it, but I couldn't think of any.  Adding
> pg_datum_image_equal actually means that users won't have to go
> through expensive record (de)construction for binary equality, which
> would be a very useful performance improvement.
> 
> > One risk of course is users assuming that some values are equal, and
> > then being surprised when more updates happen than expected, in
> > Matthias' use case of data synchronization tools.
> 
> What do you mean by "more updates than expected"?
> 
> > For pg_datum_image_equal(), we could write something along the lines of
> >
> >   This function is intended for specialized purposes such as data
> >   synchronization tools. It is not generally useful for writing queries,
> >   as it might consider two values as different, even though a comparison
> >   with the equality operator would return true.
> 
> I don't think this is useful.  The distinction is already made
> sufficiently clear in the added docs, and because the function doesn't
> map to indexable operators there's little chance people will
> accidentally use this when they actually want their type's = operator.
> 
> 
> Kind regards,
> 
> Matthias van de Meent
> Databricks (https://www.databricks.com)

Hello Matthias,

This thread caught my eye as I was working on differential selectivity
testing, so I took a look at it.  I was able to validate the function
you propose against its intended contract and have a small regression
test that you might want to add, your call.

The contract I checked: for non-NULL values of the same type,
pg_datum_image_equal(a, b) is true iff the type's send() output for a
and b is identical, with NULL vs NULL true and NULL vs non-NULL false.
That is the line between image equality and = that the function exists
to draw, so the test covers the three cases where they diverge:

   value-equal, image-unequal:  float8 0.0 vs -0.0 ; numeric 1.0 vs 1.00
   =, and image-equal:          float8 'NaN' vs 'NaN'   (float = treats NaN as
                                equal, so = and the image agree here)

plus a bool_and over int4/int8/text/bool/numeric/float8 that the
function agrees with <type>send() equality on every value pair.

Against v5 (1b91f546b62) every case holds.  One wrinkle worth a comment
in the test: a -0.0 literal is folded to +0.0 before it reaches the
function, so to get a genuine negative zero on disk the test routes
values through a table and computes (-1.0::float8) * 0.0; comparing that
to +0.0 correctly reports image-unequal.

Patch attached, as a companion to apply on top of your branch
(src/test/regress/sql/datum_image_equal.sql, added to the misc_functions
parallel group).  Fold it in or treat it as a starting point, whichever
you prefer.

best.

-greg

From 709a929b0245603c19a21c411154d84f49dfcf09 Mon Sep 17 00:00:00 2001
From: Greg Burd <[email protected]>
Date: Mon, 26 Oct 2026 11:12:34 +0000
Subject: [PATCH] Add regression test pinning the pg_datum_image_equal contract

---
 .../regress/expected/datum_image_equal.out    | 108 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   3 +-
 src/test/regress/sql/datum_image_equal.sql    |  80 +++++++++++++
 3 files changed, 190 insertions(+), 1 deletion(-)
 create mode 100644 src/test/regress/expected/datum_image_equal.out
 create mode 100644 src/test/regress/sql/datum_image_equal.sql

diff --git a/src/test/regress/expected/datum_image_equal.out 
b/src/test/regress/expected/datum_image_equal.out
new file mode 100644
index 00000000000..3b5ffe3ae94
--- /dev/null
+++ b/src/test/regress/expected/datum_image_equal.out
@@ -0,0 +1,108 @@
+--
+-- Contract for pg_datum_image_equal: it reports whether two values of the same
+-- type have identical on-disk binary images -- NOT whether they are equal by
+-- the type's = operator.  The two differ precisely at the cases this function
+-- exists to serve (REFRESH MATERIALIZED VIEW CONCURRENTLY, incremental sort):
+--
+--   value-equal but image-UNequal:  float8  0.0 vs -0.0 (sign bit)
+--                                   numeric 1.0 vs 1.00 (stored scale)
+--   value-UNequal (by IEEE) but =   float8  'NaN' vs 'NaN' -- and image-equal
+--     (PostgreSQL's float = treats NaN as equal to NaN, unlike IEEE, so here
+--     both = and the image agree; the point is the function tracks the image).
+--
+-- and NULL handling: NULL vs NULL is equal, NULL vs non-NULL is not.
+--
+-- The oracle is the type's own binary output: two non-NULL values have equal
+-- images iff their <type>send() bytes are identical.  The test checks that
+-- equivalence across a range of types and the known edge cases, so a future
+-- change that accidentally made the function track = instead of the image is
+-- caught.
+--
+-- Literals are folded, so -0.0 and a computed -0.0 are different data: route
+-- values through a table to get the genuine datums on disk.
+CREATE TABLE die_f8 (x float8);
+INSERT INTO die_f8 VALUES (0.0), ((-1.0::float8) * 0.0), ('NaN'), 
('Infinity'), (1.5);
+-- 0.0 and -0.0 are = but have different images.
+SELECT 'f8 0 vs -0' AS t,
+       (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) LIMIT 1) =
+       (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) DESC LIMIT 
1) AS by_eq,
+       pg_datum_image_equal(
+         (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) LIMIT 1),
+         (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) DESC LIMIT 
1)) AS by_image;
+     t      | by_eq | by_image 
+------------+-------+----------
+ f8 0 vs -0 | t     | f
+(1 row)
+
+-- NaN is not = to NaN, but two NaNs have the same image.
+SELECT 'f8 NaN vs NaN' AS t,
+       ('NaN'::float8 = 'NaN'::float8) AS by_eq,
+       pg_datum_image_equal('NaN'::float8, 'NaN'::float8) AS by_image;
+       t       | by_eq | by_image 
+---------------+-------+----------
+ f8 NaN vs NaN | t     | t
+(1 row)
+
+-- numeric 1.0 and 1.00 are = but stored with different scale -> different 
image.
+SELECT 'numeric 1.0 vs 1.00' AS t,
+       (1.0::numeric = 1.00::numeric) AS by_eq,
+       pg_datum_image_equal(1.0::numeric, 1.00::numeric) AS by_image;
+          t          | by_eq | by_image 
+---------------------+-------+----------
+ numeric 1.0 vs 1.00 | t     | f
+(1 row)
+
+-- NULL handling.
+SELECT 'null vs null'  AS t, pg_datum_image_equal(NULL::int, NULL::int) AS 
by_image;
+      t       | by_image 
+--------------+----------
+ null vs null | t
+(1 row)
+
+SELECT 'null vs value' AS t, pg_datum_image_equal(NULL::int, 1) AS by_image;
+       t       | by_image 
+---------------+----------
+ null vs value | f
+(1 row)
+
+-- General equivalence: for non-NULL same-type values, image equality agrees
+-- with identical binary output, across by-value and varlena types.  Every row
+-- must report t.
+SELECT format('%s image==send agrees', typ) AS t, bool_and(ok) AS all_true
+FROM (
+  SELECT 'int4' typ,
+         pg_datum_image_equal(a::int4, b::int4) = (int4send(a::int4) = 
int4send(b::int4)) ok
+  FROM (VALUES (0,0),(1,1),(1,2),(-1,-1),(2147483647,-2147483648)) v(a,b)
+  UNION ALL
+  SELECT 'int8',
+         pg_datum_image_equal(a::int8, b::int8) = (int8send(a::int8) = 
int8send(b::int8))
+  FROM (VALUES (0,0),(1,2),(9223372036854775807,-1)) v(a,b)
+  UNION ALL
+  SELECT 'text',
+         pg_datum_image_equal(a::text, b::text) = (textsend(a::text) = 
textsend(b::text))
+  FROM (VALUES ('a','a'),('a','b'),('','a'),('abc','abc')) v(a,b)
+  UNION ALL
+  SELECT 'bool',
+         pg_datum_image_equal(a::bool, b::bool) = (boolsend(a::bool) = 
boolsend(b::bool))
+  FROM (VALUES (true,true),(true,false)) v(a,b)
+  UNION ALL
+  SELECT 'numeric',
+         pg_datum_image_equal(a::numeric, b::numeric) = 
(numeric_send(a::numeric) = numeric_send(b::numeric))
+  FROM (VALUES ('1.0','1.00'),('0','0.0'),('100','1e2'),('NaN','NaN')) v(a,b)
+  UNION ALL
+  SELECT 'float8',
+         pg_datum_image_equal(a::float8, b::float8) = (float8send(a::float8) = 
float8send(b::float8))
+  FROM (VALUES ('1.5','1.5'),('NaN','NaN'),('Infinity','-Infinity')) v(a,b)
+) s
+GROUP BY typ ORDER BY typ;
+             t              | all_true 
+----------------------------+----------
+ bool image==send agrees    | t
+ float8 image==send agrees  | t
+ int4 image==send agrees    | t
+ int8 image==send agrees    | t
+ numeric image==send agrees | t
+ text image==send agrees    | t
+(6 rows)
+
+DROP TABLE die_f8;
diff --git a/src/test/regress/parallel_schedule 
b/src/test/regress/parallel_schedule
index 75063f87a4a..303a8545b7a 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -76,7 +76,8 @@ test: brin_bloom brin_multi
 # ----------
 # Another group of parallel tests
 # ----------
-test: create_table_like alter_generic alter_operator misc async dbsize merge 
misc_functions nls sysviews tsrf tid tidscan tidrangescan collate.utf8 
collate.icu.utf8 incremental_sort create_role without_overlaps generated_virtual
+test: create_table_like alter_generic alter_operator misc async dbsize merge 
misc_functions
+test: datum_image_equal nls sysviews tsrf tid tidscan tidrangescan 
collate.utf8 collate.icu.utf8 incremental_sort create_role without_overlaps 
generated_virtual
 
 # collate.linux.utf8 and collate.icu.utf8 tests cannot be run in parallel with 
each other
 # psql depends on create_am
diff --git a/src/test/regress/sql/datum_image_equal.sql 
b/src/test/regress/sql/datum_image_equal.sql
new file mode 100644
index 00000000000..27a06906c3d
--- /dev/null
+++ b/src/test/regress/sql/datum_image_equal.sql
@@ -0,0 +1,80 @@
+--
+-- Contract for pg_datum_image_equal: it reports whether two values of the same
+-- type have identical on-disk binary images -- NOT whether they are equal by
+-- the type's = operator.  The two differ precisely at the cases this function
+-- exists to serve (REFRESH MATERIALIZED VIEW CONCURRENTLY, incremental sort):
+--
+--   value-equal but image-UNequal:  float8  0.0 vs -0.0 (sign bit)
+--                                   numeric 1.0 vs 1.00 (stored scale)
+--   value-UNequal (by IEEE) but =   float8  'NaN' vs 'NaN' -- and image-equal
+--     (PostgreSQL's float = treats NaN as equal to NaN, unlike IEEE, so here
+--     both = and the image agree; the point is the function tracks the image).
+--
+-- and NULL handling: NULL vs NULL is equal, NULL vs non-NULL is not.
+--
+-- The oracle is the type's own binary output: two non-NULL values have equal
+-- images iff their <type>send() bytes are identical.  The test checks that
+-- equivalence across a range of types and the known edge cases, so a future
+-- change that accidentally made the function track = instead of the image is
+-- caught.
+--
+
+-- Literals are folded, so -0.0 and a computed -0.0 are different data: route
+-- values through a table to get the genuine datums on disk.
+CREATE TABLE die_f8 (x float8);
+INSERT INTO die_f8 VALUES (0.0), ((-1.0::float8) * 0.0), ('NaN'), 
('Infinity'), (1.5);
+
+-- 0.0 and -0.0 are = but have different images.
+SELECT 'f8 0 vs -0' AS t,
+       (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) LIMIT 1) =
+       (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) DESC LIMIT 
1) AS by_eq,
+       pg_datum_image_equal(
+         (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) LIMIT 1),
+         (SELECT x FROM die_f8 WHERE x = 0.0 ORDER BY float8send(x) DESC LIMIT 
1)) AS by_image;
+
+-- NaN is not = to NaN, but two NaNs have the same image.
+SELECT 'f8 NaN vs NaN' AS t,
+       ('NaN'::float8 = 'NaN'::float8) AS by_eq,
+       pg_datum_image_equal('NaN'::float8, 'NaN'::float8) AS by_image;
+
+-- numeric 1.0 and 1.00 are = but stored with different scale -> different 
image.
+SELECT 'numeric 1.0 vs 1.00' AS t,
+       (1.0::numeric = 1.00::numeric) AS by_eq,
+       pg_datum_image_equal(1.0::numeric, 1.00::numeric) AS by_image;
+
+-- NULL handling.
+SELECT 'null vs null'  AS t, pg_datum_image_equal(NULL::int, NULL::int) AS 
by_image;
+SELECT 'null vs value' AS t, pg_datum_image_equal(NULL::int, 1) AS by_image;
+
+-- General equivalence: for non-NULL same-type values, image equality agrees
+-- with identical binary output, across by-value and varlena types.  Every row
+-- must report t.
+SELECT format('%s image==send agrees', typ) AS t, bool_and(ok) AS all_true
+FROM (
+  SELECT 'int4' typ,
+         pg_datum_image_equal(a::int4, b::int4) = (int4send(a::int4) = 
int4send(b::int4)) ok
+  FROM (VALUES (0,0),(1,1),(1,2),(-1,-1),(2147483647,-2147483648)) v(a,b)
+  UNION ALL
+  SELECT 'int8',
+         pg_datum_image_equal(a::int8, b::int8) = (int8send(a::int8) = 
int8send(b::int8))
+  FROM (VALUES (0,0),(1,2),(9223372036854775807,-1)) v(a,b)
+  UNION ALL
+  SELECT 'text',
+         pg_datum_image_equal(a::text, b::text) = (textsend(a::text) = 
textsend(b::text))
+  FROM (VALUES ('a','a'),('a','b'),('','a'),('abc','abc')) v(a,b)
+  UNION ALL
+  SELECT 'bool',
+         pg_datum_image_equal(a::bool, b::bool) = (boolsend(a::bool) = 
boolsend(b::bool))
+  FROM (VALUES (true,true),(true,false)) v(a,b)
+  UNION ALL
+  SELECT 'numeric',
+         pg_datum_image_equal(a::numeric, b::numeric) = 
(numeric_send(a::numeric) = numeric_send(b::numeric))
+  FROM (VALUES ('1.0','1.00'),('0','0.0'),('100','1e2'),('NaN','NaN')) v(a,b)
+  UNION ALL
+  SELECT 'float8',
+         pg_datum_image_equal(a::float8, b::float8) = (float8send(a::float8) = 
float8send(b::float8))
+  FROM (VALUES ('1.5','1.5'),('NaN','NaN'),('Infinity','-Infinity')) v(a,b)
+) s
+GROUP BY typ ORDER BY typ;
+
+DROP TABLE die_f8;
-- 
2.47.3

Reply via email to