From 9c76737d860001a96d9938e276b8229e9cba5e64 Mon Sep 17 00:00:00 2001 From: Jeevan Chalke Date: Sat, 12 Sep 2026 17:20:33 +0530 Subject: [PATCH v2] Add support for ON EMPTY clause in aggregate and window functions Add an ON EMPTY clause for aggregate (and aggregate-as-window- function) calls, returning a default when the aggregate's normal result would otherwise be NULL: agg(args, default ON EMPTY) == COALESCE(agg(args), default) for both plain and window aggregates. This matches the SQL committee's own definition of ON EMPTY for SUM/PRODUCT (per Vik Fearing on -hackers), generalized here to arbitrary aggregates. Implemented as a single NULL check at finalization time, after computing the aggregate normally. No new expression step, no per-row state, and no restriction on partial/parallel aggregation. Since aggregates can share transition state across calls with the same transfn/args/filter, find_compatible_agg() and ExecInitAgg() must treat aggonempty as part of what makes two calls distinct, and build each call's ON EMPTY state independently of that sharing. The default expression must be a constant-like expression coercible to the aggregate's result type. It may not reference columns (at any query level) or contain aggregates, window functions, subqueries, or volatile functions. ON EMPTY cannot be combined with DISTINCT, and is rejected for non-aggregate window functions; it may be used with an ordered-set (WITHIN GROUP) aggregate, written before the WITHIN GROUP clause. Patch also includes documentation and regression tests for aggregates and window functions, covering empty tables, filtered results, type coercion, all-NULL inputs, GROUP BY and grouping sets, ordered-set aggregates, forced-parallel plans, multiple ON EMPTY calls sharing transition state, and view deparsing. This commit adds fields to Aggref and WindowFunc (and FuncCall). These node types are serialized into pg_rewrite (views/rules) and other stored expression trees, which requires a CATALOG_VERSION_NO bump. That is omitted here to avoid conflicts with concurrent commits; the committer should bump it at commit time. Proposed-by: Peter Eisentraut Jeevan Chalke --- doc/src/sgml/syntax.sgml | 50 +++ src/backend/executor/nodeAgg.c | 22 + src/backend/executor/nodeWindowAgg.c | 20 + src/backend/nodes/makefuncs.c | 1 + src/backend/nodes/nodeFuncs.c | 8 + src/backend/optimizer/prep/prepagg.c | 3 +- src/backend/optimizer/util/clauses.c | 6 + src/backend/parser/gram.y | 16 + src/backend/parser/parse_agg.c | 134 +++++- src/backend/parser/parse_expr.c | 11 +- src/backend/parser/parse_func.c | 37 +- src/backend/utils/adt/ruleutils.c | 22 + src/include/executor/nodeAgg.h | 3 + src/include/nodes/parsenodes.h | 1 + src/include/nodes/primnodes.h | 5 + src/include/parser/parse_agg.h | 4 +- src/include/parser/parse_node.h | 1 + src/test/regress/expected/aggregates.out | 521 +++++++++++++++++++++++ src/test/regress/expected/window.out | 131 ++++++ src/test/regress/sql/aggregates.sql | 222 ++++++++++ src/test/regress/sql/window.sql | 70 +++ 21 files changed, 1279 insertions(+), 9 deletions(-) diff --git a/doc/src/sgml/syntax.sgml b/doc/src/sgml/syntax.sgml index 67482996861..9584f1f052f 100644 --- a/doc/src/sgml/syntax.sgml +++ b/doc/src/sgml/syntax.sgml @@ -1555,6 +1555,10 @@ sqrt(2) FILTER + + ON EMPTY + + An aggregate expression represents the application of an aggregate function across the rows selected by a @@ -1564,6 +1568,7 @@ sqrt(2) aggregate_name (expression [ , ... ] [ order_by_clause ] ) [ FILTER ( WHERE filter_clause ) ] +aggregate_name (expression [ , ... ] , default_expression ON EMPTY [ order_by_clause ] ) [ FILTER ( WHERE filter_clause ) ] aggregate_name (ALL expression [ , ... ] [ order_by_clause ] ) [ FILTER ( WHERE filter_clause ) ] aggregate_name (DISTINCT expression [ , ... ] [ order_by_clause ] ) [ FILTER ( WHERE filter_clause ) ] aggregate_name ( * ) [ FILTER ( WHERE filter_clause ) ] @@ -1744,6 +1749,38 @@ FROM generate_series(1,10) AS s(i); + + If ON EMPTY is specified, then + default_expression supplies the value of the + aggregate whenever its normal result would otherwise be null; that is, + agg(args, + default_expression ON EMPTY) is + equivalent to COALESCE(agg(args), + default_expression), including for + an aggregate used as a window function. In particular, this fires not + only for a truly empty input (no rows at all, or none passing a + filter_clause), but for any other reason the + aggregate's ordinary result happens to be null, such as a group whose + rows are all null, or a statistical aggregate like + stddev or corr returning null + for insufficient or degenerate data. ON EMPTY cannot + distinguish these cases from true emptiness. For example: + +SELECT sum(i) AS plain, sum(i, -1 ON EMPTY) AS defaulted +FROM generate_series(1,10) AS s(i) WHERE i > 100; + plain | defaulted +-------+----------- + | -1 +(1 row) + + default_expression must be a constant-like + expression of a type coercible to the aggregate's result type: it may not + refer to columns or contain aggregates, window functions, subqueries, or + volatile functions. ON EMPTY cannot be combined with + DISTINCT. It may be used with an ordered-set + aggregate, written before the WITHIN GROUP clause. + + The predefined aggregate functions are described in . Other aggregate functions can be added @@ -1843,6 +1880,19 @@ EXCLUDE NO OTHERS described in . + + When an aggregate function is used as a window function, an + ON EMPTY clause may be given after its arguments + (using the same syntax as for a plain aggregate call, described in + ), with the same + COALESCE-equivalent behavior applied per output row: + the default_expression is returned for any row + whose windowed result would otherwise be null, which includes every row + whose window frame contains no rows (including a frame that becomes + empty as the frame moves). ON EMPTY is not accepted + for non-aggregate window functions. + + window_name is a reference to a named window specification defined in the query's WINDOW clause. diff --git a/src/backend/executor/nodeAgg.c b/src/backend/executor/nodeAgg.c index 29037cf3122..28c22104799 100644 --- a/src/backend/executor/nodeAgg.c +++ b/src/backend/executor/nodeAgg.c @@ -1133,6 +1133,16 @@ finalize_aggregate(AggState *aggstate, *resultIsNull = pergroupstate->transValueIsNull; } + /* + * If an ON EMPTY default is specified and the normal result came out + * NULL, substitute the default -- i.e. this is exactly + * COALESCE(normal_result, default_expression). + */ + if (peragg->aggonemptystate != NULL && *resultIsNull) + *resultVal = ExecEvalExpr(peragg->aggonemptystate, + aggstate->ss.ps.ps_ExprContext, + resultIsNull); + MemoryContextSwitchTo(oldContext); } @@ -4039,6 +4049,18 @@ ExecInitAgg(Agg *node, EState *estate, int eflags) else pertrans->aggshared = true; ReleaseSysCache(aggTuple); + + /* + * Build expression state for the ON EMPTY default expression. This + * is per-aggregate (peragg), not per-transition-state, so it must be + * done for every aggref even when its transition state is shared + * with another aggregate that already built its pertrans above. + */ + if (aggref->aggonempty) + peragg->aggonemptystate = ExecInitExpr(aggref->aggonempty, + (PlanState *) aggstate); + else + peragg->aggonemptystate = NULL; } /* diff --git a/src/backend/executor/nodeWindowAgg.c b/src/backend/executor/nodeWindowAgg.c index b86dcbba055..567c542ed11 100644 --- a/src/backend/executor/nodeWindowAgg.c +++ b/src/backend/executor/nodeWindowAgg.c @@ -171,6 +171,9 @@ typedef struct WindowStatePerAggData /* Data local to eval_windowaggregates() */ bool restart; /* need to restart this agg in this cycle? */ + + /* ExprState for evaluating ON EMPTY default value, or NULL */ + ExprState *aggonemptystate; } WindowStatePerAggData; static void initialize_windowaggregate(WindowAggState *winstate, @@ -691,6 +694,16 @@ finalize_windowaggregate(WindowAggState *winstate, *isnull = peraggstate->transValueIsNull; } + /* + * If an ON EMPTY default is specified and the normal result came out + * NULL, substitute the default -- i.e. this is exactly + * COALESCE(normal_result, default_expression). + */ + if (peraggstate->aggonemptystate != NULL && *isnull) + *result = ExecEvalExpr(peraggstate->aggonemptystate, + winstate->ss.ps.ps_ExprContext, + isnull); + MemoryContextSwitchTo(oldContext); } @@ -3164,6 +3177,13 @@ initialize_peragg(WindowAggState *winstate, WindowFunc *wfunc, &peraggstate->transtypeLen, &peraggstate->transtypeByVal); + /* Build expression state for ON EMPTY default expression */ + if (wfunc->aggonempty) + peraggstate->aggonemptystate = ExecInitExpr(wfunc->aggonempty, + (PlanState *) winstate); + else + peraggstate->aggonemptystate = NULL; + /* * initval is potentially null, so don't try to access it as a struct * field. Must do it the hard way with SysCacheGetAttr. diff --git a/src/backend/nodes/makefuncs.c b/src/backend/nodes/makefuncs.c index cdc02b274ff..a5e26c28a51 100644 --- a/src/backend/nodes/makefuncs.c +++ b/src/backend/nodes/makefuncs.c @@ -681,6 +681,7 @@ makeFuncCall(List *name, List *args, CoercionForm funcformat, int location) n->args = args; n->agg_order = NIL; n->agg_filter = NULL; + n->agg_on_empty = NULL; n->over = NULL; n->agg_within_group = false; n->agg_star = false; diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c index 9eb905fd025..7e4df76bbdb 100644 --- a/src/backend/nodes/nodeFuncs.c +++ b/src/backend/nodes/nodeFuncs.c @@ -2158,6 +2158,8 @@ expression_tree_walker_impl(Node *node, return true; if (WALK(expr->aggfilter)) return true; + if (WALK(expr->aggonempty)) + return true; } break; case T_GroupingFunc: @@ -2177,6 +2179,8 @@ expression_tree_walker_impl(Node *node, return true; if (WALK(expr->aggfilter)) return true; + if (WALK(expr->aggonempty)) + return true; if (WALK(expr->runCondition)) return true; } @@ -3061,6 +3065,7 @@ expression_tree_mutator_impl(Node *node, MUTATE(newnode->aggorder, aggref->aggorder, List *); MUTATE(newnode->aggdistinct, aggref->aggdistinct, List *); MUTATE(newnode->aggfilter, aggref->aggfilter, Expr *); + MUTATE(newnode->aggonempty, aggref->aggonempty, Expr *); return (Node *) newnode; } break; @@ -3095,6 +3100,7 @@ expression_tree_mutator_impl(Node *node, FLATCOPY(newnode, wfunc, WindowFunc); MUTATE(newnode->args, wfunc->args, List *); MUTATE(newnode->aggfilter, wfunc->aggfilter, Expr *); + MUTATE(newnode->aggonempty, wfunc->aggonempty, Expr *); return (Node *) newnode; } break; @@ -4464,6 +4470,8 @@ raw_expression_tree_walker_impl(Node *node, return true; if (WALK(fcall->agg_filter)) return true; + if (WALK(fcall->agg_on_empty)) + return true; if (WALK(fcall->over)) return true; /* function name is deemed uninteresting */ diff --git a/src/backend/optimizer/prep/prepagg.c b/src/backend/optimizer/prep/prepagg.c index 3737cc15ba1..17d3a8dd474 100644 --- a/src/backend/optimizer/prep/prepagg.c +++ b/src/backend/optimizer/prep/prepagg.c @@ -423,7 +423,8 @@ find_compatible_agg(PlannerInfo *root, Aggref *newagg, if (newagg->aggfnoid == existingRef->aggfnoid && newagg->aggtype == existingRef->aggtype && newagg->aggcollid == existingRef->aggcollid && - equal(newagg->aggdirectargs, existingRef->aggdirectargs)) + equal(newagg->aggdirectargs, existingRef->aggdirectargs) && + equal(newagg->aggonempty, existingRef->aggonempty)) { list_free(*same_input_transnos); *same_input_transnos = NIL; diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index 55cebe4a74b..3a217240c7d 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -2894,6 +2894,7 @@ eval_const_expressions_mutator(Node *node, Oid funcid = expr->winfnoid; List *args; Expr *aggfilter; + Expr *aggonempty; HeapTuple func_tuple; WindowFunc *newexpr; @@ -2923,6 +2924,10 @@ eval_const_expressions_mutator(Node *node, aggfilter = (Expr *) eval_const_expressions_mutator((Node *) expr->aggfilter, context); + /* ... and the ON EMPTY expression */ + aggonempty = (Expr *) + eval_const_expressions_mutator((Node *) expr->aggonempty, + context); /* And build the replacement WindowFunc node */ newexpr = makeNode(WindowFunc); @@ -2932,6 +2937,7 @@ eval_const_expressions_mutator(Node *node, newexpr->inputcollid = expr->inputcollid; newexpr->args = args; newexpr->aggfilter = aggfilter; + newexpr->aggonempty = aggonempty; newexpr->runCondition = expr->runCondition; newexpr->winref = expr->winref; newexpr->winstar = expr->winstar; diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y index 1015d303fbf..f973341df88 100644 --- a/src/backend/parser/gram.y +++ b/src/backend/parser/gram.y @@ -464,6 +464,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query); %type group_by_list %type group_by_item empty_grouping_set rollup_clause cube_clause %type grouping_sets_clause +%type on_empty %type opt_fdw_options fdw_options %type fdw_option @@ -16149,6 +16150,16 @@ func_application: func_name '(' ')' n->agg_order = $7; $$ = (Node *) n; } + | func_name '(' func_arg_list ',' on_empty opt_sort_clause ')' + { + FuncCall *n = makeFuncCall($1, $3, + COERCE_EXPLICIT_CALL, + @1); + + n->agg_on_empty = $5; + n->agg_order = $6; + $$ = (Node *) n; + } | func_name '(' ALL func_arg_list opt_sort_clause ')' { FuncCall *n = makeFuncCall($1, $4, @@ -16194,6 +16205,11 @@ func_application: func_name '(' ')' ; +on_empty: a_expr ON EMPTY_P + { $$ = $1; } + ; + + /* * func_expr and its cousin func_expr_windowless are split out from c_expr just * so that we have classifications for "everything that is a function call or diff --git a/src/backend/parser/parse_agg.c b/src/backend/parser/parse_agg.c index fac401e85d5..63d221f8219 100644 --- a/src/backend/parser/parse_agg.c +++ b/src/backend/parser/parse_agg.c @@ -21,6 +21,7 @@ #include "common/int.h" #include "nodes/makefuncs.h" #include "nodes/nodeFuncs.h" +#include "optimizer/clauses.h" #include "optimizer/optimizer.h" #include "parser/parse_agg.h" #include "parser/parse_clause.h" @@ -82,6 +83,9 @@ static Var *buildGroupedVar(int attnum, Index ressortgroupref, static void check_agglevels_and_constraints(ParseState *pstate, Node *expr); static List *expand_groupingset_node(GroupingSet *gs); static Node *make_agg_arg(Oid argtype, Oid argcollation); +static Node *check_agg_on_empty(ParseState *pstate, Oid aggtype, + Expr *agg_on_empty, int location); +static bool agg_on_empty_contains_var_walker(Node *node, void *context); /* @@ -111,7 +115,8 @@ static Node *make_agg_arg(Oid argtype, Oid argcollation); */ void transformAggregateCall(ParseState *pstate, Aggref *agg, - List *args, List *aggorder, bool agg_distinct) + List *args, List *aggorder, bool agg_distinct, + Expr *agg_on_empty) { List *argtypes = NIL; List *tlist = NIL; @@ -231,6 +236,8 @@ transformAggregateCall(ParseState *pstate, Aggref *agg, agg->args = tlist; agg->aggorder = torder; agg->aggdistinct = tdistinct; + agg->aggonempty = (Expr *) check_agg_on_empty(pstate, agg->aggtype, + agg_on_empty, agg->location); /* * Now build the aggargtypes list with the type OIDs of the direct and @@ -499,6 +506,14 @@ check_agglevels_and_constraints(ParseState *pstate, Node *expr) else err = _("grouping operations are not allowed in DEFAULT expressions"); + break; + case EXPR_KIND_AGG_ON_EMPTY: + + if (isAgg) + err = _("aggregate functions are not allowed in ON EMPTY expressions"); + else + err = _("grouping operations are not allowed in ON EMPTY expressions"); + break; case EXPR_KIND_INDEX_EXPRESSION: if (isAgg) @@ -884,7 +899,7 @@ check_agg_arguments_walker(Node *node, */ void transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc, - WindowDef *windef) + WindowDef *windef, Expr *agg_on_empty) { const char *err; bool errkind; @@ -995,6 +1010,9 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc, case EXPR_KIND_FUNCTION_DEFAULT: err = _("window functions are not allowed in DEFAULT expressions"); break; + case EXPR_KIND_AGG_ON_EMPTY: + err = _("window functions are not allowed in ON EMPTY expressions"); + break; case EXPR_KIND_INDEX_EXPRESSION: err = _("window functions are not allowed in index expressions"); break; @@ -1129,6 +1147,9 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc, } pstate->p_hasWindowFuncs = true; + wfunc->aggonempty = (Expr *) check_agg_on_empty(pstate, wfunc->wintype, + agg_on_empty, + wfunc->location); } /* @@ -2413,3 +2434,112 @@ make_agg_arg(Oid argtype, Oid argcollation) argp->location = -1; return (Node *) argp; } + +/* + * check_agg_on_empty - + * Checks the aggregate's ON EMPTY expression for correctness. + */ +static Node * +check_agg_on_empty(ParseState *pstate, Oid aggtype, Expr *agg_on_empty, + int location) +{ + Oid defexprtype; + + if (!agg_on_empty) + return NULL; + + /* + * default expression must be a constant - it cannot contain column + * references (Var nodes), aggregates, or window functions. + * + * Note we cannot use contain_var_clause() here: it only detects Vars of + * the current query level, so a correlated reference to an outer query's + * column (varlevelsup > 0) would slip through and make the "constant" + * default vary per outer row. Reject Vars of any level. + */ + if (agg_on_empty_contains_var_walker((Node *) agg_on_empty, NULL)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("ON EMPTY expression must be a constant value"), + parser_errposition(pstate, location))); + + if (contain_agg_clause((Node *) agg_on_empty)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("ON EMPTY expression must not contain an aggregate function"), + parser_errposition(pstate, location))); + + if (contain_windowfuncs((Node *) agg_on_empty)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("ON EMPTY expression must not contain a window function"), + parser_errposition(pstate, location))); + + /* + * Sub-selects are not constant and, more importantly, the ON EMPTY + * expression is only run through ExecInitExpr() at execution time, never + * the sub-plan setup that a normal target expression receives, so a + * sub-select here would fail (or crash) at execution. Reject it. + */ + if (checkExprHasSubLink((Node *) agg_on_empty)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("ON EMPTY expression must not contain a subquery"), + parser_errposition(pstate, location))); + + /* + * The default is evaluated only once, for the empty-input case, so a + * volatile expression would not behave like a per-row value. Restrict it + * to a stable/immutable, constant-like expression. + */ + if (contain_volatile_functions((Node *) agg_on_empty)) + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("ON EMPTY expression must not contain a volatile function"), + parser_errposition(pstate, location))); + + defexprtype = exprType((Node *) agg_on_empty); + + /* default expression must be coercible to the aggregate's result type. */ + agg_on_empty = (Expr *) coerce_to_target_type(pstate, + (Node *) agg_on_empty, + defexprtype, + aggtype, + -1, + COERCION_ASSIGNMENT, + COERCE_IMPLICIT_CAST, + -1); + + if (agg_on_empty == NULL) + ereport(ERROR, + (errcode(ERRCODE_DATATYPE_MISMATCH), + errmsg("ON EMPTY expression type %s cannot be coerced to aggregate result type %s", + format_type_be(defexprtype), + format_type_be(aggtype)), + parser_errposition(pstate, location))); + + return (Node *) agg_on_empty; +} + +/* + * agg_on_empty_contains_var_walker - + * Returns true if the node tree contains any Var (a column reference) or + * CurrentOfExpr, regardless of query level. + * + * Unlike contain_var_clause(), this also reports Vars belonging to outer + * query levels (varlevelsup > 0), which is what we need to ensure an ON EMPTY + * default really is constant. Sub-selects are rejected separately, so there + * is no need to descend into them here. This runs during parse analysis, so + * PlaceHolderVars (which the planner introduces) cannot appear; a Var inside + * one would be caught by the recursion anyway. + */ +static bool +agg_on_empty_contains_var_walker(Node *node, void *context) +{ + if (node == NULL) + return false; + if (IsA(node, Var) || IsA(node, CurrentOfExpr)) + return true; + return expression_tree_walker(node, agg_on_empty_contains_var_walker, + context); +} diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c index e4acc67851b..c06b55d857a 100644 --- a/src/backend/parser/parse_expr.c +++ b/src/backend/parser/parse_expr.c @@ -566,6 +566,7 @@ transformColumnRef(ParseState *pstate, ColumnRef *cref) case EXPR_KIND_CHECK_CONSTRAINT: case EXPR_KIND_DOMAIN_CHECK: case EXPR_KIND_FUNCTION_DEFAULT: + case EXPR_KIND_AGG_ON_EMPTY: case EXPR_KIND_INDEX_EXPRESSION: case EXPR_KIND_INDEX_PREDICATE: case EXPR_KIND_STATS_EXPRESSION: @@ -1841,6 +1842,9 @@ transformSubLink(ParseState *pstate, SubLink *sublink) case EXPR_KIND_FUNCTION_DEFAULT: err = _("cannot use subquery in DEFAULT expression"); break; + case EXPR_KIND_AGG_ON_EMPTY: + err = _("cannot use subquery in ON EMPTY expression"); + break; case EXPR_KIND_INDEX_EXPRESSION: err = _("cannot use subquery in index expression"); break; @@ -3212,6 +3216,8 @@ ParseExprKindName(ParseExprKind exprKind) case EXPR_KIND_COLUMN_DEFAULT: case EXPR_KIND_FUNCTION_DEFAULT: return "DEFAULT"; + case EXPR_KIND_AGG_ON_EMPTY: + return "ON EMPTY"; case EXPR_KIND_INDEX_EXPRESSION: return "index expression"; case EXPR_KIND_INDEX_PREDICATE: @@ -3977,7 +3983,7 @@ transformJsonAggConstructor(ParseState *pstate, JsonAggConstructor *agg_ctor, parser_errposition(pstate, agg_ctor->location)); /* parse_agg.c does additional window-func-specific processing */ - transformWindowFuncCall(pstate, wfunc, agg_ctor->over); + transformWindowFuncCall(pstate, wfunc, agg_ctor->over, NULL); node = (Node *) wfunc; } @@ -4004,7 +4010,8 @@ transformJsonAggConstructor(ParseState *pstate, JsonAggConstructor *agg_ctor, aggref->aggtransno = -1; aggref->location = agg_ctor->location; - transformAggregateCall(pstate, aggref, args, agg_ctor->agg_order, false); + transformAggregateCall(pstate, aggref, args, agg_ctor->agg_order, + false, NULL); node = (Node *) aggref; } diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c index 52c4d072f0a..1392e9a4c5d 100644 --- a/src/backend/parser/parse_func.c +++ b/src/backend/parser/parse_func.c @@ -95,6 +95,7 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, bool is_column = (fn == NULL); List *agg_order = (fn ? fn->agg_order : NIL); Expr *agg_filter = NULL; + Expr *agg_on_empty = NULL; WindowDef *over = (fn ? fn->over : NULL); bool agg_within_group = (fn ? fn->agg_within_group : false); bool agg_star = (fn ? fn->agg_star : false); @@ -130,6 +131,15 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, EXPR_KIND_FILTER, "FILTER"); + /* + * If there's an aggregate ON EMPTY default value given, transform it + * here. It is parsed in its own expression kind so that disallowed + * constructs are reported as occurring "in ON EMPTY expressions". + */ + if (fn && fn->agg_on_empty != NULL) + agg_on_empty = (Expr *) transformExpr(pstate, fn->agg_on_empty, + EXPR_KIND_AGG_ON_EMPTY); + /* * Most of the rest of the parser just assumes that functions do not have * more than FUNC_MAX_ARGS parameters. We have to test here to protect @@ -226,6 +236,7 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, */ could_be_projection = (nargs == 1 && !proc_call && agg_order == NIL && agg_filter == NULL && + agg_on_empty == NULL && !agg_star && !agg_distinct && over == NULL && !func_variadic && argnames == NIL && list_length(funcname) == 1 && @@ -339,6 +350,12 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, errmsg("ORDER BY specified, but %s is not an aggregate function", NameListToString(funcname)), parser_errposition(pstate, location))); + if (agg_on_empty != NULL) + ereport(ERROR, + (errcode(ERRCODE_WRONG_OBJECT_TYPE), + errmsg("ON EMPTY specified, but %s is not an aggregate function", + NameListToString(funcname)), + parser_errposition(pstate, location))); if (agg_filter) ereport(ERROR, (errcode(ERRCODE_WRONG_OBJECT_TYPE), @@ -876,7 +893,8 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, parser_errposition(pstate, location))); /* parse_agg.c does additional aggregate-specific processing */ - transformAggregateCall(pstate, aggref, fargs, agg_order, agg_distinct); + transformAggregateCall(pstate, aggref, fargs, agg_order, agg_distinct, + agg_on_empty); retval = (Node *) aggref; } @@ -951,6 +969,18 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, errmsg("FILTER is not implemented for non-aggregate window functions"), parser_errposition(pstate, location))); + /* + * ON EMPTY only has meaning for aggregates: it supplies the value to + * return when the aggregate processes no rows. A true window + * function does not aggregate input rows, so it would be silently + * ignored; reject it instead. + */ + if (!wfunc->winagg && agg_on_empty) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("ON EMPTY is not implemented for non-aggregate window functions"), + parser_errposition(pstate, location))); + /* * Window functions can't either take or return sets */ @@ -969,7 +999,7 @@ ParseFuncOrColumn(ParseState *pstate, List *funcname, List *fargs, parser_errposition(pstate, location))); /* parse_agg.c does additional window-func-specific processing */ - transformWindowFuncCall(pstate, wfunc, over); + transformWindowFuncCall(pstate, wfunc, over, agg_on_empty); retval = (Node *) wfunc; } @@ -2806,6 +2836,9 @@ check_srf_call_placement(ParseState *pstate, Node *last_srf, int location) case EXPR_KIND_FUNCTION_DEFAULT: err = _("set-returning functions are not allowed in DEFAULT expressions"); break; + case EXPR_KIND_AGG_ON_EMPTY: + err = _("set-returning functions are not allowed in ON EMPTY expressions"); + break; case EXPR_KIND_INDEX_EXPRESSION: err = _("set-returning functions are not allowed in index expressions"); break; diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index f42263bf9c9..0446d34244d 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -10994,6 +10994,14 @@ get_agg_expr_helper(Aggref *aggref, deparse_context *context, */ Assert(!aggref->aggvariadic); get_rule_expr((Node *) aggref->aggdirectargs, context, true); + + if (aggref->aggonempty != NULL) + { + appendStringInfoString(buf, ", "); + get_rule_expr((Node *) aggref->aggonempty, context, false); + appendStringInfoString(buf, " ON EMPTY"); + } + Assert(aggref->aggorder != NIL); appendStringInfoString(buf, ") WITHIN GROUP (ORDER BY "); get_rule_orderby(aggref->aggorder, aggref->args, false, context); @@ -11039,6 +11047,13 @@ get_agg_expr_helper(Aggref *aggref, deparse_context *context, } } + if (aggref->aggonempty != NULL) + { + appendStringInfoString(buf, ", "); + get_rule_expr((Node *) aggref->aggonempty, context, false); + appendStringInfoString(buf, " ON EMPTY"); + } + if (aggref->aggorder != NIL) { appendStringInfoString(buf, " ORDER BY "); @@ -11139,6 +11154,13 @@ get_windowfunc_expr_helper(WindowFunc *wfunc, deparse_context *context, get_rule_expr((Node *) wfunc->args, context, true); } + if (wfunc->aggonempty != NULL) + { + appendStringInfoString(buf, ", "); + get_rule_expr((Node *) wfunc->aggonempty, context, false); + appendStringInfoString(buf, " ON EMPTY"); + } + if (options) appendStringInfoString(buf, options); diff --git a/src/include/executor/nodeAgg.h b/src/include/executor/nodeAgg.h index 1e1be9666ae..de3b751e30d 100644 --- a/src/include/executor/nodeAgg.h +++ b/src/include/executor/nodeAgg.h @@ -229,6 +229,9 @@ typedef struct AggStatePerAggData * aggregates because the final function is read-write. */ bool shareable; + + /* ExprState for evaluating ON EMPTY default value, or NULL */ + ExprState *aggonemptystate; } AggStatePerAggData; /* diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h index 910eef936f1..919ff8622c3 100644 --- a/src/include/nodes/parsenodes.h +++ b/src/include/nodes/parsenodes.h @@ -461,6 +461,7 @@ typedef struct FuncCall bool agg_within_group; /* ORDER BY appeared in WITHIN GROUP */ bool agg_star; /* argument was really '*' */ bool agg_distinct; /* arguments were labeled DISTINCT */ + Node *agg_on_empty; /* user given value to use ON EMPTY set */ bool func_variadic; /* last argument was labeled VARIADIC */ CoercionForm funcformat; /* how to display this node */ ParseLoc location; /* token location, or -1 if unknown */ diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index 09b0c29408c..6b11f236295 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -497,6 +497,9 @@ typedef struct Aggref /* FILTER expression, if any */ Expr *aggfilter; + /* ON EMPTY expression, if any */ + Expr *aggonempty; + /* true if argument list was really '*' */ bool aggstar pg_node_attr(query_jumble_ignore); @@ -602,6 +605,8 @@ typedef struct WindowFunc List *args; /* FILTER expression, if any */ Expr *aggfilter; + /* ON EMPTY expression, if any */ + Expr *aggonempty; /* List of WindowFuncRunConditions to help short-circuit execution */ List *runCondition pg_node_attr(query_jumble_ignore); /* index of associated WindowClause */ diff --git a/src/include/parser/parse_agg.h b/src/include/parser/parse_agg.h index 8a5653798db..f93e956b83e 100644 --- a/src/include/parser/parse_agg.h +++ b/src/include/parser/parse_agg.h @@ -17,12 +17,12 @@ extern void transformAggregateCall(ParseState *pstate, Aggref *agg, List *args, List *aggorder, - bool agg_distinct); + bool agg_distinct, Expr *agg_on_empty); extern Node *transformGroupingFunc(ParseState *pstate, GroupingFunc *p); extern void transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc, - WindowDef *windef); + WindowDef *windef, Expr *agg_on_empty); extern void parseCheckAggregates(ParseState *pstate, Query *qry); diff --git a/src/include/parser/parse_node.h b/src/include/parser/parse_node.h index 3eaeb7a90e1..2567c0e6ede 100644 --- a/src/include/parser/parse_node.h +++ b/src/include/parser/parse_node.h @@ -70,6 +70,7 @@ typedef enum ParseExprKind EXPR_KIND_DOMAIN_CHECK, /* CHECK constraint for a domain */ EXPR_KIND_COLUMN_DEFAULT, /* default value for a table column */ EXPR_KIND_FUNCTION_DEFAULT, /* default parameter value for function */ + EXPR_KIND_AGG_ON_EMPTY, /* aggregate/window ON EMPTY default value */ EXPR_KIND_INDEX_EXPRESSION, /* index expression */ EXPR_KIND_INDEX_PREDICATE, /* index predicate */ EXPR_KIND_STATS_EXPRESSION, /* extended statistics expression */ diff --git a/src/test/regress/expected/aggregates.out b/src/test/regress/expected/aggregates.out index 7d07619956f..5414840ddc4 100644 --- a/src/test/regress/expected/aggregates.out +++ b/src/test/regress/expected/aggregates.out @@ -3958,3 +3958,524 @@ drop table agg_hash_1; drop table agg_hash_2; drop table agg_hash_3; drop table agg_hash_4; +-- Test ON EMPTY clause for aggregates +-- create a test table for ON EMPTY tests +CREATE TABLE agg_on_empty(id int, val int, grpid int); +-- with empty table +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty; + sum +----- + -1 +(1 row) + +INSERT INTO agg_on_empty VALUES (1, 10, 10), (2, 20, 10), (3, 30, 20), (4, NULL, 20), (5, 50, 30); +-- basic ON EMPTY tests with numeric default +SELECT sum(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum +----- + 0 +(1 row) + +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum +----- + -1 +(1 row) + +SELECT avg(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + avg +----- + 0 +(1 row) + +SELECT max(val, 999 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + max +----- + 999 +(1 row) + +SELECT min(val, -999 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + min +------ + -999 +(1 row) + +SELECT count(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + count +------- + 0 +(1 row) + +-- with non-empty result set (should use actual aggregate result) +SELECT sum(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + sum +----- + 30 +(1 row) + +SELECT avg(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + avg +--------------------- + 15.0000000000000000 +(1 row) + +SELECT max(val, 999 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + max +----- + 20 +(1 row) + +SELECT min(val, -999 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + min +----- + 10 +(1 row) + +-- with string default +SELECT string_agg(grpid::text, ',', 'NONE' ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + string_agg +------------ + NONE +(1 row) + +SELECT string_agg(grpid::text, ',', 'NONE' ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + string_agg +------------ + 10,10 +(1 row) + +-- with NULL default +SELECT sum(val, NULL ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum +----- + +(1 row) + +SELECT avg(val, NULL ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + avg +----- + +(1 row) + +SELECT string_agg(grpid::text, ',', NULL ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + string_agg +------------ + +(1 row) + +-- with type coercion (integer to numeric) +SELECT sum(val::numeric, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum +----- + 0 +(1 row) + +SELECT sum(val, 0.5 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum +----- + 1 +(1 row) + +-- Multiple aggregate calls in one query that share the same transition +-- function, arguments and filter (so the planner may compute them via a +-- single shared transition state) must still each get their own ON EMPTY +-- default: sharing the transition state must not mean sharing (or losing) +-- the per-aggregate ON EMPTY expression. +SELECT sum(val), sum(val, 10 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum | sum +-----+----- + | 10 +(1 row) + +SELECT sum(val, 10 ON EMPTY), sum(val) FROM agg_on_empty WHERE grpid = 40; + sum | sum +-----+----- + 10 | +(1 row) + +SELECT sum(val, 10 ON EMPTY), sum(val, 20 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum | sum +-----+----- + 10 | 20 +(1 row) + +SELECT max(val, 1 ON EMPTY), max(val, 2 ON EMPTY), max(val) FROM agg_on_empty WHERE grpid = 40; + max | max | max +-----+-----+----- + 1 | 2 | +(1 row) + +-- two references to the exact same ON EMPTY aggregate call may still share +-- everything, including the result +SELECT sum(val, 10 ON EMPTY), sum(val, 10 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + sum | sum +-----+----- + 10 | 10 +(1 row) + +-- and the same, but over non-empty input, where ON EMPTY does not fire for +-- any of them +SELECT sum(val), sum(val, 10 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + sum | sum +-----+----- + 30 | 30 +(1 row) + +SELECT sum(val, 10 ON EMPTY), sum(val, 20 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + sum | sum +-----+----- + 30 | 30 +(1 row) + +-- with GROUP BY +SELECT grpid, sum(val, 0 ON EMPTY) FROM agg_on_empty GROUP BY grpid ORDER BY grpid; + grpid | sum +-------+----- + 10 | 30 + 20 | 30 + 30 | 50 +(3 rows) + +-- with GROUP BY and empty groups: a LEFT JOIN with no match produces one +-- row with a NULL value, not zero rows, but sum's result is NULL either +-- way, so groups 100 and 200 get the ON EMPTY value here too +SELECT g, sum(val, -1 ON EMPTY) FROM (VALUES (100), (200), (10), (20)) AS groups(g) + LEFT JOIN agg_on_empty ON groups.g = agg_on_empty.grpid + GROUP BY g ORDER BY g; + g | sum +-----+----- + 10 | 30 + 20 | 30 + 100 | -1 + 200 | -1 +(4 rows) + +-- with grouping sets: per-group state is reset across grouping-set phases, so +-- ON EMPTY must not fire spuriously for groups that do have rows +SELECT grpid, sum(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY ROLLUP(grpid) ORDER BY grpid; + grpid | sum +-------+----- + 10 | 30 + 20 | 30 + 30 | 50 + | 110 +(4 rows) + +-- with grouping sets where a FILTER empties one group: ON EMPTY fires only for +-- that group (checked under both sorted and hashed grouping-set execution) +SELECT grpid, sum(val, -1 ON EMPTY) FILTER (WHERE val > 25) FROM agg_on_empty + GROUP BY GROUPING SETS ((grpid), ()) ORDER BY grpid; + grpid | sum +-------+----- + 10 | -1 + 20 | 30 + 30 | 50 + | 80 +(4 rows) + +SET enable_sort = off; +SELECT grpid, sum(val, -1 ON EMPTY) FILTER (WHERE val > 25) FROM agg_on_empty + GROUP BY GROUPING SETS ((grpid), ()) ORDER BY grpid; + grpid | sum +-------+----- + 10 | -1 + 20 | 30 + 30 | 50 + | 80 +(4 rows) + +RESET enable_sort; +-- multi-dimensional CUBE with a FILTER: ON EMPTY fires independently for each +-- empty cell (including sub-aggregates), while populated cells return real sums +SELECT grpid, id % 2 AS parity, sum(val, -1 ON EMPTY) FILTER (WHERE val > 25) AS s + FROM agg_on_empty GROUP BY CUBE(grpid, id % 2) ORDER BY grpid, parity; + grpid | parity | s +-------+--------+---- + 10 | 0 | -1 + 10 | 1 | -1 + 10 | | -1 + 20 | 0 | -1 + 20 | 1 | 30 + 20 | | 30 + 30 | 1 | 50 + 30 | | 50 + | 0 | -1 + | 1 | 80 + | | 80 +(11 rows) + +-- with FILTER clause +SELECT sum(val, 100 ON EMPTY) FILTER (WHERE val > 100) FROM agg_on_empty; + sum +----- + 100 +(1 row) + +SELECT count(val, 0 ON EMPTY) FILTER (WHERE val > 100) FROM agg_on_empty; + count +------- + 0 +(1 row) + +-- with ORDER BY in aggregate +SELECT avg(val, -999 ON EMPTY ORDER BY val) FROM agg_on_empty WHERE grpid = 40; + avg +------ + -999 +(1 row) + +SELECT avg(val, -999 ON EMPTY ORDER BY val) FROM agg_on_empty WHERE grpid = 10; + avg +--------------------- + 15.0000000000000000 +(1 row) + +-- under a forced parallel plan: ON EMPTY must work whether or not the +-- aggregate is parallelized (such aggregates are not partially aggregated). +SET max_parallel_workers_per_gather = 4; +SET parallel_setup_cost = 0; +SET parallel_tuple_cost = 0; +SET min_parallel_table_scan_size = 0; +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; -- default + sum +----- + -1 +(1 row) + +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; -- real result + sum +----- + 30 +(1 row) + +RESET max_parallel_workers_per_gather; +RESET parallel_setup_cost; +RESET parallel_tuple_cost; +RESET min_parallel_table_scan_size; +-- Error cases for ON EMPTY +-- with complex expressions (should fail - must be constant) +SELECT sum(val, val ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: ON EMPTY expression must be a constant value +LINE 1: SELECT sum(val, val ON EMPTY) FROM agg_on_empty WHERE grpid ... + ^ +-- with type mismatch (should fail) +SELECT sum(val, 'invalid' ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: invalid input syntax for type bigint: "invalid" +LINE 1: SELECT sum(val, 'invalid' ON EMPTY) FROM agg_on_empty WHERE ... + ^ +-- with non-aggregate function (should fail) +SELECT length('test', 'default' ON EMPTY); +ERROR: ON EMPTY specified, but length is not an aggregate function +LINE 1: SELECT length('test', 'default' ON EMPTY); + ^ +-- with a subquery (should fail) +SELECT sum(val, (SELECT 1) ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: cannot use subquery in ON EMPTY expression +LINE 1: SELECT sum(val, (SELECT 1) ON EMPTY) FROM agg_on_empty WHERE... + ^ +-- with a volatile function (should fail) +SELECT sum(val, random()::int ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: ON EMPTY expression must not contain a volatile function +LINE 1: SELECT sum(val, random()::int ON EMPTY) FROM agg_on_empty WH... + ^ +-- with a set-returning function (should fail) +SELECT sum(val, generate_series(1, 2) ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: set-returning functions are not allowed in ON EMPTY expressions +LINE 1: SELECT sum(val, generate_series(1, 2) ON EMPTY) FROM agg_on_... + ^ +-- with a nested aggregate (should fail) +SELECT sum(val, sum(val) ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: aggregate functions are not allowed in ON EMPTY expressions +LINE 1: SELECT sum(val, sum(val) ON EMPTY) FROM agg_on_empty WHERE g... + ^ +-- with a window function (should fail) +SELECT sum(val, row_number() OVER () ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: window functions are not allowed in ON EMPTY expressions +LINE 1: SELECT sum(val, row_number() OVER () ON EMPTY) FROM agg_on_e... + ^ +-- combined with DISTINCT (should fail - not supported by the grammar) +SELECT sum(DISTINCT val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +ERROR: syntax error at or near "ON" +LINE 1: SELECT sum(DISTINCT val, 0 ON EMPTY) FROM agg_on_empty WHERE... + ^ +-- combined with an ordered-set aggregate (WITHIN GROUP): the ON EMPTY value +-- is written before WITHIN GROUP and is returned for an empty input set +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 40; -- empty -> default + percentile_cont +----------------- + -7 +(1 row) + +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 10; -- real result + percentile_cont +----------------- + 15 +(1 row) + +-- and it round-trips through view deparsing +CREATE VIEW agg_on_empty_os_vw AS + SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) AS p + FROM agg_on_empty; +SELECT pg_get_viewdef('agg_on_empty_os_vw'); + pg_get_viewdef +--------------------------------------------------------------------------------------------------------------------------------- + SELECT percentile_cont((0.5)::double precision, '-7'::integer ON EMPTY) WITHIN GROUP (ORDER BY ((val)::double precision)) AS p+ + FROM agg_on_empty; +(1 row) + +DROP VIEW agg_on_empty_os_vw; +-- deparsing of ON EMPTY in views +CREATE VIEW agg_on_empty_vw AS + SELECT grpid, sum(val, 0 ON EMPTY ORDER BY val) AS total, + avg(val, 0 ON EMPTY) AS average, count(val, 0 ON EMPTY) AS cnt + FROM agg_on_empty GROUP BY grpid; +\d+ agg_on_empty_vw + View "public.agg_on_empty_vw" + Column | Type | Collation | Nullable | Default | Storage | Description +---------+---------+-----------+----------+---------+---------+------------- + grpid | integer | | | | plain | + total | bigint | | | | plain | + average | numeric | | | | main | + cnt | bigint | | | | plain | +View definition: + SELECT grpid, + sum(val, 0 ON EMPTY ORDER BY val) AS total, + avg(val, 0 ON EMPTY) AS average, + count(val, 0 ON EMPTY) AS cnt + FROM agg_on_empty + GROUP BY grpid; + +SELECT * FROM agg_on_empty_vw ORDER BY grpid; + grpid | total | average | cnt +-------+-------+---------------------+----- + 10 | 30 | 15.0000000000000000 | 2 + 20 | 30 | 30.0000000000000000 | 1 + 30 | 50 | 50.0000000000000000 | 1 +(3 rows) + +-- with prepared statements (must use literal constants) +PREPARE on_empty_test(int) AS + SELECT sum(val, 42 ON EMPTY) FROM agg_on_empty WHERE grpid = $1; +EXECUTE on_empty_test(40); + sum +----- + 42 +(1 row) + +EXECUTE on_empty_test(10); + sum +----- + 30 +(1 row) + +DEALLOCATE on_empty_test; +-- with HAVING clause +SELECT grpid, sum(val, 0 ON EMPTY) AS total FROM agg_on_empty + GROUP BY grpid HAVING sum(val, 0 ON EMPTY) > 20 ORDER BY grpid; + grpid | total +-------+------- + 10 | 30 + 20 | 30 + 30 | 50 +(3 rows) + +-- ON EMPTY is exactly COALESCE(normal_result, default_expression): it fires +-- whenever the aggregate's ordinary result is NULL, for whatever reason -- +-- not only for a truly empty input. In particular, a non-empty group whose +-- rows are all NULL also gets the ON EMPTY value here, because max/min's +-- ordinary result for such a group is NULL anyway. This is exercised across +-- the sorted, hashed and grouping-set execution paths. +INSERT INTO agg_on_empty VALUES (6, NULL, 50), (7, NULL, 50); +SELECT max(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 50; -- -1, same as COALESCE(max(val), -1) + max +----- + -1 +(1 row) + +SELECT min(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 50; -- -1, same as COALESCE(min(val), -1) + min +----- + -1 +(1 row) + +-- and per group, under both sorted and hashed aggregation +SELECT grpid, max(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY grpid ORDER BY grpid; + grpid | max +-------+----- + 10 | 20 + 20 | 30 + 30 | 50 + 50 | -1 +(4 rows) + +SET enable_sort = off; SET enable_hashagg = on; +SELECT grpid, max(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY grpid ORDER BY grpid; + grpid | max +-------+----- + 10 | 20 + 20 | 30 + 30 | 50 + 50 | -1 +(4 rows) + +RESET enable_sort; RESET enable_hashagg; +-- and under grouping sets +SELECT grpid, max(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY ROLLUP(grpid) ORDER BY grpid; + grpid | max +-------+----- + 10 | 20 + 20 | 30 + 30 | 50 + 50 | -1 + | 50 +(5 rows) + +-- Since ON EMPTY cannot distinguish "no rows at all" from "rows that +-- happen to compute to NULL", an aggregate whose final function can +-- legitimately return NULL from non-empty input is indistinguishable from +-- COALESCE here too: percentile_cont's own final function returns NULL for +-- an all-NULL, non-empty ordered set, so ON EMPTY fires for that case just +-- as it would for a truly empty input. +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 50; -- -7, same as COALESCE(percentile_cont(...), -7) + percentile_cont +----------------- + -7 +(1 row) + +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 60; -- empty -> -7 + percentile_cont +----------------- + -7 +(1 row) + +-- the same COALESCE-equivalence holds for a window aggregate: ON EMPTY +-- fires for any row whose ordinary windowed result is NULL, all-NULL frames +-- included +SELECT val, max(val, -1 ON EMPTY) OVER () AS m + FROM agg_on_empty WHERE grpid = 50 ORDER BY id; + val | m +-----+---- + | -1 + | -1 +(2 rows) + +-- ON EMPTY must be a constant: a correlated reference to an outer query's +-- column is not constant and must be rejected (it is not caught by a +-- current-level-only Var check) +SELECT (SELECT max(inner_t.val, agg_on_empty.val ON EMPTY) + FROM agg_on_empty inner_t WHERE false) + FROM agg_on_empty WHERE grpid = 10; +ERROR: ON EMPTY expression must be a constant value +LINE 1: SELECT (SELECT max(inner_t.val, agg_on_empty.val ON EMPTY) + ^ +-- clean up +DROP VIEW agg_on_empty_vw; +DROP TABLE agg_on_empty; diff --git a/src/test/regress/expected/window.out b/src/test/regress/expected/window.out index c0bde1c5eec..2ad169a22ed 100644 --- a/src/test/regress/expected/window.out +++ b/src/test/regress/expected/window.out @@ -6257,3 +6257,134 @@ SELECT last_value FROM null_treatment_seq; --cleanup DROP TABLE planets CASCADE; NOTICE: drop cascades to view planets_view +-- Test ON EMPTY clause with window functions +-- create a test table for ON EMPTY tests +CREATE TABLE win_on_empty(id int, val int, grpid int); +INSERT INTO win_on_empty VALUES (1, 10, 10), (2, 20, 10), (3, 30, 20), (4, NULL, 20), (5, 50, 30); +-- Empty frames using ROWS (ON EMPTY value WILL be returned) +-- On first row, "1 PRECEDING" frame is empty - should return ON EMPTY value +SELECT id, val, + sum(val, -111 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS sum_prev_row +FROM win_on_empty ORDER BY id; + id | val | sum_prev_row +----+-----+-------------- + 1 | 10 | -111 + 2 | 20 | 10 + 3 | 30 | 20 + 4 | | 30 + 5 | 50 | -111 +(5 rows) + +-- Empty frames using ROWS FOLLOWING (ON EMPTY value WILL be returned) +-- On last rows, FOLLOWING frame is empty - should return ON EMPTY value +SELECT id, val, + sum(val, -222 ON EMPTY) OVER (ORDER BY id ASC ROWS BETWEEN 1 FOLLOWING AND 2 FOLLOWING) AS sum_following +FROM win_on_empty ORDER BY id; + id | val | sum_following +----+-----+--------------- + 1 | 10 | 50 + 2 | 20 | 30 + 3 | 30 | 50 + 4 | | 50 + 5 | 50 | -222 +(5 rows) + +SELECT id, val, + sum(val, -222 ON EMPTY) OVER (ORDER BY id DESC ROWS BETWEEN 1 FOLLOWING AND 2 FOLLOWING) AS sum_following +FROM win_on_empty ORDER BY id; + id | val | sum_following +----+-----+--------------- + 1 | 10 | -222 + 2 | 20 | 10 + 3 | 30 | 30 + 4 | | 50 + 5 | 50 | 30 +(5 rows) + +-- Two window aggregate calls with different ON EMPTY defaults, same +-- underlying aggregate/args/frame (so they may share transition state): +-- each must still get its own default, not one shared between them. +SELECT id, val, + sum(val, -111 ON EMPTY) OVER w AS d1, + sum(val, -222 ON EMPTY) OVER w AS d2, + sum(val) OVER w AS plain +FROM win_on_empty +WINDOW w AS (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) +ORDER BY id; + id | val | d1 | d2 | plain +----+-----+------+------+------- + 1 | 10 | -111 | -222 | + 2 | 20 | 10 | 10 | 10 + 3 | 30 | 20 | 20 | 20 + 4 | | 30 | 30 | 30 + 5 | 50 | -111 | -222 | +(5 rows) + +-- Frames containing only NULL values: ON EMPTY is exactly +-- COALESCE(normal_windowed_result, default_expression), so it fires +-- whenever the frame's ordinary result is NULL -- which it is here, since +-- sum/avg of an all-NULL frame is NULL regardless of frame shape. +SELECT id, val, + sum(val, -333 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND CURRENT ROW) AS sum_current, + avg(val, -3.0 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND CURRENT ROW) AS avg_current +FROM win_on_empty WHERE id = 4 ORDER BY id; + id | val | sum_current | avg_current +----+-----+-------------+------------- + 4 | | -333 | -3.0 +(1 row) + +-- Add more rows with NULLs to demonstrate +INSERT INTO win_on_empty VALUES (6, NULL, 40), (7, NULL, 40); +-- Same COALESCE-equivalence with a growing (RANGE UNBOUNDED PRECEDING) +-- frame instead of a sliding one: sum/avg/max are all NULL over an all-NULL +-- partition, so ON EMPTY fires for all three here. +SELECT grpid, id, val, + sum(val, -444 ON EMPTY) OVER (PARTITION BY grpid ORDER BY id) as sum_nulls, + avg(val, -4.0 ON EMPTY) OVER (PARTITION BY grpid ORDER BY id) as avg_nulls, + max(val, -999 ON EMPTY) OVER (PARTITION BY grpid ORDER BY id) as max_nulls +FROM win_on_empty WHERE grpid = 40 ORDER BY id; + grpid | id | val | sum_nulls | avg_nulls | max_nulls +-------+----+-----+-----------+-----------+----------- + 40 | 6 | | -444 | -4.0 | -999 + 40 | 7 | | -444 | -4.0 | -999 +(2 rows) + +CREATE VIEW win_on_empty_vw AS + SELECT id, val, + sum(val, -111 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS sum_prev_row + FROM win_on_empty ORDER BY id; +\d+ win_on_empty_vw + View "public.win_on_empty_vw" + Column | Type | Collation | Nullable | Default | Storage | Description +--------------+---------+-----------+----------+---------+---------+------------- + id | integer | | | | plain | + val | integer | | | | plain | + sum_prev_row | bigint | | | | plain | +View definition: + SELECT id, + val, + sum(val, '-111'::integer ON EMPTY) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS sum_prev_row + FROM win_on_empty + ORDER BY id; + +SELECT * FROM win_on_empty_vw ORDER BY id; + id | val | sum_prev_row +----+-----+-------------- + 1 | 10 | -111 + 2 | 20 | 10 + 3 | 30 | 20 + 4 | | 30 + 5 | 50 | -111 + 6 | | 50 + 7 | | -111 +(7 rows) + +-- ON EMPTY is only meaningful for aggregates; reject it for non-aggregate +-- window functions (should fail) +SELECT lag(val, 1, 0 ON EMPTY) OVER (ORDER BY id) FROM win_on_empty; +ERROR: ON EMPTY is not implemented for non-aggregate window functions +LINE 1: SELECT lag(val, 1, 0 ON EMPTY) OVER (ORDER BY id) FROM win_o... + ^ +-- clean up +DROP VIEW win_on_empty_vw; +DROP TABLE win_on_empty; diff --git a/src/test/regress/sql/aggregates.sql b/src/test/regress/sql/aggregates.sql index 91f8342166f..028e525b09b 100644 --- a/src/test/regress/sql/aggregates.sql +++ b/src/test/regress/sql/aggregates.sql @@ -1786,3 +1786,225 @@ drop table agg_hash_1; drop table agg_hash_2; drop table agg_hash_3; drop table agg_hash_4; + + +-- Test ON EMPTY clause for aggregates + +-- create a test table for ON EMPTY tests +CREATE TABLE agg_on_empty(id int, val int, grpid int); + +-- with empty table +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty; + +INSERT INTO agg_on_empty VALUES (1, 10, 10), (2, 20, 10), (3, 30, 20), (4, NULL, 20), (5, 50, 30); + +-- basic ON EMPTY tests with numeric default +SELECT sum(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT avg(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT max(val, 999 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT min(val, -999 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT count(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with non-empty result set (should use actual aggregate result) +SELECT sum(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; +SELECT avg(val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; +SELECT max(val, 999 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; +SELECT min(val, -999 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + +-- with string default +SELECT string_agg(grpid::text, ',', 'NONE' ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT string_agg(grpid::text, ',', 'NONE' ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + +-- with NULL default +SELECT sum(val, NULL ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT avg(val, NULL ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT string_agg(grpid::text, ',', NULL ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with type coercion (integer to numeric) +SELECT sum(val::numeric, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT sum(val, 0.5 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- Multiple aggregate calls in one query that share the same transition +-- function, arguments and filter (so the planner may compute them via a +-- single shared transition state) must still each get their own ON EMPTY +-- default: sharing the transition state must not mean sharing (or losing) +-- the per-aggregate ON EMPTY expression. +SELECT sum(val), sum(val, 10 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT sum(val, 10 ON EMPTY), sum(val) FROM agg_on_empty WHERE grpid = 40; +SELECT sum(val, 10 ON EMPTY), sum(val, 20 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +SELECT max(val, 1 ON EMPTY), max(val, 2 ON EMPTY), max(val) FROM agg_on_empty WHERE grpid = 40; +-- two references to the exact same ON EMPTY aggregate call may still share +-- everything, including the result +SELECT sum(val, 10 ON EMPTY), sum(val, 10 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; +-- and the same, but over non-empty input, where ON EMPTY does not fire for +-- any of them +SELECT sum(val), sum(val, 10 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; +SELECT sum(val, 10 ON EMPTY), sum(val, 20 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; + +-- with GROUP BY +SELECT grpid, sum(val, 0 ON EMPTY) FROM agg_on_empty GROUP BY grpid ORDER BY grpid; + +-- with GROUP BY and empty groups: a LEFT JOIN with no match produces one +-- row with a NULL value, not zero rows, but sum's result is NULL either +-- way, so groups 100 and 200 get the ON EMPTY value here too +SELECT g, sum(val, -1 ON EMPTY) FROM (VALUES (100), (200), (10), (20)) AS groups(g) + LEFT JOIN agg_on_empty ON groups.g = agg_on_empty.grpid + GROUP BY g ORDER BY g; + +-- with grouping sets: per-group state is reset across grouping-set phases, so +-- ON EMPTY must not fire spuriously for groups that do have rows +SELECT grpid, sum(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY ROLLUP(grpid) ORDER BY grpid; + +-- with grouping sets where a FILTER empties one group: ON EMPTY fires only for +-- that group (checked under both sorted and hashed grouping-set execution) +SELECT grpid, sum(val, -1 ON EMPTY) FILTER (WHERE val > 25) FROM agg_on_empty + GROUP BY GROUPING SETS ((grpid), ()) ORDER BY grpid; +SET enable_sort = off; +SELECT grpid, sum(val, -1 ON EMPTY) FILTER (WHERE val > 25) FROM agg_on_empty + GROUP BY GROUPING SETS ((grpid), ()) ORDER BY grpid; +RESET enable_sort; + +-- multi-dimensional CUBE with a FILTER: ON EMPTY fires independently for each +-- empty cell (including sub-aggregates), while populated cells return real sums +SELECT grpid, id % 2 AS parity, sum(val, -1 ON EMPTY) FILTER (WHERE val > 25) AS s + FROM agg_on_empty GROUP BY CUBE(grpid, id % 2) ORDER BY grpid, parity; + +-- with FILTER clause +SELECT sum(val, 100 ON EMPTY) FILTER (WHERE val > 100) FROM agg_on_empty; +SELECT count(val, 0 ON EMPTY) FILTER (WHERE val > 100) FROM agg_on_empty; + +-- with ORDER BY in aggregate +SELECT avg(val, -999 ON EMPTY ORDER BY val) FROM agg_on_empty WHERE grpid = 40; +SELECT avg(val, -999 ON EMPTY ORDER BY val) FROM agg_on_empty WHERE grpid = 10; + +-- under a forced parallel plan: ON EMPTY must work whether or not the +-- aggregate is parallelized (such aggregates are not partially aggregated). +SET max_parallel_workers_per_gather = 4; +SET parallel_setup_cost = 0; +SET parallel_tuple_cost = 0; +SET min_parallel_table_scan_size = 0; +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; -- default +SELECT sum(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 10; -- real result +RESET max_parallel_workers_per_gather; +RESET parallel_setup_cost; +RESET parallel_tuple_cost; +RESET min_parallel_table_scan_size; + +-- Error cases for ON EMPTY + +-- with complex expressions (should fail - must be constant) +SELECT sum(val, val ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with type mismatch (should fail) +SELECT sum(val, 'invalid' ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with non-aggregate function (should fail) +SELECT length('test', 'default' ON EMPTY); + +-- with a subquery (should fail) +SELECT sum(val, (SELECT 1) ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with a volatile function (should fail) +SELECT sum(val, random()::int ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with a set-returning function (should fail) +SELECT sum(val, generate_series(1, 2) ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with a nested aggregate (should fail) +SELECT sum(val, sum(val) ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- with a window function (should fail) +SELECT sum(val, row_number() OVER () ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- combined with DISTINCT (should fail - not supported by the grammar) +SELECT sum(DISTINCT val, 0 ON EMPTY) FROM agg_on_empty WHERE grpid = 40; + +-- combined with an ordered-set aggregate (WITHIN GROUP): the ON EMPTY value +-- is written before WITHIN GROUP and is returned for an empty input set +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 40; -- empty -> default +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 10; -- real result + +-- and it round-trips through view deparsing +CREATE VIEW agg_on_empty_os_vw AS + SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) AS p + FROM agg_on_empty; +SELECT pg_get_viewdef('agg_on_empty_os_vw'); +DROP VIEW agg_on_empty_os_vw; + +-- deparsing of ON EMPTY in views +CREATE VIEW agg_on_empty_vw AS + SELECT grpid, sum(val, 0 ON EMPTY ORDER BY val) AS total, + avg(val, 0 ON EMPTY) AS average, count(val, 0 ON EMPTY) AS cnt + FROM agg_on_empty GROUP BY grpid; + +\d+ agg_on_empty_vw + +SELECT * FROM agg_on_empty_vw ORDER BY grpid; + +-- with prepared statements (must use literal constants) +PREPARE on_empty_test(int) AS + SELECT sum(val, 42 ON EMPTY) FROM agg_on_empty WHERE grpid = $1; + +EXECUTE on_empty_test(40); +EXECUTE on_empty_test(10); + +DEALLOCATE on_empty_test; + +-- with HAVING clause +SELECT grpid, sum(val, 0 ON EMPTY) AS total FROM agg_on_empty + GROUP BY grpid HAVING sum(val, 0 ON EMPTY) > 20 ORDER BY grpid; + +-- ON EMPTY is exactly COALESCE(normal_result, default_expression): it fires +-- whenever the aggregate's ordinary result is NULL, for whatever reason -- +-- not only for a truly empty input. In particular, a non-empty group whose +-- rows are all NULL also gets the ON EMPTY value here, because max/min's +-- ordinary result for such a group is NULL anyway. This is exercised across +-- the sorted, hashed and grouping-set execution paths. +INSERT INTO agg_on_empty VALUES (6, NULL, 50), (7, NULL, 50); + +SELECT max(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 50; -- -1, same as COALESCE(max(val), -1) +SELECT min(val, -1 ON EMPTY) FROM agg_on_empty WHERE grpid = 50; -- -1, same as COALESCE(min(val), -1) + +-- and per group, under both sorted and hashed aggregation +SELECT grpid, max(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY grpid ORDER BY grpid; +SET enable_sort = off; SET enable_hashagg = on; +SELECT grpid, max(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY grpid ORDER BY grpid; +RESET enable_sort; RESET enable_hashagg; + +-- and under grouping sets +SELECT grpid, max(val, -1 ON EMPTY) FROM agg_on_empty + GROUP BY ROLLUP(grpid) ORDER BY grpid; + +-- Since ON EMPTY cannot distinguish "no rows at all" from "rows that +-- happen to compute to NULL", an aggregate whose final function can +-- legitimately return NULL from non-empty input is indistinguishable from +-- COALESCE here too: percentile_cont's own final function returns NULL for +-- an all-NULL, non-empty ordered set, so ON EMPTY fires for that case just +-- as it would for a truly empty input. +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 50; -- -7, same as COALESCE(percentile_cont(...), -7) +SELECT percentile_cont(0.5, -7 ON EMPTY) WITHIN GROUP (ORDER BY val) + FROM agg_on_empty WHERE grpid = 60; -- empty -> -7 + +-- the same COALESCE-equivalence holds for a window aggregate: ON EMPTY +-- fires for any row whose ordinary windowed result is NULL, all-NULL frames +-- included +SELECT val, max(val, -1 ON EMPTY) OVER () AS m + FROM agg_on_empty WHERE grpid = 50 ORDER BY id; + +-- ON EMPTY must be a constant: a correlated reference to an outer query's +-- column is not constant and must be rejected (it is not caught by a +-- current-level-only Var check) +SELECT (SELECT max(inner_t.val, agg_on_empty.val ON EMPTY) + FROM agg_on_empty inner_t WHERE false) + FROM agg_on_empty WHERE grpid = 10; + +-- clean up +DROP VIEW agg_on_empty_vw; +DROP TABLE agg_on_empty; diff --git a/src/test/regress/sql/window.sql b/src/test/regress/sql/window.sql index 8e6f92d94c7..39cfec89b42 100644 --- a/src/test/regress/sql/window.sql +++ b/src/test/regress/sql/window.sql @@ -2301,3 +2301,73 @@ SELECT last_value FROM null_treatment_seq; --cleanup DROP TABLE planets CASCADE; + + +-- Test ON EMPTY clause with window functions + +-- create a test table for ON EMPTY tests +CREATE TABLE win_on_empty(id int, val int, grpid int); +INSERT INTO win_on_empty VALUES (1, 10, 10), (2, 20, 10), (3, 30, 20), (4, NULL, 20), (5, 50, 30); + +-- Empty frames using ROWS (ON EMPTY value WILL be returned) +-- On first row, "1 PRECEDING" frame is empty - should return ON EMPTY value +SELECT id, val, + sum(val, -111 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS sum_prev_row +FROM win_on_empty ORDER BY id; + +-- Empty frames using ROWS FOLLOWING (ON EMPTY value WILL be returned) +-- On last rows, FOLLOWING frame is empty - should return ON EMPTY value +SELECT id, val, + sum(val, -222 ON EMPTY) OVER (ORDER BY id ASC ROWS BETWEEN 1 FOLLOWING AND 2 FOLLOWING) AS sum_following +FROM win_on_empty ORDER BY id; +SELECT id, val, + sum(val, -222 ON EMPTY) OVER (ORDER BY id DESC ROWS BETWEEN 1 FOLLOWING AND 2 FOLLOWING) AS sum_following +FROM win_on_empty ORDER BY id; + +-- Two window aggregate calls with different ON EMPTY defaults, same +-- underlying aggregate/args/frame (so they may share transition state): +-- each must still get its own default, not one shared between them. +SELECT id, val, + sum(val, -111 ON EMPTY) OVER w AS d1, + sum(val, -222 ON EMPTY) OVER w AS d2, + sum(val) OVER w AS plain +FROM win_on_empty +WINDOW w AS (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) +ORDER BY id; + +-- Frames containing only NULL values: ON EMPTY is exactly +-- COALESCE(normal_windowed_result, default_expression), so it fires +-- whenever the frame's ordinary result is NULL -- which it is here, since +-- sum/avg of an all-NULL frame is NULL regardless of frame shape. +SELECT id, val, + sum(val, -333 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND CURRENT ROW) AS sum_current, + avg(val, -3.0 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN CURRENT ROW AND CURRENT ROW) AS avg_current +FROM win_on_empty WHERE id = 4 ORDER BY id; + +-- Add more rows with NULLs to demonstrate +INSERT INTO win_on_empty VALUES (6, NULL, 40), (7, NULL, 40); +-- Same COALESCE-equivalence with a growing (RANGE UNBOUNDED PRECEDING) +-- frame instead of a sliding one: sum/avg/max are all NULL over an all-NULL +-- partition, so ON EMPTY fires for all three here. +SELECT grpid, id, val, + sum(val, -444 ON EMPTY) OVER (PARTITION BY grpid ORDER BY id) as sum_nulls, + avg(val, -4.0 ON EMPTY) OVER (PARTITION BY grpid ORDER BY id) as avg_nulls, + max(val, -999 ON EMPTY) OVER (PARTITION BY grpid ORDER BY id) as max_nulls +FROM win_on_empty WHERE grpid = 40 ORDER BY id; + +CREATE VIEW win_on_empty_vw AS + SELECT id, val, + sum(val, -111 ON EMPTY) OVER (ORDER BY id ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS sum_prev_row + FROM win_on_empty ORDER BY id; + +\d+ win_on_empty_vw + +SELECT * FROM win_on_empty_vw ORDER BY id; + +-- ON EMPTY is only meaningful for aggregates; reject it for non-aggregate +-- window functions (should fail) +SELECT lag(val, 1, 0 ON EMPTY) OVER (ORDER BY id) FROM win_on_empty; + +-- clean up +DROP VIEW win_on_empty_vw; +DROP TABLE win_on_empty; -- 2.43.0