From b9236c9a87d3d60e04eeff7c0a92f8f38dde22fa Mon Sep 17 00:00:00 2001 From: Matheus Alcantara Date: Tue, 20 Oct 2026 11:37:58 -0300 Subject: [PATCH v2] Ignore dropped columns when simplifying ROW(...) IS [NOT] NULL eval_const_expressions() breaks a NullTest on a RowExpr into separate tests on its fields, and a null constant field refutes IS NOT NULL outright. However, a RowExpr of a named composite type contains a placeholder, typically a null int4 constant, for each dropped column of the rowtype. The planner treated that placeholder as a genuinely null field, so ROW(...)::sometype IS NOT NULL was simplified to constant false whenever the type had a dropped column, even when all real fields were non-null. The executor's ExecEvalRowNullInt() already skips dropped attributes, so the result depended on whether the test was simplified at plan time. This could be reached by casting a row to a named rowtype, and more visibly through virtual generated columns, whose expansion turns a whole-row reference into a RowExpr including the dropped-column placeholders. There, "tbl IS NOT NULL" returned false for every row, and a CHECK constraint like that could neither be added nor, when added as NOT VALID, be satisfied by any new row. Fix by consulting the rowtype's tuple descriptor and skipping fields that correspond to dropped columns, matching the executor's behavior. --- src/backend/optimizer/util/clauses.c | 33 +++++++++- .../regress/expected/generated_virtual.out | 39 +++++++++++ src/test/regress/expected/rowtypes.out | 64 +++++++++++++++++++ src/test/regress/sql/generated_virtual.sql | 15 +++++ src/test/regress/sql/rowtypes.sql | 29 +++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index bfba2ce0733..59fa5dc9737 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -4039,11 +4039,28 @@ eval_const_expressions_mutator(Node *node, */ RowExpr *rarg = (RowExpr *) arg; List *newargs = NIL; + TupleDesc tupdesc = NULL; + bool refuted = false; ListCell *l; + /* + * A RowExpr of a named composite type has a placeholder + * for each dropped column. Those must be ignored, as + * ExecEvalRowNullInt() does, so we need the tuple + * descriptor to identify them. + */ + if (rarg->row_typeid != RECORDOID) + tupdesc = lookup_rowtype_tupdesc(rarg->row_typeid, -1); + foreach(l, rarg->args) { Node *relem = (Node *) lfirst(l); + int i = foreach_current_index(l); + + /* Ignore dropped columns */ + if (tupdesc && i < tupdesc->natts && + TupleDescCompactAttr(tupdesc, i)->attisdropped) + continue; /* * A constant field refutes the whole NullTest if it's @@ -4056,7 +4073,10 @@ eval_const_expressions_mutator(Node *node, if (carg->constisnull ? (ntest->nulltesttype == IS_NOT_NULL) : (ntest->nulltesttype == IS_NULL)) - return makeBoolConst(false, false); + { + refuted = true; + break; + } continue; } @@ -4070,7 +4090,10 @@ eval_const_expressions_mutator(Node *node, NOTNULL_SOURCE_HASHTABLE)) { if (ntest->nulltesttype == IS_NULL) - return makeBoolConst(false, false); + { + refuted = true; + break; + } continue; } @@ -4087,6 +4110,12 @@ eval_const_expressions_mutator(Node *node, newntest->location = ntest->location; newargs = lappend(newargs, newntest); } + + if (tupdesc) + ReleaseTupleDesc(tupdesc); + + if (refuted) + return makeBoolConst(false, false); /* If all the inputs were constants, result is TRUE */ if (newargs == NIL) return makeBoolConst(true, false); diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out index b14e10fb5a0..ed0409f6b14 100644 --- a/src/test/regress/expected/generated_virtual.out +++ b/src/test/regress/expected/generated_virtual.out @@ -696,6 +696,45 @@ ERROR: new row for relation "gtest20c" violates check constraint "whole_row_che DETAIL: Failing row contains (null, virtual). ALTER TABLE gtest20c ALTER COLUMN b SET EXPRESSION AS (NULL::int); -- violates constraint ERROR: check constraint "whole_row_check" of relation "gtest20c" is violated by some row +-- whole-row reference with a dropped column: the NULL placeholder for the +-- dropped column must not make the row test as null +CREATE TABLE gtest20e (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL, c int); +ALTER TABLE gtest20e DROP COLUMN c; +INSERT INTO gtest20e VALUES (1); +SELECT gtest20e, gtest20e IS NOT NULL AS nn, gtest20e IS NULL AS n FROM gtest20e; + gtest20e | nn | n +----------+----+--- + (1,2) | t | f +(1 row) + +EXPLAIN (VERBOSE, COSTS OFF) +SELECT gtest20e IS NOT NULL FROM gtest20e; + QUERY PLAN +------------------------------------------------------- + Seq Scan on generated_virtual_tests.gtest20e + Output: ((a IS NOT NULL) AND ((a * 2) IS NOT NULL)) +(2 rows) + +EXPLAIN (COSTS OFF) +SELECT count(*) FROM gtest20e WHERE gtest20e IS NOT NULL; + QUERY PLAN +------------------------------------------------------------- + Aggregate + -> Seq Scan on gtest20e + Filter: ((a IS NOT NULL) AND ((a * 2) IS NOT NULL)) +(3 rows) + +SELECT count(*) FROM gtest20e WHERE gtest20e IS NOT NULL; + count +------- + 1 +(1 row) + +ALTER TABLE gtest20e ADD CONSTRAINT whole_row_check CHECK (gtest20e IS NOT NULL); -- ok +INSERT INTO gtest20e VALUES (2); -- ok +INSERT INTO gtest20e VALUES (NULL); -- fails +ERROR: new row for relation "gtest20e" violates check constraint "whole_row_check" +DETAIL: Failing row contains (null, virtual). -- index with whole-row reference needs rebuild CREATE TABLE gtest20d (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL); INSERT INTO gtest20d VALUES (1), (1); diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out index c402160d488..ac54ddbe688 100644 --- a/src/test/regress/expected/rowtypes.out +++ b/src/test/regress/expected/rowtypes.out @@ -538,6 +538,70 @@ select ROW() = ROW(); ERROR: cannot compare rows of zero length LINE 1: select ROW() = ROW(); ^ +-- IS [NOT] NULL on a named rowtype with a dropped column must ignore the +-- dropped column's placeholder, also when simplified by the planner +create temp table rowdropped (a int, dropme int, b int); +alter table rowdropped drop column dropme; +insert into rowdropped values (1, 2), (1, null); +explain (verbose, costs off) +select row(a, b)::rowdropped is not null from rowdropped; + QUERY PLAN +------------------------------------------------- + Seq Scan on pg_temp.rowdropped + Output: ((a IS NOT NULL) AND (b IS NOT NULL)) +(2 rows) + +select row(a, b)::rowdropped is not null as nn, + row(a, b)::rowdropped is null as n +from rowdropped; + nn | n +----+--- + t | f + f | f +(2 rows) + +-- same for a composite type with a dropped attribute +create type rowdroppedtype as (x int, y int, z int); +alter type rowdroppedtype drop attribute y; +explain (verbose, costs off) +select row(a, b)::rowdroppedtype is not null from rowdropped; + QUERY PLAN +------------------------------------------------- + Seq Scan on pg_temp.rowdropped + Output: ((a IS NOT NULL) AND (b IS NOT NULL)) +(2 rows) + +select row(a, b)::rowdroppedtype is not null as nn from rowdropped; + nn +---- + t + f +(2 rows) + +-- a stored view keeps the original expression for the dropped attribute +create temp table rowdroppedsrc (x int, y int, z int); +insert into rowdroppedsrc values (1, null, 3), (1, 2, 3); +create type rowdroppedtype2 as (x int, y int, z int); +create temp view rowdroppedview as + select row(x, y, z)::rowdroppedtype2 is not null as nn from rowdroppedsrc; +alter type rowdroppedtype2 drop attribute y; +explain (verbose, costs off) +select nn from rowdroppedview; + QUERY PLAN +----------------------------------------------------------------------------- + Seq Scan on pg_temp.rowdroppedsrc + Output: ((rowdroppedsrc.x IS NOT NULL) AND (rowdroppedsrc.z IS NOT NULL)) +(2 rows) + +select nn from rowdroppedview; + nn +---- + t + t +(2 rows) + +drop view rowdroppedview; +drop type rowdroppedtype, rowdroppedtype2; -- Check ability to create arrays of anonymous rowtypes select array[ row(1,2), row(3,4), row(5,6) ]; array diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql index e62880f941a..50a94dc262d 100644 --- a/src/test/regress/sql/generated_virtual.sql +++ b/src/test/regress/sql/generated_virtual.sql @@ -353,6 +353,21 @@ INSERT INTO gtest20c VALUES (1); -- ok INSERT INTO gtest20c VALUES (NULL); -- fails ALTER TABLE gtest20c ALTER COLUMN b SET EXPRESSION AS (NULL::int); -- violates constraint +-- whole-row reference with a dropped column: the NULL placeholder for the +-- dropped column must not make the row test as null +CREATE TABLE gtest20e (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL, c int); +ALTER TABLE gtest20e DROP COLUMN c; +INSERT INTO gtest20e VALUES (1); +SELECT gtest20e, gtest20e IS NOT NULL AS nn, gtest20e IS NULL AS n FROM gtest20e; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT gtest20e IS NOT NULL FROM gtest20e; +EXPLAIN (COSTS OFF) +SELECT count(*) FROM gtest20e WHERE gtest20e IS NOT NULL; +SELECT count(*) FROM gtest20e WHERE gtest20e IS NOT NULL; +ALTER TABLE gtest20e ADD CONSTRAINT whole_row_check CHECK (gtest20e IS NOT NULL); -- ok +INSERT INTO gtest20e VALUES (2); -- ok +INSERT INTO gtest20e VALUES (NULL); -- fails + -- index with whole-row reference needs rebuild CREATE TABLE gtest20d (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL); INSERT INTO gtest20d VALUES (1), (1); diff --git a/src/test/regress/sql/rowtypes.sql b/src/test/regress/sql/rowtypes.sql index 5aef9f5a3c3..6661f7d87db 100644 --- a/src/test/regress/sql/rowtypes.sql +++ b/src/test/regress/sql/rowtypes.sql @@ -201,6 +201,35 @@ select ROW(); select ROW() IS NULL; select ROW() = ROW(); +-- IS [NOT] NULL on a named rowtype with a dropped column must ignore the +-- dropped column's placeholder, also when simplified by the planner +create temp table rowdropped (a int, dropme int, b int); +alter table rowdropped drop column dropme; +insert into rowdropped values (1, 2), (1, null); +explain (verbose, costs off) +select row(a, b)::rowdropped is not null from rowdropped; +select row(a, b)::rowdropped is not null as nn, + row(a, b)::rowdropped is null as n +from rowdropped; +-- same for a composite type with a dropped attribute +create type rowdroppedtype as (x int, y int, z int); +alter type rowdroppedtype drop attribute y; +explain (verbose, costs off) +select row(a, b)::rowdroppedtype is not null from rowdropped; +select row(a, b)::rowdroppedtype is not null as nn from rowdropped; +-- a stored view keeps the original expression for the dropped attribute +create temp table rowdroppedsrc (x int, y int, z int); +insert into rowdroppedsrc values (1, null, 3), (1, 2, 3); +create type rowdroppedtype2 as (x int, y int, z int); +create temp view rowdroppedview as + select row(x, y, z)::rowdroppedtype2 is not null as nn from rowdroppedsrc; +alter type rowdroppedtype2 drop attribute y; +explain (verbose, costs off) +select nn from rowdroppedview; +select nn from rowdroppedview; +drop view rowdroppedview; +drop type rowdroppedtype, rowdroppedtype2; + -- Check ability to create arrays of anonymous rowtypes select array[ row(1,2), row(3,4), row(5,6) ]; -- 2.50.1 (Apple Git-155)