From b65435ef7f1aa0cb11fcecfbe3ca1825730d5429 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Wed, 19 Aug 2026 10:01:04 +0000
Subject: [PATCH v1] Fix rule rewriting failure for system columns in
 RETURNING.

When a DML query's RETURNING list referenced a system column of the
target table, and the query was rewritten by a rule with its own
RETURNING clause, ReplaceVarsFromTargetList() failed with an internal
error "could not find replacement targetlist entry for attno -1",
since the rule's RETURNING list only has entries for user columns.

Such queries can never work: the rewritten query need not scan the
original target relation at all, so a rule's RETURNING list has no
way to provide values for that relation's system columns.  To fix,
throw a suitable user-facing error instead.

This is a long-standing oversight, dating back to when rules gained
support for RETURNING, though the new OLD/NEW RETURNING syntax gives
more ways to hit it.

Bug: #19632
Reported-by: Zheng Wang <hackerzheng666@gmail.com>
Discussion: https://postgr.es/m/19632-9155d9baec763c8c@postgresql.org
---
 src/backend/rewrite/rewriteManip.c      | 19 ++++++++++++++++++-
 src/test/regress/expected/returning.out |  7 +++++++
 src/test/regress/sql/returning.sql      |  5 +++++
 3 files changed, 30 insertions(+), 1 deletion(-)

diff --git a/src/backend/rewrite/rewriteManip.c b/src/backend/rewrite/rewriteManip.c
index 9c9d1cad33b..7ea55f50246 100644
--- a/src/backend/rewrite/rewriteManip.c
+++ b/src/backend/rewrite/rewriteManip.c
@@ -1851,7 +1851,24 @@ ReplaceVarFromTargetList(const Var *var,
 		switch (nomatch_option)
 		{
 			case REPLACEVARS_REPORT_ERROR:
-				/* fall through, throw error below */
+
+				/*
+				 * A system column can never match a targetlist entry, since
+				 * those all have positive resnos.  The way to get here is to
+				 * refer to a system column of the target table in the
+				 * RETURNING list of a query that is subject to a rewrite
+				 * rule with its own RETURNING clause; the rule has no way to
+				 * provide values for such a column, so throw a suitable
+				 * user-facing error.
+				 */
+				if (var->varattno < 0)
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("cannot use system column \"%s\" in RETURNING list of a query that is rewritten by a rule",
+									get_rte_attribute_name(target_rte,
+														   var->varattno))));
+
+				/* else fall through, throw internal error below */
 				break;
 
 			case REPLACEVARS_CHANGE_VARNO:
diff --git a/src/test/regress/expected/returning.out b/src/test/regress/expected/returning.out
index ca83d9fcc09..8e3a654497b 100644
--- a/src/test/regress/expected/returning.out
+++ b/src/test/regress/expected/returning.out
@@ -702,6 +702,13 @@ DELETE FROM foo WHERE f1 = 4 RETURNING old.*,new.*, *;
   4 | conflicted | -1 | 99 |  4 | conflicted (deleted) | -1 | -1 |  4 | conflicted (deleted) | -1 | -1
 (1 row)
 
+-- system columns are not available when the query is rewritten by a rule
+DELETE FROM foo WHERE f1 = 4 RETURNING tableoid;
+ERROR:  cannot use system column "tableoid" in RETURNING list of a query that is rewritten by a rule
+DELETE FROM foo WHERE f1 = 4 RETURNING old.ctid;
+ERROR:  cannot use system column "ctid" in RETURNING list of a query that is rewritten by a rule
+DELETE FROM foo WHERE f1 = 4 RETURNING new.xmin;
+ERROR:  cannot use system column "xmin" in RETURNING list of a query that is rewritten by a rule
 -- UPDATE on view with rule
 EXPLAIN (verbose, costs off)
 UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
diff --git a/src/test/regress/sql/returning.sql b/src/test/regress/sql/returning.sql
index 27fa4a374ed..a893f47e34d 100644
--- a/src/test/regress/sql/returning.sql
+++ b/src/test/regress/sql/returning.sql
@@ -289,6 +289,11 @@ EXPLAIN (verbose, costs off)
 DELETE FROM foo WHERE f1 = 4 RETURNING old.*,new.*, *;
 DELETE FROM foo WHERE f1 = 4 RETURNING old.*,new.*, *;
 
+-- system columns are not available when the query is rewritten by a rule
+DELETE FROM foo WHERE f1 = 4 RETURNING tableoid;
+DELETE FROM foo WHERE f1 = 4 RETURNING old.ctid;
+DELETE FROM foo WHERE f1 = 4 RETURNING new.xmin;
+
 -- UPDATE on view with rule
 EXPLAIN (verbose, costs off)
 UPDATE joinview SET f3 = f3 + 1 WHERE f3 = 57
-- 
2.55.0

