[DEPARSE] Operators on ROW() constructors read back as row comparisons

From: Henson Choi <assam258(at)gmail(dot)com>
To: pgsql-hackers <pgsql-hackers(at)postgresql(dot)org>
Subject: [DEPARSE] Operators on ROW() constructors read back as row comparisons
Date: 2026-10-14 01:10:42
Message-ID: CAAAe_zAppw9B_PjFepFkUf9F1_hs1SpS+yizJuRPDRSyw3RkHQ@mail.gmail.com
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-hackers

Hi hackers,

When ruleutils.c prints an operator between two ROW() constructors, or
between a ROW() and a scalar subquery on its right, with a bare ROW(),
the parser reads it back as a row comparison, not as an operator on
record values. A view, rule or stored expression using it then
silently gives a different result after dump and restore, or does not
restore at all. The two attached patches add tests and fix it.

The problem
-----------

The parser reads "ROW(..) op ROW(..)" as a column-by-column row
comparison, and "ROW(..) op (SELECT ..)" as a row comparison sublink
(transformAExprOp). Casting a ROW() to record makes the record
operator apply, but the cast does nothing and leaves no trace in the
tree. The output comes without it and reads back as a row comparison,
which treats NULLs differently:

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

before: SELECT (ROW(1, NULL::integer) =
ROW(1, NULL::integer)) AS r;
read back, r is NULL
after: SELECT (ROW(1, NULL::integer)::record =
ROW(1, NULL::integer)::record) AS r;

Other cases do not read back at all:

row(1, 2)::record *= row(1, 2)::record
ERROR: operator does not exist: integer *= integer
row(1, null::int)::record = (select row(1, null::int)::record)
ERROR: subquery has too few columns

The ROW() need not be written by the user. A whole-row reference to a
JOIN USING alias is expanded into a ROW(), and so is a whole-row
reference to a subquery passed to an operator on a named composite
type, which is coerced to that type:

select j.* = (select row(1))
from a join b using (x) as j;
before: ROW(a.x) = ( SELECT ROW(1) AS "row")
ERROR: operator does not exist: integer = record
after: ROW(a.x)::record = ( SELECT ROW(1) AS "row")

create operator =#= (leftarg = t, rightarg = t, function = t_eq);
select s1 =#= s2 from (select 1 as a, null::int as b) s1,
(select 1, null::int) s2;
before: ROW(s1.a, s1.b) =#= ROW(s2."?column?", s2.int4)
ERROR: operator does not exist: integer =#= integer
after: ROW(s1.a, s1.b)::t =#= ROW(s2."?column?", s2.int4)::t

pg_dump prints with the same code. With the first example in a view,
a generated column and a BEGIN ATOMIC function body, a dump and
restore turned all three from true to NULL without an error. The
other examples do not restore.

The fix
-------

0002 makes get_oper_expr() print a cast after each ROW() that has no
explicit cast, when the left operand is such a ROW() and the right one
is a ROW() or a scalar subquery. The raw parse tree then has a
TypeCast there, which the parser does not treat specially. The cast
is to the type of the ROW(), so it is ::t in the =#= example above.

An explicit cast to a named type is already printed, so nothing is
added then. Other comparisons with a ROW() on one side only, such as
ROW(1, 2) = '(1,2)'::t, are unchanged. IS DISTINCT FROM and IN also
compare two ROW()s column by column, but the parser does so after
transforming the operands, so the stored expression is already
column-by-column and reads back the same. = ANY and NULLIF are not
special for ROW()s. None of these is changed.

The change also shows in EXPLAIN. In the ON CONFLICT filter in
updatable_views, a comparison between whole-row references to an
updatable view is now shown as ROW(...)::uv_iocu_view =
ROW(...)::uv_iocu_view.

Tests
-----

0001 adds the tests with the output before the fix, and 0002 updates
it, so that the diff shows what changes. For each case above, a view
is created and created again from its pg_get_viewdef() output, and
both are queried where the query can run, so that the expected output
shows whether the results agree. A plain row comparison and a ROW()
with an explicit cast to a named type are checked to keep their
output. The views are left in place, so that the dump and restore
comparison in the pg_upgrade test covers the fix. So that the tests
pass at each step, 0001 drops the views that do not survive dump and
restore yet, and 0002 removes that DROP.

Back branches
-------------

I think this should be back-patched to 15. I applied both patches to
REL_15_STABLE through REL_19_STABLE. The updatable_views.out change
applies only to 19 and master, which have that test (ON CONFLICT DO
SELECT), and has to be dropped on 15-18. Otherwise the patches apply
as they are, and make check passes on all branches.

This came out of an AI-assisted audit of ruleutils.c. I will post the
other problems it found as separate, independent reports as I work
through them.

Best regards,
Henson

Attachment Content-Type Size
v1-0001-Add-tests-for-deparsing-record-comparisons.patch application/x-patch 14.8 KB
v1-0002-Fix-deparsing-of-operators-on-ROW.patch application/x-patch 13.8 KB

Browse pgsql-hackers by date

  From Date Subject
Next Message Fujii Masao 2026-10-14 01:16:11 GIN jsonpath scans miss rows when methods follow a predicate
Previous Message Henson Choi 2026-10-14 01:10:40 [DEPARSE] IS DOCUMENT as an operand loses needed parentheses