From 714934bdd0e87817059f9581cbf90b4963d3bd6c Mon Sep 17 00:00:00 2001 From: Ziming Zhang 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