From 689a6b6b0d86cc59b9001a5a7333be3cc5b18eb7 Mon Sep 17 00:00:00 2001 From: zengxx Date: Wed, 21 Oct 2026 16:08:21 +0800 Subject: [PATCH 3/3] Add implicit LIMIT 1 for constant set-operation children A de-duplicating set operation needs only one copy of each distinct row. If a child's output is a single constant tuple, the child emits either no rows or N copies of it, so add LIMIT 1 paths to such children for UNION, INTERSECT and EXCEPT, but not for their ALL variants. Recognize pseudo-constants and equivalence-class constants using the same top-join-domain rules as SELECT DISTINCT, reject broken equivalence classes, nullable outer-join Vars/PlaceHolderVars, grouping sets, aggregates, window functions, SRFs, subplans and nested set operations. Apply the limit to partial paths as well, so parallel Append plans remain available. As with DISTINCT's Limit 1, early stopping can reduce the number of volatile qual/FROM/CTE evaluations and, when a locking clause is hidden in a non-pullable subquery or CTE, can lock only the first row. Document and test that behavior, including FOR UPDATE/FOR SHARE, zero-column targets, no-FROM children, uncorrelated sublinks, one-worker Gather plans, and finite/infinite recursive CTEs. --- src/backend/optimizer/prep/prepunion.c | 266 ++++++- src/test/regress/expected/union.out | 984 ++++++++++++++++++++++++- src/test/regress/sql/union.sql | 310 ++++++++ 3 files changed, 1519 insertions(+), 41 deletions(-) diff --git a/src/backend/optimizer/prep/prepunion.c b/src/backend/optimizer/prep/prepunion.c index a1376e1..ba7ed1b 100644 --- a/src/backend/optimizer/prep/prepunion.c +++ b/src/backend/optimizer/prep/prepunion.c @@ -31,6 +31,7 @@ #include "miscadmin.h" #include "nodes/makefuncs.h" #include "nodes/nodeFuncs.h" +#include "optimizer/clauses.h" #include "optimizer/cost.h" #include "optimizer/optimizer.h" #include "optimizer/pathnode.h" @@ -39,7 +40,9 @@ #include "optimizer/prep.h" #include "optimizer/tlist.h" #include "parser/parse_coerce.h" +#include "parser/parse_oper.h" #include "port/pg_bitutils.h" +#include "utils/lsyscache.h" #include "utils/selfuncs.h" @@ -57,7 +60,12 @@ static void build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, bool trivial_tlist, List *child_tlist, List *interesting_pathkeys, double *pNumGroups, - bool *pUnreliableStats); + bool *pUnreliableStats, + bool dedup_child); +static bool setop_child_output_is_constant(PlannerInfo *subroot); +static bool setop_child_expr_is_constant(PlannerInfo *subroot, Expr *expr); +static bool setop_child_expr_has_nullable_input(Node *node, void *context); +static bool setop_child_has_scan_input(PlannerInfo *subroot); static RelOptInfo *generate_union_paths(SetOperationStmt *op, PlannerInfo *root, List *refnames_tlist, List **pTargetList); @@ -401,7 +409,7 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, &lpath_trivial_tlist); if (lrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, lrel, lpath_trivial_tlist, lpath_tlist, - NIL, NULL, NULL); + NIL, NULL, NULL, false); lpath = lrel->cheapest_total_path; /* The right path will want to look at the left one ... */ root->non_recursive_path = lpath; @@ -413,7 +421,7 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, &rpath_trivial_tlist); if (rrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, rrel, rpath_trivial_tlist, rpath_tlist, - NIL, NULL, NULL); + NIL, NULL, NULL, false); rpath = rrel->cheapest_total_path; root->non_recursive_path = NULL; @@ -475,6 +483,187 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, return result_rel; } +/* + * setop_child_output_is_constant + * Can this set-operation child output at most one distinct row? + * + * We return true when every non-junk output column is constant for every + * output row. Such a child emits either no rows or N copies of a single + * tuple; a parent set operation that removes duplicates (UNION / INTERSECT / + * EXCEPT without ALL) therefore never needs more than one of them. + * + * "Constant" means either an expression that contains no Vars and no volatile + * functions, or an expression pinned to a constant by equivalence-class + * processing, for example "x" in "SELECT x FROM t WHERE x = 5". The latter + * mirrors the test that SELECT DISTINCT applies to its DISTINCT expressions; + * like DISTINCT, we do not chase constants through casts or arbitrary + * expressions. + * + * SRFs and window functions can produce different values per input row even + * when their expressions contain no Vars. A sublink that is represented as + * a SubPlan is rejected (it can hide volatile functions in its subplan); + * an uncorrelated sublink that becomes an InitPlan Param is constant for the + * scan and is accepted. Aggregates (including GroupingFunc) are rejected + * as well because their value can differ between groups. A child that is + * itself a set operation is left alone; its own output would need a similar + * analysis. + */ +static bool +setop_child_output_is_constant(PlannerInfo *subroot) +{ + Query *subquery = subroot->parse; + ListCell *lc; + + if (subquery->hasTargetSRFs || + subquery->hasWindowFuncs || + subquery->groupingSets || + subquery->setOperations != NULL) + return false; + + foreach(lc, subquery->targetList) + { + TargetEntry *tle = lfirst_node(TargetEntry, lc); + + if (tle->resjunk) + continue; + + if (contain_agg_clause((Node *) tle->expr) || + contain_subplans((Node *) tle->expr)) + return false; + + if (!setop_child_expr_is_constant(subroot, tle->expr)) + return false; + } + + return true; +} + +/* + * setop_child_expr_is_constant + * Is this expression constant for every output row of the child? + * + * In addition to syntactic pseudo-constants, accept expressions whose + * equivalence class is pinned to a constant. To find that class we use the + * same operator-family list that process_equivalence() would have used for an + * equality clause on the expression's type; this follows + * make_pathkey_from_sortinfo(). + * + * A constant member is only relevant if it belongs to the top join domain: + * a constant from below the nullable side of an outer join does not hold for + * every output row. For the same reason, reject expressions that are known + * to be nulled by an outer join, and broken equivalence classes, whose + * members are not guaranteed to satisfy all the derived equalities. + */ +static bool +setop_child_expr_is_constant(PlannerInfo *subroot, Expr *expr) +{ + Oid eqopr; + Oid opcintype; + Oid righttype; + List *opfamilies; + ListCell *lc; + EquivalenceClass *ec; + JoinDomain *top_domain; + + if (setop_child_expr_has_nullable_input((Node *) expr, NULL)) + return false; + + if (is_pseudo_constant_clause((Node *) expr)) + return true; + + /* Without any equivalence classes, only syntactic constants apply. */ + if (subroot->eq_classes == NIL) + return false; + + /* + * Get the type's default equality operator. If there is none, or it is + * not part of an ordering operator family, there can be no equivalence + * class to consult. + */ + get_sort_group_operators(exprType((Node *) expr), + false, false, false, + NULL, &eqopr, NULL, NULL); + if (!OidIsValid(eqopr)) + return false; + + opfamilies = get_mergejoin_opfamilies(eqopr); + if (opfamilies == NIL) + return false; + + op_input_types(eqopr, &opcintype, &righttype); + + ec = get_eclass_for_sort_expr(subroot, expr, opfamilies, opcintype, + exprCollation((Node *) expr), 0, NULL, + false); + if (ec == NULL || ec->ec_broken || !ec->ec_has_const) + return false; + + top_domain = linitial_node(JoinDomain, subroot->join_domains); + foreach(lc, ec->ec_members) + { + EquivalenceMember *em = lfirst_node(EquivalenceMember, lc); + + if (em->em_is_const && em->em_jdomain == top_domain) + return true; + } + + return false; +} + +/* + * setop_child_expr_has_nullable_input + * Does this expression contain a Var or PlaceHolderVar that is nulled by + * an outer join? + * + * Such an expression can be NULL for some output rows even if its + * equivalence class contains a top-domain constant. + */ +static bool +setop_child_expr_has_nullable_input(Node *node, void *context) +{ + (void) context; + + if (node == NULL) + return false; + if (IsA(node, Var) && + !bms_is_empty(((Var *) node)->varnullingrels)) + return true; + if (IsA(node, PlaceHolderVar) && + !bms_is_empty(((PlaceHolderVar *) node)->phnullingrels)) + return true; + return expression_tree_walker(node, setop_child_expr_has_nullable_input, + context); +} + +/* + * setop_child_has_scan_input + * Does this set-operation child read any input rows? + * + * A query without a FROM clause is represented by a dummy RTE_RESULT entry, + * which produces one row natively; a restriction qual can filter that row + * out. Limiting such a child would add a plan node without saving any work. + */ +static bool +setop_child_has_scan_input(PlannerInfo *subroot) +{ + ListCell *lc; + + foreach(lc, subroot->parse->jointree->fromlist) + { + Node *jtnode = (Node *) lfirst(lc); + RangeTblEntry *rte; + + if (!IsA(jtnode, RangeTblRef)) + return true; + + rte = subroot->simple_rte_array[((RangeTblRef *) jtnode)->rtindex]; + if (rte->rtekind != RTE_RESULT) + return true; + } + + return false; +} + /* * build_setop_child_paths * Build paths for the set op child relation denoted by 'rel'. @@ -491,12 +680,17 @@ generate_recursion_path(SetOperationStmt *setOp, PlannerInfo *root, * based on default statistics or was capped by the multi-column correlation * clamp. Grouping-set children are conservatively treated as unreliable. * Such estimates must not be used to cost parallel deduplication. + * dedup_child: true if the parent set operation removes duplicate rows from + * this child's output (i.e. it is not an ALL variant). In that case, a + * child whose output is a single constant tuple needs to be scanned only + * until its first row. Recursive UNION inputs pass false; their working + * table semantics need a separate analysis. */ static void build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, bool trivial_tlist, List *child_tlist, List *interesting_pathkeys, double *pNumGroups, - bool *pUnreliableStats) + bool *pUnreliableStats, bool dedup_child) { RelOptInfo *final_rel; List *setop_pathkeys = rel->subroot->setop_pathkeys; @@ -657,6 +851,61 @@ build_setop_child_paths(PlannerInfo *root, RelOptInfo *rel, add_partial_path(rel, partial_path); } + /* + * If the parent set operation removes duplicates and this child emits a + * single constant tuple, only its first row can affect the result. Add + * LIMIT 1 to every path we built. It is important to do this for the + * partial paths as well as for the total paths: adding a limit above the + * subquery (as a user-written LIMIT would) would remove the child's + * partial paths and defeat parallel set-operation plans. + * + * Skip this when the child has no FROM clause: it can produce only one + * row anyway, and adding a Limit node would just be noise. + * + * A one-row result trivially satisfies any pathkeys the underlying path + * claimed, so the Limit paths can keep them and compete with the + * originals; add_path() discards the latter as dominated. Detach the + * lists first, so that add_path() cannot free a path that has become a + * subpath of one of the Limit paths. + * + * As with the Limit 1 paths built for SELECT DISTINCT, we do not look + * for volatile functions in the child's quals: the scan can stop + * evaluating them early, just as it would with a user-written LIMIT. + * The same applies to CTEs and to volatile functions in FROM. DISTINCT's + * Limit 1 path already has this behavior, and the regression tests below + * document it for set operations too. + * + * The same early-stop rule applies when the child contains a locking + * clause in a non-pullable subquery or CTE: only the first row is + * fetched, so only that row is locked. This is the same observable + * behavior as the existing DISTINCT Limit 1 path. It is intentional; + * see the FOR UPDATE cases in union.sql. + */ + if (dedup_child && !is_dummy_rel(rel) && + setop_child_has_scan_input(rel->subroot) && + setop_child_output_is_constant(rel->subroot)) + { + List *paths; + + paths = rel->pathlist; + rel->pathlist = NIL; + foreach(lc, paths) + { + Path *path = (Path *) lfirst(lc); + + add_path(rel, create_dedup_limit_path(root, rel, path)); + } + + paths = rel->partial_pathlist; + rel->partial_pathlist = NIL; + foreach(lc, paths) + { + Path *path = (Path *) lfirst(lc); + + add_partial_path(rel, create_dedup_limit_path(root, rel, path)); + } + } + postprocess_setop_rel(root, rel); /* @@ -872,7 +1121,8 @@ generate_union_paths(SetOperationStmt *op, PlannerInfo *root, build_setop_child_paths(root, rel, trivial_tlist, child_tlist, union_pathkeys, op->all ? NULL : &childGroups, - op->all ? NULL : &childUnreliableStats); + op->all ? NULL : &childUnreliableStats, + !op->all); else { /* @@ -1338,12 +1588,14 @@ generate_nonunion_paths(SetOperationStmt *op, PlannerInfo *root, */ if (lrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, lrel, lpath_trivial_tlist, lpath_tlist, - nonunion_pathkeys, &dLeftGroups, NULL); + nonunion_pathkeys, &dLeftGroups, NULL, + !op->all); else dLeftGroups = lrel->rows; if (rrel->rtekind == RTE_SUBQUERY) build_setop_child_paths(root, rrel, rpath_trivial_tlist, rpath_tlist, - nonunion_pathkeys, &dRightGroups, NULL); + nonunion_pathkeys, &dRightGroups, NULL, + !op->all); else dRightGroups = rrel->rows; diff --git a/src/test/regress/expected/union.out b/src/test/regress/expected/union.out index bf15eb9..13e40f1 100644 --- a/src/test/regress/expected/union.out +++ b/src/test/regress/expected/union.out @@ -984,22 +984,26 @@ set enable_hashagg = true; set enable_groupagg = false; explain (costs off) select from generate_series(1,5) union select from generate_series(1,3); - QUERY PLAN ----------------------------------------------------------------- + QUERY PLAN +---------------------------------------------------------------------- HashAggregate -> Append - -> Function Scan on generate_series - -> Function Scan on generate_series generate_series_1 -(4 rows) + -> Limit + -> Function Scan on generate_series + -> Limit + -> Function Scan on generate_series generate_series_1 +(6 rows) explain (costs off) select from generate_series(1,5) intersect select from generate_series(1,3); - QUERY PLAN ----------------------------------------------------------- + QUERY PLAN +---------------------------------------------------------------- HashSetOp Intersect - -> Function Scan on generate_series - -> Function Scan on generate_series generate_series_1 -(3 rows) + -> Limit + -> Function Scan on generate_series + -> Limit + -> Function Scan on generate_series generate_series_1 +(5 rows) select from generate_series(1,5) union select from generate_series(1,3); -- @@ -1030,22 +1034,26 @@ set enable_hashagg = false; set enable_groupagg = true; explain (costs off) select from generate_series(1,5) union select from generate_series(1,3); - QUERY PLAN ----------------------------------------------------------------- + QUERY PLAN +---------------------------------------------------------------------- Unique -> Append - -> Function Scan on generate_series - -> Function Scan on generate_series generate_series_1 -(4 rows) + -> Limit + -> Function Scan on generate_series + -> Limit + -> Function Scan on generate_series generate_series_1 +(6 rows) explain (costs off) select from generate_series(1,5) intersect select from generate_series(1,3); - QUERY PLAN ----------------------------------------------------------- + QUERY PLAN +---------------------------------------------------------------- SetOp Intersect - -> Function Scan on generate_series - -> Function Scan on generate_series generate_series_1 -(3 rows) + -> Limit + -> Function Scan on generate_series + -> Limit + -> Function Scan on generate_series generate_series_1 +(5 rows) select from generate_series(1,5) union select from generate_series(1,3); -- @@ -1143,16 +1151,18 @@ explain (costs off) UNION SELECT * FROM t2) t WHERE ab = 'ab'; - QUERY PLAN ---------------------------------------------------- + QUERY PLAN +--------------------------------------------------------- HashAggregate Group Key: ((t1.a || t1.b)) -> Append - -> Index Scan using t1_ab_idx on t1 - Index Cond: ((a || b) = 'ab'::text) - -> Index Only Scan using t2_pkey on t2 - Index Cond: (ab = 'ab'::text) -(7 rows) + -> Limit + -> Index Scan using t1_ab_idx on t1 + Index Cond: ((a || b) = 'ab'::text) + -> Limit + -> Index Only Scan using t2_pkey on t2 + Index Cond: (ab = 'ab'::text) +(9 rows) -- -- Test that ORDER BY for UNION ALL can be pushed down to inheritance @@ -1447,23 +1457,27 @@ EXPLAIN (COSTS OFF, VERBOSE) SELECT four FROM tenk1 t2 WHERE four = 1) EXCEPT ALL SELECT ten FROM tenk1 dummy WHERE 1=2; - QUERY PLAN ------------------------------------------ + QUERY PLAN +----------------------------------------------- SetOp Intersect Output: t1.two -> Sort Output: t1.two Sort Key: t1.two - -> Seq Scan on public.tenk1 t1 + -> Limit Output: t1.two - Filter: (t1.two = 1) + -> Seq Scan on public.tenk1 t1 + Output: t1.two + Filter: (t1.two = 1) -> Sort Output: t2.four Sort Key: t2.four - -> Seq Scan on public.tenk1 t2 + -> Limit Output: t2.four - Filter: (t2.four = 1) -(14 rows) + -> Seq Scan on public.tenk1 t2 + Output: t2.four + Filter: (t2.four = 1) +(18 rows) RESET enable_hashagg; -- Ensure EXPLAIN can show a dummy set operation whose output is coerced @@ -2127,3 +2141,905 @@ reset parallel_setup_cost; reset parallel_tuple_cost; reset min_parallel_table_scan_size; reset max_parallel_workers_per_gather; +-- +-- A de-duplicating set operation needs only one copy of each row, so a +-- branch whose output is a single constant tuple can stop after its first +-- row. This applies to every child of UNION/INTERSECT/EXCEPT, but not to +-- their ALL variants, where duplicate counts are significant. +-- +create table union_const_t (a int); +insert into union_const_t select g from generate_series(1, 1000) g; +analyze union_const_t; +create sequence union_const_seq; +-- Serial plan: only the constant child is limited. +set max_parallel_workers_per_gather = 0; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; + QUERY PLAN +------------------------------------------------------- + HashAggregate + Group Key: (1) + -> Append + -> Limit + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(6 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; + count | min | max +-------+-----+------ + 1000 | 1 | 1000 +(1 row) + +-- Every constant child is limited. +explain (costs off) +select 1 from union_const_t union select 1 from union_const_t; + QUERY PLAN +------------------------------------------------------------------- + Unique + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> Seq Scan on union_const_t + -> Limit + -> Seq Scan on union_const_t union_const_t_1 +(8 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select 1 from union_const_t) s; + count | min | max +-------+-----+----- + 1 | 1 | 1 +(1 row) + +-- A locking clause hidden in a subquery or CTE is subject to the same +-- early-stop behavior as SELECT DISTINCT's Limit 1: only the first row is +-- fetched, so only that row is locked. This is intentional. +explain (costs off) +select 1 from (select a from union_const_t for update) s union select 2; + QUERY PLAN +--------------------------------------------------------------- + Unique + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> Subquery Scan on s + -> LockRows + -> Seq Scan on union_const_t + -> Result +(9 rows) + +select count(*), min(x), max(x) from + (select 1 as x from (select a from union_const_t for update) s + union select 2) t; + count | min | max +-------+-----+----- + 2 | 1 | 2 +(1 row) + +explain (costs off) +select 1 from (with c as (select a from union_const_t for update) + select a from c) s union select 2; + QUERY PLAN +----------------------------------------------------------------------- + Unique + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> Subquery Scan on s + -> CTE Scan on c + CTE c + -> LockRows + -> Seq Scan on union_const_t + -> Result +(11 rows) + +select count(*), min(x), max(x) from + (select 1 as x from (with c as (select a from union_const_t for update) + select a from c) s union select 2) t; + count | min | max +-------+-----+----- + 2 | 1 | 2 +(1 row) + +explain (costs off) +select 1 from (select a from union_const_t for share) s union select 2; + QUERY PLAN +--------------------------------------------------------------- + Unique + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> Subquery Scan on s + -> LockRows + -> Seq Scan on union_const_t + -> Result +(9 rows) + +-- The zero-column form also qualifies; it is the case where row identity +-- targets do not incidentally prevent the optimization. +explain (costs off) +select * from (select from union_const_t for update) s +union select from union_const_t; + QUERY PLAN +------------------------------------------------------------- + Unique + -> Append + -> Limit + -> Subquery Scan on s + -> LockRows + -> Seq Scan on union_const_t + -> Limit + -> Seq Scan on union_const_t union_const_t_1 +(8 rows) + +-- NULL is a pseudo-constant just like any other constant. +explain (costs off) +select null::int from union_const_t union select 1; + QUERY PLAN +--------------------------------------------------- + Unique + -> Sort + Sort Key: (NULL::integer) + -> Append + -> Limit + -> Seq Scan on union_const_t + -> Result +(7 rows) + +select count(*), count(x) from + (select null::int as x from union_const_t union select 1) s; + count | count +-------+------- + 2 | 1 +(1 row) + +-- A child with no FROM clause already produces at most one row, so the +-- planner deliberately does not add a Limit node. +explain (costs off) +select 1 union select 2; + QUERY PLAN +-------------------------- + Unique + -> Sort + Sort Key: (1) + -> Append + -> Result + -> Result +(6 rows) + +-- An uncorrelated sublink in the target list becomes an InitPlan Param; +-- it is constant for the scan and is accepted. (Correlated sublinks +-- become SubPlans and are rejected by setop_child_output_is_constant.) +explain (costs off) +select (select max(a) from union_const_t) from union_const_t +union select 999; + QUERY PLAN +--------------------------------------------------------------------------------- + Unique + -> Sort + Sort Key: ((InitPlan expr_1).col1) + -> Append + -> Limit + -> Seq Scan on union_const_t + InitPlan expr_1 + -> Aggregate + -> Seq Scan on union_const_t union_const_t_1 + -> Result +(10 rows) + +select count(*), min(x), max(x) from + (select (select max(a) from union_const_t) as x from union_const_t + union select 999) s; + count | min | max +-------+-----+------ + 2 | 999 | 1000 +(1 row) + +-- Multi-way UNION: each constant branch gets its own Limit. +explain (costs off) +select 1 from union_const_t +union select 2 from union_const_t +union select a from union_const_t; + QUERY PLAN +------------------------------------------------------------- + HashAggregate + Group Key: (1) + -> Append + -> Limit + -> Seq Scan on union_const_t + -> Limit + -> Seq Scan on union_const_t union_const_t_1 + -> Seq Scan on union_const_t union_const_t_2 +(8 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t + union select 2 from union_const_t + union select a from union_const_t) s; + count | min | max +-------+-----+------ + 1000 | 1 | 1000 +(1 row) + +-- INTERSECT and EXCEPT also remove duplicates from both inputs. +explain (costs off) +select 1 from union_const_t intersect select a from union_const_t; + QUERY PLAN +------------------------------------------------- + HashSetOp Intersect + -> Limit + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(4 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t intersect select a from union_const_t) s; + count | min | max +-------+-----+----- + 1 | 1 | 1 +(1 row) + +explain (costs off) +select a from union_const_t intersect select 1 from union_const_t; + QUERY PLAN +------------------------------------------------- + HashSetOp Intersect + -> Limit + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(4 rows) + +select count(*), min(x), max(x) from + (select a as x from union_const_t intersect select 1 from union_const_t) s; + count | min | max +-------+-----+----- + 1 | 1 | 1 +(1 row) + +explain (costs off) +select a from union_const_t except select 1 from union_const_t; + QUERY PLAN +------------------------------------------------------- + HashSetOp Except + -> Seq Scan on union_const_t + -> Limit + -> Seq Scan on union_const_t union_const_t_1 +(4 rows) + +select count(*), min(x), max(x) from + (select a as x from union_const_t except select 1 from union_const_t) s; + count | min | max +-------+-----+------ + 999 | 2 | 1000 +(1 row) + +explain (costs off) +select 1 from union_const_t except select a from union_const_t; + QUERY PLAN +------------------------------------------------- + HashSetOp Except + -> Limit + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(4 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t except select a from union_const_t) s; + count | min | max +-------+-----+----- + 0 | | +(1 row) + +-- Parallel plan: the limited partial path must remain available, otherwise +-- the whole set operation would fall back to a serial Append. +set parallel_setup_cost = 0; +set parallel_tuple_cost = 0; +set min_parallel_table_scan_size = 0; +set max_parallel_workers_per_gather = 2; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; + QUERY PLAN +---------------------------------------------------------------------------- + HashAggregate + Group Key: union_const_t.a + -> Gather + Workers Planned: 2 + -> Parallel Append + -> Parallel Seq Scan on union_const_t + -> Limit + -> Parallel Seq Scan on union_const_t union_const_t_1 +(8 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; + count | min | max +-------+-----+------ + 1000 | 1 | 1000 +(1 row) + +-- Multi-way UNION keeps the partial path as well. +explain (costs off) +select 1 from union_const_t +union select 2 from union_const_t +union select a from union_const_t; + QUERY PLAN +---------------------------------------------------------------------------- + HashAggregate + Group Key: union_const_t.a + -> Gather + Workers Planned: 2 + -> Parallel Append + -> Parallel Seq Scan on union_const_t + -> Limit + -> Parallel Seq Scan on union_const_t union_const_t_1 + -> Limit + -> Parallel Seq Scan on union_const_t union_const_t_2 +(10 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t + union select 2 from union_const_t + union select a from union_const_t) s; + count | min | max +-------+-----+------ + 1000 | 1 | 1000 +(1 row) + +-- One worker exercises the Gather row estimate with a single partial path. +set max_parallel_workers_per_gather = 1; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; + QUERY PLAN +---------------------------------------------------------------------------- + HashAggregate + Group Key: union_const_t.a + -> Gather + Workers Planned: 1 + -> Parallel Append + -> Parallel Seq Scan on union_const_t + -> Limit + -> Parallel Seq Scan on union_const_t union_const_t_1 +(8 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; + count | min | max +-------+-----+------ + 1000 | 1 | 1000 +(1 row) + +set max_parallel_workers_per_gather = 2; +reset max_parallel_workers_per_gather; +reset min_parallel_table_scan_size; +reset parallel_tuple_cost; +reset parallel_setup_cost; +-- The sort-based strategies see the limit as well. +set enable_hashagg = off; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; + QUERY PLAN +------------------------------------------------------------- + Unique + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(7 rows) + +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; + count | min | max +-------+-----+------ + 1000 | 1 | 1000 +(1 row) + +explain (costs off) +select a from union_const_t except select 1 from union_const_t; + QUERY PLAN +------------------------------------------------------- + SetOp Except + -> Sort + Sort Key: union_const_t.a + -> Seq Scan on union_const_t + -> Limit + -> Seq Scan on union_const_t union_const_t_1 +(6 rows) + +select count(*), min(x), max(x) from + (select a as x from union_const_t except select 1 from union_const_t) s; + count | min | max +-------+-----+------ + 999 | 2 | 1000 +(1 row) + +reset enable_hashagg; +-- UNION ALL preserves duplicates and must not be limited. +explain (costs off) +select 1 from union_const_t union all select a from union_const_t; + QUERY PLAN +------------------------------------------------- + Append + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(3 rows) + +select count(*) from + (select 1 from union_const_t union all select a from union_const_t) s; + count +------- + 2000 +(1 row) + +-- INTERSECT ALL / EXCEPT ALL preserve duplicate counts and must not be +-- limited either. +create table union_const_dup (a int); +insert into union_const_dup values (1), (1), (1); +explain (costs off) +select 1 from union_const_dup intersect all select 1 from union_const_dup; + QUERY PLAN +----------------------------------------------------- + SetOp Intersect All + -> Seq Scan on union_const_dup + -> Seq Scan on union_const_dup union_const_dup_1 +(3 rows) + +select count(*) from + (select 1 from union_const_dup intersect all select 1 from union_const_dup) s; + count +------- + 3 +(1 row) + +explain (costs off) +select 1 from union_const_dup except all select 2 from union_const_dup; + QUERY PLAN +----------------------------------------------------- + SetOp Except All + -> Seq Scan on union_const_dup + -> Seq Scan on union_const_dup union_const_dup_1 +(3 rows) + +select count(*) from + (select 1 from union_const_dup except all select 2 from union_const_dup) s; + count +------- + 3 +(1 row) + +-- The multiplicity of either input matters, so neither side may be limited. +explain (costs off) +select a from union_const_dup intersect all select 1 from union_const_dup; + QUERY PLAN +----------------------------------------------------- + HashSetOp Intersect All + -> Seq Scan on union_const_dup + -> Seq Scan on union_const_dup union_const_dup_1 +(3 rows) + +select count(*) from + (select a from union_const_dup intersect all select 1 from union_const_dup) s; + count +------- + 3 +(1 row) + +explain (costs off) +select 1 from union_const_dup except all select 1 from union_const_dup; + QUERY PLAN +----------------------------------------------------- + SetOp Except All + -> Seq Scan on union_const_dup + -> Seq Scan on union_const_dup union_const_dup_1 +(3 rows) + +select count(*) from + (select 1 from union_const_dup except all select 1 from union_const_dup) s; + count +------- + 0 +(1 row) + +-- The set variants of the same queries do discard duplicates. +select count(*) from + (select 1 from union_const_dup intersect select 1 from union_const_dup) s; + count +------- + 1 +(1 row) + +select count(*) from + (select 1 from union_const_dup except select 2 from union_const_dup) s; + count +------- + 1 +(1 row) + +-- Empty inputs must stay empty. +select count(*) from + (select 1 from union_const_dup where false + union select 2 where false) s; + count +------- + 0 +(1 row) + +-- Outputs that can vary from row to row must not be limited. +set max_parallel_workers_per_gather = 0; +explain (costs off) +select nextval('union_const_seq') from union_const_t +union select a from union_const_t; + QUERY PLAN +------------------------------------------------------------- + HashAggregate + Group Key: (nextval('union_const_seq'::regclass)) + -> Append + -> Seq Scan on union_const_t + -> Subquery Scan on unnamed_subquery_1 + -> Seq Scan on union_const_t union_const_t_1 +(6 rows) + +explain (costs off) +select row_number() over () from union_const_t union select a from union_const_t; + QUERY PLAN +------------------------------------------------------------- + HashAggregate + Group Key: (row_number() OVER w1) + -> Append + -> WindowAgg + Window: w1 AS (ROWS UNBOUNDED PRECEDING) + -> Seq Scan on union_const_t + -> Subquery Scan on unnamed_subquery_1 + -> Seq Scan on union_const_t union_const_t_1 +(8 rows) + +explain (costs off) +select generate_series(1, 3) from union_const_t union select a from union_const_t; + QUERY PLAN +------------------------------------------------------- + HashAggregate + Group Key: (generate_series(1, 3)) + -> Append + -> ProjectSet + -> Seq Scan on union_const_t + -> Seq Scan on union_const_t union_const_t_1 +(6 rows) + +-- Equivalence classes can pin a Var to a constant, as in SELECT DISTINCT. +explain (costs off) +select a from union_const_t where a = 5 union select 9; + QUERY PLAN +--------------------------------------------------- + Unique + -> Sort + Sort Key: union_const_t.a + -> Append + -> Limit + -> Seq Scan on union_const_t + Filter: (a = 5) + -> Result +(8 rows) + +select count(*), min(x), max(x) from + (select a as x from union_const_t where a = 5 union select 9) s; + count | min | max +-------+-----+----- + 2 | 5 | 9 +(1 row) + +explain (costs off) +select a from union_const_t group by a having a = 5 union select 9; + QUERY PLAN +--------------------------------------------------------- + Unique + -> Sort + Sort Key: union_const_t.a + -> Append + -> Limit + -> Group + -> Seq Scan on union_const_t + Filter: (a = 5) + -> Result +(9 rows) + +select count(*), min(x), max(x) from + (select a as x from union_const_t group by a having a = 5 union select 9) s; + count | min | max +-------+-----+----- + 2 | 5 | 9 +(1 row) + +-- A join equality can carry the constant to another column. +explain (costs off) +select t.unique1 from tenk1 t join int8_tbl i on t.unique1 = i.q1 +where i.q1 = 123 union select 9; + QUERY PLAN +------------------------------------------------------------------------------ + Unique + -> Sort + Sort Key: t.unique1 + -> Append + -> Limit + -> Nested Loop + -> Index Only Scan using tenk1_unique1 on tenk1 t + Index Cond: (unique1 = 123) + -> Seq Scan on int8_tbl i + Filter: (q1 = 123) + -> Result +(11 rows) + +select count(*), min(x), max(x) from + (select t.unique1 as x from tenk1 t join int8_tbl i on t.unique1 = i.q1 + where i.q1 = 123 union select 9) s; + count | min | max +-------+-----+----- + 2 | 9 | 123 +(1 row) + +-- Like DISTINCT, we do not chase constants through casts or expressions. +explain (costs off) +select a::bigint from union_const_t where a = 5 union select 9::bigint; + QUERY PLAN +----------------------------------------------- + Unique + -> Sort + Sort Key: ((union_const_t.a)::bigint) + -> Append + -> Seq Scan on union_const_t + Filter: (a = 5) + -> Result +(7 rows) + +explain (costs off) +select a + 0 from union_const_t where a = 5 union select 9; + QUERY PLAN +--------------------------------------------- + Unique + -> Sort + Sort Key: ((union_const_t.a + 0)) + -> Append + -> Seq Scan on union_const_t + Filter: (a = 5) + -> Result +(7 rows) + +-- A constant from below the nullable side of an outer join must not be +-- used: that side can also contribute NULL. +explain (costs off) +select ss.two +from tenk1 a left join + (select b.two from tenk1 b join tenk1 c on b.two = c.two + where b.two = 10) ss on a.unique1 = ss.two +union select 9; + QUERY PLAN +------------------------------------------------------------------ + HashAggregate + Group Key: b.two + -> Append + -> Hash Left Join + Hash Cond: (a.unique1 = b.two) + -> Index Only Scan using tenk1_unique1 on tenk1 a + -> Hash + -> Nested Loop + -> Seq Scan on tenk1 b + Filter: (two = 10) + -> Seq Scan on tenk1 c + Filter: (two = 10) + -> Result +(13 rows) + +-- The pinned branch can be on either side. +explain (costs off) +select 9 union select a from union_const_t where a = 5; + QUERY PLAN +--------------------------------------------------- + Unique + -> Sort + Sort Key: (9) + -> Append + -> Result + -> Limit + -> Seq Scan on union_const_t + Filter: (a = 5) +(8 rows) + +select count(*), min(x), max(x) from + (select 9 as x union select a from union_const_t where a = 5) s; + count | min | max +-------+-----+----- + 2 | 5 | 9 +(1 row) + +explain (costs off) +select 9 intersect select a from union_const_t where a = 5; + QUERY PLAN +--------------------------------------- + HashSetOp Intersect + -> Result + -> Limit + -> Seq Scan on union_const_t + Filter: (a = 5) +(5 rows) + +select count(*) from + (select 9 as x intersect select a from union_const_t where a = 5) s; + count +------- + 0 +(1 row) + +explain (costs off) +select 9 except select a from union_const_t where a = 5; + QUERY PLAN +--------------------------------------- + HashSetOp Except + -> Result + -> Limit + -> Seq Scan on union_const_t + Filter: (a = 5) +(5 rows) + +select count(*), min(x), max(x) from + (select 9 as x except select a from union_const_t where a = 5) s; + count | min | max +-------+-----+----- + 1 | 9 | 9 +(1 row) + +-- Only one of two output columns is pinned, so the tuple is not constant. +explain (costs off) +select q1, q2 from int8_tbl where q1 = 123 union select q1, q2 from int8_tbl; + QUERY PLAN +--------------------------------------------- + HashAggregate + Group Key: int8_tbl.q1, int8_tbl.q2 + -> Append + -> Seq Scan on int8_tbl + Filter: (q1 = 123) + -> Seq Scan on int8_tbl int8_tbl_1 +(6 rows) + +select count(*) from + (select q1, q2 from int8_tbl where q1 = 123 + union select q1, q2 from int8_tbl) s; + count +------- + 5 +(1 row) + +-- A generic parameter is an EC constant as well. +prepare union_ec_param(int) as + select a from union_const_t where a = $1 union select 9; +set plan_cache_mode = force_generic_plan; +explain (costs off) execute union_ec_param(5); + QUERY PLAN +--------------------------------------------------- + Unique + -> Sort + Sort Key: union_const_t.a + -> Append + -> Limit + -> Seq Scan on union_const_t + Filter: (a = $1) + -> Result +(8 rows) + +execute union_ec_param(5); + a +--- + 5 + 9 +(2 rows) + +deallocate union_ec_param; +reset plan_cache_mode; +-- A volatile qual, unlike a volatile output column, does not make the output +-- non-constant: the child still returns one distinct tuple. As with the +-- DISTINCT Limit 1 optimization, the scan may stop early and evaluate the +-- qual fewer times. EXPLAIN only; nextval() has side effects. +explain (costs off) +select 1 from union_const_t where nextval('union_const_seq') > 0 +union select a from union_const_t; + QUERY PLAN +------------------------------------------------------------------------ + HashAggregate + Group Key: (1) + -> Append + -> Limit + -> Seq Scan on union_const_t + Filter: (nextval('union_const_seq'::regclass) > 0) + -> Seq Scan on union_const_t union_const_t_1 +(7 rows) + +-- A volatile function in FROM can be evaluated fewer times as well. This +-- mirrors the DISTINCT Limit 1 behavior. EXPLAIN only; nextval() has side +-- effects. +explain (costs off) +select 1 from (select nextval('union_const_seq') as v + from generate_series(1, 10)) s +where v > 0 +union select a from union_const_t; + QUERY PLAN +---------------------------------------------------------- + HashAggregate + Group Key: (1) + -> Append + -> Limit + -> Subquery Scan on s + Filter: (s.v > 0) + -> Function Scan on generate_series + -> Seq Scan on union_const_t +(8 rows) + +-- A recursive CTE child follows the same rule. Keep the example finite. +explain (costs off) +with recursive union_limit_r(n) as ( + select 1 + union all + select n + 1 from union_limit_r where n < 10 +) +select 1 from union_limit_r union select 2; + QUERY PLAN +--------------------------------------------------------------- + Unique + CTE union_limit_r + -> Recursive Union + -> Result + -> WorkTable Scan on union_limit_r union_limit_r_1 + Filter: (n < 10) + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> CTE Scan on union_limit_r + -> Result +(12 rows) + +select count(*) from + (with recursive union_limit_r(n) as ( + select 1 + union all + select n + 1 from union_limit_r where n < 10 + ) + select 1 from union_limit_r union select 2) s; + count +------- + 2 +(1 row) + +-- An otherwise infinite recursive CTE is also stopped by the implicit +-- Limit. This matches the DISTINCT Limit 1 behavior; EXPLAIN only, since +-- the finite case above covers execution. +explain (costs off) +with recursive union_limit_inf(n) as ( + select 1 + union all + select n + 1 from union_limit_inf +) +select 1 from union_limit_inf union select 2; + QUERY PLAN +------------------------------------------------------------------- + Unique + CTE union_limit_inf + -> Recursive Union + -> Result + -> WorkTable Scan on union_limit_inf union_limit_inf_1 + -> Sort + Sort Key: (1) + -> Append + -> Limit + -> CTE Scan on union_limit_inf + -> Result +(11 rows) + +reset max_parallel_workers_per_gather; +drop table union_const_t; +drop table union_const_dup; +drop sequence union_const_seq; diff --git a/src/test/regress/sql/union.sql b/src/test/regress/sql/union.sql index 74059b7..e5cc8fc 100644 --- a/src/test/regress/sql/union.sql +++ b/src/test/regress/sql/union.sql @@ -838,3 +838,313 @@ reset parallel_tuple_cost; reset min_parallel_table_scan_size; reset max_parallel_workers_per_gather; +-- +-- A de-duplicating set operation needs only one copy of each row, so a +-- branch whose output is a single constant tuple can stop after its first +-- row. This applies to every child of UNION/INTERSECT/EXCEPT, but not to +-- their ALL variants, where duplicate counts are significant. +-- +create table union_const_t (a int); +insert into union_const_t select g from generate_series(1, 1000) g; +analyze union_const_t; +create sequence union_const_seq; + +-- Serial plan: only the constant child is limited. +set max_parallel_workers_per_gather = 0; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; + +-- Every constant child is limited. +explain (costs off) +select 1 from union_const_t union select 1 from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select 1 from union_const_t) s; + +-- A locking clause hidden in a subquery or CTE is subject to the same +-- early-stop behavior as SELECT DISTINCT's Limit 1: only the first row is +-- fetched, so only that row is locked. This is intentional. +explain (costs off) +select 1 from (select a from union_const_t for update) s union select 2; +select count(*), min(x), max(x) from + (select 1 as x from (select a from union_const_t for update) s + union select 2) t; + +explain (costs off) +select 1 from (with c as (select a from union_const_t for update) + select a from c) s union select 2; +select count(*), min(x), max(x) from + (select 1 as x from (with c as (select a from union_const_t for update) + select a from c) s union select 2) t; + +explain (costs off) +select 1 from (select a from union_const_t for share) s union select 2; + +-- The zero-column form also qualifies; it is the case where row identity +-- targets do not incidentally prevent the optimization. +explain (costs off) +select * from (select from union_const_t for update) s +union select from union_const_t; + +-- NULL is a pseudo-constant just like any other constant. +explain (costs off) +select null::int from union_const_t union select 1; +select count(*), count(x) from + (select null::int as x from union_const_t union select 1) s; + +-- A child with no FROM clause already produces at most one row, so the +-- planner deliberately does not add a Limit node. +explain (costs off) +select 1 union select 2; + +-- An uncorrelated sublink in the target list becomes an InitPlan Param; +-- it is constant for the scan and is accepted. (Correlated sublinks +-- become SubPlans and are rejected by setop_child_output_is_constant.) +explain (costs off) +select (select max(a) from union_const_t) from union_const_t +union select 999; +select count(*), min(x), max(x) from + (select (select max(a) from union_const_t) as x from union_const_t + union select 999) s; + +-- Multi-way UNION: each constant branch gets its own Limit. +explain (costs off) +select 1 from union_const_t +union select 2 from union_const_t +union select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t + union select 2 from union_const_t + union select a from union_const_t) s; + +-- INTERSECT and EXCEPT also remove duplicates from both inputs. +explain (costs off) +select 1 from union_const_t intersect select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t intersect select a from union_const_t) s; + +explain (costs off) +select a from union_const_t intersect select 1 from union_const_t; +select count(*), min(x), max(x) from + (select a as x from union_const_t intersect select 1 from union_const_t) s; + +explain (costs off) +select a from union_const_t except select 1 from union_const_t; +select count(*), min(x), max(x) from + (select a as x from union_const_t except select 1 from union_const_t) s; + +explain (costs off) +select 1 from union_const_t except select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t except select a from union_const_t) s; + +-- Parallel plan: the limited partial path must remain available, otherwise +-- the whole set operation would fall back to a serial Append. +set parallel_setup_cost = 0; +set parallel_tuple_cost = 0; +set min_parallel_table_scan_size = 0; +set max_parallel_workers_per_gather = 2; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; + +-- Multi-way UNION keeps the partial path as well. +explain (costs off) +select 1 from union_const_t +union select 2 from union_const_t +union select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t + union select 2 from union_const_t + union select a from union_const_t) s; + +-- One worker exercises the Gather row estimate with a single partial path. +set max_parallel_workers_per_gather = 1; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; +set max_parallel_workers_per_gather = 2; + +reset max_parallel_workers_per_gather; +reset min_parallel_table_scan_size; +reset parallel_tuple_cost; +reset parallel_setup_cost; + +-- The sort-based strategies see the limit as well. +set enable_hashagg = off; +explain (costs off) +select 1 from union_const_t union select a from union_const_t; +select count(*), min(x), max(x) from + (select 1 as x from union_const_t union select a from union_const_t) s; +explain (costs off) +select a from union_const_t except select 1 from union_const_t; +select count(*), min(x), max(x) from + (select a as x from union_const_t except select 1 from union_const_t) s; +reset enable_hashagg; + +-- UNION ALL preserves duplicates and must not be limited. +explain (costs off) +select 1 from union_const_t union all select a from union_const_t; +select count(*) from + (select 1 from union_const_t union all select a from union_const_t) s; + +-- INTERSECT ALL / EXCEPT ALL preserve duplicate counts and must not be +-- limited either. +create table union_const_dup (a int); +insert into union_const_dup values (1), (1), (1); +explain (costs off) +select 1 from union_const_dup intersect all select 1 from union_const_dup; +select count(*) from + (select 1 from union_const_dup intersect all select 1 from union_const_dup) s; +explain (costs off) +select 1 from union_const_dup except all select 2 from union_const_dup; +select count(*) from + (select 1 from union_const_dup except all select 2 from union_const_dup) s; + +-- The multiplicity of either input matters, so neither side may be limited. +explain (costs off) +select a from union_const_dup intersect all select 1 from union_const_dup; +select count(*) from + (select a from union_const_dup intersect all select 1 from union_const_dup) s; +explain (costs off) +select 1 from union_const_dup except all select 1 from union_const_dup; +select count(*) from + (select 1 from union_const_dup except all select 1 from union_const_dup) s; + +-- The set variants of the same queries do discard duplicates. +select count(*) from + (select 1 from union_const_dup intersect select 1 from union_const_dup) s; +select count(*) from + (select 1 from union_const_dup except select 2 from union_const_dup) s; + +-- Empty inputs must stay empty. +select count(*) from + (select 1 from union_const_dup where false + union select 2 where false) s; + +-- Outputs that can vary from row to row must not be limited. +set max_parallel_workers_per_gather = 0; +explain (costs off) +select nextval('union_const_seq') from union_const_t +union select a from union_const_t; +explain (costs off) +select row_number() over () from union_const_t union select a from union_const_t; +explain (costs off) +select generate_series(1, 3) from union_const_t union select a from union_const_t; + +-- Equivalence classes can pin a Var to a constant, as in SELECT DISTINCT. +explain (costs off) +select a from union_const_t where a = 5 union select 9; +select count(*), min(x), max(x) from + (select a as x from union_const_t where a = 5 union select 9) s; + +explain (costs off) +select a from union_const_t group by a having a = 5 union select 9; +select count(*), min(x), max(x) from + (select a as x from union_const_t group by a having a = 5 union select 9) s; + +-- A join equality can carry the constant to another column. +explain (costs off) +select t.unique1 from tenk1 t join int8_tbl i on t.unique1 = i.q1 +where i.q1 = 123 union select 9; +select count(*), min(x), max(x) from + (select t.unique1 as x from tenk1 t join int8_tbl i on t.unique1 = i.q1 + where i.q1 = 123 union select 9) s; + +-- Like DISTINCT, we do not chase constants through casts or expressions. +explain (costs off) +select a::bigint from union_const_t where a = 5 union select 9::bigint; +explain (costs off) +select a + 0 from union_const_t where a = 5 union select 9; + +-- A constant from below the nullable side of an outer join must not be +-- used: that side can also contribute NULL. +explain (costs off) +select ss.two +from tenk1 a left join + (select b.two from tenk1 b join tenk1 c on b.two = c.two + where b.two = 10) ss on a.unique1 = ss.two +union select 9; + +-- The pinned branch can be on either side. +explain (costs off) +select 9 union select a from union_const_t where a = 5; +select count(*), min(x), max(x) from + (select 9 as x union select a from union_const_t where a = 5) s; +explain (costs off) +select 9 intersect select a from union_const_t where a = 5; +select count(*) from + (select 9 as x intersect select a from union_const_t where a = 5) s; +explain (costs off) +select 9 except select a from union_const_t where a = 5; +select count(*), min(x), max(x) from + (select 9 as x except select a from union_const_t where a = 5) s; + +-- Only one of two output columns is pinned, so the tuple is not constant. +explain (costs off) +select q1, q2 from int8_tbl where q1 = 123 union select q1, q2 from int8_tbl; +select count(*) from + (select q1, q2 from int8_tbl where q1 = 123 + union select q1, q2 from int8_tbl) s; + +-- A generic parameter is an EC constant as well. +prepare union_ec_param(int) as + select a from union_const_t where a = $1 union select 9; +set plan_cache_mode = force_generic_plan; +explain (costs off) execute union_ec_param(5); +execute union_ec_param(5); +deallocate union_ec_param; +reset plan_cache_mode; + +-- A volatile qual, unlike a volatile output column, does not make the output +-- non-constant: the child still returns one distinct tuple. As with the +-- DISTINCT Limit 1 optimization, the scan may stop early and evaluate the +-- qual fewer times. EXPLAIN only; nextval() has side effects. +explain (costs off) +select 1 from union_const_t where nextval('union_const_seq') > 0 +union select a from union_const_t; + +-- A volatile function in FROM can be evaluated fewer times as well. This +-- mirrors the DISTINCT Limit 1 behavior. EXPLAIN only; nextval() has side +-- effects. +explain (costs off) +select 1 from (select nextval('union_const_seq') as v + from generate_series(1, 10)) s +where v > 0 +union select a from union_const_t; + +-- A recursive CTE child follows the same rule. Keep the example finite. +explain (costs off) +with recursive union_limit_r(n) as ( + select 1 + union all + select n + 1 from union_limit_r where n < 10 +) +select 1 from union_limit_r union select 2; +select count(*) from + (with recursive union_limit_r(n) as ( + select 1 + union all + select n + 1 from union_limit_r where n < 10 + ) + select 1 from union_limit_r union select 2) s; + +-- An otherwise infinite recursive CTE is also stopped by the implicit +-- Limit. This matches the DISTINCT Limit 1 behavior; EXPLAIN only, since +-- the finite case above covers execution. +explain (costs off) +with recursive union_limit_inf(n) as ( + select 1 + union all + select n + 1 from union_limit_inf +) +select 1 from union_limit_inf union select 2; + +reset max_parallel_workers_per_gather; + +drop table union_const_t; +drop table union_const_dup; +drop sequence union_const_seq; -- 2.43.0