From af2ff0a994d1e3149c827ef0dd2920aa62a8ed7f Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Sat, 10 Oct 2026 09:21:27 +0900 Subject: [PATCH] Don't trust NOT NULL on virtual generated columns of partitioned tables Commit 383eb21eb taught the planner to convert a NOT IN sublink to an anti-join when the sub-select's outputs are provably non-nullable. Since the sub-select has not been through any planner processing at that point, a reference to a virtual generated column is still a plain Var, and query_outputs_are_not_nullable() relied on the column's NOT NULL constraint to prove it non-nullable. That is not safe for a partitioned table. A partition can have a generation expression different from its parent's, and it enforces the NOT NULL constraint against its own expression, whereas a query on the parent computes the column using the parent's expression. So the column can be NULL when read through the parent despite the constraint, and the anti-join would then produce wrong results, as it returns rows that NOT IN should have rejected. To fix, make var_is_nonnullable() ignore the NOT NULL constraint on a virtual generated column of a partitioned table when consulting the catalogs. The other sources of NOT NULL information are only used after virtual generated columns have been expanded, so they are not affected. Author: Shihao Zhong Reviewed-by: Richard Guo Discussion: https://postgr.es/m/CAGRkXqR7VXacmRP41UBaM5u4aseeCH5ECVmQgn_YS=L7=xdhTw@mail.gmail.com Backpatch-through: 19 --- src/backend/optimizer/util/clauses.c | 12 +++++++++++ .../regress/expected/generated_virtual.out | 21 +++++++++++++++++++ src/test/regress/sql/generated_virtual.sql | 10 +++++++++ 3 files changed, 43 insertions(+) diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index 13bb0ff8555..2fbd094cc9c 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -4918,6 +4918,18 @@ var_is_nonnullable(PlannerInfo *root, Var *var, NotNullSource source) attr = TupleDescCompactAttr(RelationGetDescr(rel), var->varattno - 1); result = (attr->attnullability == ATTNULLABLE_VALID); + + /* + * We cannot trust a NOT NULL constraint on a virtual + * generated column of a partitioned table. Each partition + * enforces it against its own generation expression, which + * can differ from the parent's expression used by the query. + */ + if (result && rte->relkind == RELKIND_PARTITIONED_TABLE && + TupleDescAttr(RelationGetDescr(rel), + var->varattno - 1)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL) + result = false; + table_close(rel, NoLock); return result; diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out index 6ee029796f1..b2f8e16e63b 100644 --- a/src/test/regress/expected/generated_virtual.out +++ b/src/test/regress/expected/generated_virtual.out @@ -1886,3 +1886,24 @@ select * from gtest_cursor order by id; commit; drop table gtest_cursor; +-- NOT NULL on a partitioned table's virtual column does not prove non-nullness, +-- because a partition can have a different generation expression +CREATE TABLE gtestnn_parent2 (a int NOT NULL, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL) PARTITION BY LIST (a); +CREATE TABLE gtestnn_child2 PARTITION OF gtestnn_parent2 (b GENERATED ALWAYS AS (a + 1) VIRTUAL) FOR VALUES IN (0); +INSERT INTO gtestnn_parent2 VALUES (0); +EXPLAIN (COSTS OFF) +SELECT a FROM gtestnn_parent2 WHERE a NOT IN (SELECT b FROM gtestnn_parent2); + QUERY PLAN +--------------------------------------------------------- + Seq Scan on gtestnn_child2 gtestnn_parent2 + Filter: (NOT (ANY (a = (hashed SubPlan any_1).col1))) + SubPlan any_1 + -> Seq Scan on gtestnn_child2 gtestnn_parent2_1 +(4 rows) + +SELECT a FROM gtestnn_parent2 WHERE a NOT IN (SELECT b FROM gtestnn_parent2); + a +--- +(0 rows) + +DROP TABLE gtestnn_parent2; diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql index e4ea63bb3a1..c4c4f3d5ef1 100644 --- a/src/test/regress/sql/generated_virtual.sql +++ b/src/test/regress/sql/generated_virtual.sql @@ -1004,3 +1004,13 @@ select * from gtest_cursor order by id; commit; drop table gtest_cursor; + +-- NOT NULL on a partitioned table's virtual column does not prove non-nullness, +-- because a partition can have a different generation expression +CREATE TABLE gtestnn_parent2 (a int NOT NULL, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL) PARTITION BY LIST (a); +CREATE TABLE gtestnn_child2 PARTITION OF gtestnn_parent2 (b GENERATED ALWAYS AS (a + 1) VIRTUAL) FOR VALUES IN (0); +INSERT INTO gtestnn_parent2 VALUES (0); +EXPLAIN (COSTS OFF) +SELECT a FROM gtestnn_parent2 WHERE a NOT IN (SELECT b FROM gtestnn_parent2); +SELECT a FROM gtestnn_parent2 WHERE a NOT IN (SELECT b FROM gtestnn_parent2); +DROP TABLE gtestnn_parent2;