From d26a4e776c252ee2f2e01cdc6815b4fdbc8b1616 Mon Sep 17 00:00:00 2001
From: Marko Grujic <markoog@gmail.com>
Date: Wed, 10 Jun 2026 15:27:33 +0200
Subject: [PATCH v2] [BUG #19516] Fix mixup when RETURNING parenthesized
 OLD/NEW from DML

When a parenthesized OLD/NEW row reference is used in a RETURNING
clause of a INSERT/UPDATE/DELETE statement, the corresponding parser
namespace is wrongly resolved inside GetNSItemByRangeTablePosn.

This patch introduces GetNSItemByVar, which is explicitly aimed
for Var-shaped inputs, and ensures the returning type matches too.

Also adding tests exercising parenthesized OLD/NEW field selection,
and * expansion.

Author: Marko Grujic <markoog@gmail.com>
Suggested-by: Dean Rasheed <dean.a.rasheed@gmail.com>
Bug: #19516
Discussion: https://postgr.es/m/CAOvwyF2cO_5mAt=w=y-dFnaG5UkZ+3H8nSDoKF_iuWZHsU2ARg@mail.gmail.com
---
 src/backend/parser/parse_coerce.c       |  9 +++--
 src/backend/parser/parse_func.c         |  4 +--
 src/backend/parser/parse_relation.c     | 29 +++++++++++++++
 src/backend/parser/parse_target.c       |  2 +-
 src/include/parser/parse_relation.h     |  1 +
 src/test/regress/expected/returning.out | 47 +++++++++++++++++++++++++
 src/test/regress/sql/returning.sql      | 19 ++++++++++
 7 files changed, 102 insertions(+), 9 deletions(-)

diff --git a/src/backend/parser/parse_coerce.c b/src/backend/parser/parse_coerce.c
index 9edace67e1d..3b92ae2a920 100644
--- a/src/backend/parser/parse_coerce.c
+++ b/src/backend/parser/parse_coerce.c
@@ -1035,13 +1035,12 @@ coerce_record_to_complex(ParseState *pstate, Node *node,
 	else if (node && IsA(node, Var) &&
 			 ((Var *) node)->varattno == InvalidAttrNumber)
 	{
-		int			rtindex = ((Var *) node)->varno;
-		int			sublevels_up = ((Var *) node)->varlevelsup;
-		int			vlocation = ((Var *) node)->location;
+		Var		   *var = (Var *) node;
 		ParseNamespaceItem *nsitem;
 
-		nsitem = GetNSItemByRangeTablePosn(pstate, rtindex, sublevels_up);
-		args = expandNSItemVars(pstate, nsitem, sublevels_up, vlocation, NULL);
+		nsitem = GetNSItemByVar(pstate, var);
+		args = expandNSItemVars(pstate, nsitem, var->varlevelsup,
+								var->location, NULL);
 	}
 	else
 		ereport(ERROR,
diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c
index e07e7911f87..2e4cc1de50d 100644
--- a/src/backend/parser/parse_func.c
+++ b/src/backend/parser/parse_func.c
@@ -2056,9 +2056,7 @@ ParseComplexProjection(ParseState *pstate, const char *funcname, Node *first_arg
 	{
 		ParseNamespaceItem *nsitem;
 
-		nsitem = GetNSItemByRangeTablePosn(pstate,
-										   ((Var *) first_arg)->varno,
-										   ((Var *) first_arg)->varlevelsup);
+		nsitem = GetNSItemByVar(pstate, (Var *) first_arg);
 		/* Return a Var if funcname matches a column, else NULL */
 		return scanNSItemForColumn(pstate, nsitem,
 								   ((Var *) first_arg)->varlevelsup,
diff --git a/src/backend/parser/parse_relation.c b/src/backend/parser/parse_relation.c
index 43460e4a5a5..03af6d809b5 100644
--- a/src/backend/parser/parse_relation.c
+++ b/src/backend/parser/parse_relation.c
@@ -533,6 +533,35 @@ GetNSItemByRangeTablePosn(ParseState *pstate,
 	return NULL;				/* keep compiler quiet */
 }
 
+/*
+ * Given a Var, find the corresponding ParseNamespaceItem (there must be one).
+ *
+ * Like GetNSItemByRangeTablePosn, but uses the Var's varno, varlevelsup, and
+ * varreturningtype.
+ */
+ParseNamespaceItem *
+GetNSItemByVar(ParseState *pstate, Var *var)
+{
+	int			sublevels_up = var->varlevelsup;
+	ListCell   *lc;
+
+	while (sublevels_up-- > 0)
+	{
+		pstate = pstate->parentParseState;
+		Assert(pstate != NULL);
+	}
+	foreach(lc, pstate->p_namespace)
+	{
+		ParseNamespaceItem *nsitem = (ParseNamespaceItem *) lfirst(lc);
+
+		if (nsitem->p_rtindex == var->varno &&
+			nsitem->p_returning_type == var->varreturningtype)
+			return nsitem;
+	}
+	elog(ERROR, "nsitem not found (internal error)");
+	return NULL;				/* keep compiler quiet */
+}
+
 /*
  * Given an RT index and nesting depth, find the corresponding RTE.
  * (Note that the RTE need not be in the query's namespace.)
diff --git a/src/backend/parser/parse_target.c b/src/backend/parser/parse_target.c
index 541fef5f183..6a862d5a4f9 100644
--- a/src/backend/parser/parse_target.c
+++ b/src/backend/parser/parse_target.c
@@ -1451,7 +1451,7 @@ ExpandRowReference(ParseState *pstate, Node *expr,
 		Var		   *var = (Var *) expr;
 		ParseNamespaceItem *nsitem;
 
-		nsitem = GetNSItemByRangeTablePosn(pstate, var->varno, var->varlevelsup);
+		nsitem = GetNSItemByVar(pstate, var);
 		return ExpandSingleTable(pstate, nsitem, var->varlevelsup, var->location, make_target_entry);
 	}
 
diff --git a/src/include/parser/parse_relation.h b/src/include/parser/parse_relation.h
index 59721a70efb..1bf39922c4c 100644
--- a/src/include/parser/parse_relation.h
+++ b/src/include/parser/parse_relation.h
@@ -32,6 +32,7 @@ extern void checkNameSpaceConflicts(ParseState *pstate, List *namespace1,
 extern ParseNamespaceItem *GetNSItemByRangeTablePosn(ParseState *pstate,
 													 int varno,
 													 int sublevels_up);
+extern ParseNamespaceItem *GetNSItemByVar(ParseState *pstate, Var *var);
 extern RangeTblEntry *GetRTEByRangeTablePosn(ParseState *pstate,
 											 int varno,
 											 int sublevels_up);
diff --git a/src/test/regress/expected/returning.out b/src/test/regress/expected/returning.out
index 196829e94fa..bede35135e3 100644
--- a/src/test/regress/expected/returning.out
+++ b/src/test/regress/expected/returning.out
@@ -542,6 +542,53 @@ DELETE FROM foo WHERE f1 = 5
  foo      | (0,7) |  5 | ok | 42 | 100 |          |      |    |    |    |    |  5 | ok | 42 | 100
 (1 row)
 
+-- Parenthesized OLD/NEW field selection matches the unparenthesized form
+INSERT INTO foo VALUES (6, 'paren-test', 60, 600)
+  RETURNING old.f4 AS old_dot, (old).f4 AS old_paren,
+            new.f4 AS new_dot, (new).f4 AS new_paren;
+ old_dot | old_paren | new_dot | new_paren 
+---------+-----------+---------+-----------
+         |           |     600 |       600
+(1 row)
+
+UPDATE foo SET f4 = 700 WHERE f1 = 6
+  RETURNING old.f4 AS old_dot, (old).f4 AS old_paren,
+            new.f4 AS new_dot, (new).f4 AS new_paren;
+ old_dot | old_paren | new_dot | new_paren 
+---------+-----------+---------+-----------
+     600 |       600 |     700 |       700
+(1 row)
+
+DELETE FROM foo WHERE f1 = 6
+  RETURNING old.f4 AS old_dot, (old).f4 AS old_paren,
+            new.f4 AS new_dot, (new).f4 AS new_paren;
+ old_dot | old_paren | new_dot | new_paren 
+---------+-----------+---------+-----------
+     700 |       700 |         |          
+(1 row)
+
+-- Parenthesized OLD/NEW star expansion matches the unparenthesized set
+INSERT INTO foo VALUES (8, 'paren-star', 80, 800)
+  RETURNING old.*, (old).*, new.*, (new).*;
+ f1 | f2 | f3 | f4 | f1 | f2 | f3 | f4 | f1 |     f2     | f3 | f4  | f1 |     f2     | f3 | f4  
+----+----+----+----+----+----+----+----+----+------------+----+-----+----+------------+----+-----
+    |    |    |    |    |    |    |    |  8 | paren-star | 80 | 800 |  8 | paren-star | 80 | 800
+(1 row)
+
+UPDATE foo SET f4 = 900 WHERE f1 = 8
+  RETURNING old.*, (old).*, new.*, (new).*;
+ f1 |     f2     | f3 | f4  | f1 |     f2     | f3 | f4  | f1 |     f2     | f3 | f4  | f1 |     f2     | f3 | f4  
+----+------------+----+-----+----+------------+----+-----+----+------------+----+-----+----+------------+----+-----
+  8 | paren-star | 80 | 800 |  8 | paren-star | 80 | 800 |  8 | paren-star | 80 | 900 |  8 | paren-star | 80 | 900
+(1 row)
+
+DELETE FROM foo WHERE f1 = 8
+  RETURNING old.*, (old).*, new.*, (new).*;
+ f1 |     f2     | f3 | f4  | f1 |     f2     | f3 | f4  | f1 | f2 | f3 | f4 | f1 | f2 | f3 | f4 
+----+------------+----+-----+----+------------+----+-----+----+----+----+----+----+----+----+----
+  8 | paren-star | 80 | 900 |  8 | paren-star | 80 | 900 |    |    |    |    |    |    |    |   
+(1 row)
+
 -- RETURNING OLD and NEW from subquery
 EXPLAIN (verbose, costs off)
 INSERT INTO foo VALUES (5, 'subquery test')
diff --git a/src/test/regress/sql/returning.sql b/src/test/regress/sql/returning.sql
index b3c8c5df550..cd7c01c4240 100644
--- a/src/test/regress/sql/returning.sql
+++ b/src/test/regress/sql/returning.sql
@@ -243,6 +243,25 @@ DELETE FROM foo WHERE f1 = 5
   RETURNING old.tableoid::regclass, old.ctid, old.*,
             new.tableoid::regclass, new.ctid, new.*, *;
 
+-- Parenthesized OLD/NEW field selection matches the unparenthesized form
+INSERT INTO foo VALUES (6, 'paren-test', 60, 600)
+  RETURNING old.f4 AS old_dot, (old).f4 AS old_paren,
+            new.f4 AS new_dot, (new).f4 AS new_paren;
+UPDATE foo SET f4 = 700 WHERE f1 = 6
+  RETURNING old.f4 AS old_dot, (old).f4 AS old_paren,
+            new.f4 AS new_dot, (new).f4 AS new_paren;
+DELETE FROM foo WHERE f1 = 6
+  RETURNING old.f4 AS old_dot, (old).f4 AS old_paren,
+            new.f4 AS new_dot, (new).f4 AS new_paren;
+
+-- Parenthesized OLD/NEW star expansion matches the unparenthesized set
+INSERT INTO foo VALUES (8, 'paren-star', 80, 800)
+  RETURNING old.*, (old).*, new.*, (new).*;
+UPDATE foo SET f4 = 900 WHERE f1 = 8
+  RETURNING old.*, (old).*, new.*, (new).*;
+DELETE FROM foo WHERE f1 = 8
+  RETURNING old.*, (old).*, new.*, (new).*;
+
 -- RETURNING OLD and NEW from subquery
 EXPLAIN (verbose, costs off)
 INSERT INTO foo VALUES (5, 'subquery test')

base-commit: e18b0cb7344cb4bd28468f6c0aeeb9b9241d30aa
-- 
2.39.5 (Apple Git-154)

