From 56a82881a3bbe1057d850c5a07ba56e17f55d0ec Mon Sep 17 00:00:00 2001
From: Linden Lance <bestxmg@gmail.com>
Date: Mon, 2 Nov 2026 08:51:27 +1300
Subject: [PATCH v5 2/2] Hash a ScalarArrayOpExpr whose array is fixed for one
 execution

Since PG 14 (50e17ad281), a ScalarArrayOpExpr can use a hash table
instead of a linear search, but only when the array is a Const.  In a
generic plan, a parameterised list such as "col = ANY($1)",
"col IN ($1, $2, ..., $N)", "col = ANY($1::int[])" or
"col = ANY(string_to_array($1, ','))" is a Param, ArrayExpr, cast or
FuncExpr, so each row searches the list linearly: O(rows * N).  The
same happens to an array computed by a stable function, in any plan.
JDBC setArray, psycopg 3, asyncpg and drivers that turn an IN list into
bind parameters all produce such lists, and a prepared statement
switches to its generic plan after five executions if that plan looks
cheaper.

Now an array that is fixed for one execution is hashed too, such as one
computed from Consts and external Params by non-volatile functions and
operators.  It is evaluated once, on the first row.  An array whose
length is known only at run time, such as ANY($1), is still costed as a
linear search, so its plan does not change.  A list of known length,
such as IN ($1, ..., $9), is costed as hashed.

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

Discussion: https://postgr.es/m/TY6PR01MB17327691C30B403369B16B02AF4B02@TY6PR01MB17327.jpnprd01.prod.outlook.com
---
 src/backend/executor/execExpr.c           |  41 ++--
 src/backend/executor/execExprInterp.c     | 194 ++++++++++-------
 src/backend/optimizer/path/costsize.c     |  10 +-
 src/backend/optimizer/plan/planner.c      |   2 +-
 src/backend/optimizer/util/clauses.c      | 185 +++++++++++++----
 src/include/executor/execExpr.h           |   6 +-
 src/include/nodes/primnodes.h             |   4 +-
 src/test/regress/expected/expressions.out | 241 ++++++++++++++++++++++
 src/test/regress/sql/expressions.sql      | 132 ++++++++++++
 9 files changed, 683 insertions(+), 132 deletions(-)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 408a657d6b8..2c848510968 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,26 +1334,21 @@ 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,
 									&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);
+					/* 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..f0bf61175ce 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;
+	ArrayType  *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,24 +4248,23 @@ 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.
+ * 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)
 {
 	ScalarArrayOpExprHashTable *elements_tab;
 	ScalarArrayOpExpr *saop = op->d.hashedscalararrayop.saop;
+	ExprState  *array_expr = op->d.hashedscalararrayop.array_expr;
 	FunctionCallInfo fcinfo = op->d.hashedscalararrayop.fcinfo_data;
 	bool		inclause = op->d.hashedscalararrayop.inclause;
 	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;
@@ -4267,26 +4273,88 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 	bool		hashfound;
 	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);
+	bool		null_lhs_isnull = false;
+	bool		null_lhs_result = false;
 
 	oldcontext = MemoryContextSwitchTo(cxt);
 
+	/* Build the array in per-query memory: the hash table points into it. */
+	arr_value = array_expr->evalfunc(array_expr, econtext, &arr_isnull);
+
 	elements_tab = (ScalarArrayOpExprHashTable *)
 		palloc0(offsetof(ScalarArrayOpExprHashTable, hash_fcinfo_data) +
 				SizeForFunctionCallInfo(1));
 	op->d.hashedscalararrayop.elements_tab = elements_tab;
 	elements_tab->op = op;
+	elements_tab->array_isnull = arr_isnull;
+
+	if (!arr_isnull)
+	{
+		arr = DatumGetArrayTypeP(arr_value);
+		elements_tab->cached_array = 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);
 
+	/*
+	 * Work out the result for a NULL scalar once, as it depends only on the
+	 * array.  As in ExecEvalScalarArrayOp(), a NULL array gives NULL, and an
+	 * empty array gives false (true for NOT IN), even if the function is
+	 * strict.
+	 */
+	if (arr_isnull)
+		null_lhs_isnull = true;
+	else if (elements_tab->nitems == 0)
+		null_lhs_result = !inclause;
+	else if (strictfunc)
+		null_lhs_isnull = true;
+	else
+	{
+		/*
+		 * When we have a non-strict equality function, check and cache the
+		 * result from looking up a NULL.  Non-strict functions are free to
+		 * treat a NULL as equal to any other value, e.g. a 0 or an empty
+		 * string.  Here we perform a linear search over the array and cache
+		 * the outcome so that we can use that result any time we receive a
+		 * NULL.
+		 */
+		NullableDatum lhs = fcinfo->args[0];
+
+		fcinfo->args[0].value = (Datum) 0;
+		fcinfo->args[0].isnull = true;
+
+		ExecEvalArrayCompareInternal(fcinfo, arr, elements_tab->typlen,
+									 elements_tab->typbyval,
+									 elements_tab->typalign, true, &result,
+									 &resultnull);
+
+		null_lhs_result = DatumGetBool(result);
+
+		/* invert non-NULL results for NOT IN */
+		if (!resultnull && !inclause)
+			null_lhs_result = !null_lhs_result;
+
+		null_lhs_isnull = resultnull;
+
+		/* put back the scalar, which the search of a short array reads */
+		fcinfo->args[0] = lhs;
+	}
+
+	op->d.hashedscalararrayop.null_lhs_isnull = null_lhs_isnull;
+	op->d.hashedscalararrayop.null_lhs_result = null_lhs_result;
+
+	/* 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 +4371,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 +4388,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);
@@ -4344,47 +4414,19 @@ saop_hash_setup(ExprEvalStep *op, ExprContext *econtext)
 	 */
 	op->d.hashedscalararrayop.has_nulls = has_nulls;
 
-	/*
-	 * When we have a non-strict equality function, check and cache the result
-	 * from looking up a NULL.  Non-strict functions are free to treat a NULL
-	 * as equal to any other value, e.g. a 0 or an empty string.  Here we
-	 * perform a linear search over the array and cache the outcome so that we
-	 * can use that result any time we receive a NULL.
-	 */
-	if (!strictfunc)
-	{
-		bool		null_lhs_result;
-
-		fcinfo->args[0].value = (Datum) 0;
-		fcinfo->args[0].isnull = true;
-
-		ExecEvalArrayCompareInternal(fcinfo, arr, typlen, typbyval,
-									 typalign, true, &result,
-									 &resultnull);
-
-		null_lhs_result = DatumGetBool(result);
-
-		/* invert non-NULL results for NOT IN */
-		if (!resultnull && !inclause)
-			null_lhs_result = !null_lhs_result;
-
-		op->d.hashedscalararrayop.null_lhs_isnull = resultnull;
-		op->d.hashedscalararrayop.null_lhs_result = null_lhs_result;
-	}
-
 	return elements_tab;
 }
 
 /*
- * 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].
+ * Source array is evaluated by saop_hash_setup(), scalar arg is already
+ * evaluated into fcinfo->args[0].
  *
  * The operator always yields boolean.
  */
@@ -4394,40 +4436,52 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco
 	ScalarArrayOpExprHashTable *elements_tab = op->d.hashedscalararrayop.elements_tab;
 	FunctionCallInfo fcinfo = op->d.hashedscalararrayop.fcinfo_data;
 	bool		inclause = op->d.hashedscalararrayop.inclause;
-	bool		strictfunc = fcinfo->flinfo->fn_strict;
 	Datum		scalar = fcinfo->args[0].value;
 	bool		scalar_isnull = fcinfo->args[0].isnull;
 	Datum		result;
 	bool		resultnull;
 	bool		hashfound;
 
-	/* We don't setup a hashed scalar array op if the array const is null. */
-	Assert(!*op->resnull);
+	/* Set up on first evaluation */
+	if (elements_tab == NULL)
+		elements_tab = saop_hash_setup(op, econtext);
 
 	/*
-	 * If the scalar is NULL, and the function is strict, return NULL; no
-	 * point in executing the search.
+	 * The result for a NULL scalar depends only on the array, so
+	 * saop_hash_setup() worked it out once.
 	 */
-	if (scalar_isnull && strictfunc)
+	if (scalar_isnull)
 	{
-		*op->resnull = true;
+		*op->resnull = op->d.hashedscalararrayop.null_lhs_isnull;
+		*op->resvalue = BoolGetDatum(op->d.hashedscalararrayop.null_lhs_result);
 		return;
 	}
 
-	/* Set up on first evaluation */
-	if (elements_tab == NULL)
-		elements_tab = saop_hash_setup(op, econtext);
-
 	/*
-	 * When looking up an SQL NULL value with non-strict functions, we defer
-	 * to the value we cached when building the hash table.
+	 * No hash table means that the array is NULL, empty or short.  The
+	 * planner rules this out for a Const array, but a non-Const array is only
+	 * known at run time.  Handle these cases as ExecEvalScalarArrayOp() does.
 	 */
-	if (scalar_isnull)
+	if (elements_tab->hashtab == NULL)
 	{
-		Assert(!strictfunc);
+		if (elements_tab->array_isnull)
+		{
+			*op->resnull = true;
+			return;
+		}
+		/* search linearly; an empty array finds no match */
+		ExecEvalArrayCompareInternal(fcinfo, elements_tab->cached_array,
+									 elements_tab->typlen,
+									 elements_tab->typbyval,
+									 elements_tab->typalign,
+									 true, &result, &resultnull);
 
-		*op->resnull = op->d.hashedscalararrayop.null_lhs_isnull;
-		*op->resvalue = BoolGetDatum(op->d.hashedscalararrayop.null_lhs_result);
+		/* NOT IN uses "=", so invert non-NULL results */
+		if (!resultnull && !inclause)
+			result = BoolGetDatum(!DatumGetBool(result));
+
+		*op->resvalue = result;
+		*op->resnull = resultnull;
 		return;
 	}
 
@@ -4451,7 +4505,7 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco
 	 */
 	if (!hashfound && op->d.hashedscalararrayop.has_nulls)
 	{
-		if (strictfunc)
+		if (fcinfo->flinfo->fn_strict)
 		{
 
 			/*
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 7bbddb8bee4..1276c62ce9a 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -5134,7 +5134,15 @@ cost_qual_eval_walker(Node *node, cost_qual_eval_context *context)
 		add_function_cost(context->root, saop->opfuncid, NULL,
 						  &sacosts);
 
-		if (OidIsValid(saop->hashfuncid))
+		/*
+		 * Charge for hash lookups only for a Const or a one-dimensional
+		 * ARRAY[...], whose length the planner checked.  Any other array is
+		 * hashed only if it turns out long enough, so charge a linear search.
+		 */
+		if (OidIsValid(saop->hashfuncid) &&
+			(IsA(arraynode, Const) ||
+			 (IsA(arraynode, ArrayExpr) &&
+			  !castNode(ArrayExpr, arraynode)->multidims)))
 		{
 			/* Handle costs for hashed ScalarArrayOpExpr */
 			hcosts.startup = hcosts.per_tuple = 0;
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..12908c3e35c 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,107 @@ 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_CaseWhen:
+			{
+				/*
+				 * Not reached: expression_tree_walker() walks the condition
+				 * and result of each CaseWhen itself.  Check them here too,
+				 * in case that changes.
+				 */
+				CaseWhen   *when = castNode(CaseWhen, node);
+
+				return saop_array_may_vary((Node *) when->expr) ||
+					saop_array_may_vary((Node *) when->result);
+			}
+
+		case T_List:
+		case T_NamedArgExpr:
+		case T_ScalarArrayOpExpr:
+		case T_BoolExpr:
+		case T_RelabelType:
+		case T_CoerceViaIO:
+		case T_ConvertRowtypeExpr:
+		case T_CollateExpr:
+		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..6c211f27675 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -641,11 +641,13 @@ typedef struct ExprEvalStep
 		{
 			bool		has_nulls;
 			bool		inclause;	/* true for IN and false for NOT IN */
-			bool		null_lhs_result;	/* for non-strict lookups, we
-											 * cache what looking up NULL
+			bool		null_lhs_result;	/* we cache what looking up NULL
 											 * returns. */
 			bool		null_lhs_isnull;
 			struct ScalarArrayOpExprHashTable *elements_tab;
+
+			/* the array, evaluated once */
+			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

