From acff7acd590479e7c2e2e3654fb13bd550336aca Mon Sep 17 00:00:00 2001 From: Alena Rybakina Date: Tue, 14 Jul 2026 14:13:53 +0300 Subject: [PATCH] With the Postgres planner (optimizer=off or an ORCA fallback), a correlated scalar subquery with an aggregate is pulled up into an INNER join with a grouped subquery (convert_EXPR_to_join), which drops outer rows that have no match. The original subquery keeps them: it computes the aggregate over empty input, so e.g. COUNT yields 0 there: select ... from t1 where t1.a > (select count(*) from t2 where t2.a = t1.d); A row with no match in t2 must be compared as "t1.a > 0" and can pass, but the INNER join dropped it. To fix this, pull the subquery up into a LEFT join, so no-match rows survive as null-extended rows, and rewrite the comparison to return the same value the subquery would: outer OP CASE WHEN match_flag THEN expr ELSE empty_input_default END match_flag is a constant TRUE column added to the subquery. For a matched row the CASE returns the real expression; for a null-extended row the flag is NULL and the CASE returns the empty-input default (0 for COUNT, NULL for other aggregates). The comparison runs above the LEFT join as a filter, not as the join condition: as a join qual it would null-extend matched rows that fail it, and the default would let them back in. The LEFT join is not always needed. If a no-match row cannot pass the comparison anyway -- e.g. "1 = (select count(*) ...)" turns into "1 = 0" for it -- dropping it is fine and the INNER join is kept as before. This is detected by substituting the empty-input default into the comparison and constant-folding it. Ordinary sum/avg/min/max comparisons fall into this group: their empty-input value is NULL, and a comparison with NULL does not pass, so those plans do not change. If the comparison cannot be placed above the join (the sublink is in an outer join's ON clause) or the subquery's targetlist is correlated, the pull-up bails out and the sublink runs as a SubPlan, as before. Co-Authored-By: excaliiibur --- src/backend/cdb/cdbsubselect.c | 189 +++++++++++- src/backend/optimizer/prep/prepjointree.c | 28 ++ src/test/regress/expected/bfv_dd.out | 1 + src/test/regress/expected/eagerfree.out | 18 +- src/test/regress/expected/subselect_gp.out | 271 ++++++++++++++++++ .../expected/subselect_gp_optimizer.out | 271 ++++++++++++++++++ src/test/regress/sql/subselect_gp.sql | 157 ++++++++++ 7 files changed, 923 insertions(+), 12 deletions(-) diff --git a/src/backend/cdb/cdbsubselect.c b/src/backend/cdb/cdbsubselect.c index 1fd53422aac..85fefbc1f98 100644 --- a/src/backend/cdb/cdbsubselect.c +++ b/src/backend/cdb/cdbsubselect.c @@ -17,6 +17,7 @@ #include "access/htup_details.h" #include "access/skey.h" #include "catalog/pg_operator.h" +#include "catalog/pg_proc.h" #include "catalog/pg_type.h" #include "nodes/makefuncs.h" #include "optimizer/clauses.h" @@ -43,6 +44,11 @@ static JoinExpr *make_join_expr(Node *larg, int r_rtindex, int join_type); static Node *make_lasj_quals(PlannerInfo *root, SubLink *sublink, int subquery_indx); static Node *add_null_match_clause(Node *clause); +static Expr *build_match_flag_case_expr(Var *flagVar, Var *aggVar, Expr *defaultExpr); +static Expr *build_empty_input_default_expr(Node *expr); +static Node *replace_agg_with_empty_default_mutator(Node *node, void *context); +static bool no_match_row_survives(PlannerInfo *root, OpExpr *opexp, + Expr *defaultExpr); typedef struct NonNullableVarsContext { @@ -522,6 +528,14 @@ safe_to_convert_EXPR(SubLink *sublink, ConvertSubqueryToJoinContext *ctx1) if (list_length(subselect->targetList) != 1) return false; + /** + * Correlation in the targetlist cannot be handled: the pulled-up + * expression (and the empty-input default derived from it) would carry + * upper-level Vars out of the subquery. + */ + if (contain_vars_of_level_or_above((Node *) subselect->targetList, 1)) + return false; + /** * Walk the quals of the subquery to do a more fine grained check as to whether this subquery @@ -585,6 +599,51 @@ convert_EXPR_to_join(PlannerInfo *root, OpExpr *opexp) subselect->jointree->quals = ctx1.innerQual; + /* + * An INNER join drops outer rows that have no matching inner + * rows. Without the pull-up they are kept: the subquery + * computes its expression over empty input (COUNT = 0, other + * aggregates NULL) and the comparison may still pass. + * + * So plug the empty-input value into the comparison and run + * eval_const_expressions() on it. FALSE or NULL means no-match + * rows cannot pass and the INNER join is correct; otherwise use + * a LEFT join to keep them. + */ + Expr *defaultExpr; + TargetEntry *flagTLE = NULL; + bool use_left_join; + + defaultExpr = build_empty_input_default_expr((Node *) origSubqueryTLE->expr); + use_left_join = no_match_row_survives(root, opexp, defaultExpr); + + if (use_left_join) + { + /* + * After the LEFT join the expression column is NULL both for a + * no-match row and for a matched group whose expression is + * genuinely NULL. To tell them apart, add a constant-TRUE + * match-flag column to the subquery: it can be NULL only when + * the LEFT join found no match and filled the subquery's + * columns with NULLs. + * + * The flag goes BEFORE the expression column: with this + * order the planner can drop the SubqueryScan node from the + * plan. + */ + TargetEntry *aggTLE = (TargetEntry *) llast(subselect->targetList); + + flagTLE = makeTargetEntry((Expr *) makeBoolConst(true, false), + aggTLE->resno, + pstrdup("csq_count_flag"), + false); + aggTLE->resno++; + subselect->targetList = list_truncate(subselect->targetList, + list_length(subselect->targetList) - 1); + subselect->targetList = lappend(subselect->targetList, flagTLE); + subselect->targetList = lappend(subselect->targetList, aggTLE); + } + /** * Construct a new range table entry for the new pulled up subquery. */ @@ -612,7 +671,8 @@ convert_EXPR_to_join(PlannerInfo *root, OpExpr *opexp) join_expr->quals = joinQual; - TargetEntry *subselectAggTLE = (TargetEntry *) list_nth(subselect->targetList, list_length(subselect->targetList) - 1); + /* The pulled-up expression column is last in either layout. */ + TargetEntry *subselectAggTLE = (TargetEntry *) llast(subselect->targetList); /** * modify the op expr to involve the column that has the computed aggregate that needs to compared. @@ -624,7 +684,20 @@ convert_EXPR_to_join(PlannerInfo *root, OpExpr *opexp) exprCollation((Node *) subselectAggTLE->expr), 0); - list_nth_replace(opexp->args, 1, aggVar); + if (use_left_join) + { + Var *flagVar; + + join_expr->jointype = JOIN_LEFT; + flagVar = (Var *) makeVar(rteIndex, flagTLE->resno, BOOLOID, -1, + InvalidOid, 0); + list_nth_replace(opexp->args, 1, + build_match_flag_case_expr(flagVar, aggVar, defaultExpr)); + } + else + { + list_nth_replace(opexp->args, 1, aggVar); + } return join_expr; } @@ -632,6 +705,118 @@ convert_EXPR_to_join(PlannerInfo *root, OpExpr *opexp) return NULL; } +/* + * Build "CASE WHEN flagVar THEN aggVar ELSE defaultExpr END". + * + * flagVar is the subquery's match-flag column: TRUE for a matched group, + * NULL for a null-extended no-match row. + */ +static Expr * +build_match_flag_case_expr(Var *flagVar, Var *aggVar, Expr *defaultExpr) +{ + CaseWhen *casewhen; + CaseExpr *caseexpr; + + Assert(flagVar != NULL); + Assert(aggVar != NULL); + Assert(defaultExpr != NULL); + + casewhen = makeNode(CaseWhen); + casewhen->expr = (Expr *) flagVar; + casewhen->result = (Expr *) aggVar; + casewhen->location = -1; + + caseexpr = makeNode(CaseExpr); + caseexpr->casetype = exprType((Node *) aggVar); + caseexpr->casecollid = exprCollation((Node *) aggVar); + caseexpr->arg = NULL; + caseexpr->args = list_make1(casewhen); + caseexpr->defresult = defaultExpr; + caseexpr->location = -1; + + return (Expr *) caseexpr; +} + +static Expr * +build_empty_input_default_expr(Node *expr) +{ + Node *rewritten; + + rewritten = replace_agg_with_empty_default_mutator(copyObject(expr), NULL); + return (Expr *) rewritten; +} + +static Node * +replace_agg_with_empty_default_mutator(Node *node, void *context) +{ + Aggref *aggref; + Oid default_type; + Oid default_collation; + int16 typlen; + bool typbyval; + + if (node == NULL) + return NULL; + + if (IsA(node, Aggref)) + { + bool is_count; + + aggref = (Aggref *) node; + is_count = (aggref->aggfnoid == COUNT_ANY_OID || + aggref->aggfnoid == COUNT_STAR_OID); + if (is_count) + { + default_type = INT8OID; + default_collation = InvalidOid; + } + else + { + default_type = aggref->aggtype; + default_collation = exprCollation((Node *) aggref); + } + + /* + * COUNT is 0 over empty input; every other aggregate is NULL. The + * choice must follow the aggregate, not its result type: sum(int4) + * also returns int8 but its empty-input value is NULL. + */ + get_typlenbyval(default_type, &typlen, &typbyval); + return (Node *) makeConst(default_type, -1, default_collation, typlen, + is_count ? Int64GetDatum(0) : (Datum) 0, + !is_count, typbyval); + } + + return expression_tree_mutator(node, replace_agg_with_empty_default_mutator, + context); +} + +/* + * no_match_row_survives + * + * Could a no-match row satisfy "outerExpr OP (subquery)"? Plug defaultExpr in + * for the subquery and constant-fold: false if it folds to FALSE/NULL, else true. + */ +static bool +no_match_row_survives(PlannerInfo *root, OpExpr *opexp, Expr *defaultExpr) +{ + OpExpr *testexpr = (OpExpr *) copyObject(opexp); + Node *folded; + + list_nth_replace(testexpr->args, 1, copyObject(defaultExpr)); + folded = eval_const_expressions(root, (Node *) testexpr); + + if (IsA(folded, Const)) + { + Const *c = (Const *) folded; + + if (c->constisnull || !DatumGetBool(c->constvalue)) + return false; + } + + return true; +} + /* NOTIN subquery transformation -start */ /* check if NOT IN conversion to antijoin is possible */ diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c index b8a505c6991..72da6440294 100644 --- a/src/backend/optimizer/prep/prepjointree.c +++ b/src/backend/optimizer/prep/prepjointree.c @@ -686,11 +686,39 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node, if (IsA(rarg, SubLink)) { + /* + * The pulled-up join is spliced in at *jtlink1, and in the + * LEFT-join case the comparison itself moves there too, so + * every Var of this query level used by the clause must be + * available at that attach point. Otherwise (e.g. an outer + * join's ON clause referencing the non-nullable side) leave + * the sublink to be planned as a SubPlan. + */ + if (!bms_is_subset(pull_varnos(node), available_rels1)) + return node; + j = convert_EXPR_to_join(root, opexp); if (j) { /* Yes, insert the new join node into the join tree */ j->larg = *jtlink1; + + if (j->jointype == JOIN_LEFT) + { + /* + * COUNT-preserving pull-up (see convert_EXPR_to_join). + * opexp must run ABOVE the LEFT JOIN, not as its join + * condition: as a join qual a matched row that fails it + * would be treated as unmatched, null-extended, and let + * back in by the no-match default of the CASE built by + * convert_EXPR_to_join. Wrap the join in a FromExpr so + * opexp stays a post-join filter. + */ + *jtlink1 = (Node *) makeFromExpr(list_make1(j), node); + return NULL; + } + + /* Inner-join case: opexp stays as an ordinary qual. */ *jtlink1 = (Node *) j; } return node; diff --git a/src/test/regress/expected/bfv_dd.out b/src/test/regress/expected/bfv_dd.out index ff2c62bb4bf..3be2c1cc5d0 100644 --- a/src/test/regress/expected/bfv_dd.out +++ b/src/test/regress/expected/bfv_dd.out @@ -908,6 +908,7 @@ INFO: (slice 1) Dispatch command to SINGLE content select * from dd_singlecol_1 t1 where a=1 and b < (select count(*) from dd_singlecol_2 t2 where t2.a=t1.a); INFO: (slice 1) Dispatch command to ALL contents: 0 1 2 INFO: (slice 2) Dispatch command to ALL contents: 0 1 2 +INFO: (slice 3) Dispatch command to ALL contents: 0 1 2 a | b ---+--- (0 rows) diff --git a/src/test/regress/expected/eagerfree.out b/src/test/regress/expected/eagerfree.out index cf5a9eaee41..db9874f7ed2 100644 --- a/src/test/regress/expected/eagerfree.out +++ b/src/test/regress/expected/eagerfree.out @@ -1376,18 +1376,16 @@ where i < (select count(*) from smallt where smallt.i = smallt2.i); QUERY PLAN --------------------------------------------------------------------------------------- Gather Motion 3:1 (slice1; segments: 3) (cost=5.10..8.08 rows=17 width=15) - -> Hash Join (cost=5.10..8.08 rows=6 width=15) - Hash Cond: smallt2.i = "Expr_SUBQUERY".csq_c0 - Join Filter: smallt2.i < "Expr_SUBQUERY".csq_c1 + -> Hash Left Join (cost=5.10..8.08 rows=6 width=15) + Hash Cond: smallt2.i = smallt.i + Filter: smallt2.i < CASE WHEN true THEN (count(*)) ELSE '0'::bigint END -> Seq Scan on smallt2 (cost=0.00..2.50 rows=17 width=15) - -> Hash (cost=4.97..4.97 rows=4 width=12) - -> Subquery Scan on "Expr_SUBQUERY" (cost=4.75..4.97 rows=4 width=12) - -> HashAggregate (cost=4.75..4.88 rows=4 width=12) - Filter: smallt.i < count(*) - Group Key: smallt.i - -> Seq Scan on smallt (cost=0.00..4.00 rows=34 width=4) + -> Hash (cost=4.97..4.97 rows=4 width=13) + -> HashAggregate (cost=4.75..4.88 rows=4 width=13) + Group Key: smallt.i + -> Seq Scan on smallt (cost=0.00..4.00 rows=34 width=4) Optimizer: Postgres query optimizer -(12 rows) +(10 rows) -- Sort in MergeJoin -- start_ignore diff --git a/src/test/regress/expected/subselect_gp.out b/src/test/regress/expected/subselect_gp.out index 51baa596aa3..98d460e51d6 100644 --- a/src/test/regress/expected/subselect_gp.out +++ b/src/test/regress/expected/subselect_gp.out @@ -499,6 +499,277 @@ select count(*) from csq_t1 t1 where a > ( select avg(a)::int from csq_t1 t2 whe 49 (1 row) +-- COUNT correlated scalar subquery should keep no-match rows (COUNT = 0) +-- +-- t_in has no match for t_out's row: the subquery computes COUNT = 0 over +-- empty input, "1 > 0" is true and the row must be returned +drop table if exists t_out, t_in; +NOTICE: table "t_out" does not exist, skipping +NOTICE: table "t_in" does not exist, skipping +create table t_out as select 1 as a distributed by (a); +create table t_in as select 2 as a distributed by (a); +-- Scenario 1: optimizer=off (always PostgreSQL planner path) +set optimizer=off; +select * from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 0 = (select count(*) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +-- Recreate t_in so that t_out's row now HAS matches: count = 2, "1 > 2" is +-- false, and the row must NOT be returned. If the comparison ran as the LEFT +-- join's condition, this row would look unmatched, get the COUNT = 0 default +-- and wrongly pass as "1 > 0". +drop table t_in; +create table t_in as select 1 as a union all select 1 distributed by (a); +select * from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- a matched group whose expression is NULL (nullif(2, 2)) must not be +-- revived by the no-match default either +select * from t_out +where 0 = (select nullif(count(*), 2) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- restore the no-match fixture +drop table t_in; +create table t_in as select 2 as a distributed by (a); +reset optimizer; +-- Scenario 2: optimizer=on, but query shape forces ORCA fallback to PostgreSQL +-- planner (ordered aggregate disabled in ORCA by default) +-- +-- Note on the EXPLAIN below: the filter prints as "CASE WHEN true THEN ...". +-- The plan has no SubqueryScan node for the pulled-up subquery (the planner +-- removes it as a no-op), so EXPLAIN falls back to printing the flag column +-- by its definition -- the constant TRUE. At run time the executor reads the +-- join column, which is NULL (not true) for null-extended no-match rows. +set optimizer=on; +set optimizer_enable_orderedagg=off; +explain select string_agg(a::text, ',' order by a) +from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + QUERY PLAN +----------------------------------------------------------------------------------------- + Aggregate (cost=2.26..2.27 rows=1 width=32) + -> Gather Motion 3:1 (slice1; segments: 3) (cost=1.05..2.23 rows=4 width=4) + -> Hash Left Join (cost=1.05..2.10 rows=2 width=4) + Hash Cond: (t_out.a = t_in.a) + Filter: (t_out.a > CASE WHEN (true) THEN (count(*)) ELSE '0'::bigint END) + -> Seq Scan on t_out (cost=0.00..1.01 rows=1 width=4) + -> Hash (cost=1.03..1.03 rows=1 width=13) + -> HashAggregate (cost=1.01..1.02 rows=1 width=13) + Group Key: t_in.a + -> Seq Scan on t_in (cost=0.00..1.01 rows=1 width=4) + Optimizer: Postgres query optimizer +(11 rows) + +select string_agg(a::text, ',' order by a) +from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + string_agg +------------ + 1 +(1 row) + +reset optimizer; +reset optimizer_enable_orderedagg; +-- Non-plain COUNT expressions should also preserve no-match semantics +-- NB: the sublink must be the RIGHT operand of the comparison, otherwise the +-- pull-up transform is not applied and the query runs as a plain SubPlan. +-- Scenario 1: optimizer=off (always PostgreSQL planner path) +set optimizer=off; +-- for the no-match row count(*) + 1 = 1 +select * from t_out +where 1 = (select count(*) + 1 from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 1 = (select (count(*) + 1)::bigint from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 1 = (select case when count(*) > 0 then count(*) + 1 else 1 end + from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 1 = (select abs(count(*) - 1) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 0 = (select count(*) + coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where -1 = (select coalesce(count(*) + sum(t_in.a), -1) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +-- sum()'s empty-input value is NULL, not 0: the no-match row must NOT satisfy this +select * from t_out +where 0 = (select count(*) + sum(t_in.a) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- ... while the no-match default of nullif(count(*), 1) is 0 and must keep it +select * from t_out +where 0 = (select nullif(count(*), 1) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +reset optimizer; +-- Scenario 2: optimizer=on, but query shape forces ORCA fallback to PostgreSQL planner +set optimizer=on; +set optimizer_enable_orderedagg=off; +select string_agg(a::text, ',' order by a) +from t_out +where 1 = (select count(*) + 1 from t_in where t_in.a = t_out.a); + string_agg +------------ + 1 +(1 row) + +reset optimizer; +reset optimizer_enable_orderedagg; +-- The sublink sits in an outer join's ON clause and references the join's +-- non-nullable side. The pulled-up join (and, in the LEFT-join case, the +-- comparison itself) cannot be attached there, so the pull-up must bail out +-- and the sublink runs as a SubPlan. +set optimizer=off; +explain (costs off) select count(*) from t_out t1 left join t_out t3 + on t1.a > (select count(*) from t_in t2 where t2.a = t1.a); + QUERY PLAN +----------------------------------------------------------------------------------------- + Aggregate + -> Gather Motion 3:1 (slice3; segments: 3) + -> Aggregate + -> Nested Loop Left Join + Join Filter: (t1.a > (SubPlan 1)) + -> Seq Scan on t_out t1 + -> Materialize + -> Broadcast Motion 3:3 (slice1; segments: 3) + -> Seq Scan on t_out t3 + SubPlan 1 (slice3; segments: 3) + -> Aggregate + -> Result + Filter: (t2.a = t1.a) + -> Materialize + -> Broadcast Motion 3:3 (slice2; segments: 3) + -> Seq Scan on t_in t2 + Optimizer: Postgres query optimizer +(17 rows) + +select count(*) from t_out t1 left join t_out t3 + on t1.a > (select count(*) from t_in t2 where t2.a = t1.a); + count +------- + 1 +(1 row) + +reset optimizer; +-- No-match rows can pass the predicate without any COUNT involved. +-- coalesce(sum(..), 0) evaluates to 0 over empty input, so the no-match row +-- satisfies "0 = ..." and must survive the pull-up. +set optimizer=off; +explain (costs off) select * from t_out +where 0 = (select coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + QUERY PLAN +------------------------------------------------------------------------------------------------------- + Gather Motion 3:1 (slice1; segments: 3) + -> Hash Left Join + Hash Cond: (t_out.a = t_in.a) + Filter: (0 = CASE WHEN (true) THEN (COALESCE(sum(t_in.a), '0'::bigint)) ELSE '0'::bigint END) + -> Seq Scan on t_out + -> Hash + -> HashAggregate + Group Key: t_in.a + -> Seq Scan on t_in + Optimizer: Postgres query optimizer +(10 rows) + +select * from t_out +where 0 = (select coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +-- In contrast, a plain aggregate whose empty-input value is NULL (min, max, +-- sum, avg) under a strict comparison keeps the INNER join: the no-match row +-- evaluates "1 = NULL", which cannot pass, so dropping it early is correct +explain (costs off) select * from t_out +where a = (select min(t_in.a) from t_in where t_in.a = t_out.a); + QUERY PLAN +---------------------------------------------------------- + Gather Motion 3:1 (slice1; segments: 3) + -> Hash Join + Hash Cond: (t_out.a = "Expr_SUBQUERY".csq_c1) + -> Seq Scan on t_out + -> Hash + -> Subquery Scan on "Expr_SUBQUERY" + -> HashAggregate + Group Key: t_in.a + Filter: (min(t_in.a) = t_in.a) + -> Seq Scan on t_in + Optimizer: Postgres query optimizer +(11 rows) + +select * from t_out +where a = (select min(t_in.a) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- A non-strict comparison operator can return TRUE for "outer OP NULL", so +-- the no-match row must survive even when the expression's empty-input value +-- is plain NULL. +create function csq_ns_eq(int8, int8) returns bool as +$$ select coalesce($1, 0) = coalesce($2, 0) $$ language sql immutable; +create operator |=| (procedure = csq_ns_eq, leftarg = int8, rightarg = int8); +select * from t_out +where 0::int8 |=| (select sum(t_in.a) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +drop operator |=| (int8, int8); +drop function csq_ns_eq(int8, int8); +reset optimizer; +drop table t_out, t_in; -- -- correlation in a func expr -- diff --git a/src/test/regress/expected/subselect_gp_optimizer.out b/src/test/regress/expected/subselect_gp_optimizer.out index d328e6a5864..d2d7d0d6899 100644 --- a/src/test/regress/expected/subselect_gp_optimizer.out +++ b/src/test/regress/expected/subselect_gp_optimizer.out @@ -452,6 +452,277 @@ select count(*) from csq_t1 t1 where a > ( select avg(a)::int from csq_t1 t2 whe 49 (1 row) +-- COUNT correlated scalar subquery should keep no-match rows (COUNT = 0) +-- +-- t_in has no match for t_out's row: the subquery computes COUNT = 0 over +-- empty input, "1 > 0" is true and the row must be returned +drop table if exists t_out, t_in; +NOTICE: table "t_out" does not exist, skipping +NOTICE: table "t_in" does not exist, skipping +create table t_out as select 1 as a distributed by (a); +create table t_in as select 2 as a distributed by (a); +-- Scenario 1: optimizer=off (always PostgreSQL planner path) +set optimizer=off; +select * from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 0 = (select count(*) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +-- Recreate t_in so that t_out's row now HAS matches: count = 2, "1 > 2" is +-- false, and the row must NOT be returned. If the comparison ran as the LEFT +-- join's condition, this row would look unmatched, get the COUNT = 0 default +-- and wrongly pass as "1 > 0". +drop table t_in; +create table t_in as select 1 as a union all select 1 distributed by (a); +select * from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- a matched group whose expression is NULL (nullif(2, 2)) must not be +-- revived by the no-match default either +select * from t_out +where 0 = (select nullif(count(*), 2) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- restore the no-match fixture +drop table t_in; +create table t_in as select 2 as a distributed by (a); +reset optimizer; +-- Scenario 2: optimizer=on, but query shape forces ORCA fallback to PostgreSQL +-- planner (ordered aggregate disabled in ORCA by default) +-- +-- Note on the EXPLAIN below: the filter prints as "CASE WHEN true THEN ...". +-- The plan has no SubqueryScan node for the pulled-up subquery (the planner +-- removes it as a no-op), so EXPLAIN falls back to printing the flag column +-- by its definition -- the constant TRUE. At run time the executor reads the +-- join column, which is NULL (not true) for null-extended no-match rows. +set optimizer=on; +set optimizer_enable_orderedagg=off; +explain select string_agg(a::text, ',' order by a) +from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + QUERY PLAN +----------------------------------------------------------------------------------------- + Aggregate (cost=2.26..2.27 rows=1 width=32) + -> Gather Motion 3:1 (slice1; segments: 3) (cost=1.05..2.23 rows=4 width=4) + -> Hash Left Join (cost=1.05..2.10 rows=2 width=4) + Hash Cond: (t_out.a = t_in.a) + Filter: (t_out.a > CASE WHEN (true) THEN (count(*)) ELSE '0'::bigint END) + -> Seq Scan on t_out (cost=0.00..1.01 rows=1 width=4) + -> Hash (cost=1.03..1.03 rows=1 width=13) + -> HashAggregate (cost=1.01..1.02 rows=1 width=13) + Group Key: t_in.a + -> Seq Scan on t_in (cost=0.00..1.01 rows=1 width=4) + Optimizer: Postgres query optimizer +(11 rows) + +select string_agg(a::text, ',' order by a) +from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + string_agg +------------ + 1 +(1 row) + +reset optimizer; +reset optimizer_enable_orderedagg; +-- Non-plain COUNT expressions should also preserve no-match semantics +-- NB: the sublink must be the RIGHT operand of the comparison, otherwise the +-- pull-up transform is not applied and the query runs as a plain SubPlan. +-- Scenario 1: optimizer=off (always PostgreSQL planner path) +set optimizer=off; +-- for the no-match row count(*) + 1 = 1 +select * from t_out +where 1 = (select count(*) + 1 from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 1 = (select (count(*) + 1)::bigint from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 1 = (select case when count(*) > 0 then count(*) + 1 else 1 end + from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 1 = (select abs(count(*) - 1) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where 0 = (select count(*) + coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +select * from t_out +where -1 = (select coalesce(count(*) + sum(t_in.a), -1) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +-- sum()'s empty-input value is NULL, not 0: the no-match row must NOT satisfy this +select * from t_out +where 0 = (select count(*) + sum(t_in.a) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- ... while the no-match default of nullif(count(*), 1) is 0 and must keep it +select * from t_out +where 0 = (select nullif(count(*), 1) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +reset optimizer; +-- Scenario 2: optimizer=on, but query shape forces ORCA fallback to PostgreSQL planner +set optimizer=on; +set optimizer_enable_orderedagg=off; +select string_agg(a::text, ',' order by a) +from t_out +where 1 = (select count(*) + 1 from t_in where t_in.a = t_out.a); + string_agg +------------ + 1 +(1 row) + +reset optimizer; +reset optimizer_enable_orderedagg; +-- The sublink sits in an outer join's ON clause and references the join's +-- non-nullable side. The pulled-up join (and, in the LEFT-join case, the +-- comparison itself) cannot be attached there, so the pull-up must bail out +-- and the sublink runs as a SubPlan. +set optimizer=off; +explain (costs off) select count(*) from t_out t1 left join t_out t3 + on t1.a > (select count(*) from t_in t2 where t2.a = t1.a); + QUERY PLAN +----------------------------------------------------------------------------------------- + Aggregate + -> Gather Motion 3:1 (slice3; segments: 3) + -> Aggregate + -> Nested Loop Left Join + Join Filter: (t1.a > (SubPlan 1)) + -> Seq Scan on t_out t1 + -> Materialize + -> Broadcast Motion 3:3 (slice1; segments: 3) + -> Seq Scan on t_out t3 + SubPlan 1 (slice3; segments: 3) + -> Aggregate + -> Result + Filter: (t2.a = t1.a) + -> Materialize + -> Broadcast Motion 3:3 (slice2; segments: 3) + -> Seq Scan on t_in t2 + Optimizer: Postgres query optimizer +(17 rows) + +select count(*) from t_out t1 left join t_out t3 + on t1.a > (select count(*) from t_in t2 where t2.a = t1.a); + count +------- + 1 +(1 row) + +reset optimizer; +-- No-match rows can pass the predicate without any COUNT involved. +-- coalesce(sum(..), 0) evaluates to 0 over empty input, so the no-match row +-- satisfies "0 = ..." and must survive the pull-up. +set optimizer=off; +explain (costs off) select * from t_out +where 0 = (select coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + QUERY PLAN +------------------------------------------------------------------------------------------------------- + Gather Motion 3:1 (slice1; segments: 3) + -> Hash Left Join + Hash Cond: (t_out.a = t_in.a) + Filter: (0 = CASE WHEN (true) THEN (COALESCE(sum(t_in.a), '0'::bigint)) ELSE '0'::bigint END) + -> Seq Scan on t_out + -> Hash + -> HashAggregate + Group Key: t_in.a + -> Seq Scan on t_in + Optimizer: Postgres query optimizer +(10 rows) + +select * from t_out +where 0 = (select coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +-- In contrast, a plain aggregate whose empty-input value is NULL (min, max, +-- sum, avg) under a strict comparison keeps the INNER join: the no-match row +-- evaluates "1 = NULL", which cannot pass, so dropping it early is correct +explain (costs off) select * from t_out +where a = (select min(t_in.a) from t_in where t_in.a = t_out.a); + QUERY PLAN +---------------------------------------------------------- + Gather Motion 3:1 (slice1; segments: 3) + -> Hash Join + Hash Cond: (t_out.a = "Expr_SUBQUERY".csq_c1) + -> Seq Scan on t_out + -> Hash + -> Subquery Scan on "Expr_SUBQUERY" + -> HashAggregate + Group Key: t_in.a + Filter: (min(t_in.a) = t_in.a) + -> Seq Scan on t_in + Optimizer: Postgres query optimizer +(11 rows) + +select * from t_out +where a = (select min(t_in.a) from t_in where t_in.a = t_out.a); + a +--- +(0 rows) + +-- A non-strict comparison operator can return TRUE for "outer OP NULL", so +-- the no-match row must survive even when the expression's empty-input value +-- is plain NULL. +create function csq_ns_eq(int8, int8) returns bool as +$$ select coalesce($1, 0) = coalesce($2, 0) $$ language sql immutable; +create operator |=| (procedure = csq_ns_eq, leftarg = int8, rightarg = int8); +select * from t_out +where 0::int8 |=| (select sum(t_in.a) from t_in where t_in.a = t_out.a); + a +--- + 1 +(1 row) + +drop operator |=| (int8, int8); +drop function csq_ns_eq(int8, int8); +reset optimizer; +drop table t_out, t_in; -- -- correlation in a func expr -- diff --git a/src/test/regress/sql/subselect_gp.sql b/src/test/regress/sql/subselect_gp.sql index 68a92f83e70..8202e1e84be 100644 --- a/src/test/regress/sql/subselect_gp.sql +++ b/src/test/regress/sql/subselect_gp.sql @@ -224,6 +224,163 @@ select count(*) from csq_t1 t1 where a > (SELECT x.b FROM ( select avg(a)::int a select count(*) from csq_t1 t1 where a > ( select avg(a)::int from csq_t1 t2 where t2.a=t1.d) ; +-- COUNT correlated scalar subquery should keep no-match rows (COUNT = 0) +-- +-- t_in has no match for t_out's row: the subquery computes COUNT = 0 over +-- empty input, "1 > 0" is true and the row must be returned +drop table if exists t_out, t_in; +create table t_out as select 1 as a distributed by (a); +create table t_in as select 2 as a distributed by (a); + +-- Scenario 1: optimizer=off (always PostgreSQL planner path) +set optimizer=off; + +select * from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + +select * from t_out +where 0 = (select count(*) from t_in where t_in.a = t_out.a); + +-- Recreate t_in so that t_out's row now HAS matches: count = 2, "1 > 2" is +-- false, and the row must NOT be returned. If the comparison ran as the LEFT +-- join's condition, this row would look unmatched, get the COUNT = 0 default +-- and wrongly pass as "1 > 0". +drop table t_in; +create table t_in as select 1 as a union all select 1 distributed by (a); + +select * from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + +-- a matched group whose expression is NULL (nullif(2, 2)) must not be +-- revived by the no-match default either +select * from t_out +where 0 = (select nullif(count(*), 2) from t_in where t_in.a = t_out.a); + +-- restore the no-match fixture +drop table t_in; +create table t_in as select 2 as a distributed by (a); + +reset optimizer; + +-- Scenario 2: optimizer=on, but query shape forces ORCA fallback to PostgreSQL +-- planner (ordered aggregate disabled in ORCA by default) +-- +-- Note on the EXPLAIN below: the filter prints as "CASE WHEN true THEN ...". +-- The plan has no SubqueryScan node for the pulled-up subquery (the planner +-- removes it as a no-op), so EXPLAIN falls back to printing the flag column +-- by its definition -- the constant TRUE. At run time the executor reads the +-- join column, which is NULL (not true) for null-extended no-match rows. +set optimizer=on; +set optimizer_enable_orderedagg=off; + +explain select string_agg(a::text, ',' order by a) +from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + +select string_agg(a::text, ',' order by a) +from t_out +where a > (select count(*) from t_in where t_in.a = t_out.a); + +reset optimizer; +reset optimizer_enable_orderedagg; + +-- Non-plain COUNT expressions should also preserve no-match semantics +-- NB: the sublink must be the RIGHT operand of the comparison, otherwise the +-- pull-up transform is not applied and the query runs as a plain SubPlan. +-- Scenario 1: optimizer=off (always PostgreSQL planner path) +set optimizer=off; + +-- for the no-match row count(*) + 1 = 1 +select * from t_out +where 1 = (select count(*) + 1 from t_in where t_in.a = t_out.a); + +select * from t_out +where 1 = (select (count(*) + 1)::bigint from t_in where t_in.a = t_out.a); + +select * from t_out +where 1 = (select case when count(*) > 0 then count(*) + 1 else 1 end + from t_in where t_in.a = t_out.a); + +select * from t_out +where 1 = (select abs(count(*) - 1) from t_in where t_in.a = t_out.a); + +select * from t_out +where 0 = (select count(*) + coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + +select * from t_out +where -1 = (select coalesce(count(*) + sum(t_in.a), -1) from t_in where t_in.a = t_out.a); + +-- sum()'s empty-input value is NULL, not 0: the no-match row must NOT satisfy this +select * from t_out +where 0 = (select count(*) + sum(t_in.a) from t_in where t_in.a = t_out.a); + +-- ... while the no-match default of nullif(count(*), 1) is 0 and must keep it +select * from t_out +where 0 = (select nullif(count(*), 1) from t_in where t_in.a = t_out.a); + +reset optimizer; + +-- Scenario 2: optimizer=on, but query shape forces ORCA fallback to PostgreSQL planner +set optimizer=on; +set optimizer_enable_orderedagg=off; + +select string_agg(a::text, ',' order by a) +from t_out +where 1 = (select count(*) + 1 from t_in where t_in.a = t_out.a); + +reset optimizer; +reset optimizer_enable_orderedagg; + +-- The sublink sits in an outer join's ON clause and references the join's +-- non-nullable side. The pulled-up join (and, in the LEFT-join case, the +-- comparison itself) cannot be attached there, so the pull-up must bail out +-- and the sublink runs as a SubPlan. +set optimizer=off; + +explain (costs off) select count(*) from t_out t1 left join t_out t3 + on t1.a > (select count(*) from t_in t2 where t2.a = t1.a); + +select count(*) from t_out t1 left join t_out t3 + on t1.a > (select count(*) from t_in t2 where t2.a = t1.a); + +reset optimizer; + +-- No-match rows can pass the predicate without any COUNT involved. +-- coalesce(sum(..), 0) evaluates to 0 over empty input, so the no-match row +-- satisfies "0 = ..." and must survive the pull-up. +set optimizer=off; + +explain (costs off) select * from t_out +where 0 = (select coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + +select * from t_out +where 0 = (select coalesce(sum(t_in.a), 0) from t_in where t_in.a = t_out.a); + +-- In contrast, a plain aggregate whose empty-input value is NULL (min, max, +-- sum, avg) under a strict comparison keeps the INNER join: the no-match row +-- evaluates "1 = NULL", which cannot pass, so dropping it early is correct +explain (costs off) select * from t_out +where a = (select min(t_in.a) from t_in where t_in.a = t_out.a); + +select * from t_out +where a = (select min(t_in.a) from t_in where t_in.a = t_out.a); + +-- A non-strict comparison operator can return TRUE for "outer OP NULL", so +-- the no-match row must survive even when the expression's empty-input value +-- is plain NULL. +create function csq_ns_eq(int8, int8) returns bool as +$$ select coalesce($1, 0) = coalesce($2, 0) $$ language sql immutable; +create operator |=| (procedure = csq_ns_eq, leftarg = int8, rightarg = int8); + +select * from t_out +where 0::int8 |=| (select sum(t_in.a) from t_in where t_in.a = t_out.a); + +drop operator |=| (int8, int8); +drop function csq_ns_eq(int8, int8); +reset optimizer; + +drop table t_out, t_in; + -- -- correlation in a func expr --