From 714934bdd0e87817059f9581cbf90b4963d3bd6c Mon Sep 17 00:00:00 2001
From: Ziming Zhang <protectione055@foxmail.com>
Date: Fri, 23 Oct 2026 11:58:23 +0800
Subject: [PATCH] Fix whole-row type errors in postgres_fdw UPDATE joins

UPDATE ... FROM can fail with "attribute N of type record has wrong
type" when a SET expression references the target's whole row and
postgres_fdw pushes down the join.  The query needs a named-composite
whole-row Var for the user expression and a RECORDOID whole-row Var for
row identity.  search_indexed_tlist_for_var() matches only varno and
varattno, so both references can point to the same output column while
retaining their different types.  The executor rejects the row-identity
reference because its type does not match that column's type.

Match vartype as well, following tlist_member_match_var().  Also preserve
whole-row Vars of different types in add_vars_to_targetlist(), so that
the local EvalPlanQual plan can supply both outputs.  The RECORDOID row
identity still allows sharing of junk columns across inheritance children.

Add postgres_fdw regression coverage for row_to_json() and a text cast
of the target's whole row in UPDATE expressions with a pushed-down join.
---
 .../postgres_fdw/expected/postgres_fdw.out    | 44 +++++++++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     | 16 +++++++
 src/backend/optimizer/plan/initsplan.c        | 31 ++++++++++++-
 src/backend/optimizer/plan/setrefs.c          | 11 ++++-
 4 files changed, 99 insertions(+), 3 deletions(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index a6295674daf..a108932acb5 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -7285,6 +7285,50 @@ UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
 
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
+-- A target whole-row reference in SET must be distinguished from the
+-- RECORD-typed row identity when the join is pushed down.
+BEGIN;
+INSERT INTO ft2 (c1, c2, c3) VALUES (2001, 42, 'whole-row');
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ft2 d SET c2 = (row_to_json(d)->>'c2')::int + 1
+  FROM ft2 t WHERE d.c1 = t.c1 AND d.c1 = 2001
+  RETURNING d.c1, d.c2, d.c3;
+                                                                                                                                                                                                                                        QUERY PLAN                                                                                                                                                                                                                                        
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Update on public.ft2 d
+   Output: d.c1, d.c2, d.c3
+   Remote SQL: UPDATE "S 1"."T 1" SET c2 = $2 WHERE ctid = $1 RETURNING "C 1", c2, c3
+   ->  Foreign Scan
+         Output: (((row_to_json(d.*) ->> 'c2'::text))::integer + 1), d.ctid, d.*, t.*
+         Relations: (public.ft2 d) INNER JOIN (public.ft2 t)
+         Remote SQL: SELECT CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, r1.ctid, CASE WHEN (r1.*)::text IS NOT NULL THEN ROW(r1."C 1", r1.c2, r1.c3, r1.c4, r1.c5, r1.c6, r1.c7, r1.c8) END, CASE WHEN (r2.*)::text IS NOT NULL THEN ROW(r2."C 1", r2.c2, r2.c3, r2.c4, r2.c5, r2.c6, r2.c7, r2.c8) END FROM ("S 1"."T 1" r1 INNER JOIN "S 1"."T 1" r2 ON (((r2."C 1" = 2001)) AND ((r1."C 1" = 2001)))) FOR UPDATE OF r1
+         ->  Nested Loop
+               Output: d.*, d.ctid, d.*, t.*
+               ->  Foreign Scan on public.ft2 d
+                     Output: d.*, d.ctid, d.*, d.c1
+                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8, ctid FROM "S 1"."T 1" WHERE (("C 1" = 2001)) FOR UPDATE
+               ->  Foreign Scan on public.ft2 t
+                     Output: t.*, t.c1
+                     Remote SQL: SELECT "C 1", c2, c3, c4, c5, c6, c7, c8 FROM "S 1"."T 1" WHERE (("C 1" = 2001))
+(15 rows)
+
+UPDATE ft2 d SET c2 = (row_to_json(d)->>'c2')::int + 1
+  FROM ft2 t WHERE d.c1 = t.c1 AND d.c1 = 2001
+  RETURNING d.c1, d.c2, d.c3;
+  c1  | c2 |    c3     
+------+----+-----------
+ 2001 | 43 | whole-row
+(1 row)
+
+UPDATE ft2 d SET c2 = length(d::text)
+  FROM ft2 t WHERE d.c1 = t.c1 AND d.c1 = 2001
+  RETURNING d.c1, d.c2;
+  c1  | c2 
+------+----
+ 2001 | 36
+(1 row)
+
+ROLLBACK;
 -- Test UPDATE/DELETE with WHERE or JOIN/ON conditions containing
 -- user-defined operators/functions
 ALTER SERVER loopback OPTIONS (DROP extensions);
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index eaeb90485e8..b415e8bd7fa 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1982,6 +1982,22 @@ UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
 UPDATE ft2 d SET c2 = CASE WHEN random() >= 0 THEN d.c2 ELSE 0 END
   FROM ft2 AS t WHERE d.c1 = t.c1 AND d.c1 > 1000;
 
+-- A target whole-row reference in SET must be distinguished from the
+-- RECORD-typed row identity when the join is pushed down.
+BEGIN;
+INSERT INTO ft2 (c1, c2, c3) VALUES (2001, 42, 'whole-row');
+EXPLAIN (VERBOSE, COSTS OFF)
+UPDATE ft2 d SET c2 = (row_to_json(d)->>'c2')::int + 1
+  FROM ft2 t WHERE d.c1 = t.c1 AND d.c1 = 2001
+  RETURNING d.c1, d.c2, d.c3;
+UPDATE ft2 d SET c2 = (row_to_json(d)->>'c2')::int + 1
+  FROM ft2 t WHERE d.c1 = t.c1 AND d.c1 = 2001
+  RETURNING d.c1, d.c2, d.c3;
+UPDATE ft2 d SET c2 = length(d::text)
+  FROM ft2 t WHERE d.c1 = t.c1 AND d.c1 = 2001
+  RETURNING d.c1, d.c2;
+ROLLBACK;
+
 -- Test UPDATE/DELETE with WHERE or JOIN/ON conditions containing
 -- user-defined operators/functions
 ALTER SERVER loopback OPTIONS (DROP extensions);
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index fb6f81453ea..a39b532510d 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -312,15 +312,42 @@ add_vars_to_targetlist(PlannerInfo *root, List *vars,
 			Var		   *var = (Var *) node;
 			RelOptInfo *rel = find_base_rel(root, var->varno);
 			int			attno = var->varattno;
+			bool		has_tlist_entry;
 
 			if (bms_is_subset(where_needed, rel->relids))
 				continue;
 			Assert(attno >= rel->min_attr && attno <= rel->max_attr);
 			attno -= rel->min_attr;
-			if (rel->attr_needed[attno] == NULL)
+			if (var->varattno == InvalidAttrNumber)
+			{
+				ListCell   *lc;
+
+				/*
+				 * Whole-row Vars for a table can have its named composite
+				 * type or RECORDOID for a row identity.  attr_needed tracks
+				 * only the attribute number, so check for the requested type.
+				 */
+				has_tlist_entry = false;
+				foreach(lc, rel->reltarget->exprs)
+				{
+					Var		   *existing_var = (Var *) lfirst(lc);
+
+					if (IsA(existing_var, Var) &&
+						existing_var->varattno == var->varattno &&
+						existing_var->vartype == var->vartype)
+					{
+						has_tlist_entry = true;
+						break;
+					}
+				}
+			}
+			else
+				has_tlist_entry = (rel->attr_needed[attno] != NULL);
+
+			if (!has_tlist_entry)
 			{
 				/*
-				 * Variable not yet requested, so add to rel's targetlist.
+				 * Add the variable to rel's targetlist.
 				 *
 				 * The value available at the rel's scan level has not been
 				 * nulled by any outer join, so drop its varnullingrels.
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 8a641402a96..f2becbffe89 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -41,6 +41,7 @@ typedef struct
 {
 	int			varno;			/* RT index of Var */
 	AttrNumber	varattno;		/* attr number of Var */
+	Oid			vartype;		/* type OID of Var */
 	AttrNumber	resno;			/* TLE position of Var */
 	Bitmapset  *varnullingrels; /* Var's varnullingrels */
 } tlist_vinfo;
@@ -2831,6 +2832,7 @@ build_tlist_index(List *tlist)
 
 			vinfo->varno = var->varno;
 			vinfo->varattno = var->varattno;
+			vinfo->vartype = var->vartype;
 			vinfo->resno = tle->resno;
 			vinfo->varnullingrels = var->varnullingrels;
 			vinfo++;
@@ -2884,6 +2886,7 @@ build_tlist_index_other_vars(List *tlist, int ignore_rel)
 			{
 				vinfo->varno = var->varno;
 				vinfo->varattno = var->varattno;
+				vinfo->vartype = var->vartype;
 				vinfo->resno = tle->resno;
 				vinfo->varnullingrels = var->varnullingrels;
 				vinfo++;
@@ -2906,6 +2909,11 @@ build_tlist_index_other_vars(List *tlist, int ignore_rel)
  * Also ensure that varnosyn is incremented by rtoffset.
  * If no match, return NULL.
  *
+ * Match on vartype as well as varno/varattno, since a whole-row Var can
+ * have either the relation's named composite type or RECORDOID when used
+ * as a row identity.  As in tlist_member_match_var(), we allow typmod to
+ * differ, but require the same type OID.
+ *
  * We cross-check the varnullingrels of the subplan output Var based on
  * nrm_match.  Most call sites should pass NRM_EQUAL indicating we expect
  * an exact match.  However, there are places where we haven't cleaned
@@ -2925,7 +2933,8 @@ search_indexed_tlist_for_var(Var *var, indexed_tlist *itlist,
 	i = itlist->num_vars;
 	while (i-- > 0)
 	{
-		if (vinfo->varno == varno && vinfo->varattno == varattno)
+		if (vinfo->varno == varno && vinfo->varattno == varattno &&
+			vinfo->vartype == var->vartype)
 		{
 			/* Found a match */
 			Var		   *newvar = copyVar(var);
-- 
2.34.1

