From 56158eb6efc37e4136b5a499d54ee0afa44a9cf5 Mon Sep 17 00:00:00 2001 From: Narayanan Venkateswaran Date: Wed, 28 Oct 2026 10:39:50 +0000 Subject: [PATCH] Skip self-join elimination for queries with grouping sets. Self-join elimination (SJE) removes one side of a self-join on a unique key and rewrites references to the removed relation to point to the kept relation via ChangeVarNodes(). When grouping sets (GROUPING SETS, CUBE, or ROLLUP) are present, each grouping set can independently nullify grouping items that are omitted from that set. When both sides of a self-join appear in the grouping items, SJE's rewrite causes two distinct grouping items to become the exact same Var expression. Consequently, make_pathkeys_for_sortclauses() treats the corresponding ORDER BY sort keys as redundant and drops the second key. This leads to wrong result ordering for rows that tie on the first key, and can return incorrect rows when combined with LIMIT and OFFSET. To prevent this, skip self-join elimination when the current query level contains grouping sets (root->parse->groupingSets != NIL). --- src/backend/optimizer/plan/analyzejoins.c | 15 +- src/test/regress/expected/join.out | 226 ++++++++++++++++++++++ src/test/regress/sql/join.sql | 63 ++++++ 3 files changed, 302 insertions(+), 2 deletions(-) diff --git a/src/backend/optimizer/plan/analyzejoins.c b/src/backend/optimizer/plan/analyzejoins.c index 7aad91fd34e..a8d4dd63d0e 100644 --- a/src/backend/optimizer/plan/analyzejoins.c +++ b/src/backend/optimizer/plan/analyzejoins.c @@ -2146,8 +2146,19 @@ self_join_candidates_cmp(const void *a, const void *b) bool remove_useless_self_joins(PlannerInfo *root, List *joinlist) { - /* Skip if SJE is disabled, or if the joinlist has less than 2 members. */ - if (!enable_self_join_elimination || joinlist == NIL || + /* + * Skip if SJE is disabled, if the query uses grouping sets, or if the + * joinlist has less than 2 members. + * + * When grouping sets are present, each grouping set can independently + * replace grouping items with NULLs. Replacing references to a removed + * relation with references to the kept relation can cause distinct + * grouping items to appear identical, leading downstream pathkey + * deduplication to drop valid ORDER BY keys as redundant. + */ + if (!enable_self_join_elimination || + root->parse->groupingSets != NIL || + joinlist == NIL || (list_length(joinlist) == 1 && !IsA(linitial(joinlist), List))) return false; diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out index 1e6aa380257..2096abc4553 100644 --- a/src/test/regress/expected/join.out +++ b/src/test/regress/expected/join.out @@ -9197,6 +9197,232 @@ where t1.a = s.a; Filter: (a IS NOT NULL) (2 rows) +-- Ensure that SJE is not performed when grouping sets are present, since +-- grouping sets can independently nullify columns that would otherwise be +-- considered equivalent, causing ORDER BY sort keys to be dropped. +-- 1. CUBE with aggregates +explain (costs off) +select t1.a, t2.a, count(*), sum(t1.b) from sj t1 join sj t2 on t1.a = t2.a +group by cube (t1.a, t2.a) order by 1, 2; + QUERY PLAN +------------------------------------------- + Sort + Sort Key: t1.a, t2.a + -> MixedAggregate + Hash Key: t1.a, t2.a + Hash Key: t1.a + Hash Key: t2.a + Group Key: () + -> Nested Loop + Join Filter: (t1.a = t2.a) + -> Seq Scan on sj t1 + -> Materialize + -> Seq Scan on sj t2 +(12 rows) + +select t1.a, t2.a, count(*), sum(t1.b) from sj t1 join sj t2 on t1.a = t2.a +group by cube (t1.a, t2.a) order by 1, 2; + a | a | count | sum +---+---+-------+----- + 1 | 1 | 1 | + 1 | | 1 | + 2 | 2 | 1 | 1 + 2 | | 1 | 1 + | 1 | 1 | + | 2 | 1 | 1 + | | 2 | 1 +(7 rows) + +-- 2. ROLLUP +explain (costs off) +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by rollup (t1.a, t2.a) order by 1, 2; + QUERY PLAN +------------------------------------------------- + Sort + Sort Key: t1.a, t2.a + -> GroupAggregate + Group Key: t1.a, t2.a + Group Key: t1.a + Group Key: () + -> Sort + Sort Key: t1.a + -> Nested Loop + Join Filter: (t1.a = t2.a) + -> Seq Scan on sj t1 + -> Materialize + -> Seq Scan on sj t2 +(13 rows) + +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by rollup (t1.a, t2.a) order by 1, 2; + a | a | count +---+---+------- + 1 | 1 | 1 + 1 | | 1 + 2 | 2 | 1 + 2 | | 1 + | | 2 +(5 rows) + +-- 3. Explicit GROUPING SETS +explain (costs off) +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a, t2.a), (t1.a), (t2.a), ()) order by 1, 2; + QUERY PLAN +------------------------------------------- + Sort + Sort Key: t1.a, t2.a + -> MixedAggregate + Hash Key: t1.a, t2.a + Hash Key: t1.a + Hash Key: t2.a + Group Key: () + -> Nested Loop + Join Filter: (t1.a = t2.a) + -> Seq Scan on sj t1 + -> Materialize + -> Seq Scan on sj t2 +(12 rows) + +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a, t2.a), (t1.a), (t2.a), ()) order by 1, 2; + a | a | count +---+---+------- + 1 | 1 | 1 + 1 | | 1 + 2 | 2 | 1 + 2 | | 1 + | 1 | 1 + | 2 | 1 + | | 2 +(7 rows) + +-- 4. Asymmetric GROUPING SETS +explain (costs off) +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a), (t2.a)) order by 1, 2; + QUERY PLAN +------------------------------------------- + Sort + Sort Key: t1.a, t2.a + -> HashAggregate + Hash Key: t1.a + Hash Key: t2.a + -> Nested Loop + Join Filter: (t1.a = t2.a) + -> Seq Scan on sj t1 + -> Materialize + -> Seq Scan on sj t2 +(10 rows) + +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a), (t2.a)) order by 1, 2; + a | a | count +---+---+------- + 1 | | 1 + 2 | | 1 + | 1 | 1 + | 2 | 1 +(4 rows) + +-- 5. Multiple unique keys with CUBE +explain (costs off) +select t1.a, t2.c, count(*) from sj t1 join sj t2 on t1.a = t2.a and t1.c = t2.c +group by cube (t1.a, t2.c) order by 1, 2; + QUERY PLAN +-------------------------------------------------------------- + Sort + Sort Key: t1.a, t2.c + -> MixedAggregate + Hash Key: t1.a, t2.c + Hash Key: t1.a + Hash Key: t2.c + Group Key: () + -> Nested Loop + Join Filter: ((t2.a = t1.a) AND (t1.c = t2.c)) + -> Seq Scan on sj t1 + -> Materialize + -> Seq Scan on sj t2 +(12 rows) + +select t1.a, t2.c, count(*) from sj t1 join sj t2 on t1.a = t2.a and t1.c = t2.c +group by cube (t1.a, t2.c) order by 1, 2; + a | c | count +---+---+------- + 1 | 2 | 1 + 1 | | 1 + 2 | 1 | 1 + 2 | | 1 + | 1 | 1 + | 2 | 1 + | | 2 +(7 rows) + +-- 6. Pulled-up subquery with self-join under grouping sets +explain (costs off) +select x, y, count(*) +from (select t1.a as x, t2.a as y from sj t1 join sj t2 on t1.a = t2.a) s +group by cube (x, y) order by 1, 2; + QUERY PLAN +------------------------------------------- + Sort + Sort Key: t1.a, t2.a + -> MixedAggregate + Hash Key: t1.a, t2.a + Hash Key: t1.a + Hash Key: t2.a + Group Key: () + -> Nested Loop + Join Filter: (t1.a = t2.a) + -> Seq Scan on sj t1 + -> Materialize + -> Seq Scan on sj t2 +(12 rows) + +select x, y, count(*) +from (select t1.a as x, t2.a as y from sj t1 join sj t2 on t1.a = t2.a) s +group by cube (x, y) order by 1, 2; + x | y | count +---+---+------- + 1 | 1 | 1 + 1 | | 1 + 2 | 2 | 1 + 2 | | 1 + | 1 | 1 + | 2 | 1 + | | 2 +(7 rows) + +-- 7. Outer self-join with inner subquery containing grouping sets (SJE should still apply) +explain (costs off) +select t1.a, t2.a, sub.ct from sj t1 join sj t2 on t1.a = t2.a +join (select a, count(*) as ct from sj group by rollup(a)) sub on t1.a = sub.a +order by 1, 2; + QUERY PLAN +--------------------------------------------- + Sort + Sort Key: t2.a + -> Nested Loop + Join Filter: (t2.a = sj.a) + -> MixedAggregate + Hash Key: sj.a + Group Key: () + -> Seq Scan on sj + -> Materialize + -> Seq Scan on sj t2 + Filter: (a IS NOT NULL) +(11 rows) + +select t1.a, t2.a, sub.ct from sj t1 join sj t2 on t1.a = t2.a +join (select a, count(*) as ct from sj group by rollup(a)) sub on t1.a = sub.a +order by 1, 2; + a | a | ct +---+---+---- + 1 | 1 | 1 + 2 | 2 | 1 +(2 rows) + -- Degenerated case. explain (costs off) select * from diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql index 6264f23e081..7917407193f 100644 --- a/src/test/regress/sql/join.sql +++ b/src/test/regress/sql/join.sql @@ -3517,6 +3517,69 @@ select * from sj t1 on true where t1.a = s.a; +-- Ensure that SJE is not performed when grouping sets are present, since +-- grouping sets can independently nullify columns that would otherwise be +-- considered equivalent, causing ORDER BY sort keys to be dropped. +-- 1. CUBE with aggregates +explain (costs off) +select t1.a, t2.a, count(*), sum(t1.b) from sj t1 join sj t2 on t1.a = t2.a +group by cube (t1.a, t2.a) order by 1, 2; + +select t1.a, t2.a, count(*), sum(t1.b) from sj t1 join sj t2 on t1.a = t2.a +group by cube (t1.a, t2.a) order by 1, 2; + +-- 2. ROLLUP +explain (costs off) +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by rollup (t1.a, t2.a) order by 1, 2; + +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by rollup (t1.a, t2.a) order by 1, 2; + +-- 3. Explicit GROUPING SETS +explain (costs off) +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a, t2.a), (t1.a), (t2.a), ()) order by 1, 2; + +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a, t2.a), (t1.a), (t2.a), ()) order by 1, 2; + +-- 4. Asymmetric GROUPING SETS +explain (costs off) +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a), (t2.a)) order by 1, 2; + +select t1.a, t2.a, count(*) from sj t1 join sj t2 on t1.a = t2.a +group by grouping sets ((t1.a), (t2.a)) order by 1, 2; + +-- 5. Multiple unique keys with CUBE +explain (costs off) +select t1.a, t2.c, count(*) from sj t1 join sj t2 on t1.a = t2.a and t1.c = t2.c +group by cube (t1.a, t2.c) order by 1, 2; + +select t1.a, t2.c, count(*) from sj t1 join sj t2 on t1.a = t2.a and t1.c = t2.c +group by cube (t1.a, t2.c) order by 1, 2; + +-- 6. Pulled-up subquery with self-join under grouping sets +explain (costs off) +select x, y, count(*) +from (select t1.a as x, t2.a as y from sj t1 join sj t2 on t1.a = t2.a) s +group by cube (x, y) order by 1, 2; + +select x, y, count(*) +from (select t1.a as x, t2.a as y from sj t1 join sj t2 on t1.a = t2.a) s +group by cube (x, y) order by 1, 2; + +-- 7. Outer self-join with inner subquery containing grouping sets (SJE should still apply) +explain (costs off) +select t1.a, t2.a, sub.ct from sj t1 join sj t2 on t1.a = t2.a +join (select a, count(*) as ct from sj group by rollup(a)) sub on t1.a = sub.a +order by 1, 2; + +select t1.a, t2.a, sub.ct from sj t1 join sj t2 on t1.a = t2.a +join (select a, count(*) as ct from sj group by rollup(a)) sub on t1.a = sub.a +order by 1, 2; + -- Degenerated case. explain (costs off) select * from -- 2.56.0.317.g454f8302e1-goog