BUG #19498: Anonymous ROW() field expansion fails after scalar subquery relay

From: PG Bug reporting form <noreply(at)postgresql(dot)org>
To: pgsql-bugs(at)lists(dot)postgresql(dot)org
Cc: yankairong(at)ruc(dot)edu(dot)cn
Subject: BUG #19498: Anonymous ROW() field expansion fails after scalar subquery relay
Date: 2026-05-27 11:50:12
Message-ID: 19498-e49069f1ed6487cd@postgresql.org
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-bugs

The following bug has been logged on the website:

Bug reference: 19498
Logged by: muyehu
Email address: yankairong(at)ruc(dot)edu(dot)cn
PostgreSQL version: 18.4
Operating system: ubuntu22.04
Description:

PostgreSQL version: 18.4
Platform: x86_64-pc-linux-gnu, gcc 11.4.0
Component: parser / rowtypes / expression analysis

Hi,

I found an inconsistency in field expansion of anonymous ROW() values.

A direct relay of an anonymous composite value allows field extraction:

SELECT (c).f1
FROM (SELECT row(1, 2) AS c) s;

This returns:

f1
----
1

However, if the same anonymous composite value is first relayed through a
scalar subquery and then exposed as an output column, later field expansion
fails with:

ERROR: record type has not been registered

The failure is triggered by operations that need field-level tuple
descriptor information, such as:

- (c).f1
- f1(c)
- (c).*
- INSERT ... SELECT (c).*

At the same time, the relayed value itself still appears to be preserved:
operations such as row_to_json(c), to_jsonb(c), and c::text work on the same
query shape. This suggests that the issue is not that the anonymous record
value is lost, but that its tuple descriptor cannot be recovered later
during field expansion.

Minimal reproduction:

BEGIN;

-- Direct relay works.
SELECT (c).f1
FROM (SELECT row(1, 2) AS c) s;

-- Scalar subquery relay fails.
SELECT (c).f1
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

-- Star expansion fails in the same way.
SELECT (c).*
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

ROLLBACK;

Actual result for the second and third queries:

ERROR: record type has not been registered

Expected behavior:

Since the direct relay of the same anonymous ROW() value allows field
expansion, I expected the scalar-subquery relay to preserve enough row
descriptor information for the same field expansion, or at least to behave
consistently with the direct relay case.

The same behavior can also be reproduced with a CTE relay:

WITH cte(c) AS MATERIALIZED (
SELECT row(1, 2)
),
pass1(c) AS (
SELECT (SELECT z.c FROM (SELECT cte.c) z)
FROM cte
)
SELECT (c).f1
FROM pass1;

This also fails with:

ERROR: record type has not been registered

The same relay shape also fails with functional field access:

SELECT f1(c)
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

and with DML projection:

CREATE TEMP TABLE t(f1 int, f2 int);

INSERT INTO t
SELECT (c).*
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

Both fail with:

ERROR: record type has not been registered

Adjacent operations that work:

On the same scalar-subquery relay shape, the following operations succeed:

SELECT row_to_json(c)
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

SELECT to_jsonb(c)
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

SELECT c::text
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

For example, c::text returns:

(1,2)

This suggests that the scalar subquery does relay the anonymous record
value, but later field expansion cannot recover its descriptor.

Possible relation to existing rowtype tests:

This looks possibly related to previous rowtype /
indirect-composite-reference issues, including bug #18077.

The existing regression tests in src/test/regress/sql/rowtypes.sql cover
several indirect composite reference paths. For example, this shape works:

WITH cte(c) AS MATERIALIZED (SELECT row(1, 2)),
cte2(c) AS (SELECT * FROM cte)
SELECT (c).f1
FROM cte2;

But replacing the relay column with a scalar subquery returning the same
anonymous composite value fails:

WITH cte(c) AS MATERIALIZED (SELECT row(1, 2)),
cte2(c) AS (
SELECT (SELECT c FROM cte) AS c
)
SELECT (c).f1
FROM cte2;

So the remaining uncovered shape seems to be:

anonymous ROW()
-> scalar subquery returning record
-> exposed as an upper query output column
-> later field expansion using (c).f1 / (c).*

Code inspection note:

From code inspection, this may be related to how expandRecordVariable() in
src/backend/parser/parse_target.c follows RTE_SUBQUERY / RTE_CTE output
expressions.

It appears to recurse through some simple Var-based relay paths, but when
the output expression is a SubLink returning record, it may fall back to
get_expr_result_tupdesc(). For an anonymous record value, that fallback does
not appear to recover the tuple descriptor and raises:

ERROR: record type has not been registered

A parallel path may exist in ParseComplexProjection() in
src/backend/parser/parse_func.c, which would explain why f1(c) fails
similarly to (c).f1.

Suggested regression test:

A small regression test near the existing rowtype / bug #18077 cases might
be enough:

-- scalar subquery relay of anonymous record followed by field expansion
WITH cte(c) AS MATERIALIZED (SELECT row(1, 2)),
cte2(c) AS (
SELECT (SELECT c FROM cte) AS c
)
SELECT (c).f1
FROM cte2;

-- scalar subquery relay followed by star expansion
SELECT (c).*
FROM (
SELECT (SELECT z.c FROM (SELECT row(1, 2) AS c) z) AS c
) s;

Best regards,
muyehu

Browse pgsql-bugs by date

  From Date Subject
Next Message Andrey Borodin 2026-05-27 12:08:27 Re: BUG #19490: Streaming standby on 16.14 stops applying WAL on MultiXactOffsetSLRU when primary is 16.8
Previous Message Heikki Linnakangas 2026-05-27 09:06:45 Re: BUG #19490: Streaming standby on 16.14 stops applying WAL on MultiXactOffsetSLRU when primary is 16.8