From 92f2ef66d35d11df28c9ab291ed711c206726a3e Mon Sep 17 00:00:00 2001
From: Linden Lance <bestxmg@gmail.com>
Date: Fri, 9 Oct 2026 01:34:45 +1300
Subject: [PATCH v4 2/2] Hash a ScalarArrayOpExpr whose array is fixed for one
 execution

Only a Const array was hashed.  So "x = ANY ($1)" or "x IN ($1, ...)"
in a generic plan searched the whole array for every row, and a
prepared statement could get much slower once it switched to a generic
plan.

Now an array built only from Consts, external Params and non-volatile
expressions is hashed too.  It is evaluated once, on the first row.

Still not hashed: an array that can change from row to row, and a
non-Const array in a standalone expression (such as a PL/pgSQL simple
expression) or in an expression with soft errors (as in JSON ON ERROR).

Discussion: https://postgr.es/m/TY6PR01MB17327691C30B403369B16B02AF4B02@TY6PR01MB17327.jpnprd01.prod.outlook.com
---
 src/backend/executor/execExpr.c           |  42 +++-
 src/backend/executor/execExprInterp.c     | 142 ++++++++++---
 src/backend/optimizer/plan/planner.c      |   2 +-
 src/backend/optimizer/util/clauses.c      | 170 +++++++++++----
 src/include/executor/execExpr.h           |   3 +
 src/include/nodes/primnodes.h             |   4 +-
 src/test/regress/expected/expressions.out | 241 ++++++++++++++++++++++
 src/test/regress/sql/expressions.sql      | 132 ++++++++++++
 8 files changed, 655 insertions(+), 81 deletions(-)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 408a657d6b8..a9a9ce8afef 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1272,14 +1272,32 @@ ExecInitExprRec(Expr *node, ExprState *state,
 				FunctionCallInfo fcinfo;
 				AclResult	aclresult;
 				Oid			cmpfuncid;
+				bool		use_hashed_step;
+
+				Assert(list_length(opexpr->args) == 2);
+				scalararg = (Expr *) linitial(opexpr->args);
+				arrayarg = (Expr *) lsecond(opexpr->args);
+
+				/*
+				 * The hashed step reads a non-Const array only once.  That is
+				 * wrong where the expression is reused with new parameter
+				 * values (no parent, as in PL/pgSQL) or must catch errors
+				 * (escontext, as in JSON ON ERROR).
+				 */
+				use_hashed_step = OidIsValid(opexpr->hashfuncid) &&
+					(IsA(arrayarg, Const) ||
+					 (state->parent != NULL && state->escontext == NULL));
 
 				/*
 				 * Select the correct comparison function.  When we do hashed
 				 * NOT IN clauses, the opfuncid will be the inequality
 				 * comparison function and negfuncid will be set to equality.
 				 * We need to use the equality function for hash probes.
+				 *
+				 * Only the hashed step may use negfuncid; the plain step must
+				 * use the operator itself ("<>").
 				 */
-				if (OidIsValid(opexpr->negfuncid))
+				if (use_hashed_step && OidIsValid(opexpr->negfuncid))
 				{
 					Assert(OidIsValid(opexpr->hashfuncid));
 					cmpfuncid = opexpr->negfuncid;
@@ -1287,10 +1305,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 +1314,7 @@ ExecInitExprRec(Expr *node, ExprState *state,
 								   get_func_name(cmpfuncid));
 				InvokeFunctionExecuteHook(cmpfuncid);
 
-				if (OidIsValid(opexpr->hashfuncid))
+				if (use_hashed_step)
 				{
 					aclresult = object_aclcheck(ProcedureRelationId, opexpr->hashfuncid,
 												GetUserId(),
@@ -1320,13 +1334,13 @@ ExecInitExprRec(Expr *node, ExprState *state,
 										 opexpr->inputcollid, NULL, NULL);
 
 				/*
-				 * If hashfuncid is set, we create a EEOP_HASHED_SCALARARRAYOP
+				 * If use_hashed_step, we create a EEOP_HASHED_SCALARARRAYOP
 				 * step instead of a EEOP_SCALARARRAYOP.  This provides much
 				 * faster lookup performance than the normal linear search
 				 * when the number of items in the array is anything but very
 				 * small.
 				 */
-				if (OidIsValid(opexpr->hashfuncid))
+				if (use_hashed_step)
 				{
 					/* Evaluate scalar directly into left function argument */
 					ExecInitExprRec(scalararg, state,
@@ -1339,7 +1353,17 @@ ExecInitExprRec(Expr *node, ExprState *state,
 					 * EEOP_HASHED_SCALARARRAYOP, and will not be passed to
 					 * any other expression.
 					 */
-					ExecInitExprRec(arrayarg, state, resv, resnull);
+					if (IsA(arrayarg, Const))
+					{
+						ExecInitExprRec(arrayarg, state, resv, resnull);
+						scratch.d.hashedscalararrayop.array_expr = NULL;
+					}
+					else
+					{
+						/* evaluated once, by EEOP_HASHED_SCALARARRAYOP */
+						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 8b1b0b79137..ab8bb450339 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -226,8 +226,15 @@ static uint32 saop_element_hash(struct saophash_hash *tb, Datum key);
  */
 typedef struct ScalarArrayOpExprHashTable
 {
-	saophash_hash *hashtab;		/* underlying hash table */
+	saophash_hash *hashtab;		/* underlying hash table, or NULL if not
+								 * hashed */
 	struct ExprEvalStep *op;
+	Datum		cached_array;	/* the array */
+	bool		array_isnull;
+	int			nitems;			/* number of array elements */
+	int16		typlen;			/* element type, to walk the array */
+	bool		typbyval;
+	char		typalign;
 	FmgrInfo	hash_finfo;		/* function's lookup data */
 	FunctionCallInfoBaseData hash_fcinfo_data;	/* arguments etc */
 } ScalarArrayOpExprHashTable;
@@ -4241,8 +4248,32 @@ saop_hash_element_match(struct saophash_hash *tb, Datum key1, Datum key2)
 }
 
 /*
- * Set up and return the run-time state on the step's first evaluation: build
- * the hash table.
+ * Return the array.  A Const array is already in op->resvalue; any other
+ * array is computed here.
+ *
+ * Const arrays do not take the non-Const route, because that route copies
+ * the array, and a Const array needs no copy: it is part of the plan.
+ */
+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);
+}
+
+/*
+ * Set up and return the run-time state on the step's first evaluation: keep
+ * the array, and build the hash table if the array is long enough.
  */
 static ScalarArrayOpExprHashTable *
 saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
@@ -4254,11 +4285,9 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 	bool		strictfunc = fcinfo->flinfo->fn_strict;
 	MemoryContext cxt = econtext->ecxt_per_query_memory;
 	MemoryContext oldcontext;
-	ArrayType  *arr;
-	int			nitems;
-	int16		typlen;
-	bool		typbyval;
-	char		typalign;
+	Datum		arr_value;
+	bool		arr_isnull;
+	ArrayType  *arr = NULL;
 	uint8		typalignby;
 	bool		has_nulls = false;
 	char	   *s;
@@ -4268,14 +4297,7 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 	Datum		result;
 	bool		resultnull;
 
-	arr = DatumGetArrayTypeP(*op->resvalue);
-	nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr));
-
-	get_typlenbyvalalign(ARR_ELEMTYPE(arr),
-						 &typlen,
-						 &typbyval,
-						 &typalign);
-	typalignby = typalign_to_alignby(typalign);
+	saop_hash_eval_array(op, econtext, &arr_value, &arr_isnull);
 
 	oldcontext = MemoryContextSwitchTo(cxt);
 
@@ -4284,9 +4306,32 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 				SizeForFunctionCallInfo(1));
 	op->d.hashedscalararrayop.elements_tab = elements_tab;
 	elements_tab->op = op;
+	elements_tab->array_isnull = arr_isnull;
+
+	if (!arr_isnull)
+	{
+		/* copy a computed array, which may be short-lived or toasted */
+		if (op->d.hashedscalararrayop.array_expr != NULL)
+			arr = DatumGetArrayTypePCopy(arr_value);
+		else
+			arr = DatumGetArrayTypeP(arr_value);
+		elements_tab->cached_array = PointerGetDatum(arr);
+		elements_tab->nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr));
+
+		get_typlenbyvalalign(ARR_ELEMTYPE(arr),
+							 &elements_tab->typlen,
+							 &elements_tab->typbyval,
+							 &elements_tab->typalign);
+	}
 
 	MemoryContextSwitchTo(oldcontext);
 
+	/* not worth hashing (also for a NULL or empty array) */
+	if (elements_tab->nitems < MIN_ARRAY_SIZE_FOR_HASHED_SAOP)
+		return elements_tab;
+
+	typalignby = typalign_to_alignby(elements_tab->typalign);
+
 	fmgr_info_cxt(saop->hashfuncid, &elements_tab->hash_finfo, cxt);
 	fmgr_info_set_expr((Node *) saop, &elements_tab->hash_finfo);
 
@@ -4303,12 +4348,13 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 	 * array happens to contain many duplicate values then it'll just mean
 	 * that we sized the table a bit on the large side.
 	 */
-	elements_tab->hashtab = saophash_create(cxt, nitems, elements_tab);
+	elements_tab->hashtab = saophash_create(cxt, elements_tab->nitems,
+											elements_tab);
 
 	s = (char *) ARR_DATA_PTR(arr);
 	bitmap = ARR_NULLBITMAP(arr);
 	bitmask = 1;
-	for (int i = 0; i < nitems; i++)
+	for (int i = 0; i < elements_tab->nitems; i++)
 	{
 		/* Get array element, checking for NULL. */
 		if (bitmap && (*bitmap & bitmask) == 0)
@@ -4319,8 +4365,9 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 		{
 			Datum		element;
 
-			element = fetch_att(s, typbyval, typlen);
-			s = att_addlength_pointer(s, typlen, s);
+			element = fetch_att(s, elements_tab->typbyval,
+								elements_tab->typlen);
+			s = att_addlength_pointer(s, elements_tab->typlen, s);
 			s = (char *) att_nominal_alignby(s, typalignby);
 
 			saophash_insert(elements_tab->hashtab, element, &hashfound);
@@ -4358,8 +4405,9 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 		fcinfo->args[0].value = (Datum) 0;
 		fcinfo->args[0].isnull = true;
 
-		ExecEvalArrayCompareInternal(fcinfo, arr, typlen, typbyval,
-									 typalign, true, &result,
+		ExecEvalArrayCompareInternal(fcinfo, arr, elements_tab->typlen,
+									 elements_tab->typbyval,
+									 elements_tab->typalign, true, &result,
 									 &resultnull);
 
 		null_lhs_result = DatumGetBool(result);
@@ -4376,15 +4424,15 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 }
 
 /*
- * Evaluate "scalar op ANY (const array)".
+ * Evaluate "scalar op ANY (fixed array)".
  *
  * 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 source array is in our result area (see saop_hash_setup() for
+ * others), scalar arg is already evaluated into fcinfo->args[0].
  *
  * The operator always yields boolean.
  */
@@ -4402,7 +4450,28 @@ 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);
+	Assert(op->d.hashedscalararrayop.array_expr != NULL || !*op->resnull);
+
+	/* Set up on first evaluation */
+	if (elements_tab == NULL)
+		elements_tab = saop_hash_setup(op, econtext);
+
+	/*
+	 * Unlike a Const array, a non-Const array can be NULL, empty or short.
+	 * Handle these cases as ExecEvalScalarArrayOp() does, in the same order,
+	 * so that NULL = ANY ('{}') gives false.
+	 */
+	if (elements_tab->array_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
@@ -4414,9 +4483,24 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco
 		return;
 	}
 
-	/* Set up on first evaluation */
-	if (elements_tab == NULL)
-		elements_tab = saop_hash_setup(op, econtext);
+	/* short array: search linearly, as ExecEvalScalarArrayOp() does */
+	if (elements_tab->hashtab == NULL)
+	{
+		ExecEvalArrayCompareInternal(fcinfo,
+									 DatumGetArrayTypeP(elements_tab->cached_array),
+									 elements_tab->typlen,
+									 elements_tab->typbyval,
+									 elements_tab->typalign,
+									 true, &result, &resultnull);
+
+		/* NOT IN uses "=", so invert non-NULL results */
+		if (!resultnull && !inclause)
+			result = BoolGetDatum(!DatumGetBool(result));
+
+		*op->resvalue = result;
+		*op->resnull = resultnull;
+		return;
+	}
 
 	/*
 	 * When looking up an SQL NULL value with non-strict functions, we defer
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index edaa42d713a..e6acd561d0c 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -1460,7 +1460,7 @@ preprocess_expression(PlannerInfo *root, Node *expr, int kind)
 	}
 
 	/*
-	 * Check for ANY ScalarArrayOpExpr with Const arrays and set the
+	 * Check for ANY ScalarArrayOpExpr with fixed arrays and set the
 	 * hashfuncid of any that might execute more quickly by using hash lookups
 	 * instead of a linear search.
 	 */
diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 6a54c47bef7..848e57e1364 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -132,6 +132,8 @@ 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(Node *node);
+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);
@@ -2658,13 +2660,15 @@ 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 (the array) is a non-null Const, or is fixed for one
+ *	  execution (see saop_array_may_vary()).
  * 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.
+ *	  worthwhile using a hash table rather than a linear search.  If the
+ *	  count is only known at run time, the executor checks it.
  */
 void
 convert_saop_to_hashed_saop(Node *node)
@@ -2685,9 +2689,37 @@ 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)
+		if (arrayarg && IsA(arrayarg, Const))
+		{
+			Const	   *arrconst = castNode(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(arrayarg) &&
+				 !contain_volatile_functions(arrayarg))
+		{
+			if (IsA(arrayarg, ArrayExpr) &&
+				!castNode(ArrayExpr, arrayarg)->multidims)
+				try_hashing =
+					list_length(castNode(ArrayExpr, arrayarg)->elements) >=
+					MIN_ARRAY_SIZE_FOR_HASHED_SAOP;
+			else
+			{
+				/* length unknown until run time; the executor checks it */
+				try_hashing = true;
+			}
+		}
+
+		if (try_hashing)
 		{
 			if (saop->useOr)
 			{
@@ -2695,23 +2727,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 */
@@ -2728,29 +2745,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);
 				}
 			}
 		}
@@ -2759,6 +2761,92 @@ 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
+ *		True unless 'node', a ScalarArrayOpExpr's array, is certain to be the
+ *		same for every row of one execution.
+ *
+ * Only Consts, external Params and the node types listed in
+ * saop_array_may_vary_walker() are accepted; anything else may vary, e.g.
+ * merge_action().  Vars of any level are rejected: this runs before
+ * SS_replace_correlation_vars(), so an outer reference is still a Var.  The
+ * caller rejects volatile functions.
+ */
+static bool
+saop_array_may_vary(Node *node)
+{
+	return saop_array_may_vary_walker(node, NULL);
+}
+
+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 castNode(Param, node)->paramkind != PARAM_EXTERN;
+
+		case T_FuncExpr:
+			if (castNode(FuncExpr, node)->funcretset)
+				return true;
+			break;
+
+		case T_OpExpr:
+			if (castNode(OpExpr, node)->opretset)
+				return true;
+			break;
+
+		case T_DistinctExpr:
+			if (castNode(DistinctExpr, node)->opretset)
+				return true;
+			break;
+
+		case T_NullIfExpr:
+			if (castNode(NullIfExpr, node)->opretset)
+				return true;
+			break;
+
+		case T_ArrayCoerceExpr:
+
+			/* elemexpr reads elements via a CaseTestExpr: check only arg */
+			return saop_array_may_vary((Node *) castNode(ArrayCoerceExpr, node)->arg);
+
+		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:
+
+			/* fixed if their inputs are */
+			break;
+
+		default:
+
+			/* anything else, including future node types, may vary */
+			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..3f026505e56 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -646,6 +646,9 @@ typedef struct ExprEvalStep
 											 * returns. */
 			bool		null_lhs_isnull;
 			struct ScalarArrayOpExprHashTable *elements_tab;
+
+			/* non-Const array, evaluated once; NULL for a Const */
+			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 7439c6732a7..0229d502263 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -891,11 +891,13 @@ 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
+ * a Const or is fixed for one execution (see convert_saop_to_hashed_saop()).
  * 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
  * the more traditional means based on if the hashfuncid is set or not.
+ * Exceptions: see ExecInitExprRec() and ExecEvalHashedScalarArrayOp().
  *
  * When performing hashed NOT IN, the negfuncid will also be set to the
  * equality function which the hash table must use to build and probe the hash
diff --git a/src/test/regress/expected/expressions.out b/src/test/regress/expected/expressions.out
index 730f7bc7eba..d0c5b2a4ed5 100644
--- a/src/test/regress/expected/expressions.out
+++ b/src/test/regress/expected/expressions.out
@@ -327,6 +327,247 @@ select return_text_input('a') not in ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i
  f
 (1 row)
 
+rollback;
+--
+-- Hashed ScalarArrayOpExpr with a non-Const array fixed for one execution
+--
+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;
+-- 8 and 9 elements: below and at the threshold
+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)
+
+-- an operator inside the array
+select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4}') || saop_intarr('{5,6,7,8,9}'));
+      array_agg      
+---------------------
+ {1,2,3,4,5,6,7,8,9}
+(1 row)
+
+-- empty array and NULL scalar: false for IN, true for NOT IN
+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, with a NULL element, and on a short array (searched linearly)
+select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,null}'));
+ count 
+-------
+     0
+(1 row)
+
+select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3}'));
+ count 
+-------
+    17
+(1 row)
+
+-- generic plan: $1 stays a Param; long, short, NULL and empty arrays
+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
+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;
+-- soft error reporting (JSON DEFAULT ... ON ERROR): not hashed
+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;
+-- rescans of the inner side reuse the hash table
+set plan_cache_mode = force_generic_plan;
+prepare saop_rs(int[]) as
+  select d.x, s.c
+  from (values (1),(2),(3)) d(x)
+  cross join lateral
+    (select count(*) as c from saop_stab where i = any ($1) and i > d.x) s
+  order by d.x;
+explain (costs off) execute saop_rs('{1,2,3,4,5,6,7,8,9,10}');
+                                QUERY PLAN                                 
+---------------------------------------------------------------------------
+ Sort
+   Sort Key: "*VALUES*".column1
+   ->  Nested Loop
+         ->  Values Scan on "*VALUES*"
+         ->  Aggregate
+               ->  Seq Scan on saop_stab
+                     Filter: ((i > "*VALUES*".column1) AND (i = ANY ($1)))
+(7 rows)
+
+execute saop_rs('{1,2,3,4,5,6,7,8,9,10}');
+ x | c 
+---+---
+ 1 | 9
+ 2 | 8
+ 3 | 7
+(3 rows)
+
+deallocate saop_rs;
+reset plan_cache_mode;
+-- two hashable ScalarArrayOpExprs in one qual
+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;
+-- not hashed: an array that varies per row (a Var)
+select count(*) from saop_stab
+where i = any (array[i, -1, -2, -3, -4, -5, -6, -7, -8]);
+ count 
+-------
+    20
+(1 row)
+
+-- ... nor per group (an aggregate in HAVING)
+select i % 3 as g, count(*) from saop_stab
+group by i % 3
+having min(i) = any (array_agg(i))
+order by g;
+ g | count 
+---+-------
+ 0 |     6
+ 1 |     7
+ 2 |     7
+(3 rows)
+
+-- ... nor per rescan (an outer reference in a sub-select); compare with the
+-- 8-element array, which is never hashed
+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)
+
+-- PL/pgSQL simple expressions are not hashed; NOT IN must still use "<>"
+create function saop_ne_all(x int, a int[]) returns bool language plpgsql
+  as $$ begin return x <> all (a); 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)
+
+-- ... nor an array with merge_action()
+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 with a set-returning function
+select 'b' = any (regexp_matches('abcdefghijklmnopqrstuvwxyz',
+                                 '(.)(.)(.)(.)(.)(.)(.)(.)(.)', 'g')) as has_b;
+ has_b 
+-------
+ t
+ f
+(2 rows)
+
+-- ... nor one with OLD/NEW of a view's expression column
+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..aa81744863f 100644
--- a/src/test/regress/sql/expressions.sql
+++ b/src/test/regress/sql/expressions.sql
@@ -134,6 +134,138 @@ select return_text_input('a') not in ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i
 
 rollback;
 
+--
+-- Hashed ScalarArrayOpExpr with a non-Const array fixed for one execution
+--
+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;
+
+-- 8 and 9 elements: below and at the threshold
+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}'));
+-- an operator inside the array
+select array_agg(i order by i) from saop_stab where i = any (saop_intarr('{1,2,3,4}') || saop_intarr('{5,6,7,8,9}'));
+
+-- empty array and NULL scalar: false for IN, true for NOT IN
+select x, x = any (saop_intarr('{}')) as "in",
+       x <> all (saop_intarr('{}')) as not_in
+from (values (1), (null)) v(x);
+
+-- NOT IN, with a NULL element, and on a short array (searched linearly)
+select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3,4,5,6,7,8,9,null}'));
+select count(*) from saop_stab where i <> all (saop_intarr('{1,2,3}'));
+
+-- generic plan: $1 stays a Param; long, short, NULL and empty arrays
+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
+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;
+-- soft error reporting (JSON DEFAULT ... ON ERROR): not hashed
+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;
+
+-- rescans of the inner side reuse the hash table
+set plan_cache_mode = force_generic_plan;
+prepare saop_rs(int[]) as
+  select d.x, s.c
+  from (values (1),(2),(3)) d(x)
+  cross join lateral
+    (select count(*) as c from saop_stab where i = any ($1) and i > d.x) s
+  order by d.x;
+explain (costs off) execute saop_rs('{1,2,3,4,5,6,7,8,9,10}');
+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
+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;
+
+-- not hashed: an array that varies per row (a Var)
+select count(*) from saop_stab
+where i = any (array[i, -1, -2, -3, -4, -5, -6, -7, -8]);
+
+-- ... nor per group (an aggregate in HAVING)
+select i % 3 as g, count(*) from saop_stab
+group by i % 3
+having min(i) = any (array_agg(i))
+order by g;
+
+-- ... nor per rescan (an outer reference in a sub-select); compare with the
+-- 8-element array, which is never hashed
+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;
+
+-- PL/pgSQL simple expressions are not hashed; NOT IN must still use "<>"
+create function saop_ne_all(x int, a int[]) returns bool language plpgsql
+  as $$ begin return x <> all (a); 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}');
+
+-- ... nor an array with merge_action()
+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 with a set-returning function
+select 'b' = any (regexp_matches('abcdefghijklmnopqrstuvwxyz',
+                                 '(.)(.)(.)(.)(.)(.)(.)(.)(.)', 'g')) as has_b;
+
+-- ... nor one with OLD/NEW of a view's expression column
+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

