From c0d76c4713dbbb9d7e27052ba74542f019889e12 Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Fri, 9 Oct 2026 12:00:00 +0900 Subject: [PATCH v1] Give antijoins made from sublinks a relid When pull_up_sublinks() converts a sublink to an antijoin, it builds a JoinExpr with no RTE, on the grounds that nothing above the join can reference its RHS. That overlooks PlaceHolderVars. A PHV with no Vars of its own is evaluated at its phrels, and for the outputs of a pulled-up subquery phrels is all the relids in the subquery's jointree, including the RHS of such a join. A qual using the PHV thus appears to reference the RHS rels, and can be pushed down into the RHS as a parameterized join clause. There it has the opposite of the intended effect: outer rows that fail the qual find no match and are emitted, giving wrong results. For an outer join, including an antijoin made by reducing a left join, this cannot happen because the join's relid is among the qual's relids. Before v16, the qual's nullable_relids prevented it. To fix, make a join RTE for an antijoin built from a sublink, so that it has a relid like any other outer join. Semijoins are left as they are: a qual evaluated within a semijoin's RHS does not change the result. --- src/backend/optimizer/README | 8 ++--- src/backend/optimizer/plan/initsplan.c | 21 +++++------- src/backend/optimizer/plan/subselect.c | 47 ++++++++++++++++++++++++-- src/backend/optimizer/util/relnode.c | 8 ++--- src/include/nodes/pathnodes.h | 3 +- src/include/nodes/primnodes.h | 5 +-- src/test/regress/expected/join.out | 29 ++++++++++++++++ src/test/regress/sql/join.sql | 18 ++++++++++ 8 files changed, 111 insertions(+), 28 deletions(-) diff --git a/src/backend/optimizer/README b/src/backend/optimizer/README index 78a307cc523..73d0b7536ac 100644 --- a/src/backend/optimizer/README +++ b/src/backend/optimizer/README @@ -1593,11 +1593,9 @@ does not preserve its inner rows in the join output, so a partial aggregate computed on the inner side would not survive the join and could not be combined by the final aggregation. We therefore do not push partial aggregation down to the inner side of a semi/anti join. -(An anti join reduced from an outer join null-extends its inner side, -so that inner relation is already excluded by the outer-join condition -above; the case specifically addressed here is a semi/anti join that -does not null-extend its inner side, such as one formed from an -EXISTS, IN, NOT EXISTS, or NOT IN sublink.) +(An anti join has a relid like any outer join, so its inner relation +is already excluded by the outer-join condition above; the case +specifically addressed here is a semi join, which has none.) During the construction of the join tree, we evaluate each base or join relation to determine if eager aggregation can be applied. If diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c index 199e5d0a580..48d0efa1a9b 100644 --- a/src/backend/optimizer/plan/initsplan.c +++ b/src/backend/optimizer/plan/initsplan.c @@ -1635,19 +1635,16 @@ deconstruct_recurse(PlannerInfo *root, Node *jtnode, child_domain->jd_relids); jtitem->qualscope = bms_union(left_item->qualscope, right_item->qualscope); - /* caution: ANTI join derived from SEMI will lack rtindex */ - if (j->rtindex != 0) - { - parent_domain->jd_relids = - bms_add_member(parent_domain->jd_relids, - j->rtindex); - jtitem->qualscope = bms_add_member(jtitem->qualscope, + Assert(j->rtindex != 0); + parent_domain->jd_relids = + bms_add_member(parent_domain->jd_relids, + j->rtindex); + jtitem->qualscope = bms_add_member(jtitem->qualscope, + j->rtindex); + root->outer_join_rels = bms_add_member(root->outer_join_rels, j->rtindex); - root->outer_join_rels = bms_add_member(root->outer_join_rels, - j->rtindex); - mark_rels_nulled_by_join(root, j->rtindex, - right_item->qualscope); - } + mark_rels_nulled_by_join(root, j->rtindex, + right_item->qualscope); jtitem->inner_join_rels = bms_union(left_item->inner_join_rels, right_item->inner_join_rels); jtitem->left_rels = left_item->qualscope; diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c index 7df1a1cb514..7547143c4fe 100644 --- a/src/backend/optimizer/plan/subselect.c +++ b/src/backend/optimizer/plan/subselect.c @@ -93,6 +93,7 @@ static bool contain_outer_selfref_walker(Node *node, Index *depth); static void inline_cte(PlannerInfo *root, CommonTableExpr *cte); static bool inline_cte_walker(Node *node, inline_cte_walker_context *context); static bool sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink); +static int add_antijoin_rte(Query *parse); static bool simplify_EXISTS_query(PlannerInfo *root, Query *query); static Query *convert_EXISTS_to_ANY(PlannerInfo *root, Query *subselect, Node **testexpr, List **paramIds); @@ -1469,7 +1470,8 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink, result->join_using_alias = NULL; result->quals = quals; result->alias = NULL; - result->rtindex = 0; /* we don't need an RTE for it */ + /* a semijoin doesn't need an RTE, but see add_antijoin_rte() */ + result->rtindex = under_not ? add_antijoin_rte(parse) : 0; return result; } @@ -1781,11 +1783,52 @@ convert_EXISTS_sublink_to_join(PlannerInfo *root, SubLink *sublink, result->join_using_alias = NULL; result->quals = whereClause; result->alias = NULL; - result->rtindex = 0; /* we don't need an RTE for it */ + /* a semijoin doesn't need an RTE, but see add_antijoin_rte() */ + result->rtindex = under_not ? add_antijoin_rte(parse) : 0; return result; } +/* + * add_antijoin_rte: make a join RTE for an antijoin built from a sublink + * + * Giving the antijoin a relid, like an antijoin made by reducing a left join + * has, is what tells the rest of the planner that anything coming from above + * the join must not be evaluated within its RHS. Normally nothing above can + * reference the RHS at all, but a PlaceHolderVar whose phrels cover the whole + * join can make a qual appear to; the join's relid in phrels then keeps such + * a qual from being pushed down into the RHS, where it would have the + * opposite of the intended effect. (For a semijoin that would be harmless, + * so those are left without a relid.) + * + * Returns the new RTE's index. + */ +static int +add_antijoin_rte(Query *parse) +{ + ParseState *pstate; + ParseNamespaceItem *nsitem; + + /* Create a dummy ParseState for addRangeTableEntryForJoin */ + pstate = make_parsestate(NULL); + + /* The join has no alias and no columns that anything could reference */ + nsitem = addRangeTableEntryForJoin(pstate, + NIL, + NULL, + JOIN_ANTI, + 0, + NIL, + NIL, + NIL, + NULL, + NULL, + false); + parse->rtable = lappend(parse->rtable, nsitem->p_rte); + + return list_length(parse->rtable); +} + /* * simplify_EXISTS_query: remove any useless stuff in an EXISTS's subquery * diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c index 34dfb757152..f2ccba52f20 100644 --- a/src/backend/optimizer/util/relnode.c +++ b/src/backend/optimizer/util/relnode.c @@ -2964,11 +2964,9 @@ eager_aggregation_possible_for_relation(PlannerInfo *root, RelOptInfo *rel) * computed on the inner side would not survive the join and could not be * combined by the final aggregation. * - * Note that an anti join reduced from an outer join null-extends its - * inner side, so that inner relation already carries nulling_relids and - * is handled by the outer-join check above. The case this check adds is - * a semi/anti join that does not null-extend its inner side, such as one - * formed from an EXISTS, IN, NOT EXISTS, or NOT IN sublink. + * Note that the inner side of an anti join already carries nulling_relids + * and so is handled by the outer-join check above. The case this check + * adds is a semi join, which has no relid. */ foreach(lc, root->join_info_list) { diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index 1c6d1fe3d04..8615db98a55 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -3165,8 +3165,7 @@ typedef struct PlaceHolderVar * join_info_list member are only LEFT, FULL, SEMI, or ANTI. * * ojrelid is the RT index of the join RTE representing this outer join, - * if there is one. It is zero when jointype is INNER or SEMI, and can be - * zero for jointype ANTI (if the join was transformed from a SEMI join). + * if there is one. It is zero when jointype is INNER or SEMI. * One use for this field is that when constructing the output targetlist of a * join relation that implements this OJ, we add ojrelid to the varnullingrels * and phnullingrels fields of nullable (RHS) output columns, so that the diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index 2a832a27f49..726c186c7d2 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -2326,8 +2326,9 @@ typedef struct RangeTblRef * During parse analysis, an RTE is created for the Join, and its index * is filled into rtindex. This RTE is present mainly so that Vars can * be created that refer to the outputs of the join. The planner sometimes - * generates JoinExprs internally; these can have rtindex = 0 if there are - * no join alias variables referencing such joins. + * generates JoinExprs internally. Those for semijoins have rtindex = 0, + * since no join alias variables can reference them; but an antijoin is + * given an RTE anyway, because it needs a relid as other outer joins do. *---------- */ typedef struct JoinExpr diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 4ee94d00ade..409d07171d5 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -12137,3 +12137,32 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2 19000 (1 row) +-- +-- Test that a qual from above an antijoin made from a sublink is not pushed +-- down into the antijoin's RHS. +-- +EXPLAIN (COSTS OFF) +SELECT s.* +FROM (VALUES (1)) AS v(z) LEFT JOIN + (SELECT 123456 AS c, t1.f1 AS x FROM int4_tbl t1 + WHERE NOT EXISTS (SELECT 1 FROM tenk1 t2 WHERE t2.unique1 = t1.f1)) s ON true +WHERE s.x = s.c; + QUERY PLAN +------------------------------------------------------- + Nested Loop Anti Join + Filter: (t1.f1 = 123456) + -> Seq Scan on int4_tbl t1 + -> Index Only Scan using tenk1_unique1 on tenk1 t2 + Index Cond: (unique1 = t1.f1) +(5 rows) + +SELECT s.* +FROM (VALUES (1)) AS v(z) LEFT JOIN + (SELECT 123456 AS c, t1.f1 AS x FROM int4_tbl t1 + WHERE NOT EXISTS (SELECT 1 FROM tenk1 t2 WHERE t2.unique1 = t1.f1)) s ON true +WHERE s.x = s.c; + c | x +--------+-------- + 123456 | 123456 +(1 row) + diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index bf8153afbcd..d08151ad03d 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -4633,3 +4633,21 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2 ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand); SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2 ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand); + +-- +-- Test that a qual from above an antijoin made from a sublink is not pushed +-- down into the antijoin's RHS. +-- + +EXPLAIN (COSTS OFF) +SELECT s.* +FROM (VALUES (1)) AS v(z) LEFT JOIN + (SELECT 123456 AS c, t1.f1 AS x FROM int4_tbl t1 + WHERE NOT EXISTS (SELECT 1 FROM tenk1 t2 WHERE t2.unique1 = t1.f1)) s ON true +WHERE s.x = s.c; + +SELECT s.* +FROM (VALUES (1)) AS v(z) LEFT JOIN + (SELECT 123456 AS c, t1.f1 AS x FROM int4_tbl t1 + WHERE NOT EXISTS (SELECT 1 FROM tenk1 t2 WHERE t2.unique1 = t1.f1)) s ON true +WHERE s.x = s.c; -- 2.37.1 (Apple Git-137.1)