From 90c3da3d86cdb3008f6957590b0743e2a695b55e Mon Sep 17 00:00:00 2001 From: Hannu Krosing Date: Wed, 16 Sep 2026 13:32:07 +0000 Subject: [PATCH 3/4] Support UNION DISTINCT ON for non-recursive set operations Extends set operations to support duplicate elimination on a subset of columns using the syntax: select_stmt UNION DISTINCT ON (keys [ORDER BY sort_keys]) select_stmt Builds upon the setop group clause refactoring to plan both HashAgg and Sort->Unique paths for set operations with partial group lists. Also prevents invalid subquery qual pushdown onto non-distinct attributes via the UNSAFE_NOTIN_DISTINCTON_CLAUSE safety check. --- doc/src/sgml/ref/select.sgml | 8 +- src/backend/optimizer/path/allpaths.c | 19 ++++ src/backend/optimizer/plan/planner.c | 6 +- src/backend/optimizer/prep/prepunion.c | 57 +++++------ src/backend/parser/analyze.c | 123 ++++++++++++++++++++---- src/backend/parser/gram.y | 26 ++++- src/backend/rewrite/rewriteGraphTable.c | 2 +- src/include/nodes/parsenodes.h | 1 + src/include/parser/analyze.h | 2 +- src/test/regress/expected/union.out | 48 +++++++++ src/test/regress/sql/union.sql | 28 ++++++ 11 files changed, 268 insertions(+), 52 deletions(-) diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml index 2a18ed13490..788ed99935f 100644 --- a/doc/src/sgml/ref/select.sgml +++ b/doc/src/sgml/ref/select.sgml @@ -1294,7 +1294,7 @@ SELECT DISTINCT ON (location ORDER BY time DESC) location, time, report The UNION clause has this general form: -select_statement UNION [ ALL | DISTINCT ] select_statement +select_statement UNION [ ALL | DISTINCT | DISTINCT ON ( expression [, ...] [ ORDER BY sort_expression [ ASC | DESC ] [, ...] ] ) ] select_statement select_statement is any SELECT statement without an ORDER BY, LIMIT, FOR NO KEY UPDATE, FOR UPDATE, @@ -1325,6 +1325,12 @@ SELECT DISTINCT ON (location ORDER BY time DESC) location, time, report UNION; use ALL when you can.) DISTINCT can be written to explicitly specify the default behavior of eliminating duplicate rows. + DISTINCT ON keeps only the first row of each set of rows + that are duplicates according to the specified expressions. + If the optional ORDER BY clause is specified inside + DISTINCT ON, it determines which row is kept from each set of + duplicates (the first row according to this sort order). Otherwise, the kept + row is unpredictable. diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c index 24a6a8d11dd..97c2d76b6db 100644 --- a/src/backend/optimizer/path/allpaths.c +++ b/src/backend/optimizer/path/allpaths.c @@ -4171,6 +4171,25 @@ subquery_is_pushdown_safe(Query *subquery, Query *topquery, */ if (subquery->setOperations == NULL) check_output_expressions(subquery, safetyInfo); + else if (subquery->hasDistinctOn) + { + ListCell *lc; + + foreach(lc, subquery->targetList) + { + TargetEntry *tle = (TargetEntry *) lfirst(lc); + + if (tle->resjunk) + continue; + + if ((safetyInfo->unsafeFlags[tle->resno] & + UNSAFE_NOTIN_DISTINCTON_CLAUSE) == 0 && + !targetIsInSortList(tle, InvalidOid, subquery->distinctClause)) + { + safetyInfo->unsafeFlags[tle->resno] |= UNSAFE_NOTIN_DISTINCTON_CLAUSE; + } + } + } /* Are we at top level, or looking at a setop component? */ if (subquery == topquery) diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index 99a9ca9ba9d..5ee24e3b4d8 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -1774,7 +1774,6 @@ grouping_planner(PlannerInfo *root, double tuple_fraction, /* * Calculate pathkeys that represent result ordering requirements */ - Assert(parse->distinctClause == NIL); root->sort_pathkeys = make_pathkeys_for_sortclauses(root, parse->sortClause, root->processed_tlist); @@ -3889,7 +3888,7 @@ standard_qp_callback(PlannerInfo *root, void *extra) false, false, &sortable, - false); + true); if (!sortable) root->setop_pathkeys = NIL; } @@ -8644,7 +8643,8 @@ get_nth_nonjunk_tle(List *tlist, int n) static List * generate_setop_child_grouplist(SetOperationStmt *op, List *targetlist) { - List *grouplist = copyObject(op->groupClauses); + List *clauses = op->sortClauses ? op->sortClauses : op->groupClauses; + List *grouplist = copyObject(clauses); ListCell *lg; foreach(lg, grouplist) diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c index 1efd0d915b7..a424865f61e 100644 --- a/src/backend/optimizer/prep/prepunion.c +++ b/src/backend/optimizer/prep/prepunion.c @@ -113,7 +113,6 @@ plan_set_operations(PlannerInfo *root) Assert(parse->groupClause == NIL); Assert(parse->havingQual == NULL); Assert(parse->windowClause == NIL); - Assert(parse->distinctClause == NIL); /* * In the outer query level, equivalence classes are limited to classes @@ -934,32 +933,34 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, * Try a hash aggregate plan on 'apath'. This is the cheapest * available path containing each append child. */ - path = (Path *) create_agg_path(root, - result_rel, - apath, - result_rel->reltarget, - AGG_HASHED, - AGGSPLIT_SIMPLE, - groupList, - NIL, - NULL, - dNumChildGroups); - add_path(result_rel, path); - - /* Try hash aggregate on the Gather path, if valid */ - if (gpath != NULL) - { - /* Hashed aggregate plan --- no sort needed */ - path = (Path *) create_agg_path(root, + path = (Path *) create_agg_path_ext(root, result_rel, - gpath, + apath, result_rel->reltarget, AGG_HASHED, AGGSPLIT_SIMPLE, groupList, NIL, NULL, - dNumChildGroups); + dNumChildGroups, + op->sortClauses); + add_path(result_rel, path); + + /* Try hash aggregate on the Gather path, if valid */ + if (gpath != NULL) + { + /* Hashed aggregate plan --- no sort needed */ + path = (Path *) create_agg_path_ext(root, + result_rel, + gpath, + result_rel->reltarget, + AGG_HASHED, + AGGSPLIT_SIMPLE, + groupList, + NIL, + NULL, + dNumChildGroups, + op->sortClauses); add_path(result_rel, path); } } @@ -967,17 +968,18 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, if (can_sort) { Path *path = apath; + List *sort_clauses = op->sortClauses ? op->sortClauses : groupList; /* Try Sort -> Unique on the Append path */ - if (groupList != NIL) + if (sort_clauses != NIL) path = (Path *) create_sort_path(root, result_rel, path, - make_pathkeys_for_sortclauses(root, groupList, tlist), + make_pathkeys_for_sortclauses(root, sort_clauses, tlist), -1.0); path = (Path *) create_unique_path(root, result_rel, path, - list_length(path->pathkeys), + list_length(groupList), dNumChildGroups); add_path(result_rel, path); @@ -987,14 +989,15 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, { path = gpath; - path = (Path *) create_sort_path(root, result_rel, path, - make_pathkeys_for_sortclauses(root, groupList, tlist), - -1.0); + if (sort_clauses != NIL) + path = (Path *) create_sort_path(root, result_rel, path, + make_pathkeys_for_sortclauses(root, sort_clauses, tlist), + -1.0); path = (Path *) create_unique_path(root, result_rel, path, - list_length(path->pathkeys), + list_length(groupList), dNumChildGroups); add_path(result_rel, path); } diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c index 27e088107a4..0f8d45aa475 100644 --- a/src/backend/parser/analyze.c +++ b/src/backend/parser/analyze.c @@ -89,7 +89,9 @@ static Query *transformSelectStmt(ParseState *pstate, SelectStmt *stmt, static Query *transformValuesClause(ParseState *pstate, SelectStmt *stmt); static Query *transformSetOperationStmt(ParseState *pstate, SelectStmt *stmt); static Node *transformSetOperationTree(ParseState *pstate, SelectStmt *stmt, - bool isTopLevel, List **targetlist); + bool isTopLevel, List **targetlist, + List *distinctClause); +static bool col_in_distinct_on(const char *colname, int resno, List *distinctClause); static void determineRecursiveColTypes(ParseState *pstate, Node *larg, List *nrtargetlist); static Query *transformReturnStmt(ParseState *pstate, ReturnStmt *stmt); @@ -2175,6 +2177,10 @@ transformSetOperationStmt(ParseState *pstate, SelectStmt *stmt) ParseNamespaceColumn *sortnscolumns; int sortcolindex; int tllen; + List *distinctClause = stmt->distinctClause; + List *distinctSortClause = stmt->distinctSortClause; + List *transformed_distinctSortClause = NIL; + int distinct_tllen; qry->commandType = CMD_SELECT; @@ -2210,6 +2216,8 @@ transformSetOperationStmt(ParseState *pstate, SelectStmt *stmt) withClause = stmt->withClause; stmt->sortClause = NIL; + stmt->distinctClause = NIL; + stmt->distinctSortClause = NIL; stmt->limitOffset = NULL; stmt->limitCount = NULL; stmt->lockingClause = NIL; @@ -2237,7 +2245,7 @@ transformSetOperationStmt(ParseState *pstate, SelectStmt *stmt) * Recursively transform the components of the tree. */ sostmt = castNode(SetOperationStmt, - transformSetOperationTree(pstate, stmt, true, NULL)); + transformSetOperationTree(pstate, stmt, true, NULL, distinctClause)); Assert(sostmt); qry->setOperations = (Node *) sostmt; @@ -2353,11 +2361,22 @@ transformSetOperationStmt(ParseState *pstate, SelectStmt *stmt) EXPR_KIND_ORDER_BY, false /* allow SQL92 rules */ ); + distinct_tllen = list_length(qry->targetList); + if (distinctSortClause) + { + List *full_sortby = prepend_distinct_to_sortby(distinctClause, distinctSortClause); + transformed_distinctSortClause = transformSortClause(pstate, + full_sortby, + &qry->targetList, + EXPR_KIND_ORDER_BY, + false); + } + /* restore namespace, remove join RTE from rtable */ pstate->p_namespace = sv_namespace; pstate->p_rtable = list_truncate(pstate->p_rtable, sv_rtable_length); - if (tllen != list_length(qry->targetList)) + if (tllen != distinct_tllen) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("invalid UNION/INTERSECT/EXCEPT ORDER BY clause"), @@ -2366,6 +2385,26 @@ transformSetOperationStmt(ParseState *pstate, SelectStmt *stmt) parser_errposition(pstate, exprLocation(list_nth(qry->targetList, tllen))))); + if (distinct_tllen != list_length(qry->targetList)) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("invalid UNION/INTERSECT/EXCEPT DISTINCT ON ORDER BY clause"), + errdetail("Only result column names can be used, not expressions or functions."), + parser_errposition(pstate, + exprLocation(list_nth(qry->targetList, distinct_tllen))))); + + qry->distinctSortClause = transformed_distinctSortClause; + sostmt->sortClauses = transformed_distinctSortClause; + + if (distinctClause) + { + qry->distinctClause = transformDistinctOnClause(pstate, + distinctClause, + &qry->targetList, + transformed_distinctSortClause ? transformed_distinctSortClause : qry->sortClause); + qry->hasDistinctOn = true; + } + qry->limitOffset = transformLimitClause(pstate, limitOffset, EXPR_KIND_OFFSET, "OFFSET", stmt->limitOption); @@ -2454,7 +2493,8 @@ makeSortGroupClauseForSetOp(Oid rescoltype, bool require_hash) */ static Node * transformSetOperationTree(ParseState *pstate, SelectStmt *stmt, - bool isTopLevel, List **targetlist) + bool isTopLevel, List **targetlist, + List *distinctClause) { bool isLeaf; @@ -2599,7 +2639,8 @@ transformSetOperationTree(ParseState *pstate, SelectStmt *stmt, */ op->larg = transformSetOperationTree(pstate, stmt->larg, false, - <argetlist); + <argetlist, + NIL); /* * If we are processing a recursive union query, now is the time to @@ -2615,10 +2656,11 @@ transformSetOperationTree(ParseState *pstate, SelectStmt *stmt, */ op->rarg = transformSetOperationTree(pstate, stmt->rarg, false, - &rtargetlist); + &rtargetlist, + NIL); constructSetOpTargetlist(pstate, op, ltargetlist, rtargetlist, targetlist, - context, recursive); + context, recursive, distinctClause); return (Node *) op; } @@ -2639,7 +2681,8 @@ transformSetOperationTree(ParseState *pstate, SelectStmt *stmt, void constructSetOpTargetlist(ParseState *pstate, SetOperationStmt *op, const List *ltargetlist, const List *rtargetlist, - List **targetlist, const char *context, bool recursive) + List **targetlist, const char *context, bool recursive, + List *distinctClause) { ListCell *ltl; ListCell *rtl; @@ -2762,18 +2805,27 @@ constructSetOpTargetlist(ParseState *pstate, SetOperationStmt *op, */ if (op->op != SETOP_UNION || !op->all) { - ParseCallbackState pcbstate; - SortGroupClause *grpcl; + bool active = false; + if (distinctClause == NIL) + active = true; + else + active = col_in_distinct_on(ltle->resname, resno, distinctClause); - setup_parser_errposition_callback(&pcbstate, pstate, - bestlocation); + if (active) + { + ParseCallbackState pcbstate; + SortGroupClause *grpcl; + + setup_parser_errposition_callback(&pcbstate, pstate, + bestlocation); - /* If it's a recursive union, we need to require hashing support. */ - grpcl = makeSortGroupClauseForSetOp(rescoltype, recursive); - grpcl->tleSortGroupRef = resno; - op->groupClauses = lappend(op->groupClauses, grpcl); + /* If it's a recursive union, we need to require hashing support. */ + grpcl = makeSortGroupClauseForSetOp(rescoltype, recursive); + grpcl->tleSortGroupRef = resno; + op->groupClauses = lappend(op->groupClauses, grpcl); - cancel_parser_errposition_callback(&pcbstate); + cancel_parser_errposition_callback(&pcbstate); + } } /* @@ -4147,3 +4199,40 @@ test_raw_expression_coverage(Node *node, void *context) context); } #endif /* DEBUG_NODE_TESTS_ENABLED */ + +/* + * col_in_distinct_on - + * Check if a column name or its 1-based position matches any expression in distinctClause + */ +static bool +col_in_distinct_on(const char *colname, int resno, List *distinctClause) +{ + ListCell *lc; + + foreach(lc, distinctClause) + { + Node *n = (Node *) lfirst(lc); + + if (IsA(n, ColumnRef)) + { + ColumnRef *cr = (ColumnRef *) n; + + if (list_length(cr->fields) == 1 && IsA(linitial(cr->fields), String)) + { + if (strcmp(strVal(linitial(cr->fields)), colname) == 0) + return true; + } + } + else if (IsA(n, A_Const)) + { + A_Const *aconst = (A_Const *) n; + + if (IsA(&aconst->val, Integer)) + { + if (intVal(&aconst->val) == resno) + return true; + } + } + } + return false; +} diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y index a005940374c..28dba491c00 100644 --- a/src/backend/parser/gram.y +++ b/src/backend/parser/gram.y @@ -13751,9 +13751,31 @@ simple_select: n->fromClause = list_make1($2); $$ = (Node *) n; } - | select_clause UNION set_quantifier select_clause + | select_clause UNION select_clause %prec UNION { - $$ = makeSetOp(SETOP_UNION, $3 == SET_QUANTIFIER_ALL, $1, $4); + $$ = makeSetOp(SETOP_UNION, false, $1, $3); + } + | select_clause UNION ALL select_clause %prec UNION + { + $$ = makeSetOp(SETOP_UNION, true, $1, $4); + } + | select_clause UNION distinct_clause select_clause %prec UNION + { + List *distinctClause = linitial($3); + List *distinctSortClause = lsecond($3); + Node *n = makeSetOp(SETOP_UNION, false, $1, $4); + SelectStmt *s = (SelectStmt *) n; + if (linitial(distinctClause) == NULL && distinctSortClause == NIL) + { + s->distinctClause = NIL; + s->distinctSortClause = NIL; + } + else + { + s->distinctClause = distinctClause; + s->distinctSortClause = distinctSortClause; + } + $$ = (Node *) s; } | select_clause INTERSECT set_quantifier select_clause { diff --git a/src/backend/rewrite/rewriteGraphTable.c b/src/backend/rewrite/rewriteGraphTable.c index 0eaf28b3de5..f7e19ef7f0d 100644 --- a/src/backend/rewrite/rewriteGraphTable.c +++ b/src/backend/rewrite/rewriteGraphTable.c @@ -752,7 +752,7 @@ generate_setop_from_pathqueries(List *pathqueries, List **rtable, List **targetl sostmt->all = true; sostmt->larg = (Node *) lrtr; sostmt->rarg = rarg; - constructSetOpTargetlist(NULL, sostmt, lquery->targetList, rtargetlist, targetlist, "UNION", false); + constructSetOpTargetlist(NULL, sostmt, lquery->targetList, rtargetlist, targetlist, "UNION", false, NIL); return (Node *) sostmt; } diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h index a8ed100eb02..fff776e72c8 100644 --- a/src/include/nodes/parsenodes.h +++ b/src/include/nodes/parsenodes.h @@ -2390,6 +2390,7 @@ typedef struct SetOperationStmt /* a list of SortGroupClause's */ List *groupClauses pg_node_attr(query_jumble_ignore); /* groupClauses is NIL if UNION ALL, but must be set otherwise */ + List *sortClauses pg_node_attr(query_jumble_ignore); /* inline DISTINCT ON ORDER BY clauses */ } SetOperationStmt; diff --git a/src/include/parser/analyze.h b/src/include/parser/analyze.h index 9da833e40e5..9e601af96b2 100644 --- a/src/include/parser/analyze.h +++ b/src/include/parser/analyze.h @@ -67,6 +67,6 @@ extern List *BuildOnConflictExcludedTargetlist(Relation targetrel, extern SortGroupClause *makeSortGroupClauseForSetOp(Oid rescoltype, bool require_hash); extern void constructSetOpTargetlist(ParseState *pstate, SetOperationStmt *op, const List *ltargetlist, const List *rtargetlist, - List **targetlist, const char *context, bool recursive); + List **targetlist, const char *context, bool recursive, List *distinctClause); #endif /* ANALYZE_H */ diff --git a/src/test/regress/expected/union.out b/src/test/regress/expected/union.out index 84abcd6b14f..30efade7968 100644 --- a/src/test/regress/expected/union.out +++ b/src/test/regress/expected/union.out @@ -1706,3 +1706,51 @@ join (select ten from tenk1 union select ten from onek) s on s.ten = t.unique1; Index Cond: (unique1 = tenk1.ten) (8 rows) +-- +-- UNION DISTINCT ON +-- +CREATE TABLE union_distinct_u1 (a int, b int); +CREATE TABLE union_distinct_u2 (a int, b int); +INSERT INTO union_distinct_u1 VALUES (1, 10), (2, 20); +INSERT INTO union_distinct_u2 VALUES (1, 100), (3, 30); +-- UNION DISTINCT ON without ORDER BY +SELECT a, b FROM union_distinct_u1 UNION DISTINCT ON (a) SELECT a, b FROM union_distinct_u2 ORDER BY a; + a | b +---+---- + 1 | 10 + 2 | 20 + 3 | 30 +(3 rows) + +-- UNION DISTINCT ON with ORDER BY (DESC) +SELECT a, b FROM union_distinct_u1 UNION DISTINCT ON (a ORDER BY b DESC) SELECT a, b FROM union_distinct_u2 ORDER BY a; + a | b +---+----- + 1 | 100 + 2 | 20 + 3 | 30 +(3 rows) + +-- UNION DISTINCT ON with ORDER BY (ASC) +SELECT a, b FROM union_distinct_u1 UNION DISTINCT ON (a ORDER BY b ASC) SELECT a, b FROM union_distinct_u2 ORDER BY a; + a | b +---+---- + 1 | 10 + 2 | 20 + 3 | 30 +(3 rows) + +-- Test subquery pushdown safety with UNION DISTINCT ON +SELECT * FROM ( + SELECT a, b FROM union_distinct_u1 + UNION DISTINCT ON (a ORDER BY b DESC) + SELECT a, b FROM union_distinct_u2 +) s WHERE b > 15 ORDER BY a; + a | b +---+----- + 1 | 100 + 2 | 20 + 3 | 30 +(3 rows) + +DROP TABLE union_distinct_u1, union_distinct_u2; diff --git a/src/test/regress/sql/union.sql b/src/test/regress/sql/union.sql index c8de276c2b5..9c4844bfdb1 100644 --- a/src/test/regress/sql/union.sql +++ b/src/test/regress/sql/union.sql @@ -674,3 +674,31 @@ select null::int[] union all select null::int[] union all select null::bigint[]; explain (costs off) select * from tenk1 t join (select ten from tenk1 union select ten from onek) s on s.ten = t.unique1; + +-- +-- UNION DISTINCT ON +-- + +CREATE TABLE union_distinct_u1 (a int, b int); +CREATE TABLE union_distinct_u2 (a int, b int); +INSERT INTO union_distinct_u1 VALUES (1, 10), (2, 20); +INSERT INTO union_distinct_u2 VALUES (1, 100), (3, 30); + +-- UNION DISTINCT ON without ORDER BY +SELECT a, b FROM union_distinct_u1 UNION DISTINCT ON (a) SELECT a, b FROM union_distinct_u2 ORDER BY a; + +-- UNION DISTINCT ON with ORDER BY (DESC) +SELECT a, b FROM union_distinct_u1 UNION DISTINCT ON (a ORDER BY b DESC) SELECT a, b FROM union_distinct_u2 ORDER BY a; + +-- UNION DISTINCT ON with ORDER BY (ASC) +SELECT a, b FROM union_distinct_u1 UNION DISTINCT ON (a ORDER BY b ASC) SELECT a, b FROM union_distinct_u2 ORDER BY a; + +-- Test subquery pushdown safety with UNION DISTINCT ON +SELECT * FROM ( + SELECT a, b FROM union_distinct_u1 + UNION DISTINCT ON (a ORDER BY b DESC) + SELECT a, b FROM union_distinct_u2 +) s WHERE b > 15 ORDER BY a; + +DROP TABLE union_distinct_u1, union_distinct_u2; + -- 2.55.0.1082.g2b9226bbc0-goog