From f9ca5f42cee7235f495df3eada39f7f675a9a6e4 Mon Sep 17 00:00:00 2001 From: Richard Guo Date: Fri, 4 Sep 2026 11:42:13 +0900 Subject: [PATCH v1] Fix duplicate qual clauses in parameterized paths When outer-join identity 3 permits a join to commute with lower outer joins, we generate multiple clone versions of its join clause, of which only one should be applied in any given plan. When building a join relation's restriction list, subbuild_joinrel_restrictlist selects the appropriate clone by checking required_relids and incompatible_relids, but no such selection was made for movable join clauses pushed down into a parameterized path. As a result, a parameterized scan or join could enforce more than one clone of the same condition, shown by EXPLAIN as a duplicate qual. This wastes effort evaluating the same condition repeatedly. What is worse, it applies the clause's selectivity multiple times, underestimating the result's row count. To fix, in get_baserel_parampathinfo and get_joinrel_parampathinfo, skip a clone clause if any outer join listed in its incompatible_relids has already been computed below the point of evaluation. --- src/backend/optimizer/util/relnode.c | 14 ++++++++ src/include/nodes/pathnodes.h | 2 +- src/test/regress/expected/join.out | 51 ++++++++++++++++++++++++++-- src/test/regress/sql/join.sql | 22 ++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c index ee69f81945f..8862004cbad 100644 --- a/src/backend/optimizer/util/relnode.c +++ b/src/backend/optimizer/util/relnode.c @@ -1760,6 +1760,15 @@ get_baserel_parampathinfo(PlannerInfo *root, RelOptInfo *baserel, { RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc); + /* + * A clone clause must not be enforced here if an outer join it is + * incompatible with has already been computed below the point of + * evaluation; some other clone is the right one to apply. + */ + if ((rinfo->has_clone || rinfo->is_clone) && + bms_overlap(rinfo->incompatible_relids, joinrelids)) + continue; + if (join_clause_is_movable_into(rinfo, baserel->relids, joinrelids)) @@ -1892,6 +1901,11 @@ get_joinrel_parampathinfo(PlannerInfo *root, RelOptInfo *joinrel, { RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc); + /* As above, reject clones incompatible with a computed outer join */ + if ((rinfo->has_clone || rinfo->is_clone) && + bms_overlap(rinfo->incompatible_relids, join_and_req)) + continue; + if (join_clause_is_movable_into(rinfo, joinrel->relids, join_and_req) && diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index c48e656ce80..460c4f4d8dc 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -2823,7 +2823,7 @@ typedef struct LimitPath * clause and null Vars that it uses. In practice we only bother to populate * it for "clone" clauses, as it's currently only needed to prevent multiple * clones of the same clause from being accepted for evaluation at the same - * join level. + * plan level, whether at a join or within a parameterized path. * * There is also an outer_relids field, which is NULL except for outer join * clauses; for those, it is the set of relids on the outer side of the diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 6a85bb246ef..cee3a7b1f22 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -6664,8 +6664,8 @@ select count(*) from int4_tbl t1 left join left join lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3 on t2.bx = t3.cnt; - QUERY PLAN -------------------------------------------------------------------------- + QUERY PLAN +------------------------------------------------------------ Aggregate -> Nested Loop Left Join -> Seq Scan on int4_tbl t1 @@ -6677,7 +6677,7 @@ select count(*) from int4_tbl t1 left join -> Hash -> Seq Scan on int8_tbl b -> Subquery Scan on t3 - Filter: ((b.q1 = t3.cnt) AND (b.q1 = t3.cnt)) + Filter: (b.q1 = t3.cnt) -> Seq Scan on int4_tbl c Filter: (f1 = (1)) (14 rows) @@ -6693,6 +6693,51 @@ select count(*) from int4_tbl t1 left join 25 (1 row) +-- +-- check that a cloned outer-join qual is not enforced multiple times when +-- it is moved into a parameterized join +-- +explain (costs off) +select count(*) from int4_tbl t1 left join + (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2 + on true + left join + (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3 + join int4_tbl t4 on t3.cnt = t4.f1) + on t2.bx = t3.cnt + t4.f1; + QUERY PLAN +---------------------------------------------------------------- + Aggregate + -> Nested Loop Left Join + -> Seq Scan on int4_tbl t1 + -> Materialize + -> Nested Loop Left Join + -> Hash Left Join + Hash Cond: (a.f1 = b.q2) + -> Seq Scan on int4_tbl a + -> Hash + -> Seq Scan on int8_tbl b + -> Hash Join + Hash Cond: (t4.f1 = c.f1) + Join Filter: (b.q1 = (c.f1 + t4.f1)) + -> Seq Scan on int4_tbl t4 + -> Hash + -> Seq Scan on int4_tbl c + Filter: (f1 = (1)) +(17 rows) + +select count(*) from int4_tbl t1 left join + (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2 + on true + left join + (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3 + join int4_tbl t4 on t3.cnt = t4.f1) + on t2.bx = t3.cnt + t4.f1; + count +------- + 25 +(1 row) + -- -- test successful handling of full join underneath left join (bug #14105) -- diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index c541321e6a2..b353073f21e 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -2402,6 +2402,28 @@ select count(*) from int4_tbl t1 left join (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3 on t2.bx = t3.cnt; +-- +-- check that a cloned outer-join qual is not enforced multiple times when +-- it is moved into a parameterized join +-- + +explain (costs off) +select count(*) from int4_tbl t1 left join + (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2 + on true + left join + (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3 + join int4_tbl t4 on t3.cnt = t4.f1) + on t2.bx = t3.cnt + t4.f1; + +select count(*) from int4_tbl t1 left join + (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2 + on true + left join + (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3 + join int4_tbl t4 on t3.cnt = t4.f1) + on t2.bx = t3.cnt + t4.f1; + -- -- test successful handling of full join underneath left join (bug #14105) -- -- 2.37.1 (Apple Git-137.1)