Hi Tom,

> Indeed.  I think this is a parser bug and you're trying to band-aid
> around it in the wrong place.  get_rule_expr() would already do the
> right thing if the RowExpr had row_format == COERCE_EXPLICIT_CAST,
> but it does not, which seems like it could fool more things than
> only deparsing.

Recording the cast in the parser fixes the case you describe, and
v2-0002 does that.  It cannot fix trees that are already stored, though.
I started a server with only that patch on a data directory made before
it.  A view created there with

    row(1, null::int)::record = row(1, null::int)::record

still printed without the casts, and after pg_dump and restore it
returned NULL instead of true.  Its original text is gone, so it cannot
be fixed by creating it again.  So the ruleutils.c change from v1 is
still needed, and it is the only part that helps the back branches.
With A, B and C as described below, I would propose:

  Back branches (15 and later): C only, that is the tests and 0004, with
  the expected output adjusted for each branch.  The parser changes
  would only change how new objects are printed there.

  Master: A + C, that is 0001, 0002 and 0004.  The parser keeps the cast
  the user wrote, and C covers the rest.  B adds nothing that C does not
  already cover, and has to guess in make_op() what C sees directly, so
  I would leave 0003 out.

I attached A + B + C so that each part can be seen on its own; dropping
0003 gives the master proposal.

There is a trade-off depending on where the fix goes.  I tried these
ways:

  A   Parser, casts the user wrote: coerce_type() marks a copy of the
      RowExpr as COERCE_EXPLICIT_CAST when asked for an explicit cast of
      a RowExpr to its own type.  (v2-0002)

  B   Parser, ROW()s the parser makes itself: mark the ROW() for a
      whole-row reference to a JOIN USING alias as an explicit cast, and
      have make_op() pass COERCE_EXPLICIT_CAST when it coerces a
      whole-row reference of type record to an operator's composite
      input type and the operands would take the form that
      transformAExprOp reads as a row comparison.  (v2-0003)

  B'  A shorter form of B: keep the JOIN USING part, but instead of
      the make_op() change, have coerce_record_to_complex() in
      src/backend/parser/parse_coerce.c always mark the ROW() it
      builds as an explicit cast, ignoring the cformat it is given.
      (not attached)

      @@ -1133,2 +1133,2 @@ coerce_record_to_complex(ParseState *pstate,
           rowexpr->row_typeid = baseTypeId;
      -    rowexpr->row_format = cformat;
      +    rowexpr->row_format = COERCE_EXPLICIT_CAST;

  C   ruleutils.c: when the left operand of an operator is a ROW()
      not marked as a cast and the right one is a ROW() or a
      scalar subquery, print a cast after each such ROW().
      (v1-0002, now v2-0004)

against these cases:

  1   a ROW() cast to record by the user, as in the view above
  2   a whole-row reference to a JOIN USING alias
  3   a whole-row reference coerced to an operator's composite type
  4   objects stored before the fix, as above
  5   ROW()s made by the rewriter, as for whole-row references to an
      updatable view in EXPLAIN; nothing stored changes

         1   2   3   4   5   other output changed
  A      o   -   -   -   -   none
  B      -   o   o   -   -   j.* prints ::record wherever used
  B'     -   o   o   -   -   every record-to-composite coercion,
                             e.g. function arguments and defaults
  C      o   o   o   o   o   none

Whether a ROW() needs a cast depends on its parent and its sibling: it
does only as the left operand of an operator whose right operand is a
ROW() or a scalar subquery.  For 1 the cast is the user's own, so A can
record it on the node itself.  For 2 and 3 the parser builds the ROW()
without seeing either; it sees them only in make_op(), before the
operands are coerced, so B has to tell from their types what they will
become, and B' marks every such ROW() instead.  ruleutils.c sees the
operator and both operands complete, so C needs no such guess.

In short, A keeps a cast that must not be dropped when the conversion
is simplified, while B and C add one the user did not write.  B adds
it when parsing, to the ROW()s the transformation makes: in make_op()
only where it is needed, and always for a JOIN USING alias.  C adds it
when printing, whatever made the tree, so it also covers trees parsed
before the code for A and B came in (4) and ROW()s the rewriter makes
after parsing (5).

Since only C reaches 4 and 5, the ways combine like this:

  C alone (v1)
    + smallest change; the only one that helps the back branches
    - the tree still loses the cast the user wrote
  A alone (what you suggested)
    + fixes the parser bug
    - 2 to 5 stay broken
  A + C
    + parser records what the user wrote; C covers the rest
    - C decides from the shape of the output
  A + B + C (attached v2)
    + the tree also records 2 and 3 when they are parsed
    - make_op() has to guess the operands before coercing them;
      C is still needed
  A + B' + C
    + B' is a single line
    - ignores the cformat it is given, and changes the output of
      every record-to-composite coercion

Once the back branches are counted, none of these is clean.  What
matters there is the objects that already exist, so only C helps them,
while in master the parser fix still leaves C needed for 4 and 5.  C
ends up in every choice, and master fixes the same bug in more places
than the back branches, which is why I split the proposal above.

> So roughly speaking, I think
> we want something that's like hide_coercion_node() but can install
> a cformat other than COERCE_IMPLICIT_CAST, and then invoke that
> in the coerce_type() paths that don't build a new node.

A function like that sees only the node it is given, but whether a ROW()
needs a cast depends on its parent and its sibling, as above, and
coerce_type() is told neither.  Putting a function that looks at them
into the existing logic would mean passing them down through the
callers, which makes the change much larger.  So A only records the cast
the user wrote, in the same-type path, and leaves the rest to C.

0001 now has fewer tests than in v1: one view per case, recreated from
its pg_get_viewdef() output and compared with the original.

Best regards,
Henson
From e2c245d63d52263f39a33aee569f6ee65cb78b80 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 21 Oct 2026 10:57:26 +0900
Subject: [PATCH v2 1/4] Add tests for deparsing record comparisons of ROW()s

The parser reads "ROW(..) op ROW(..)" as a column-by-column row
comparison and "ROW(..) op (SELECT ..)" as a row comparison sublink.
ruleutils.c prints an operator on record values between such operands
in that form, so it is read back as something else:

    row(1, null::int)::record = row(1, null::int)::record
        printed as ROW(1, NULL::integer) = ROW(1, NULL::integer),
        which gives NULL instead of true
    row(1, null::int)::record = (select row(1, null::int)::record)
        ERROR:  subquery has too few columns
    j.* = (select row(1)), for a JOIN USING alias j
        ERROR:  operator does not exist: integer = record
    s1 =#= s2, for an operator on a named composite type
        ERROR:  operator does not exist: integer =#= integer

Add a view for each case and create it again from its
pg_get_viewdef() output, and check that a plain row comparison and
ROW()s compared with other expressions print as before.  The expected
output shows the current behavior; the following commits fix it.
---
 src/test/regress/expected/rowtypes.out | 113 +++++++++++++++++++++++++
 src/test/regress/sql/rowtypes.sql      |  58 +++++++++++++
 2 files changed, 171 insertions(+)

diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out
index 956bc2d02fc..3363bbc62e3 100644
--- a/src/test/regress/expected/rowtypes.out
+++ b/src/test/regress/expected/rowtypes.out
@@ -1408,3 +1408,116 @@ ERROR:  column "oid" not found in data type compositetable
 LINE 1: SELECT (NULL::compositetable).oid;
                 ^
 DROP TABLE compositetable;
+-- A record operator between ROW()s cast to record must not be read back as a
+-- row comparison, which gives NULL here
+CREATE VIEW rowtypes_cmp_v1 AS
+SELECT (ROW(1, NULL::int)::record = ROW(1, NULL::int)::record) AS r;
+SELECT pg_get_viewdef('rowtypes_cmp_v1'::regclass);
+                        pg_get_viewdef                         
+---------------------------------------------------------------
+  SELECT (ROW(1, NULL::integer) = ROW(1, NULL::integer)) AS r;
+(1 row)
+
+SELECT pg_get_viewdef('rowtypes_cmp_v1'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v1_re AS :def
+SELECT * FROM rowtypes_cmp_v1, rowtypes_cmp_v1_re;
+ r | r 
+---+---
+ t | 
+(1 row)
+
+-- Nor as a row comparison with the columns of a scalar subquery
+CREATE VIEW rowtypes_cmp_v2 AS
+SELECT (ROW(1, NULL::int)::record = (SELECT ROW(1, NULL::int)::record)) AS r;
+SELECT pg_get_viewdef('rowtypes_cmp_v2'::regclass);
+                                  pg_get_viewdef                                  
+----------------------------------------------------------------------------------
+  SELECT (ROW(1, NULL::integer) = ( SELECT ROW(1, NULL::integer) AS "row")) AS r;
+(1 row)
+
+SELECT pg_get_viewdef('rowtypes_cmp_v2'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v2_re AS :def
+ERROR:  subquery has too few columns
+LINE 1: ...types_cmp_v2_re AS  SELECT (ROW(1, NULL::integer) = ( SELECT...
+                                                             ^
+SELECT * FROM rowtypes_cmp_v2, rowtypes_cmp_v2_re;
+ERROR:  relation "rowtypes_cmp_v2_re" does not exist
+LINE 1: SELECT * FROM rowtypes_cmp_v2, rowtypes_cmp_v2_re;
+                                       ^
+-- The parser expands a whole-row reference to a JOIN USING alias into a ROW()
+CREATE TABLE rowtypes_cmp_a (x int);
+CREATE TABLE rowtypes_cmp_b (x int);
+INSERT INTO rowtypes_cmp_a VALUES (1), (2);
+INSERT INTO rowtypes_cmp_b VALUES (1), (2);
+CREATE VIEW rowtypes_cmp_v3 AS
+SELECT x, (j.* = (SELECT ROW(1))) AS r
+  FROM rowtypes_cmp_a JOIN rowtypes_cmp_b USING (x) AS j;
+SELECT pg_get_viewdef('rowtypes_cmp_v3'::regclass);
+                        pg_get_viewdef                        
+--------------------------------------------------------------
+  SELECT rowtypes_cmp_a.x,                                   +
+     (ROW(rowtypes_cmp_a.x) = ( SELECT ROW(1) AS "row")) AS r+
+    FROM (rowtypes_cmp_a                                     +
+      JOIN rowtypes_cmp_b USING (x) AS j);
+(1 row)
+
+SELECT pg_get_viewdef('rowtypes_cmp_v3'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v3_re AS :def
+ERROR:  operator does not exist: integer = record
+LINE 2:     (ROW(rowtypes_cmp_a.x) = ( SELECT ROW(1) AS "row")) AS r
+                                   ^
+DETAIL:  No operator of that name accepts the given argument types.
+HINT:  You might need to add explicit type casts.
+SELECT * FROM rowtypes_cmp_v3 JOIN rowtypes_cmp_v3_re USING (x) ORDER BY x;
+ERROR:  relation "rowtypes_cmp_v3_re" does not exist
+LINE 1: SELECT * FROM rowtypes_cmp_v3 JOIN rowtypes_cmp_v3_re USING ...
+                                           ^
+-- So does coercing a whole-row reference to an operator's composite input
+-- type
+CREATE TYPE rowtypes_cmp_t AS (a int, b int);
+CREATE FUNCTION rowtypes_cmp_eq(rowtypes_cmp_t, rowtypes_cmp_t) RETURNS bool
+  LANGUAGE sql IMMUTABLE
+  AS 'SELECT $1.a IS NOT DISTINCT FROM $2.a AND $1.b IS NOT DISTINCT FROM $2.b';
+CREATE OPERATOR =#= (LEFTARG = rowtypes_cmp_t, RIGHTARG = rowtypes_cmp_t,
+  FUNCTION = rowtypes_cmp_eq);
+CREATE VIEW rowtypes_cmp_v4 AS
+SELECT (s1 =#= s2) AS r
+  FROM (SELECT 1 AS a, NULL::int AS b) s1, (SELECT 1, NULL::int) s2;
+SELECT pg_get_viewdef('rowtypes_cmp_v4'::regclass);
+                         pg_get_viewdef                         
+----------------------------------------------------------------
+  SELECT (ROW(s1.a, s1.b) =#= ROW(s2."?column?", s2.int4)) AS r+
+    FROM ( SELECT 1 AS a,                                      +
+             NULL::integer AS b) s1,                           +
+     ( SELECT 1 AS "?column?",                                 +
+             NULL::integer AS int4) s2;
+(1 row)
+
+SELECT pg_get_viewdef('rowtypes_cmp_v4'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v4_re AS :def
+ERROR:  operator does not exist: integer =#= integer
+LINE 1: ...EW rowtypes_cmp_v4_re AS  SELECT (ROW(s1.a, s1.b) =#= ROW(s2...
+                                                             ^
+DETAIL:  No operator of that name accepts the given argument types.
+HINT:  You might need to add explicit type casts.
+SELECT * FROM rowtypes_cmp_v4, rowtypes_cmp_v4_re;
+ERROR:  relation "rowtypes_cmp_v4_re" does not exist
+LINE 1: SELECT * FROM rowtypes_cmp_v4, rowtypes_cmp_v4_re;
+                                       ^
+-- A row comparison, and a ROW() compared with something other than a ROW()
+-- or a subquery, keep their output
+CREATE VIEW rowtypes_cmp_v5 AS
+SELECT (ROW(1, NULL::int) = ROW(1, NULL::int)) AS eq,
+       (ROW(1, 2)::rowtypes_cmp_t = ROW(1, 2)) AS named,
+       (ROW(1, 2) = '(1,2)'::rowtypes_cmp_t) AS one;
+SELECT pg_get_viewdef('rowtypes_cmp_v5'::regclass);
+                        pg_get_viewdef                        
+--------------------------------------------------------------
+  SELECT ((1 = 1) AND (NULL::integer = NULL::integer)) AS eq,+
+     (ROW(1, 2)::rowtypes_cmp_t = ROW(1, 2)) AS named,       +
+     (ROW(1, 2) = '(1,2)'::rowtypes_cmp_t) AS one;
+(1 row)
+
+-- leave the views around for the pg_upgrade test, except the ones that do
+-- not survive dump and restore yet
+DROP VIEW rowtypes_cmp_v1, rowtypes_cmp_v2, rowtypes_cmp_v3, rowtypes_cmp_v4;
diff --git a/src/test/regress/sql/rowtypes.sql b/src/test/regress/sql/rowtypes.sql
index 174b062144a..877dd631d7b 100644
--- a/src/test/regress/sql/rowtypes.sql
+++ b/src/test/regress/sql/rowtypes.sql
@@ -562,3 +562,61 @@ SELECT (NULL::compositetable).a;
 SELECT (NULL::compositetable).oid;
 
 DROP TABLE compositetable;
+
+-- A record operator between ROW()s cast to record must not be read back as a
+-- row comparison, which gives NULL here
+CREATE VIEW rowtypes_cmp_v1 AS
+SELECT (ROW(1, NULL::int)::record = ROW(1, NULL::int)::record) AS r;
+SELECT pg_get_viewdef('rowtypes_cmp_v1'::regclass);
+SELECT pg_get_viewdef('rowtypes_cmp_v1'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v1_re AS :def
+SELECT * FROM rowtypes_cmp_v1, rowtypes_cmp_v1_re;
+
+-- Nor as a row comparison with the columns of a scalar subquery
+CREATE VIEW rowtypes_cmp_v2 AS
+SELECT (ROW(1, NULL::int)::record = (SELECT ROW(1, NULL::int)::record)) AS r;
+SELECT pg_get_viewdef('rowtypes_cmp_v2'::regclass);
+SELECT pg_get_viewdef('rowtypes_cmp_v2'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v2_re AS :def
+SELECT * FROM rowtypes_cmp_v2, rowtypes_cmp_v2_re;
+
+-- The parser expands a whole-row reference to a JOIN USING alias into a ROW()
+CREATE TABLE rowtypes_cmp_a (x int);
+CREATE TABLE rowtypes_cmp_b (x int);
+INSERT INTO rowtypes_cmp_a VALUES (1), (2);
+INSERT INTO rowtypes_cmp_b VALUES (1), (2);
+CREATE VIEW rowtypes_cmp_v3 AS
+SELECT x, (j.* = (SELECT ROW(1))) AS r
+  FROM rowtypes_cmp_a JOIN rowtypes_cmp_b USING (x) AS j;
+SELECT pg_get_viewdef('rowtypes_cmp_v3'::regclass);
+SELECT pg_get_viewdef('rowtypes_cmp_v3'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v3_re AS :def
+SELECT * FROM rowtypes_cmp_v3 JOIN rowtypes_cmp_v3_re USING (x) ORDER BY x;
+
+-- So does coercing a whole-row reference to an operator's composite input
+-- type
+CREATE TYPE rowtypes_cmp_t AS (a int, b int);
+CREATE FUNCTION rowtypes_cmp_eq(rowtypes_cmp_t, rowtypes_cmp_t) RETURNS bool
+  LANGUAGE sql IMMUTABLE
+  AS 'SELECT $1.a IS NOT DISTINCT FROM $2.a AND $1.b IS NOT DISTINCT FROM $2.b';
+CREATE OPERATOR =#= (LEFTARG = rowtypes_cmp_t, RIGHTARG = rowtypes_cmp_t,
+  FUNCTION = rowtypes_cmp_eq);
+CREATE VIEW rowtypes_cmp_v4 AS
+SELECT (s1 =#= s2) AS r
+  FROM (SELECT 1 AS a, NULL::int AS b) s1, (SELECT 1, NULL::int) s2;
+SELECT pg_get_viewdef('rowtypes_cmp_v4'::regclass);
+SELECT pg_get_viewdef('rowtypes_cmp_v4'::regclass) AS def \gset
+CREATE VIEW rowtypes_cmp_v4_re AS :def
+SELECT * FROM rowtypes_cmp_v4, rowtypes_cmp_v4_re;
+
+-- A row comparison, and a ROW() compared with something other than a ROW()
+-- or a subquery, keep their output
+CREATE VIEW rowtypes_cmp_v5 AS
+SELECT (ROW(1, NULL::int) = ROW(1, NULL::int)) AS eq,
+       (ROW(1, 2)::rowtypes_cmp_t = ROW(1, 2)) AS named,
+       (ROW(1, 2) = '(1,2)'::rowtypes_cmp_t) AS one;
+SELECT pg_get_viewdef('rowtypes_cmp_v5'::regclass);
+
+-- leave the views around for the pg_upgrade test, except the ones that do
+-- not survive dump and restore yet
+DROP VIEW rowtypes_cmp_v1, rowtypes_cmp_v2, rowtypes_cmp_v3, rowtypes_cmp_v4;
-- 
2.54.0 (Apple Git-157)

From 7e9bb91e3e2afc249f0474ed55360a630085daf8 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 21 Oct 2026 10:58:16 +0900
Subject: [PATCH v2 2/4] Record explicit casts of ROW() constructors in the
 parse tree

A cast of a ROW() to its own type, as in ROW(1, 2)::record, does
nothing, so coerce_type() returned the RowExpr unchanged and the cast
left no trace in the tree.  The cast matters to the parser, though: it
keeps an operator on the ROW() from being read as a row comparison.
Since ruleutils.c printed the ROW() without it,

    row(1, null::int)::record = row(1, null::int)::record

was printed as ROW(1, NULL::integer) = ROW(1, NULL::integer), which
gives NULL instead of true, and a comparison with a scalar subquery
did not read back, failing with "subquery has too few columns".

When coerce_type() is asked for an explicit cast of a RowExpr to its
own type, mark a copy of the RowExpr as COERCE_EXPLICIT_CAST, so that
the cast is printed.  This covers objects created after this change
only.
---
 src/backend/parser/parse_coerce.c      | 17 +++++++++++++++-
 src/test/regress/expected/rowtypes.out | 27 +++++++++++++-------------
 src/test/regress/sql/rowtypes.sql      |  2 +-
 3 files changed, 30 insertions(+), 16 deletions(-)

diff --git a/src/backend/parser/parse_coerce.c b/src/backend/parser/parse_coerce.c
index d3240f4b265..22cb4be8cac 100644
--- a/src/backend/parser/parse_coerce.c
+++ b/src/backend/parser/parse_coerce.c
@@ -166,7 +166,22 @@ coerce_type(ParseState *pstate, Node *node,
 	if (targetTypeId == inputTypeId ||
 		node == NULL)
 	{
-		/* no conversion needed */
+		/*
+		 * No conversion needed.  But an explicit cast of a ROW() changes how
+		 * an operator on it is parsed, so record it in the RowExpr, else
+		 * ruleutils.c prints a bare ROW() that reads back as a row
+		 * comparison.  Copy the node, since we must not modify the input.
+		 */
+		if (node != NULL && IsA(node, RowExpr) &&
+			cformat == COERCE_EXPLICIT_CAST &&
+			((RowExpr *) node)->row_format != COERCE_EXPLICIT_CAST)
+		{
+			RowExpr    *rowexpr = makeNode(RowExpr);
+
+			memcpy(rowexpr, node, sizeof(RowExpr));
+			rowexpr->row_format = COERCE_EXPLICIT_CAST;
+			return (Node *) rowexpr;
+		}
 		return node;
 	}
 	if (targetTypeId == ANYOID ||
diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out
index 3363bbc62e3..54a2b3b109d 100644
--- a/src/test/regress/expected/rowtypes.out
+++ b/src/test/regress/expected/rowtypes.out
@@ -1413,9 +1413,9 @@ DROP TABLE compositetable;
 CREATE VIEW rowtypes_cmp_v1 AS
 SELECT (ROW(1, NULL::int)::record = ROW(1, NULL::int)::record) AS r;
 SELECT pg_get_viewdef('rowtypes_cmp_v1'::regclass);
-                        pg_get_viewdef                         
----------------------------------------------------------------
-  SELECT (ROW(1, NULL::integer) = ROW(1, NULL::integer)) AS r;
+                                pg_get_viewdef                                 
+-------------------------------------------------------------------------------
+  SELECT (ROW(1, NULL::integer)::record = ROW(1, NULL::integer)::record) AS r;
 (1 row)
 
 SELECT pg_get_viewdef('rowtypes_cmp_v1'::regclass) AS def \gset
@@ -1423,27 +1423,26 @@ CREATE VIEW rowtypes_cmp_v1_re AS :def
 SELECT * FROM rowtypes_cmp_v1, rowtypes_cmp_v1_re;
  r | r 
 ---+---
- t | 
+ t | t
 (1 row)
 
 -- Nor as a row comparison with the columns of a scalar subquery
 CREATE VIEW rowtypes_cmp_v2 AS
 SELECT (ROW(1, NULL::int)::record = (SELECT ROW(1, NULL::int)::record)) AS r;
 SELECT pg_get_viewdef('rowtypes_cmp_v2'::regclass);
-                                  pg_get_viewdef                                  
-----------------------------------------------------------------------------------
-  SELECT (ROW(1, NULL::integer) = ( SELECT ROW(1, NULL::integer) AS "row")) AS r;
+                                          pg_get_viewdef                                          
+--------------------------------------------------------------------------------------------------
+  SELECT (ROW(1, NULL::integer)::record = ( SELECT ROW(1, NULL::integer)::record AS "row")) AS r;
 (1 row)
 
 SELECT pg_get_viewdef('rowtypes_cmp_v2'::regclass) AS def \gset
 CREATE VIEW rowtypes_cmp_v2_re AS :def
-ERROR:  subquery has too few columns
-LINE 1: ...types_cmp_v2_re AS  SELECT (ROW(1, NULL::integer) = ( SELECT...
-                                                             ^
 SELECT * FROM rowtypes_cmp_v2, rowtypes_cmp_v2_re;
-ERROR:  relation "rowtypes_cmp_v2_re" does not exist
-LINE 1: SELECT * FROM rowtypes_cmp_v2, rowtypes_cmp_v2_re;
-                                       ^
+ r | r 
+---+---
+ t | t
+(1 row)
+
 -- The parser expands a whole-row reference to a JOIN USING alias into a ROW()
 CREATE TABLE rowtypes_cmp_a (x int);
 CREATE TABLE rowtypes_cmp_b (x int);
@@ -1520,4 +1519,4 @@ SELECT pg_get_viewdef('rowtypes_cmp_v5'::regclass);
 
 -- leave the views around for the pg_upgrade test, except the ones that do
 -- not survive dump and restore yet
-DROP VIEW rowtypes_cmp_v1, rowtypes_cmp_v2, rowtypes_cmp_v3, rowtypes_cmp_v4;
+DROP VIEW rowtypes_cmp_v3, rowtypes_cmp_v4;
diff --git a/src/test/regress/sql/rowtypes.sql b/src/test/regress/sql/rowtypes.sql
index 877dd631d7b..169933dddbc 100644
--- a/src/test/regress/sql/rowtypes.sql
+++ b/src/test/regress/sql/rowtypes.sql
@@ -619,4 +619,4 @@ SELECT pg_get_viewdef('rowtypes_cmp_v5'::regclass);
 
 -- leave the views around for the pg_upgrade test, except the ones that do
 -- not survive dump and restore yet
-DROP VIEW rowtypes_cmp_v1, rowtypes_cmp_v2, rowtypes_cmp_v3, rowtypes_cmp_v4;
+DROP VIEW rowtypes_cmp_v3, rowtypes_cmp_v4;
-- 
2.54.0 (Apple Git-157)

From 5d1bc71346b8688085587a77e0796b90619aca74 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 21 Oct 2026 10:59:32 +0900
Subject: [PATCH v2 3/4] Show casts of ROW() constructors made by the parser

The parser makes a ROW() of its own for a whole-row reference to a
JOIN USING alias, which lists only some columns of the join, and when
it coerces a whole-row reference of type record to an operator's
composite input type.  Both were marked as implicit, so ruleutils.c
printed a bare ROW(), and an operator on it was read back as a row
comparison, which failed:

    j.* = (select row(1)), for a JOIN USING alias j
        printed as ROW(a.x) = ( SELECT ROW(1) AS "row")
        ERROR:  operator does not exist: integer = record
    s1 =#= s2, for an operator on a named composite type and
    subqueries s1 and s2 with columns a and b
        printed as ROW(s1.a, s1.b) =#= ROW(s2.a, s2.b)
        ERROR:  operator does not exist: integer =#= integer

Mark the ROW() for a JOIN USING alias as COERCE_EXPLICIT_CAST, so that
it is printed with ::record wherever it is used.  In make_op(), when
the operands would take the form that transformAExprOp reads as a row
comparison, coerce the ROW() operands with COERCE_EXPLICIT_CAST, so
that they are printed with a cast to the operator's input type; other
coercions of records to composite types, as for function arguments,
are not affected.  This covers objects created after this change only.
---
 src/backend/parser/parse_expr.c        |  3 +-
 src/backend/parser/parse_oper.c        | 54 ++++++++++++++++++++++++++
 src/test/regress/expected/rowtypes.out | 53 +++++++++++--------------
 src/test/regress/sql/rowtypes.sql      |  4 +-
 4 files changed, 80 insertions(+), 34 deletions(-)

diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index 05a6b72d4c9..6f76bb4a99b 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -2695,7 +2695,8 @@ transformWholeRowRef(ParseState *pstate, ParseNamespaceItem *nsitem,
 		rowexpr->args = list_truncate(fields,
 									  list_length(nsitem->p_names->colnames));
 		rowexpr->row_typeid = RECORDOID;
-		rowexpr->row_format = COERCE_IMPLICIT_CAST;
+		/* show a cast, as a bare ROW() reads back as a row comparison */
+		rowexpr->row_format = COERCE_EXPLICIT_CAST;
 		rowexpr->colnames = copyObject(nsitem->p_names->colnames);
 		rowexpr->location = location;
 
diff --git a/src/backend/parser/parse_oper.c b/src/backend/parser/parse_oper.c
index dc0f047ca25..baadf82cb14 100644
--- a/src/backend/parser/parse_oper.c
+++ b/src/backend/parser/parse_oper.c
@@ -809,6 +809,60 @@ make_op(ParseState *pstate, List *opname, Node *ltree, Node *rtree,
 						"internal"),
 				 parser_errposition(pstate, location)));
 
+	/*
+	 * transformAExprOp reads "ROW(..) op ROW(..)" and "ROW(..) op (SELECT
+	 * ..)" as a row comparison.  An operand gets into that form here when a
+	 * whole-row value of type record is coerced to the operator's composite
+	 * input type, which expands it into a ROW() (see
+	 * coerce_record_to_complex).  Show that coercion as a cast, else the
+	 * operator is printed in a form that reads back as a row comparison.  The
+	 * operands coerced here get their actual type set to the declared one, so
+	 * that make_fn_arguments does not coerce them again.
+	 */
+	if (nargs == 2)
+	{
+		bool		lrow;
+		bool		rrow;
+
+		if (actual_arg_types[0] == declared_arg_types[0])
+			lrow = IsA(ltree, RowExpr);
+		else
+			lrow = (actual_arg_types[0] == RECORDOID &&
+					ISCOMPLEX(declared_arg_types[0]));
+		if (actual_arg_types[1] == declared_arg_types[1])
+			rrow = IsA(rtree, RowExpr);
+		else
+			rrow = (actual_arg_types[1] == RECORDOID &&
+					ISCOMPLEX(declared_arg_types[1]));
+
+		if (lrow &&
+			(rrow ||
+			 (IsA(rtree, SubLink) &&
+			  ((SubLink *) rtree)->subLinkType == EXPR_SUBLINK)))
+		{
+			if (actual_arg_types[0] != declared_arg_types[0])
+			{
+				linitial(args) = coerce_type(pstate, ltree,
+											 actual_arg_types[0],
+											 declared_arg_types[0], -1,
+											 COERCION_IMPLICIT,
+											 COERCE_EXPLICIT_CAST,
+											 -1);
+				actual_arg_types[0] = declared_arg_types[0];
+			}
+			if (rrow && actual_arg_types[1] != declared_arg_types[1])
+			{
+				lsecond(args) = coerce_type(pstate, rtree,
+											actual_arg_types[1],
+											declared_arg_types[1], -1,
+											COERCION_IMPLICIT,
+											COERCE_EXPLICIT_CAST,
+											-1);
+				actual_arg_types[1] = declared_arg_types[1];
+			}
+		}
+	}
+
 	/* perform the necessary typecasting of arguments */
 	make_fn_arguments(pstate, args, actual_arg_types, declared_arg_types);
 
diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out
index 54a2b3b109d..2faf49e0c7a 100644
--- a/src/test/regress/expected/rowtypes.out
+++ b/src/test/regress/expected/rowtypes.out
@@ -1452,25 +1452,23 @@ CREATE VIEW rowtypes_cmp_v3 AS
 SELECT x, (j.* = (SELECT ROW(1))) AS r
   FROM rowtypes_cmp_a JOIN rowtypes_cmp_b USING (x) AS j;
 SELECT pg_get_viewdef('rowtypes_cmp_v3'::regclass);
-                        pg_get_viewdef                        
---------------------------------------------------------------
-  SELECT rowtypes_cmp_a.x,                                   +
-     (ROW(rowtypes_cmp_a.x) = ( SELECT ROW(1) AS "row")) AS r+
-    FROM (rowtypes_cmp_a                                     +
+                            pg_get_viewdef                            
+----------------------------------------------------------------------
+  SELECT rowtypes_cmp_a.x,                                           +
+     (ROW(rowtypes_cmp_a.x)::record = ( SELECT ROW(1) AS "row")) AS r+
+    FROM (rowtypes_cmp_a                                             +
       JOIN rowtypes_cmp_b USING (x) AS j);
 (1 row)
 
 SELECT pg_get_viewdef('rowtypes_cmp_v3'::regclass) AS def \gset
 CREATE VIEW rowtypes_cmp_v3_re AS :def
-ERROR:  operator does not exist: integer = record
-LINE 2:     (ROW(rowtypes_cmp_a.x) = ( SELECT ROW(1) AS "row")) AS r
-                                   ^
-DETAIL:  No operator of that name accepts the given argument types.
-HINT:  You might need to add explicit type casts.
 SELECT * FROM rowtypes_cmp_v3 JOIN rowtypes_cmp_v3_re USING (x) ORDER BY x;
-ERROR:  relation "rowtypes_cmp_v3_re" does not exist
-LINE 1: SELECT * FROM rowtypes_cmp_v3 JOIN rowtypes_cmp_v3_re USING ...
-                                           ^
+ x | r | r 
+---+---+---
+ 1 | t | t
+ 2 | f | f
+(2 rows)
+
 -- So does coercing a whole-row reference to an operator's composite input
 -- type
 CREATE TYPE rowtypes_cmp_t AS (a int, b int);
@@ -1483,26 +1481,23 @@ CREATE VIEW rowtypes_cmp_v4 AS
 SELECT (s1 =#= s2) AS r
   FROM (SELECT 1 AS a, NULL::int AS b) s1, (SELECT 1, NULL::int) s2;
 SELECT pg_get_viewdef('rowtypes_cmp_v4'::regclass);
-                         pg_get_viewdef                         
-----------------------------------------------------------------
-  SELECT (ROW(s1.a, s1.b) =#= ROW(s2."?column?", s2.int4)) AS r+
-    FROM ( SELECT 1 AS a,                                      +
-             NULL::integer AS b) s1,                           +
-     ( SELECT 1 AS "?column?",                                 +
+                                         pg_get_viewdef                                         
+------------------------------------------------------------------------------------------------
+  SELECT (ROW(s1.a, s1.b)::rowtypes_cmp_t =#= ROW(s2."?column?", s2.int4)::rowtypes_cmp_t) AS r+
+    FROM ( SELECT 1 AS a,                                                                      +
+             NULL::integer AS b) s1,                                                           +
+     ( SELECT 1 AS "?column?",                                                                 +
              NULL::integer AS int4) s2;
 (1 row)
 
 SELECT pg_get_viewdef('rowtypes_cmp_v4'::regclass) AS def \gset
 CREATE VIEW rowtypes_cmp_v4_re AS :def
-ERROR:  operator does not exist: integer =#= integer
-LINE 1: ...EW rowtypes_cmp_v4_re AS  SELECT (ROW(s1.a, s1.b) =#= ROW(s2...
-                                                             ^
-DETAIL:  No operator of that name accepts the given argument types.
-HINT:  You might need to add explicit type casts.
 SELECT * FROM rowtypes_cmp_v4, rowtypes_cmp_v4_re;
-ERROR:  relation "rowtypes_cmp_v4_re" does not exist
-LINE 1: SELECT * FROM rowtypes_cmp_v4, rowtypes_cmp_v4_re;
-                                       ^
+ r | r 
+---+---
+ t | t
+(1 row)
+
 -- A row comparison, and a ROW() compared with something other than a ROW()
 -- or a subquery, keep their output
 CREATE VIEW rowtypes_cmp_v5 AS
@@ -1517,6 +1512,4 @@ SELECT pg_get_viewdef('rowtypes_cmp_v5'::regclass);
      (ROW(1, 2) = '(1,2)'::rowtypes_cmp_t) AS one;
 (1 row)
 
--- leave the views around for the pg_upgrade test, except the ones that do
--- not survive dump and restore yet
-DROP VIEW rowtypes_cmp_v3, rowtypes_cmp_v4;
+-- leave the views around for the pg_upgrade test
diff --git a/src/test/regress/sql/rowtypes.sql b/src/test/regress/sql/rowtypes.sql
index 169933dddbc..bce8d9dac28 100644
--- a/src/test/regress/sql/rowtypes.sql
+++ b/src/test/regress/sql/rowtypes.sql
@@ -617,6 +617,4 @@ SELECT (ROW(1, NULL::int) = ROW(1, NULL::int)) AS eq,
        (ROW(1, 2) = '(1,2)'::rowtypes_cmp_t) AS one;
 SELECT pg_get_viewdef('rowtypes_cmp_v5'::regclass);
 
--- leave the views around for the pg_upgrade test, except the ones that do
--- not survive dump and restore yet
-DROP VIEW rowtypes_cmp_v3, rowtypes_cmp_v4;
+-- leave the views around for the pg_upgrade test
-- 
2.54.0 (Apple Git-157)

From c3204d8eb40b34af10b7de12ed6fd419fc5527e5 Mon Sep 17 00:00:00 2001
From: Henson Choi <[email protected]>
Date: Wed, 21 Oct 2026 11:00:02 +0900
Subject: [PATCH v2 4/4] Fix deparsing of operators on ROW() constructors

The previous two commits mark ROW()s that an operator needs to see as
casts when parsing, but a stored tree is not parsed again.  A view
created before them keeps a bare ROW() in its tree:

    create view v as
      select row(1, null::int)::record = row(1, null::int)::record as r;

is still printed as

    SELECT (ROW(1, NULL::integer) = ROW(1, NULL::integer)) AS r;

so a dump and restore turns its result from true to NULL, and the
other cases still do not read back.  The text it was created from is
not kept, so it cannot be fixed by creating it again; for such a tree,
the only place to fix it is where it is printed.  The rewriter makes
such ROW()s too: EXPLAIN shows a comparison between whole-row
references to an updatable view as a row comparison of its columns.

When the left operand of an operator is a ROW() without an explicit
cast and the right one is a ROW() or a scalar subquery, print a cast
after each such ROW(), so that the raw parse tree has a TypeCast there
instead.

Backpatch-through: 15
---
 src/backend/utils/adt/ruleutils.c             | 24 +++++++++++++++++++
 src/test/regress/expected/updatable_views.out |  6 ++---
 2 files changed, 27 insertions(+), 3 deletions(-)

diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 908f9056fee..f0e40dfeb07 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -10834,13 +10834,37 @@ get_oper_expr(OpExpr *expr, deparse_context *context)
 		/* binary operator */
 		Node	   *arg1 = (Node *) linitial(args);
 		Node	   *arg2 = (Node *) lsecond(args);
+		bool		bare1 = IsA(arg1, RowExpr) &&
+			((RowExpr *) arg1)->row_format != COERCE_EXPLICIT_CAST;
+		bool		bare2 = IsA(arg2, RowExpr) &&
+			((RowExpr *) arg2)->row_format != COERCE_EXPLICIT_CAST;
+		bool		castrows;
+
+		/*
+		 * The parser reads "ROW(..) op ROW(..)" as a column-by-column row
+		 * comparison, and "ROW(..) op (SELECT ..)" as a row comparison
+		 * sublink, rather than as an operator on record values (see
+		 * transformAExprOp).  If the operands would print that way, print an
+		 * explicit cast after each bare ROW(), so that the raw parse tree has
+		 * a TypeCast there instead.
+		 */
+		castrows = bare1 &&
+			(bare2 ||
+			 (IsA(arg2, SubLink) &&
+			  ((SubLink *) arg2)->subLinkType == EXPR_SUBLINK));
 
 		get_rule_expr_paren(arg1, context, true, (Node *) expr);
+		if (castrows)
+			appendStringInfo(buf, "::%s",
+							 format_type_with_typemod(exprType(arg1), -1));
 		appendStringInfo(buf, " %s ",
 						 generate_operator_name(opno,
 												exprType(arg1),
 												exprType(arg2)));
 		get_rule_expr_paren(arg2, context, true, (Node *) expr);
+		if (castrows && bare2)
+			appendStringInfo(buf, "::%s",
+							 format_type_with_typemod(exprType(arg2), -1));
 	}
 	else
 	{
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 9cea538b8e8..8e51ef5464f 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -3756,12 +3756,12 @@ select * from uv_iocu_view;
 explain (costs off)
 insert into uv_iocu_view (aa,bb) values (1,'Rejected: (y,1,"(1,y)")')
    on conflict (aa) do select where uv_iocu_view.* = excluded.* returning *;
-                                                           QUERY PLAN                                                            
----------------------------------------------------------------------------------------------------------------------------------
+                                                                         QUERY PLAN                                                                          
+-------------------------------------------------------------------------------------------------------------------------------------------------------------
  Insert on uv_iocu_tab
    Conflict Resolution: SELECT
    Conflict Arbiter Indexes: uv_iocu_tab_a_key
-   Conflict Filter: (ROW(uv_iocu_tab.b, uv_iocu_tab.a, (uv_iocu_tab.*)::text) = ROW(excluded.b, excluded.a, (excluded.*)::text))
+   Conflict Filter: (ROW(uv_iocu_tab.b, uv_iocu_tab.a, (uv_iocu_tab.*)::text)::uv_iocu_view = ROW(excluded.b, excluded.a, (excluded.*)::text)::uv_iocu_view)
    ->  Result
 (5 rows)
 
-- 
2.54.0 (Apple Git-157)

Reply via email to