From ac1229a5c8039d656b73de032ce648bcd378d71d Mon Sep 17 00:00:00 2001 From: Linden Lance Date: Fri, 9 Oct 2026 01:34:45 +1300 Subject: [PATCH v2 2/2] Hash a ScalarArrayOpExpr whose array is fixed for one execution Since PG 14 (50e17ad281) a ScalarArrayOpExpr with "useOr" can be evaluated with a hash table instead of a linear scan of the array, but only when the array argument is a Const. In a generic plan, a parameterised list -- "col = ANY($1)", "col IN ($1, $2, ..., $N)", "col = ANY($1::int[])", "col = ANY(string_to_array($1, ','))" -- keeps a Param / ArrayExpr / FuncExpr in the plan and falls back to the O(rows * N) linear path; so does an array computed by a stable function, in any plan. That is the shape emitted by JDBC setArray, psycopg and asyncpg, and by any driver that expands an IN list into bind parameters, and a prepared statement switches to its generic plan after five executions when that plan looks cheaper. MySQL and MariaDB binary-search their sorted in_vector for the same query and stay sub-linear. Treat "is this array fixed for the execution?" as the property that matters, not the node type. * clauses.c: convert_saop_to_hashed_saop_walker() accepts, besides a non-null Const, any non-Const array argument that cannot vary from one row to the next (nor from group to group, nor from one window frame to the next). saop_array_may_vary_walker() decides that with an allowlist: it accepts only Consts, external Params, and node types that compute their result from their inputs alone -- function and operator calls that don't return a set, casts, array and row constructors, CASE, COALESCE and the like -- and treats everything else as varying: a Var or PlaceHolderVar of any level, an aggregate, grouping or window function, merge_action(), a ReturningExpr (OLD/NEW of a view column that is an expression, which is NULL when that row doesn't exist), a sub-select, a non-external Param, a CaseTestExpr or CoerceToDomainValue placeholder, and any node type added later. An ArrayCoerceExpr's per-element expression is not examined, as it reads each element through its own CaseTestExpr. Volatile functions are rejected too. A Var of any level must be rejected because this runs in preprocess_expression() before SS_replace_correlation_vars(), so an outer-query reference is still a Var, not a Param. The element count is known at plan time for a Const and for a one-dimensional ArrayExpr, so the MIN_ARRAY_SIZE_FOR_HASHED_SAOP cutoff is applied there; for anything else the executor applies it once the run-time array is known. The walker now also descends into the arguments of a ScalarArrayOpExpr it has just handled, as it already did for a non-Const array, so nested ones are considered too. * execExpr.c: for a non-Const stable array in a plan node the hashed step no longer emits the array sub-expression inline (which would rebuild it every row); it is compiled into an independent ExprState (array_expr) that the step owns and evaluates once. A standalone ExprState -- a PL/pgSQL "simple expression", whose compiled state is reused across calls with different parameter values -- gets a plain EEOP_SCALARARRAYOP instead, which must then use the operator's own function rather than the equality function a hashed NOT IN uses. So does an expression compiled to report errors softly (a JSON DEFAULT ... ON ERROR expression), since the separately compiled array would raise them. * execExprInterp.c: ExecEvalHashedScalarArrayOp() evaluates array_expr once, keeps a detoasted copy of the result in the run-time state, and builds the hash table from it with saop_build_hashtable(). A run-time array shorter than the threshold is searched linearly through ExecEvalArrayCompareInternal() instead, using element type metadata cached in the run-time state. As in ExecEvalScalarArrayOp(), a NULL array yields NULL and an empty one false (true for NOT IN) even for a NULL scalar, so the NULL-scalar shortcut for a strict function now comes after the array is known. array_expr takes the place of the finfo field removed by the previous patch, so ExprEvalStep does not grow. Regression tests for the new array shapes -- external Param, IN ($1,...,$N), $1::int[], string_to_array(), a stable function, a NULL scalar with an empty array, a JSON DEFAULT ... ON ERROR expression, the correlated-sub-select exclusion, merge_action() in MERGE ... RETURNING, a set-returning function, OLD/NEW of a view column, and NOT IN in a PL/pgSQL simple expression -- are added to src/test/regress/sql/expressions.sql next to the existing hashed-SAOP tests. Not handled: a correlated array (a Var / PlaceHolderVar / PARAM_EXEC, or an ARRAY(SELECT ...) sub-select), which is deliberately excluded and stays on the linear path. Discussion: https://postgr.es/m/TY6PR01MB17327691C30B403369B16B02AF4B02@TY6PR01MB17327.jpnprd01.prod.outlook.com --- src/backend/executor/execExpr.c | 61 +++-- src/backend/executor/execExprInterp.c | 153 ++++++++--- src/backend/optimizer/plan/planner.c | 6 +- src/backend/optimizer/util/clauses.c | 177 ++++++++++--- src/include/executor/execExpr.h | 6 + src/include/nodes/primnodes.h | 5 +- src/test/regress/expected/expressions.out | 295 ++++++++++++++++++++++ src/test/regress/sql/expressions.sql | 169 +++++++++++++ 8 files changed, 782 insertions(+), 90 deletions(-) diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c index 10cdac20de6..cc90c89e14c 100644 --- a/src/backend/executor/execExpr.c +++ b/src/backend/executor/execExpr.c @@ -1272,6 +1272,25 @@ ExecInitExprRec(Expr *node, ExprState *state, FunctionCallInfo fcinfo; AclResult aclresult; Oid cmpfuncid; + bool use_hash; + + Assert(list_length(opexpr->args) == 2); + scalararg = (Expr *) linitial(opexpr->args); + arrayarg = (Expr *) lsecond(opexpr->args); + + /* + * Use a hash table if the planner set hashfuncid. But a + * non-Const array is only known to be fixed within one + * execution of a plan, so a standalone expression (such as a + * PL/pgSQL "simple expression", which is reused with + * different parameter values) searches it linearly instead. + * So does an expression whose errors are to be reported + * softly (a JSON DEFAULT ... ON ERROR expression, say), since + * the separately compiled array below would raise them. + */ + use_hash = OidIsValid(opexpr->hashfuncid) && + (IsA(arrayarg, Const) || + (state->parent != NULL && state->escontext == NULL)); /* * Select the correct comparison function. When we do hashed @@ -1279,7 +1298,7 @@ ExecInitExprRec(Expr *node, ExprState *state, * comparison function and negfuncid will be set to equality. * We need to use the equality function for hash probes. */ - if (OidIsValid(opexpr->negfuncid)) + if (use_hash && OidIsValid(opexpr->negfuncid)) { Assert(OidIsValid(opexpr->hashfuncid)); cmpfuncid = opexpr->negfuncid; @@ -1287,10 +1306,6 @@ ExecInitExprRec(Expr *node, ExprState *state, else cmpfuncid = opexpr->opfuncid; - Assert(list_length(opexpr->args) == 2); - scalararg = (Expr *) linitial(opexpr->args); - arrayarg = (Expr *) lsecond(opexpr->args); - /* Check permission to call function */ aclresult = object_aclcheck(ProcedureRelationId, cmpfuncid, GetUserId(), @@ -1300,7 +1315,7 @@ ExecInitExprRec(Expr *node, ExprState *state, get_func_name(cmpfuncid)); InvokeFunctionExecuteHook(cmpfuncid); - if (OidIsValid(opexpr->hashfuncid)) + if (use_hash) { aclresult = object_aclcheck(ProcedureRelationId, opexpr->hashfuncid, GetUserId(), @@ -1326,20 +1341,36 @@ ExecInitExprRec(Expr *node, ExprState *state, * when the number of items in the array is anything but very * small. */ - if (OidIsValid(opexpr->hashfuncid)) + if (use_hash) { /* Evaluate scalar directly into left function argument */ ExecInitExprRec(scalararg, state, &fcinfo->args[0].value, &fcinfo->args[0].isnull); - /* - * Evaluate array argument into our return value. There's - * no danger in that, because the return value is - * guaranteed to be overwritten by - * EEOP_HASHED_SCALARARRAYOP, and will not be passed to - * any other expression. - */ - ExecInitExprRec(arrayarg, state, resv, resnull); + if (IsA(arrayarg, Const)) + { + /* + * Evaluate array argument into our return value. + * There's no danger in that, because the return value + * is guaranteed to be overwritten by + * EEOP_HASHED_SCALARARRAYOP, and will not be passed + * to any other expression. + */ + ExecInitExprRec(arrayarg, state, resv, resnull); + scratch.d.hashedscalararrayop.array_expr = NULL; + } + else + { + /* + * The planner has proven that any other array is + * fixed for one execution (see + * convert_saop_to_hashed_saop()). Compile it as a + * separate expression, which + * EEOP_HASHED_SCALARARRAYOP evaluates only once. + */ + scratch.d.hashedscalararrayop.array_expr = + ExecInitExpr(arrayarg, state->parent); + } /* And perform the operation */ scratch.opcode = EEOP_HASHED_SCALARARRAYOP; diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c index 816c2baea23..5b4d75a4675 100644 --- a/src/backend/executor/execExprInterp.c +++ b/src/backend/executor/execExprInterp.c @@ -226,11 +226,21 @@ static void saop_build_hashtable(ExprEvalStep *op, ExprContext *econtext, /* * ScalarArrayOpExprHashTable * Hash table for EEOP_HASHED_SCALARARRAYOP + * + * An array that is not a Const is evaluated once and kept in cached_array. + * An array too short to be worth hashing leaves hashtab NULL and is searched + * linearly. */ typedef struct ScalarArrayOpExprHashTable { - saophash_hash *hashtab; /* underlying hash table */ + saophash_hash *hashtab; /* underlying hash table, or NULL */ struct ExprEvalStep *op; + Datum cached_array; /* the array the state was built from */ + bool cache_isnull; /* the array is NULL */ + int nitems; /* number of elements in the array */ + int16 typlen; /* element type, for a linear search */ + bool typbyval; + char typalign; FmgrInfo hash_finfo; /* function's lookup data */ FunctionCallInfoBaseData hash_fcinfo_data; /* arguments etc */ } ScalarArrayOpExprHashTable; @@ -4360,15 +4370,41 @@ saop_build_hashtable(ExprEvalStep *op, ExprContext *econtext, ArrayType *arr, } /* - * Evaluate "scalar op ANY (const array)". + * Get the array for ExecEvalHashedScalarArrayOp(), setting *arr_value and + * *arr_isnull. A Const array is already in the step's result area; any other + * array is evaluated here. + */ +static void +saop_hash_eval_array(ExprEvalStep *op, ExprContext *econtext, + Datum *arr_value, bool *arr_isnull) +{ + ExprState *array_expr = op->d.hashedscalararrayop.array_expr; + + if (array_expr == NULL) + { + *arr_value = *op->resvalue; + *arr_isnull = *op->resnull; + return; + } + + /* Open-coded rather than ExecEvalExpr() to avoid including executor.h. */ + *arr_value = array_expr->evalfunc(array_expr, econtext, arr_isnull); +} + +/* + * Evaluate "scalar op ANY (array)", where the array is fixed for the whole + * execution. * * Similar to ExecEvalScalarArrayOp, but optimized for faster repeat lookups * by building a hashtable on the first lookup. This hashtable will be reused * by subsequent lookups. Unlike ExecEvalScalarArrayOp, this version only * supports OR semantics. * - * Source array is in our result area, scalar arg is already evaluated into - * fcinfo->args[0]. + * A Const array is in our result area; any other array is evaluated once, + * using op->d.hashedscalararrayop.array_expr. If the planner could not check + * the array's length and it turns out shorter than + * MIN_ARRAY_SIZE_FOR_HASHED_SAOP, the array is searched linearly instead. + * The scalar arg is already evaluated into fcinfo->args[0]. * * The operator always yields boolean. */ @@ -4386,35 +4422,18 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco bool hashfound; /* We don't setup a hashed scalar array op if the array const is null. */ - Assert(!*op->resnull); - - /* - * If the scalar is NULL, and the function is strict, return NULL; no - * point in executing the search. - */ - if (scalar_isnull && strictfunc) - { - *op->resnull = true; - return; - } + Assert(op->d.hashedscalararrayop.array_expr != NULL || !*op->resnull); /* Build the hash table on first evaluation */ if (elements_tab == NULL) { - int16 typlen; - bool typbyval; - char typalign; - int nitems; + int nitems = 0; MemoryContext oldcontext; - ArrayType *arr; + ArrayType *arr = NULL; + Datum arr_value; + bool arr_isnull; - arr = DatumGetArrayTypeP(*op->resvalue); - nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); - - get_typlenbyvalalign(ARR_ELEMTYPE(arr), - &typlen, - &typbyval, - &typalign); + saop_hash_eval_array(op, econtext, &arr_value, &arr_isnull); oldcontext = MemoryContextSwitchTo(econtext->ecxt_per_query_memory); @@ -4423,11 +4442,87 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco SizeForFunctionCallInfo(1)); op->d.hashedscalararrayop.elements_tab = elements_tab; elements_tab->op = op; + elements_tab->cache_isnull = arr_isnull; + + if (!arr_isnull) + { + /* + * An evaluated array may be short-lived or toasted, so keep a + * flat copy of it for the whole execution. A Const already lasts + * that long. + */ + if (op->d.hashedscalararrayop.array_expr != NULL) + arr = DatumGetArrayTypePCopy(arr_value); + else + arr = DatumGetArrayTypeP(arr_value); + elements_tab->cached_array = PointerGetDatum(arr); + nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); + elements_tab->nitems = nitems; + + get_typlenbyvalalign(ARR_ELEMTYPE(arr), + &elements_tab->typlen, + &elements_tab->typbyval, + &elements_tab->typalign); + } MemoryContextSwitchTo(oldcontext); - saop_build_hashtable(op, econtext, arr, nitems, - typlen, typbyval, typalign); + /* A shorter array is searched linearly instead, see below */ + if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP) + saop_build_hashtable(op, econtext, arr, nitems, + elements_tab->typlen, + elements_tab->typbyval, + elements_tab->typalign); + } + + /* + * As in ExecEvalScalarArrayOp(), a NULL array yields NULL even if the + * function isn't strict, and an empty array yields false (true for NOT + * IN) even if the scalar is NULL. Only a non-Const array can be NULL or + * empty here. + */ + if (elements_tab->cache_isnull) + { + *op->resnull = true; + return; + } + if (elements_tab->nitems == 0) + { + *op->resvalue = BoolGetDatum(!inclause); + *op->resnull = false; + return; + } + + /* + * If the scalar is NULL, and the function is strict, return NULL; no + * point in executing the search. + */ + if (scalar_isnull && strictfunc) + { + *op->resnull = true; + return; + } + + /* + * An array too short to be worth hashing is searched linearly, the same + * way ExecEvalScalarArrayOp() does it. + */ + if (elements_tab->hashtab == NULL) + { + ExecEvalArrayCompareInternal(fcinfo, + DatumGetArrayTypeP(elements_tab->cached_array), + elements_tab->typlen, + elements_tab->typbyval, + elements_tab->typalign, + true, &result, &resultnull); + + /* invert non-NULL results for NOT IN */ + if (!resultnull && !inclause) + result = BoolGetDatum(!DatumGetBool(result)); + + *op->resvalue = result; + *op->resnull = resultnull; + return; } /* diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index c3c158a253d..c9da75795e3 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -1476,9 +1476,9 @@ preprocess_expression(PlannerInfo *root, Node *expr, int kind) } /* - * Check for ANY ScalarArrayOpExpr with Const arrays and set the - * hashfuncid of any that might execute more quickly by using hash lookups - * instead of a linear search. + * Check for ANY ScalarArrayOpExpr whose array is fixed for one execution + * and set the hashfuncid of any that might execute more quickly by using + * hash lookups instead of a linear search. */ if (kind == EXPRKIND_QUAL || kind == EXPRKIND_TARGET) { diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index f78f874e5c3..3207b4e564b 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -132,6 +132,7 @@ static Relids find_nonnullable_rels_walker(Node *node, bool top_level); static List *find_nonnullable_vars_walker(Node *node, bool top_level); static bool is_strict_saop(ScalarArrayOpExpr *expr, bool falseOK); static bool convert_saop_to_hashed_saop_walker(Node *node, void *context); +static bool saop_array_may_vary_walker(Node *node, void *context); static bool grouping_conflict_walker(Node *node, grouping_walker_ctx *ctx); static bool grouping_check_operands(Oid opno, Oid inputcollid, List *args, grouping_walker_ctx *ctx); @@ -2637,13 +2638,19 @@ eval_const_expressions(PlannerInfo *root, Node *node) * evaluate using a hash table rather than a linear search. * * We'll use a hash table if all of the following conditions are met: - * 1. The 2nd argument of the array contain only Consts. + * 1. The 2nd argument is a non-null Const array, or a non-Const expression + * whose value is fixed for the duration of one execution: one built only + * from Consts, external Params and simple expressions over them, with no + * volatile or set-returning functions (see saop_array_may_vary_walker()). + * In the latter case the executor evaluates it once and builds the hash + * table from the run-time value. * 2. useOr is true or there is a valid negator operator for the * ScalarArrayOpExpr's opno. * 3. There's valid hash function for both left and righthand operands and * these hash functions are the same. - * 4. If the array contains enough elements for us to consider it to be - * worthwhile using a hash table rather than a linear search. + * 4. If the array is a Const, it contains enough elements to be worth hashing + * rather than doing a linear search. For a non-Const array the count is + * not known here, so the executor applies that cutoff at run time. */ void convert_saop_to_hashed_saop(Node *node) @@ -2664,9 +2671,41 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) Node *arrayarg = (Node *) lsecond(saop->args); Oid lefthashfunc; Oid righthashfunc; + bool try_hashing = false; - if (arrayarg && IsA(arrayarg, Const) && - !((Const *) arrayarg)->constisnull) + /* + * Hash the array when it is fixed for the whole execution and has at + * least MIN_ARRAY_SIZE_FOR_HASHED_SAOP elements: a non-null Const, or + * a non-Const expression that saop_array_may_vary_walker() accepts + * and that has no volatile function. The size cutoff is applied here + * when the count is known now (a Const, or a 1-D ArrayExpr); + * otherwise the executor applies it at run time. + */ + if (arrayarg && IsA(arrayarg, Const)) + { + Const *arrconst = (Const *) arrayarg; + + if (!arrconst->constisnull) + { + ArrayType *arr = (ArrayType *) DatumGetPointer(arrconst->constvalue); + + try_hashing = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)) >= + MIN_ARRAY_SIZE_FOR_HASHED_SAOP; + } + } + else if (arrayarg && + !saop_array_may_vary_walker(arrayarg, NULL) && + !contain_volatile_functions(arrayarg)) + { + if (IsA(arrayarg, ArrayExpr) && + !((ArrayExpr *) arrayarg)->multidims) + try_hashing = list_length(((ArrayExpr *) arrayarg)->elements) >= + MIN_ARRAY_SIZE_FOR_HASHED_SAOP; + else + try_hashing = true; + } + + if (try_hashing) { if (saop->useOr) { @@ -2674,23 +2713,8 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) &lefthashfunc, &righthashfunc) && lefthashfunc == righthashfunc) { - Datum arrdatum = ((Const *) arrayarg)->constvalue; - ArrayType *arr = (ArrayType *) DatumGetPointer(arrdatum); - int nitems; - - /* - * Only fill in the hash functions if the array looks - * large enough for it to be worth hashing instead of - * doing a linear search. - */ - nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); - - if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP) - { - /* Looks good. Fill in the hash functions */ - saop->hashfuncid = lefthashfunc; - } - return false; + /* Looks good. Fill in the hash functions */ + saop->hashfuncid = lefthashfunc; } } else /* !saop->useOr */ @@ -2707,29 +2731,14 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) &lefthashfunc, &righthashfunc) && lefthashfunc == righthashfunc) { - Datum arrdatum = ((Const *) arrayarg)->constvalue; - ArrayType *arr = (ArrayType *) DatumGetPointer(arrdatum); - int nitems; + /* Looks good. Fill in the hash functions */ + saop->hashfuncid = lefthashfunc; /* - * Only fill in the hash functions if the array looks - * large enough for it to be worth hashing instead of - * doing a linear search. + * Also set the negfuncid. The executor will need that to + * perform hashtable lookups. */ - nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr)); - - if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP) - { - /* Looks good. Fill in the hash functions */ - saop->hashfuncid = lefthashfunc; - - /* - * Also set the negfuncid. The executor will need - * that to perform hashtable lookups. - */ - saop->negfuncid = get_opcode(negator); - } - return false; + saop->negfuncid = get_opcode(negator); } } } @@ -2738,6 +2747,90 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context) return expression_tree_walker(node, convert_saop_to_hashed_saop_walker, NULL); } +/* + * saop_array_may_vary_walker + * True unless 'node', the array argument of a ScalarArrayOpExpr, is + * certain to have the same value for every row of one execution, so that + * it can be evaluated once and reused. + * + * We accept only Consts, external Params (whose values are fixed for the + * execution), and the node types listed below, which compute their result + * from their inputs alone. Anything else may vary from row to row: a Var or + * PlaceHolderVar of any level (this runs before SS_replace_correlation_vars, + * so an outer reference is still a Var), an aggregate, grouping or window + * function, merge_action(), OLD/NEW in RETURNING (a ReturningExpr), a + * sub-select, a non-external Param, a set-returning function, or a + * CaseTestExpr or CoerceToDomainValue, which stands for a value supplied by + * an enclosing node. Volatile functions are checked by the caller. + */ +static bool +saop_array_may_vary_walker(Node *node, void *context) +{ + if (node == NULL) + return false; + + switch (nodeTag(node)) + { + case T_Const: + return false; + + case T_Param: + return ((Param *) node)->paramkind != PARAM_EXTERN; + + case T_FuncExpr: + if (((FuncExpr *) node)->funcretset) + return true; + break; + + case T_OpExpr: + case T_DistinctExpr: + case T_NullIfExpr: + if (((OpExpr *) node)->opretset) + return true; + break; + + case T_ArrayCoerceExpr: + + /* + * The per-element expression reads each element through its own + * CaseTestExpr, so only the input array matters. + */ + return saop_array_may_vary_walker((Node *) ((ArrayCoerceExpr *) node)->arg, + context); + + case T_List: + case T_ScalarArrayOpExpr: + case T_BoolExpr: + case T_RelabelType: + case T_CoerceViaIO: + case T_ConvertRowtypeExpr: + case T_CoerceToDomain: + case T_ArrayExpr: + case T_RowExpr: + case T_RowCompareExpr: + case T_CoalesceExpr: + case T_MinMaxExpr: + case T_NullTest: + case T_BooleanTest: + case T_FieldSelect: + case T_SubscriptingRef: + case T_CaseExpr: + case T_SQLValueFunction: + + /* These are fixed if their inputs are, which we check below. */ + break; + + default: + + /* + * Assume any other node type may vary, so that we stay safe if + * someone adds a new one that does. + */ + return true; + } + + return expression_tree_walker(node, saop_array_may_vary_walker, context); +} /*-------------------- * estimate_expression_value diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h index fd1454d685b..056a491b5e0 100644 --- a/src/include/executor/execExpr.h +++ b/src/include/executor/execExpr.h @@ -646,6 +646,12 @@ typedef struct ExprEvalStep * returns. */ bool null_lhs_isnull; struct ScalarArrayOpExprHashTable *elements_tab; + + /* + * Compiled non-Const array argument, evaluated once at run time; + * NULL when the array is a Const filled in by a preceding step. + */ + struct ExprState *array_expr; FunctionCallInfo fcinfo_data; /* arguments etc */ ScalarArrayOpExpr *saop; } hashedscalararrayop; diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h index 948a85b94fb..3c1268444e1 100644 --- a/src/include/nodes/primnodes.h +++ b/src/include/nodes/primnodes.h @@ -891,7 +891,10 @@ typedef OpExpr NullIfExpr; * the result type (or the collation) because it must be boolean. * * A ScalarArrayOpExpr with a valid hashfuncid is evaluated during execution - * by building a hash table containing the Const values from the RHS arg. + * by building a hash table containing the values from the RHS arg, which is + * either a Const or an expression the planner has proven to be fixed for one + * execution (see convert_saop_to_hashed_saop()). The executor evaluates such + * an expression once, when it builds the hash table. * This table is probed during expression evaluation. The planner will set * hashfuncid to the hash function which must be used to build and probe the * hash table. The executor determines if it should use hash-based checks or diff --git a/src/test/regress/expected/expressions.out b/src/test/regress/expected/expressions.out index 730f7bc7eba..8799179bdce 100644 --- a/src/test/regress/expected/expressions.out +++ b/src/test/regress/expected/expressions.out @@ -327,6 +327,301 @@ select return_text_input('a') not in ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i f (1 row) +rollback; +-- +-- Hashed ScalarArrayOpExpr when the array argument is not a Const but is fixed +-- for the whole execution: external params, IN ($1,...,$N), stable functions. +-- Check the hashed path returns what the linear path does, and that the planner +-- does not hash an array that can vary per row or per group. +-- +begin; +create table saop_stab (i int); +insert into saop_stab select g from generate_series(1, 20) g; +-- a stable plpgsql function is never inlined, so the array stays non-Const +create function saop_intarr(int[]) returns int[] as + $$ begin return $1; end $$ language plpgsql stable; +-- just below / at / above the hashing threshold of 9 +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8}')); + array_agg +------------------- + {1,2,3,4,5,6,7,8} +(1 row) + +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9}')); + array_agg +--------------------- + {1,2,3,4,5,6,7,8,9} +(1 row) + +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9,10,11,12}')); + array_agg +------------------------------ + {1,2,3,4,5,6,7,8,9,10,11,12} +(1 row) + +-- NULL array yields NULL; empty array and no-match array yield no rows +select count(*) from saop_stab where i = any (saop_intarr(null)); + count +------- + 0 +(1 row) + +select count(*) from saop_stab where i = any (saop_intarr('{}')); + count +------- + 0 +(1 row) + +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{5,5,5,5,5,5,5,5,5,5}')); + array_agg +----------- + {5} +(1 row) + +-- an empty array gives false for IN and true for NOT IN, even for a NULL +select x, x = any (saop_intarr('{}')) as "in", + x <> all (saop_intarr('{}')) as not_in +from (values (1), (null)) v(x); + x | in | not_in +---+----+-------- + 1 | f | t + | f | t +(2 rows) + +-- NOT IN / <> ALL, with and without a NULL element (three-valued logic) +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,10}')); + count +------- + 10 +(1 row) + +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,null}')); + count +------- + 0 +(1 row) + +-- bare external Param array, generic plan (stays a Param); re-EXECUTE with a +-- different array, then NULL and empty +set plan_cache_mode = force_generic_plan; +prepare saop_p(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_p('{1,2,3,4,5,6,7,8,9,10}'); + array_agg +------------------------ + {1,2,3,4,5,6,7,8,9,10} +(1 row) + +execute saop_p('{11,12,13}'); + array_agg +------------ + {11,12,13} +(1 row) + +execute saop_p(null); + array_agg +----------- + +(1 row) + +execute saop_p('{}'); + array_agg +----------- + +(1 row) + +deallocate saop_p; +-- IN ($1, ..., $N) is an ArrayExpr of Params +prepare saop_in(int,int,int,int,int,int,int,int,int,int) as + select array_agg(i order by i) from saop_stab + where i in ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10); +execute saop_in(1,2,3,4,5,6,7,8,9,10); + array_agg +------------------------ + {1,2,3,4,5,6,7,8,9,10} +(1 row) + +deallocate saop_in; +-- $1::int[] cast, and string_to_array($1, ',') +prepare saop_cast(text) as + select array_agg(i order by i) from saop_stab where i = any ($1::int[]); +execute saop_cast('{2,4,6,8,10,12,14,16,18,20}'); + array_agg +----------------------------- + {2,4,6,8,10,12,14,16,18,20} +(1 row) + +deallocate saop_cast; +prepare saop_sta(text) as + select array_agg(i order by i) from saop_stab + where i::text = any (string_to_array($1, ',')); +execute saop_sta('1,2,3,4,5,6,7,8,9,10,11,12'); + array_agg +------------------------------ + {1,2,3,4,5,6,7,8,9,10,11,12} +(1 row) + +deallocate saop_sta; +-- errors in a JSON DEFAULT ... ON ERROR expression are handled softly, so an +-- array there is searched linearly and its error reported like any other +prepare saop_json(text) as + select json_value(jsonb '"x"', '$' returning int + default textcat((5 = any ($1::int[]))::text, '1')::int on error); +savepoint saop_json_error; +execute saop_json('not an array'); +ERROR: could not coerce ON ERROR expression (DEFAULT) to the RETURNING type +DETAIL: malformed array literal: "not an array" +rollback to savepoint saop_json_error; +deallocate saop_json; +-- same query under a custom plan: $1 folds to a Const and the pre-existing +-- Const path handles it -- must match the generic-plan result above +set plan_cache_mode = force_custom_plan; +prepare saop_c(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_c('{1,2,3,4,5,6,7,8,9,10}'); + array_agg +------------------------ + {1,2,3,4,5,6,7,8,9,10} +(1 row) + +deallocate saop_c; +-- rescan: a stable Param array on the inner side of a nestloop +set plan_cache_mode = force_generic_plan; +prepare saop_rs(int[]) as + select d.x, count(*) from (values (1),(2),(3)) d(x) + join saop_stab on saop_stab.i = any ($1) + group by d.x order by d.x; +execute saop_rs('{1,2,3,4,5,6,7,8,9,10}'); + x | count +---+------- + 1 | 10 + 2 | 10 + 3 | 10 +(3 rows) + +deallocate saop_rs; +reset plan_cache_mode; +-- two hashable ScalarArrayOpExprs in one qual: both must be applied (cf. +-- b136db07c6) and both correct +prepare saop_two(int[], int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1) or i = any ($2); +execute saop_two('{1,2,3,4,5,6,7,8,9,10}', '{15,16,17,18,19,20,1,2,3,4}'); + array_agg +------------------------------------------ + {1,2,3,4,5,6,7,8,9,10,15,16,17,18,19,20} +(1 row) + +deallocate saop_two; +-- the planner must NOT hash an array that varies per row: a Var in the array +-- keeps a plain (linear) ScalarArrayOpExpr +explain (costs off) +select i from saop_stab +where i = any (array[i,i+1,i+2,i+3,i+4,i+5,i+6,i+7,i+8]); + QUERY PLAN +-------------------------------------------------------------------------------------------------------- + Seq Scan on saop_stab + Filter: (i = ANY (ARRAY[i, (i + 1), (i + 2), (i + 3), (i + 4), (i + 5), (i + 6), (i + 7), (i + 8)])) +(2 rows) + +-- ... nor an array_agg() in a HAVING clause (a value per group, not per +-- execution): must not be hashed and must not error with "Aggref found in +-- non-Agg plan node" +select i % 3 as g, count(*) from saop_stab +group by i % 3 +having (i % 3) = any (array_agg(1)) +order by g; + g | count +---+------- + 1 | 7 +(1 row) + +-- ... nor an array built from an outer-query reference in a correlated +-- sub-select: it varies per rescan, so it must stay linear and give the same +-- answer as the below-threshold (never-hashed) form. convert_saop_to_hashed_saop +-- runs before uplevel Vars become Params, so the check must reject Vars of any +-- level. +select d.k, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7,d.k+8])) as ge9, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7])) as lt9 +from (values (1),(8),(15)) d(k) +order by d.k; + k | ge9 | lt9 +----+-----+----- + 1 | 9 | 8 + 8 | 9 | 8 + 15 | 6 | 6 +(3 rows) + +-- A PL/pgSQL "simple expression" is compiled once and reused with different +-- parameter values, so its array is searched linearly; NOT IN must then still +-- compare with the inequality operator, not the equality one used for hashing +create function saop_ne_all(x int, a int[]) returns bool language plpgsql + as $$ begin return x <> all (a); end $$; +create function saop_not_in(x int, a1 int, a2 int, a3 int, a4 int, a5 int, + a6 int, a7 int, a8 int, a9 int, a10 int) + returns bool language plpgsql + as $$ begin return x not in (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10); end $$; +select saop_ne_all(11, '{1,2,3,4,5,6,7,8,9,10}'), + saop_ne_all(5, '{1,2,3,4,5,6,7,8,9,10}'), + saop_ne_all(5, '{11,12,13,14,15,16,17,18,19,20}'); + saop_ne_all | saop_ne_all | saop_ne_all +-------------+-------------+------------- + t | f | t +(1 row) + +select saop_not_in(11, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10), + saop_not_in(5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + saop_not_in | saop_not_in +-------------+------------- + t | f +(1 row) + +-- ... and an array containing merge_action() changes from row to row, so it +-- must not be hashed +create table saop_merge (id int); +insert into saop_merge values (1); +with m as ( + merge into saop_merge t using (values (1), (2)) s(id) on t.id = s.id + when matched then update set id = s.id + when not matched then insert values (s.id) + returning merge_action() as action, + 'INSERT' = any (array[merge_action(), 'a', 'b', 'c', 'd', 'e', + 'f', 'g', 'h', 'i']) as is_insert) +select action, is_insert from m order by action; + action | is_insert +--------+----------- + INSERT | t + UPDATE | f +(2 rows) + +-- ... nor one containing a set-returning function, which gives a new array for +-- each row +select 'b' = any (regexp_matches('abcdefghijklmnopqrstuvwxyz', + '(.)(.)(.)(.)(.)(.)(.)(.)(.)', 'g')) as has_b; + has_b +------- + t + f +(2 rows) + +-- ... nor OLD/NEW of a view column that is an expression, which is NULL when +-- that row doesn't exist +create view saop_merge_v as + select id, saop_intarr('{1,2,3,4,5,6,7,8,9,10}') as arr from saop_merge; +with m as ( + merge into saop_merge_v t using (values (1), (3)) s(id) on t.id = s.id + when matched then update set id = s.id + when not matched then insert values (s.id) + returning merge_action() as action, 5 = any (old.arr) as old_has_5) +select action, old_has_5 from m order by action; + action | old_has_5 +--------+----------- + INSERT | + UPDATE | t +(2 rows) + rollback; -- Test with non-strict equality function. -- We need to create our own type for this. diff --git a/src/test/regress/sql/expressions.sql b/src/test/regress/sql/expressions.sql index 3b3048f9731..adfe3eb54fd 100644 --- a/src/test/regress/sql/expressions.sql +++ b/src/test/regress/sql/expressions.sql @@ -134,6 +134,175 @@ select return_text_input('a') not in ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i rollback; +-- +-- Hashed ScalarArrayOpExpr when the array argument is not a Const but is fixed +-- for the whole execution: external params, IN ($1,...,$N), stable functions. +-- Check the hashed path returns what the linear path does, and that the planner +-- does not hash an array that can vary per row or per group. +-- +begin; + +create table saop_stab (i int); +insert into saop_stab select g from generate_series(1, 20) g; + +-- a stable plpgsql function is never inlined, so the array stays non-Const +create function saop_intarr(int[]) returns int[] as + $$ begin return $1; end $$ language plpgsql stable; + +-- just below / at / above the hashing threshold of 9 +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8}')); +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9}')); +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4,5,6,7,8,9,10,11,12}')); + +-- NULL array yields NULL; empty array and no-match array yield no rows +select count(*) from saop_stab where i = any (saop_intarr(null)); +select count(*) from saop_stab where i = any (saop_intarr('{}')); +select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{5,5,5,5,5,5,5,5,5,5}')); +-- an empty array gives false for IN and true for NOT IN, even for a NULL +select x, x = any (saop_intarr('{}')) as "in", + x <> all (saop_intarr('{}')) as not_in +from (values (1), (null)) v(x); + +-- NOT IN / <> ALL, with and without a NULL element (three-valued logic) +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,10}')); +select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,null}')); + +-- bare external Param array, generic plan (stays a Param); re-EXECUTE with a +-- different array, then NULL and empty +set plan_cache_mode = force_generic_plan; +prepare saop_p(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_p('{1,2,3,4,5,6,7,8,9,10}'); +execute saop_p('{11,12,13}'); +execute saop_p(null); +execute saop_p('{}'); +deallocate saop_p; + +-- IN ($1, ..., $N) is an ArrayExpr of Params +prepare saop_in(int,int,int,int,int,int,int,int,int,int) as + select array_agg(i order by i) from saop_stab + where i in ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10); +execute saop_in(1,2,3,4,5,6,7,8,9,10); +deallocate saop_in; + +-- $1::int[] cast, and string_to_array($1, ',') +prepare saop_cast(text) as + select array_agg(i order by i) from saop_stab where i = any ($1::int[]); +execute saop_cast('{2,4,6,8,10,12,14,16,18,20}'); +deallocate saop_cast; +prepare saop_sta(text) as + select array_agg(i order by i) from saop_stab + where i::text = any (string_to_array($1, ',')); +execute saop_sta('1,2,3,4,5,6,7,8,9,10,11,12'); +deallocate saop_sta; +-- errors in a JSON DEFAULT ... ON ERROR expression are handled softly, so an +-- array there is searched linearly and its error reported like any other +prepare saop_json(text) as + select json_value(jsonb '"x"', '$' returning int + default textcat((5 = any ($1::int[]))::text, '1')::int on error); +savepoint saop_json_error; +execute saop_json('not an array'); +rollback to savepoint saop_json_error; +deallocate saop_json; + +-- same query under a custom plan: $1 folds to a Const and the pre-existing +-- Const path handles it -- must match the generic-plan result above +set plan_cache_mode = force_custom_plan; +prepare saop_c(int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1); +execute saop_c('{1,2,3,4,5,6,7,8,9,10}'); +deallocate saop_c; + +-- rescan: a stable Param array on the inner side of a nestloop +set plan_cache_mode = force_generic_plan; +prepare saop_rs(int[]) as + select d.x, count(*) from (values (1),(2),(3)) d(x) + join saop_stab on saop_stab.i = any ($1) + group by d.x order by d.x; +execute saop_rs('{1,2,3,4,5,6,7,8,9,10}'); +deallocate saop_rs; +reset plan_cache_mode; + +-- two hashable ScalarArrayOpExprs in one qual: both must be applied (cf. +-- b136db07c6) and both correct +prepare saop_two(int[], int[]) as + select array_agg(i order by i) from saop_stab where i = any ($1) or i = any ($2); +execute saop_two('{1,2,3,4,5,6,7,8,9,10}', '{15,16,17,18,19,20,1,2,3,4}'); +deallocate saop_two; + +-- the planner must NOT hash an array that varies per row: a Var in the array +-- keeps a plain (linear) ScalarArrayOpExpr +explain (costs off) +select i from saop_stab +where i = any (array[i,i+1,i+2,i+3,i+4,i+5,i+6,i+7,i+8]); + +-- ... nor an array_agg() in a HAVING clause (a value per group, not per +-- execution): must not be hashed and must not error with "Aggref found in +-- non-Agg plan node" +select i % 3 as g, count(*) from saop_stab +group by i % 3 +having (i % 3) = any (array_agg(1)) +order by g; + +-- ... nor an array built from an outer-query reference in a correlated +-- sub-select: it varies per rescan, so it must stay linear and give the same +-- answer as the below-threshold (never-hashed) form. convert_saop_to_hashed_saop +-- runs before uplevel Vars become Params, so the check must reject Vars of any +-- level. +select d.k, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7,d.k+8])) as ge9, + (select count(*) from saop_stab + where i = any (array[d.k,d.k+1,d.k+2,d.k+3,d.k+4,d.k+5,d.k+6,d.k+7])) as lt9 +from (values (1),(8),(15)) d(k) +order by d.k; + +-- A PL/pgSQL "simple expression" is compiled once and reused with different +-- parameter values, so its array is searched linearly; NOT IN must then still +-- compare with the inequality operator, not the equality one used for hashing +create function saop_ne_all(x int, a int[]) returns bool language plpgsql + as $$ begin return x <> all (a); end $$; +create function saop_not_in(x int, a1 int, a2 int, a3 int, a4 int, a5 int, + a6 int, a7 int, a8 int, a9 int, a10 int) + returns bool language plpgsql + as $$ begin return x not in (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10); end $$; +select saop_ne_all(11, '{1,2,3,4,5,6,7,8,9,10}'), + saop_ne_all(5, '{1,2,3,4,5,6,7,8,9,10}'), + saop_ne_all(5, '{11,12,13,14,15,16,17,18,19,20}'); +select saop_not_in(11, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10), + saop_not_in(5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + +-- ... and an array containing merge_action() changes from row to row, so it +-- must not be hashed +create table saop_merge (id int); +insert into saop_merge values (1); +with m as ( + merge into saop_merge t using (values (1), (2)) s(id) on t.id = s.id + when matched then update set id = s.id + when not matched then insert values (s.id) + returning merge_action() as action, + 'INSERT' = any (array[merge_action(), 'a', 'b', 'c', 'd', 'e', + 'f', 'g', 'h', 'i']) as is_insert) +select action, is_insert from m order by action; + +-- ... nor one containing a set-returning function, which gives a new array for +-- each row +select 'b' = any (regexp_matches('abcdefghijklmnopqrstuvwxyz', + '(.)(.)(.)(.)(.)(.)(.)(.)(.)', 'g')) as has_b; + +-- ... nor OLD/NEW of a view column that is an expression, which is NULL when +-- that row doesn't exist +create view saop_merge_v as + select id, saop_intarr('{1,2,3,4,5,6,7,8,9,10}') as arr from saop_merge; +with m as ( + merge into saop_merge_v t using (values (1), (3)) s(id) on t.id = s.id + when matched then update set id = s.id + when not matched then insert values (s.id) + returning merge_action() as action, 5 = any (old.arr) as old_has_5) +select action, old_has_5 from m order by action; + +rollback; + -- Test with non-strict equality function. -- We need to create our own type for this. -- 2.53.0