From 6785bca7570a7ec0a2b6eec8aeac1e50886bc1b0 Mon Sep 17 00:00:00 2001
From: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Date: Sun, 6 Sep 2026 15:15:31 +0500
Subject: [PATCH v5] Fix qual pushdown using btree equalimage

Refuse to push a non-operand reference to a grouping column below the
grouping boundary unless the grouping equality is image equality
(BTEQUALIMAGE_PROC).  That covers jsonb, numeric, float8 and similar
types, and subsumes the old nondeterministic-collation check for
wrapped references.

Cache the default btree equalimage support procedure OID in
TypeCacheEntry.  Add type_is_equalimage() for eager aggregation,
opfamily_is_equalimage() for btree deduplication, and
equality_op_is_equalimage() for the pushdown walker.

Document in select.sgml that when equality merges non-interchangeable
images, which member appears after GROUP BY, DISTINCT, or a set
operation other than UNION ALL is unspecified.  Update btree.sgml so
equalimage is not described as index-only.

Bug: #19649
Author: Andrey Rachitskiy <pl0h0yp1@gmail.com>
Reported-by: chunling qin <303677365@qq.com>
---
 doc/src/sgml/btree.sgml                 |   9 +-
 doc/src/sgml/ref/select.sgml            |  16 +++
 src/backend/access/nbtree/nbtutils.c    |  17 +---
 src/backend/optimizer/plan/initsplan.c  |  30 +-----
 src/backend/optimizer/util/clauses.c    |  37 ++++---
 src/backend/optimizer/util/relnode.c    |  30 +-----
 src/backend/utils/cache/lsyscache.c     |  90 +++++++++++++++++
 src/backend/utils/cache/typcache.c      |  49 ++++++++-
 src/include/utils/lsyscache.h           |   2 +
 src/include/utils/typcache.h            |   4 +
 src/test/regress/expected/subselect.out | 128 ++++++++++++++++++++++++
 src/test/regress/sql/subselect.sql      |  60 +++++++++++
 12 files changed, 383 insertions(+), 89 deletions(-)

diff --git a/doc/src/sgml/btree.sgml b/doc/src/sgml/btree.sgml
index 027361f20bb..704c1c74e55 100644
--- a/doc/src/sgml/btree.sgml
+++ b/doc/src/sgml/btree.sgml
@@ -464,9 +464,12 @@ returns bool
      <function>equalimage</function> (<quote>equality implies image
       equality</quote>) support functions, registered under support
      function number 4.  These functions allow the core code to
-     determine when it is safe to apply the btree deduplication
-     optimization.  Currently, <function>equalimage</function>
-     functions are only called when building or rebuilding an index.
+     determine when two values that compare equal may be freely
+     substituted for one another.  They are called when building or
+     rebuilding an index, to decide whether the btree deduplication
+     optimization is safe, and during query planning, to decide whether
+     an optimization that merges equal values may discard distinctions
+     among those values.
     </para>
     <para>
      An <function>equalimage</function> function must have the
diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml
index 68fb4911769..4060f2bc564 100644
--- a/doc/src/sgml/ref/select.sgml
+++ b/doc/src/sgml/ref/select.sgml
@@ -857,6 +857,22 @@ GROUP BY [ ALL | DISTINCT ] <replaceable class="parameter">grouping_element</rep
     input-column name rather than an output column name.
    </para>
 
+   <para>
+    For some data types, values that compare as equal under the type's
+    equality operator are not interchangeable in every expression.
+    Examples include <type>numeric</type> values that differ only in
+    display scale, <type>float8</type> <literal>0</literal> and
+    <literal>-0</literal>, and <type>jsonb</type> numbers written with
+    different amounts of trailing precision.  When such values are
+    merged by <literal>GROUP BY</literal>, <literal>DISTINCT</literal>,
+    or a set operation other than <literal>UNION ALL</literal>, which
+    member of each set of equals appears in the output (and therefore
+    what a distinguishing expression such as a cast to <type>text</type>
+    sees) is unspecified.  The same holds for <literal>DISTINCT ON</literal>
+    unless <literal>ORDER BY</literal> determines which row of each set is
+    kept (see <xref linkend="sql-distinct"/>).
+   </para>
+
    <para>
     If any of <literal>GROUPING SETS</literal>, <literal>ROLLUP</literal> or
     <literal>CUBE</literal> are present as grouping elements, then the
diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c
index 014faa1622f..e525bdf1847 100644
--- a/src/backend/access/nbtree/nbtutils.c
+++ b/src/backend/access/nbtree/nbtutils.c
@@ -1183,21 +1183,12 @@ _bt_allequalimage(Relation rel, bool debugmessage)
 
 	for (int i = 0; i < IndexRelationGetNumberOfKeyAttributes(rel); i++)
 	{
-		Oid			opfamily = rel->rd_opfamily[i];
-		Oid			opcintype = rel->rd_opcintype[i];
-		Oid			collation = rel->rd_indcollation[i];
-		Oid			equalimageproc;
-
-		equalimageproc = get_opfamily_proc(opfamily, opcintype, opcintype,
-										   BTEQUALIMAGE_PROC);
-
 		/*
-		 * If there is no BTEQUALIMAGE_PROC then deduplication is assumed to
-		 * be unsafe.  Otherwise, actually call proc and see what it says.
+		 * An opclass that lacks a BTEQUALIMAGE_PROC, or whose procedure
+		 * returns false, makes deduplication unsafe for the whole index.
 		 */
-		if (!OidIsValid(equalimageproc) ||
-			!DatumGetBool(OidFunctionCall1Coll(equalimageproc, collation,
-											   ObjectIdGetDatum(opcintype))))
+		if (!opfamily_is_equalimage(rel->rd_opfamily[i], rel->rd_opcintype[i],
+									rel->rd_indcollation[i]))
 		{
 			allequalimage = false;
 			break;
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index fb6f81453ea..e4c0e432fac 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -14,7 +14,6 @@
  */
 #include "postgres.h"
 
-#include "access/nbtree.h"
 #include "access/sysattr.h"
 #include "catalog/pg_constraint.h"
 #include "catalog/pg_type.h"
@@ -884,8 +883,6 @@ create_grouping_expr_infos(PlannerInfo *root)
 	{
 		SortGroupClause *sgc = lfirst_node(SortGroupClause, lc);
 		TargetEntry *tle = get_sortgroupclause_tle(sgc, root->processed_tlist);
-		TypeCacheEntry *tce;
-		Oid			equalimageproc;
 
 		Assert(tle->ressortgroupref > 0);
 
@@ -903,30 +900,11 @@ create_grouping_expr_infos(PlannerInfo *root)
 		 *
 		 * For instance, the NUMERIC data type is not supported, as values
 		 * that are considered equal by the equality operator (e.g., 0 and
-		 * 0.0) can have different scales.
+		 * 0.0) can have different scales.  Pass the expression's actual
+		 * collation rather than the type default.
 		 */
-		tce = lookup_type_cache(exprType((Node *) tle->expr),
-								TYPECACHE_BTREE_OPFAMILY);
-		if (!OidIsValid(tce->btree_opf) ||
-			!OidIsValid(tce->btree_opintype))
-			return;
-
-		equalimageproc = get_opfamily_proc(tce->btree_opf,
-										   tce->btree_opintype,
-										   tce->btree_opintype,
-										   BTEQUALIMAGE_PROC);
-
-		/*
-		 * If there is no BTEQUALIMAGE_PROC, eager aggregation is assumed to
-		 * be unsafe.  Otherwise, we call the procedure to check.  We must be
-		 * careful to pass the expression's actual collation, rather than the
-		 * data type's default collation, to ensure that non-deterministic
-		 * collations are correctly handled.
-		 */
-		if (!OidIsValid(equalimageproc) ||
-			!DatumGetBool(OidFunctionCall1Coll(equalimageproc,
-											   exprCollation((Node *) tle->expr),
-											   ObjectIdGetDatum(tce->btree_opintype))))
+		if (!type_is_equalimage(exprType((Node *) tle->expr),
+								exprCollation((Node *) tle->expr)))
 			return;
 
 		exprs = lappend(exprs, tle->expr);
diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 55cebe4a74b..02052402595 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -6414,18 +6414,18 @@ pull_paramids_walker(Node *node, Bitmapset **context)
  * semantics compatible with the grouping eqop, or, for a nondeterministic
  * collation, when the comparison applies a collation other than the column's.
  *
- * For a nondeterministic collation, every other reference is rejected: a
- * comparison under a different collation, and any function or operator over
- * the column, because we cannot tell whether the function yields the same
- * result for values the grouping treats as equal, and many do not.  A column
- * with a deterministic collation is not restricted this way.
+ * Every other reference -- a wrapper, a non-comparison operator, or a bare
+ * boolean column -- is opaque to us.  Accept it only when the grouping's
+ * equality is image equality (see equality_op_is_equalimage).  Then values
+ * the grouping merges are interchangeable for ordinary expressions.
+ * Otherwise a wrapper such as ::text can tell apart numeric 1 and 1.0,
+ * jsonb 1 and 1.0, or float8 0 and -0.  This also covers text under a
+ * nondeterministic collation: the equalimage procedure answers false for
+ * those collations.
  *
- * This leaves one case uncaught: with a deterministic collation, a function
- * over the column can still feed a finer comparison than the direct-operand
- * check sees, for example record_image_ops over a rebuilt record, or scale()
- * over numeric where two equal values differ in scale.  Catching it would
- * require knowing that a type's equality is bitwise, which we do not test
- * here.
+ * Image equality is not quite bitwise equality for varlena (TOAST).  We do
+ * not try to catch expressions that expose physical representation, such as
+ * pg_column_size().
  *
  * Returns true if any such conflict exists.
  */
@@ -6477,18 +6477,17 @@ grouping_conflict_walker(Node *node, grouping_walker_ctx *ctx)
 	if (IsA(node, Var))
 	{
 		Var		   *var = (Var *) node;
+		Oid			grouping_eqop = ctx->get_eqop(var, ctx->cb_context);
 
 		/*
 		 * A grouping column reaches here when it was not handled as a direct
-		 * operand by a comparison node above (see the function header).  That
-		 * is safe for a deterministic collation, but not for a
-		 * nondeterministic one, where the reference may distinguish values
-		 * the grouping considers equal.  A bare boolean qual is safe too:
-		 * boolean is not collatable, so it takes the deterministic path here.
+		 * operand by a comparison node above.  Accept it only if grouping
+		 * equality is image equality.  That subsumes the old
+		 * nondeterministic-collation check.  A bare boolean qual stays safe:
+		 * boolean equality is image equality.
 		 */
-		if (OidIsValid(ctx->get_eqop(var, ctx->cb_context)) &&
-			OidIsValid(var->varcollid) &&
-			!get_collation_isdeterministic(var->varcollid))
+		if (OidIsValid(grouping_eqop) &&
+			!equality_op_is_equalimage(grouping_eqop, var->varcollid))
 			return true;
 		return false;
 	}
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index ee69f81945f..327593e282b 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -16,7 +16,6 @@
 
 #include <limits.h>
 
-#include "access/nbtree.h"
 #include "catalog/pg_constraint.h"
 #include "miscadmin.h"
 #include "nodes/nodeFuncs.h"
@@ -3037,36 +3036,15 @@ init_grouping_targets(PlannerInfo *root, RelOptInfo *rel,
 			 * 'destiny', which is crucial for maintaining correctness.
 			 */
 			SortGroupClause *sgc;
-			TypeCacheEntry *tce;
-			Oid			equalimageproc;
 
 			/*
 			 * But first, check if equality implies image equality for this
 			 * expression.  If not, we cannot use it as a grouping key.  See
-			 * comments in create_grouping_expr_infos().
+			 * comments in create_grouping_expr_infos().  Pass the
+			 * expression's actual collation rather than the type default.
 			 */
-			tce = lookup_type_cache(exprType((Node *) expr),
-									TYPECACHE_BTREE_OPFAMILY);
-			if (!OidIsValid(tce->btree_opf) ||
-				!OidIsValid(tce->btree_opintype))
-				return false;
-
-			equalimageproc = get_opfamily_proc(tce->btree_opf,
-											   tce->btree_opintype,
-											   tce->btree_opintype,
-											   BTEQUALIMAGE_PROC);
-
-			/*
-			 * If there is no BTEQUALIMAGE_PROC, eager aggregation is assumed
-			 * to be unsafe.  Otherwise, we call the procedure to check.  We
-			 * must be careful to pass the expression's actual collation,
-			 * rather than the data type's default collation, to ensure that
-			 * non-deterministic collations are correctly handled.
-			 */
-			if (!OidIsValid(equalimageproc) ||
-				!DatumGetBool(OidFunctionCall1Coll(equalimageproc,
-												   exprCollation((Node *) expr),
-												   ObjectIdGetDatum(tce->btree_opintype))))
+			if (!type_is_equalimage(exprType((Node *) expr),
+									exprCollation((Node *) expr)))
 				return false;
 
 			/* Create the SortGroupClause. */
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 9ef3922d17c..859abb1312e 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -17,6 +17,7 @@
 
 #include "access/hash.h"
 #include "access/htup_details.h"
+#include "access/nbtree.h"
 #include "bootstrap/bootstrap.h"
 #include "catalog/namespace.h"
 #include "catalog/pg_am.h"
@@ -1038,6 +1039,95 @@ get_opfamily_proc(Oid opfamily, Oid lefttype, Oid righttype, int16 procnum)
 	return result;
 }
 
+/*
+ * opfamily_is_equalimage
+ *		Return true if opfamily promises "equality implies image equality"
+ *		for the given input type and collation.
+ *
+ * A true result means that whenever the opfamily's ordering method reports
+ * two values equal, those values are interchangeable without loss of
+ * semantic information.  Used by B-tree deduplication and by
+ * equality_op_is_equalimage().  Callers that know a type OID rather than an
+ * opfamily should use type_is_equalimage() instead, which caches the support
+ * procedure in TypeCacheEntry.
+ *
+ * An opfamily that registers no BTEQUALIMAGE_PROC makes no such promise.
+ * Pass the collation actually in use, not the type's default.
+ */
+bool
+opfamily_is_equalimage(Oid opfamily, Oid opcintype, Oid collation)
+{
+	Oid			equalimageproc;
+
+	equalimageproc = get_opfamily_proc(opfamily, opcintype, opcintype,
+									   BTEQUALIMAGE_PROC);
+	if (!OidIsValid(equalimageproc))
+		return false;
+
+	return DatumGetBool(OidFunctionCall1Coll(equalimageproc, collation,
+											 ObjectIdGetDatum(opcintype)));
+}
+
+/*
+ * equality_op_is_equalimage
+ *		Return true if eqop defines an equivalence under which equal values
+ *		are interchangeable without loss of semantic information.
+ *
+ * Used when we know a grouping equality operator and not its opfamily.
+ *
+ * When eqop is the type's default equality operator, defer to
+ * type_is_equalimage().  Otherwise require a promise from every mergejoin
+ * opfamily in which eqop is the equality member: texteq belongs to both
+ * text_ops and text_pattern_ops, and under a nondeterministic collation they
+ * disagree.  text_pattern_ops registers btequalimage for any collation, so
+ * trusting it alone would be wrong for a case-insensitive grouping.
+ *
+ * A false result means "not proven".  Cross-type operators always land there.
+ * 'collation' must be the collation actually applied to the values.
+ */
+bool
+equality_op_is_equalimage(Oid eqop, Oid collation)
+{
+	Oid			lefttype;
+	Oid			righttype;
+	TypeCacheEntry *typentry;
+	List	   *opfamilies;
+	bool		result;
+	ListCell   *lc;
+
+	op_input_types(eqop, &lefttype, &righttype);
+
+	/* Equalimage describes one type.  Grouping eqops are never cross-type. */
+	if (lefttype != righttype)
+		return false;
+
+	/*
+	 * Common case: grouping uses the type's default equality.  Share the
+	 * typcache path used by eager aggregation.
+	 */
+	typentry = lookup_type_cache(lefttype, TYPECACHE_EQ_OPR);
+	if (OidIsValid(typentry->eq_opr) && eqop == typentry->eq_opr)
+		return type_is_equalimage(lefttype, collation);
+
+	opfamilies = get_mergejoin_opfamilies(eqop);
+
+	/* No ordering opfamily at all means nothing promised anything. */
+	result = (opfamilies != NIL);
+
+	foreach(lc, opfamilies)
+	{
+		if (!opfamily_is_equalimage(lfirst_oid(lc), lefttype, collation))
+		{
+			result = false;
+			break;
+		}
+	}
+
+	list_free(opfamilies);
+
+	return result;
+}
+
 
 /*				---------- ATTRIBUTE CACHES ----------					 */
 
diff --git a/src/backend/utils/cache/typcache.c b/src/backend/utils/cache/typcache.c
index eca2d73231a..59970765891 100644
--- a/src/backend/utils/cache/typcache.c
+++ b/src/backend/utils/cache/typcache.c
@@ -117,6 +117,7 @@ static TypeCacheEntry *firstDomainTypeEntry = NULL;
 #define TCFLAGS_HAVE_FIELD_EXTENDED_HASHING	0x040000
 #define TCFLAGS_CHECKED_DOMAIN_CONSTRAINTS	0x080000
 #define TCFLAGS_DOMAIN_BASE_IS_COMPOSITE	0x100000
+#define TCFLAGS_CHECKED_EQUALIMAGE_PROC		0x200000
 
 /* The flags associated with equality/comparison/hashing are all but these: */
 #define TCFLAGS_OPERATOR_FLAGS \
@@ -584,7 +585,7 @@ lookup_type_cache(Oid type_id, int flags)
 	if ((flags & (TYPECACHE_EQ_OPR | TYPECACHE_LT_OPR | TYPECACHE_GT_OPR |
 				  TYPECACHE_CMP_PROC |
 				  TYPECACHE_EQ_OPR_FINFO | TYPECACHE_CMP_PROC_FINFO |
-				  TYPECACHE_BTREE_OPFAMILY)) &&
+				  TYPECACHE_BTREE_OPFAMILY | TYPECACHE_EQUALIMAGE_PROC)) &&
 		!(typentry->flags & TCFLAGS_CHECKED_BTREE_OPCLASS))
 	{
 		Oid			opclass;
@@ -609,7 +610,8 @@ lookup_type_cache(Oid type_id, int flags)
 		typentry->flags &= ~(TCFLAGS_CHECKED_EQ_OPR |
 							 TCFLAGS_CHECKED_LT_OPR |
 							 TCFLAGS_CHECKED_GT_OPR |
-							 TCFLAGS_CHECKED_CMP_PROC);
+							 TCFLAGS_CHECKED_CMP_PROC |
+							 TCFLAGS_CHECKED_EQUALIMAGE_PROC);
 		typentry->flags |= TCFLAGS_CHECKED_BTREE_OPCLASS;
 	}
 
@@ -780,6 +782,25 @@ lookup_type_cache(Oid type_id, int flags)
 		typentry->cmp_proc = cmp_proc;
 		typentry->flags |= TCFLAGS_CHECKED_CMP_PROC;
 	}
+	if ((flags & TYPECACHE_EQUALIMAGE_PROC) &&
+		!(typentry->flags & TCFLAGS_CHECKED_EQUALIMAGE_PROC))
+	{
+		Oid			equalimage_proc = InvalidOid;
+
+		/*
+		 * Cache only the support-function OID.  Whether equality implies
+		 * image equality can still depend on collation, so callers must
+		 * invoke the procedure with the collation actually in use.
+		 */
+		if (typentry->btree_opf != InvalidOid)
+			equalimage_proc = get_opfamily_proc(typentry->btree_opf,
+												typentry->btree_opintype,
+												typentry->btree_opintype,
+												BTEQUALIMAGE_PROC);
+
+		typentry->equalimage_proc = equalimage_proc;
+		typentry->flags |= TCFLAGS_CHECKED_EQUALIMAGE_PROC;
+	}
 	if ((flags & (TYPECACHE_HASH_PROC | TYPECACHE_HASH_PROC_FINFO)) &&
 		!(typentry->flags & TCFLAGS_CHECKED_HASH_PROC))
 	{
@@ -980,6 +1001,30 @@ lookup_type_cache(Oid type_id, int flags)
 	return typentry;
 }
 
+/*
+ * type_is_equalimage
+ *		Return true if the type's default btree equality implies image
+ *		equality under the given collation.
+ *
+ * This is the type-oriented counterpart of opfamily_is_equalimage(), for
+ * callers that know a type OID rather than an opfamily.  The equalimage
+ * support procedure OID is cached in TypeCacheEntry; the boolean answer is
+ * not, because it can depend on collation.
+ */
+bool
+type_is_equalimage(Oid type_id, Oid collation)
+{
+	TypeCacheEntry *typentry;
+
+	typentry = lookup_type_cache(type_id, TYPECACHE_EQUALIMAGE_PROC);
+	if (!OidIsValid(typentry->equalimage_proc))
+		return false;
+
+	return DatumGetBool(OidFunctionCall1Coll(typentry->equalimage_proc,
+											 collation,
+											 ObjectIdGetDatum(typentry->btree_opintype)));
+}
+
 /*
  * load_typcache_tupdesc --- helper routine to set up composite type's tupDesc
  */
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 865980cb0f1..09887ddf093 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -95,6 +95,8 @@ extern bool collations_agree_on_equality(Oid coll1, Oid coll2);
 extern bool op_is_safe_index_member(Oid opno);
 extern Oid	get_opfamily_proc(Oid opfamily, Oid lefttype, Oid righttype,
 							  int16 procnum);
+extern bool opfamily_is_equalimage(Oid opfamily, Oid opcintype, Oid collation);
+extern bool equality_op_is_equalimage(Oid eqop, Oid collation);
 extern char *get_attname(Oid relid, AttrNumber attnum, bool missing_ok);
 extern AttrNumber get_attnum(Oid relid, const char *attname);
 extern char get_attgenerated(Oid relid, AttrNumber attnum);
diff --git a/src/include/utils/typcache.h b/src/include/utils/typcache.h
index 5a4aa9ec840..952f478266c 100644
--- a/src/include/utils/typcache.h
+++ b/src/include/utils/typcache.h
@@ -65,6 +65,7 @@ typedef struct TypeCacheEntry
 	Oid			cmp_proc;		/* the btree comparison function */
 	Oid			hash_proc;		/* the hash calculation function */
 	Oid			hash_extended_proc; /* the extended hash calculation function */
+	Oid			equalimage_proc;	/* btree equalimage support function */
 
 	/*
 	 * Pre-set-up fmgr call info for the equality operator, the btree
@@ -152,6 +153,7 @@ typedef struct TypeCacheEntry
 #define TYPECACHE_HASH_EXTENDED_PROC		0x04000
 #define TYPECACHE_HASH_EXTENDED_PROC_FINFO	0x08000
 #define TYPECACHE_MULTIRANGE_INFO			0x10000
+#define TYPECACHE_EQUALIMAGE_PROC			0x20000
 
 /* This value will not equal any valid tupledesc identifier, nor 0 */
 #define INVALID_TUPLEDESC_IDENTIFIER ((uint64) 1)
@@ -178,6 +180,8 @@ typedef struct SharedRecordTypmodRegistry SharedRecordTypmodRegistry;
 
 extern TypeCacheEntry *lookup_type_cache(Oid type_id, int flags);
 
+extern bool type_is_equalimage(Oid type_id, Oid collation);
+
 extern void InitDomainConstraintRef(Oid type_id, DomainConstraintRef *ref,
 									MemoryContext refctx, bool need_exprstate);
 
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index cf295d56507..1ca3094175e 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -2151,6 +2151,134 @@ WHERE (CASE id WHEN 1 THEN 1 ELSE 0 END) = 1;
                Filter: (CASE id WHEN 1 THEN 1 ELSE 0 END = 1)
 (5 rows)
 
+-- Wrapped references over grouped subqueries.  When grouping equality is
+-- not image equality (jsonb, numeric), a wrapper must not be pushed below
+-- the grouping boundary.  int equality is image equality, so i::text
+-- remains pushable.
+CREATE TEMP TABLE pdt_eqimg (id int, j jsonb, n numeric, i int);
+INSERT INTO pdt_eqimg VALUES
+  (1, '1', 1, 1),
+  (2, '1.0', 1.0, 1);
+-- jsonb DISTINCT ON: ::text wrapper stays above Unique
+EXPLAIN (COSTS OFF)
+SELECT * FROM (SELECT DISTINCT ON (j) id, j FROM pdt_eqimg ORDER BY j, id) s
+WHERE j::text = '1.0';
+                    QUERY PLAN                     
+---------------------------------------------------
+ Subquery Scan on s
+   Filter: ((s.j)::text = '1.0'::text)
+   ->  Unique
+         ->  Sort
+               Sort Key: pdt_eqimg.j, pdt_eqimg.id
+               ->  Seq Scan on pdt_eqimg
+(6 rows)
+
+SELECT * FROM (SELECT DISTINCT ON (j) id, j FROM pdt_eqimg ORDER BY j, id) s
+WHERE j::text = '1.0';
+ id | j 
+----+---
+(0 rows)
+
+-- jsonb GROUP BY: ::text matching the group representative keeps count = 2
+EXPLAIN (COSTS OFF)
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j::text = '1';
+                    QUERY PLAN                     
+---------------------------------------------------
+ Subquery Scan on s
+   ->  HashAggregate
+         Group Key: pdt_eqimg.j
+         Filter: ((pdt_eqimg.j)::text = '1'::text)
+         ->  Seq Scan on pdt_eqimg
+(5 rows)
+
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j::text = '1';
+ c 
+---
+ 2
+(1 row)
+
+-- jsonb GROUP BY: other image yields no row, not a split group
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j::text = '1.0';
+ c 
+---
+(0 rows)
+
+-- jsonb GROUP BY: same-eqop comparison remains pushable / correct
+EXPLAIN (COSTS OFF)
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j = '1'::jsonb;
+               QUERY PLAN               
+----------------------------------------
+ Subquery Scan on s
+   ->  GroupAggregate
+         ->  Seq Scan on pdt_eqimg
+               Filter: (j = '1'::jsonb)
+(4 rows)
+
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j = '1'::jsonb;
+ c 
+---
+ 2
+(1 row)
+
+-- jsonb GROUP BY: wrapped HAVING stays above the grouping
+EXPLAIN (COSTS OFF)
+SELECT j, count(*) FROM pdt_eqimg GROUP BY j
+HAVING starts_with(j::text, '1.');
+                  QUERY PLAN                  
+----------------------------------------------
+ HashAggregate
+   Group Key: j
+   Filter: starts_with((j)::text, '1.'::text)
+   ->  Seq Scan on pdt_eqimg
+(4 rows)
+
+SELECT j, count(*) FROM pdt_eqimg GROUP BY j
+HAVING starts_with(j::text, '1.');
+ j | count 
+---+-------
+(0 rows)
+
+-- numeric GROUP BY: ::text wrapper stays on the Agg (not jsonb-only)
+EXPLAIN (COSTS OFF)
+SELECT c FROM (SELECT n, count(*) c FROM pdt_eqimg GROUP BY n) s
+WHERE n::text = '1';
+                    QUERY PLAN                     
+---------------------------------------------------
+ Subquery Scan on s
+   ->  HashAggregate
+         Group Key: pdt_eqimg.n
+         Filter: ((pdt_eqimg.n)::text = '1'::text)
+         ->  Seq Scan on pdt_eqimg
+(5 rows)
+
+SELECT c FROM (SELECT n, count(*) c FROM pdt_eqimg GROUP BY n) s
+WHERE n::text = '1';
+ c 
+---
+ 2
+(1 row)
+
+-- int GROUP BY: ::text is still pushed to Seq Scan
+SET enable_hashagg TO off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM (SELECT i, count(*) c FROM pdt_eqimg GROUP BY i) s
+WHERE i::text = '5';
+                  QUERY PLAN                   
+-----------------------------------------------
+ GroupAggregate
+   Group Key: pdt_eqimg.i
+   ->  Sort
+         Sort Key: pdt_eqimg.i
+         ->  Seq Scan on pdt_eqimg
+               Filter: ((i)::text = '5'::text)
+(6 rows)
+
+RESET enable_hashagg;
 -- Set operations: any operation other than UNION ALL groups rows by equality,
 -- so the same opfamily-mismatch rules apply.
 CREATE TEMP TABLE u1 (a t_rec);
diff --git a/src/test/regress/sql/subselect.sql b/src/test/regress/sql/subselect.sql
index 07438694f6e..71295d2f4e7 100644
--- a/src/test/regress/sql/subselect.sql
+++ b/src/test/regress/sql/subselect.sql
@@ -1052,6 +1052,66 @@ EXPLAIN (COSTS OFF)
 SELECT * FROM (SELECT DISTINCT id FROM pdt) s
 WHERE (CASE id WHEN 1 THEN 1 ELSE 0 END) = 1;
 
+-- Wrapped references over grouped subqueries.  When grouping equality is
+-- not image equality (jsonb, numeric), a wrapper must not be pushed below
+-- the grouping boundary.  int equality is image equality, so i::text
+-- remains pushable.
+CREATE TEMP TABLE pdt_eqimg (id int, j jsonb, n numeric, i int);
+INSERT INTO pdt_eqimg VALUES
+  (1, '1', 1, 1),
+  (2, '1.0', 1.0, 1);
+
+-- jsonb DISTINCT ON: ::text wrapper stays above Unique
+EXPLAIN (COSTS OFF)
+SELECT * FROM (SELECT DISTINCT ON (j) id, j FROM pdt_eqimg ORDER BY j, id) s
+WHERE j::text = '1.0';
+
+SELECT * FROM (SELECT DISTINCT ON (j) id, j FROM pdt_eqimg ORDER BY j, id) s
+WHERE j::text = '1.0';
+
+-- jsonb GROUP BY: ::text matching the group representative keeps count = 2
+EXPLAIN (COSTS OFF)
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j::text = '1';
+
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j::text = '1';
+
+-- jsonb GROUP BY: other image yields no row, not a split group
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j::text = '1.0';
+
+-- jsonb GROUP BY: same-eqop comparison remains pushable / correct
+EXPLAIN (COSTS OFF)
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j = '1'::jsonb;
+
+SELECT c FROM (SELECT j, count(*) c FROM pdt_eqimg GROUP BY j) s
+WHERE j = '1'::jsonb;
+
+-- jsonb GROUP BY: wrapped HAVING stays above the grouping
+EXPLAIN (COSTS OFF)
+SELECT j, count(*) FROM pdt_eqimg GROUP BY j
+HAVING starts_with(j::text, '1.');
+
+SELECT j, count(*) FROM pdt_eqimg GROUP BY j
+HAVING starts_with(j::text, '1.');
+
+-- numeric GROUP BY: ::text wrapper stays on the Agg (not jsonb-only)
+EXPLAIN (COSTS OFF)
+SELECT c FROM (SELECT n, count(*) c FROM pdt_eqimg GROUP BY n) s
+WHERE n::text = '1';
+
+SELECT c FROM (SELECT n, count(*) c FROM pdt_eqimg GROUP BY n) s
+WHERE n::text = '1';
+
+-- int GROUP BY: ::text is still pushed to Seq Scan
+SET enable_hashagg TO off;
+EXPLAIN (COSTS OFF)
+SELECT * FROM (SELECT i, count(*) c FROM pdt_eqimg GROUP BY i) s
+WHERE i::text = '5';
+RESET enable_hashagg;
+
 -- Set operations: any operation other than UNION ALL groups rows by equality,
 -- so the same opfamily-mismatch rules apply.
 CREATE TEMP TABLE u1 (a t_rec);
-- 
2.53.0

