From f5b3f8c0da7b62035d1eb633500ff146da0cd470 Mon Sep 17 00:00:00 2001
From: Linden Lance <bestxmg@gmail.com>
Date: Thu, 10 Sep 2026 04:15:36 +1200
Subject: [PATCH v1] 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.  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.  That is the
shape emitted by JDBC setArray, psycopg and asyncpg, and by any driver
that expands an IN list into bind parameters; 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): no volatile functions, and -- via
  saop_array_arg_has_unstable_node_walker() -- no Var or PlaceHolderVar of
  any level, no aggregate / grouping / window function, no sub-select, and
  no Param other than PARAM_EXTERN.  The walker rejects a Var of any level
  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.

* 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: the "fixed for one execution" proof relies on an execution
  boundary that a reused state does not have.

* execExprInterp.c: ExecEvalHashedScalarArrayOp() evaluates array_expr
  once and keeps a detoasted copy of the result (DatumGetArrayTypePCopy(),
  the call ExecEvalArrayCoerce() uses) in the run-time state, then builds
  the hash table from that.  A run-time array shorter than the threshold
  keeps hashtab NULL and does a linear search through the existing
  ExecEvalArrayCompareInternal(); a NULL array yields NULL.  The element
  type metadata is cached in the side struct so the linear-search path
  does not re-probe the type cache per row, matching
  ExecEvalScalarArrayOp().  The now-redundant finfo field of the hashed
  step is replaced by array_expr, so ExprEvalStep does not grow.

primnodes.h: the ScalarArrayOpExpr.hashfuncid comment now covers the
non-Const case, and MIN_ARRAY_SIZE_FOR_HASHED_SAOP moves here so the
planner and the executor can share it.

Regression tests for the new array shapes -- external Param, IN
($1,...,$N), $1::int[], string_to_array(), a stable function, and the
correlated-sub-select exclusion -- 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.
---
 src/backend/executor/execExpr.c           |  48 ++--
 src/backend/executor/execExprInterp.c     | 318 ++++++++++++++--------
 src/backend/optimizer/util/clauses.c      | 121 +++++---
 src/include/executor/execExpr.h           |   7 +-
 src/include/nodes/primnodes.h             |  20 +-
 src/test/regress/expected/expressions.out | 207 ++++++++++++++
 src/test/regress/sql/expressions.sql      | 113 ++++++++
 7 files changed, 658 insertions(+), 176 deletions(-)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 82e846a1f4f..b081dd9f980 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -1320,34 +1320,48 @@ ExecInitExprRec(Expr *node, ExprState *state,
 										 opexpr->inputcollid, NULL, NULL);
 
 				/*
-				 * If hashfuncid is set, 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.
+				 * A valid hashfuncid means use EEOP_HASHED_SCALARARRAYOP,
+				 * which hash-probes the array instead of scanning it.  A
+				 * non-Const array is only safe to hash inside a real
+				 * execution, so for a standalone ExprState (a reused PL/pgSQL
+				 * "simple expression") fall back to the linear
+				 * EEOP_SCALARARRAYOP.
 				 */
-				if (OidIsValid(opexpr->hashfuncid))
+				if (OidIsValid(opexpr->hashfuncid) &&
+					(IsA(arrayarg, Const) || state->parent != NULL))
 				{
 					/* 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);
-
-					/* And perform the operation */
 					scratch.opcode = EEOP_HASHED_SCALARARRAYOP;
 					scratch.d.hashedscalararrayop.inclause = opexpr->useOr;
-					scratch.d.hashedscalararrayop.finfo = finfo;
 					scratch.d.hashedscalararrayop.fcinfo_data = fcinfo;
 					scratch.d.hashedscalararrayop.saop = opexpr;
 
+					if (IsA(arrayarg, Const))
+					{
+						/*
+						 * Evaluate the Const array into our return value.
+						 * There's no danger in that: it is overwritten by
+						 * EEOP_HASHED_SCALARARRAYOP and not passed to any
+						 * other expression.
+						 */
+						ExecInitExprRec(arrayarg, state, resv, resnull);
+						scratch.d.hashedscalararrayop.array_expr = NULL;
+					}
+					else
+					{
+						/*
+						 * A non-Const array that the planner proved is fixed
+						 * for one execution (see
+						 * convert_saop_to_hashed_saop). Compile it as an
+						 * independent sub-expression; the hashed step
+						 * evaluates it once and caches the result.
+						 */
+						scratch.d.hashedscalararrayop.array_expr =
+							ExecInitExpr((Expr *) arrayarg, state->parent);
+					}
 
 					ExprEvalPushStep(state, &scratch);
 				}
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 397219f7a3a..b4675626caa 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -222,12 +222,24 @@ static uint32 saop_element_hash(struct saophash_hash *tb, Datum key);
 
 /*
  * ScalarArrayOpExprHashTable
- *		Hash table for EEOP_HASHED_SCALARARRAYOP
+ *		Run-time state for EEOP_HASHED_SCALARARRAYOP.
+ *
+ * A hash table of the array elements, built once and probed per row.  When the
+ * array is not a Const, the step's array_expr is evaluated once and copied into
+ * cached_array, and the table is built from that.  A short array leaves hashtab
+ * NULL and is searched linearly.
  */
 typedef struct ScalarArrayOpExprHashTable
 {
-	saophash_hash *hashtab;		/* underlying hash table */
+	saophash_hash *hashtab;		/* element hash table, or NULL for a short
+								 * (linear-search) array */
 	struct ExprEvalStep *op;
+	Datum		cached_array;	/* array value the state was built from */
+	bool		cache_isnull;	/* the array evaluated to SQL NULL */
+	/* element type metadata, cached when the array is built (type is fixed) */
+	int16		typlen;
+	bool		typbyval;
+	char		typalign;
 	FmgrInfo	hash_finfo;		/* function's lookup data */
 	FunctionCallInfoBaseData hash_fcinfo_data;	/* arguments etc */
 } ScalarArrayOpExprHashTable;
@@ -4235,39 +4247,64 @@ saop_hash_element_match(struct saophash_hash *tb, Datum key1, Datum key2)
 	fcinfo->args[1].value = key2;
 	fcinfo->args[1].isnull = false;
 
-	result = elements_tab->op->d.hashedscalararrayop.finfo->fn_addr(fcinfo);
+	result = fcinfo->flinfo->fn_addr(fcinfo);
 
 	return DatumGetBool(result);
 }
 
 /*
- * Evaluate "scalar op ANY (const array)".
+ * Fetch the array for ExecEvalHashedScalarArrayOp(), setting *arr_value and
+ * *arr_isnull.  A Const is already in the step's result area; a non-Const
+ * array_expr sub-expression is evaluated here (once, on the first call).
+ */
+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)".
  *
- * 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.
+ * Similar to ExecEvalScalarArrayOp, but builds a hash table of the array
+ * elements on the first call and probes it thereafter; OR semantics only.
  *
- * Source array is in our result area, scalar arg is already evaluated into
- * fcinfo->args[0].
+ * The array is fixed for the whole execution -- either a Const in our result
+ * area, or the sub-expression the planner compiled into
+ * op->d.hashedscalararrayop.array_expr -- and is evaluated once.  A NULL array
+ * yields NULL; one with fewer than MIN_ARRAY_SIZE_FOR_HASHED_SAOP elements is
+ * linear-searched every call (hashtab left NULL), as ExecEvalScalarArrayOp does.
  *
- * The operator always yields boolean.
+ * The scalar arg is already evaluated into fcinfo->args[0].  The operator
+ * always yields boolean.
  */
 void
 ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 {
 	ScalarArrayOpExprHashTable *elements_tab = op->d.hashedscalararrayop.elements_tab;
+	ExprState  *array_expr = op->d.hashedscalararrayop.array_expr;
 	FunctionCallInfo fcinfo = op->d.hashedscalararrayop.fcinfo_data;
 	bool		inclause = op->d.hashedscalararrayop.inclause;
-	bool		strictfunc = op->d.hashedscalararrayop.finfo->fn_strict;
+	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);
+	ArrayType  *arr;
+	Datum		arr_value = (Datum) 0;
+	bool		arr_isnull = false;
 
 	/*
 	 * If the scalar is NULL, and the function is strict, return NULL; no
@@ -4279,33 +4316,19 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco
 		return;
 	}
 
-	/* Build the hash table on first evaluation */
+	/*
+	 * On the first call, obtain the (single, execution-long) array value and
+	 * build the hash table from it -- or, if it turns out to be too short to
+	 * be worth hashing, decide on a linear search.
+	 */
 	if (elements_tab == NULL)
 	{
-		ScalarArrayOpExpr *saop;
-		int16		typlen;
-		bool		typbyval;
-		char		typalign;
-		uint8		typalignby;
-		int			nitems;
-		bool		has_nulls = false;
-		char	   *s;
-		uint8	   *bitmap;
-		int			bitmask;
 		MemoryContext oldcontext;
-		ArrayType  *arr;
 
-		saop = op->d.hashedscalararrayop.saop;
-
-		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);
+		/* Evaluate the array in the current (short-lived) context ... */
+		saop_hash_eval_array(op, econtext, &arr_value, &arr_isnull);
 
+		/* ... then keep our run-time state for the whole execution. */
 		oldcontext = MemoryContextSwitchTo(econtext->ecxt_per_query_memory);
 
 		elements_tab = (ScalarArrayOpExprHashTable *)
@@ -4314,94 +4337,171 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco
 		op->d.hashedscalararrayop.elements_tab = elements_tab;
 		elements_tab->op = op;
 
-		fmgr_info(saop->hashfuncid, &elements_tab->hash_finfo);
-		fmgr_info_set_expr((Node *) saop, &elements_tab->hash_finfo);
+		if (arr_isnull)
+		{
+			elements_tab->cache_isnull = true;
+		}
+		else
+		{
+			int16		typlen;
+			bool		typbyval;
+			char		typalign;
+			int			nitems;
 
-		InitFunctionCallInfoData(elements_tab->hash_fcinfo_data,
-								 &elements_tab->hash_finfo,
-								 1,
-								 saop->inputcollid,
-								 NULL,
-								 NULL);
+			/*
+			 * A computed array may be short-lived and toasted -- copy it into
+			 * the per-query context; a Const is already flat and query-lived.
+			 */
+			if (array_expr != NULL)
+				arr = DatumGetArrayTypePCopy(arr_value);
+			else
+				arr = DatumGetArrayTypeP(arr_value);
+			elements_tab->cached_array = PointerGetDatum(arr);
+			elements_tab->cache_isnull = false;
 
-		/*
-		 * Create the hash table sizing it according to the number of elements
-		 * in the array.  This does assume that the array has no duplicates.
-		 * If the 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(CurrentMemoryContext, nitems,
-												elements_tab);
+			nitems = ArrayGetNItems(ARR_NDIM(arr), ARR_DIMS(arr));
+			get_typlenbyvalalign(ARR_ELEMTYPE(arr), &typlen, &typbyval, &typalign);
 
-		MemoryContextSwitchTo(oldcontext);
+			/*
+			 * Cache the element type metadata so the linear-search path below
+			 * need not re-probe the type cache on every call, matching what
+			 * ExecEvalScalarArrayOp() does for the non-hashed step.
+			 */
+			elements_tab->typlen = typlen;
+			elements_tab->typbyval = typbyval;
+			elements_tab->typalign = typalign;
 
-		s = (char *) ARR_DATA_PTR(arr);
-		bitmap = ARR_NULLBITMAP(arr);
-		bitmask = 1;
-		for (int i = 0; i < nitems; i++)
-		{
-			/* Get array element, checking for NULL. */
-			if (bitmap && (*bitmap & bitmask) == 0)
-			{
-				has_nulls = true;
-			}
-			else
+			if (nitems >= MIN_ARRAY_SIZE_FOR_HASHED_SAOP)
 			{
-				Datum		element;
-
-				element = fetch_att(s, typbyval, typlen);
-				s = att_addlength_pointer(s, typlen, s);
-				s = (char *) att_nominal_alignby(s, typalignby);
+				ScalarArrayOpExpr *saop = op->d.hashedscalararrayop.saop;
+				uint8		typalignby = typalign_to_alignby(typalign);
+				bool		has_nulls = false;
+				char	   *s;
+				uint8	   *bitmap;
+				int			bitmask;
+
+				fmgr_info(saop->hashfuncid, &elements_tab->hash_finfo);
+				fmgr_info_set_expr((Node *) saop, &elements_tab->hash_finfo);
+				InitFunctionCallInfoData(elements_tab->hash_fcinfo_data,
+										 &elements_tab->hash_finfo, 1,
+										 saop->inputcollid, NULL, NULL);
 
-				saophash_insert(elements_tab->hashtab, element, &hashfound);
-			}
+				/*
+				 * Create the hash table sizing it according to the number of
+				 * elements in the array.  This does assume that the array has
+				 * no duplicates.  If it does, we just sized a bit large.
+				 */
+				elements_tab->hashtab = saophash_create(CurrentMemoryContext,
+														nitems, elements_tab);
 
-			/* Advance bitmap pointer if any. */
-			if (bitmap)
-			{
-				bitmask <<= 1;
-				if (bitmask == 0x100)
+				s = (char *) ARR_DATA_PTR(arr);
+				bitmap = ARR_NULLBITMAP(arr);
+				bitmask = 1;
+				for (int i = 0; i < nitems; i++)
 				{
-					bitmap++;
-					bitmask = 1;
+					/* Get array element, checking for NULL. */
+					if (bitmap && (*bitmap & bitmask) == 0)
+					{
+						has_nulls = true;
+					}
+					else
+					{
+						Datum		element;
+
+						element = fetch_att(s, typbyval, typlen);
+						s = att_addlength_pointer(s, typlen, s);
+						s = (char *) att_nominal_alignby(s, typalignby);
+
+						saophash_insert(elements_tab->hashtab, element, &hashfound);
+					}
+
+					/* Advance bitmap pointer if any. */
+					if (bitmap)
+					{
+						bitmask <<= 1;
+						if (bitmask == 0x100)
+						{
+							bitmap++;
+							bitmask = 1;
+						}
+					}
 				}
-			}
-		}
 
-		/*
-		 * Remember if we had any nulls so that we know if we need to execute
-		 * non-strict functions with a null lhs value if no match is found.
-		 */
-		op->d.hashedscalararrayop.has_nulls = has_nulls;
+				/*
+				 * Remember if we had any nulls so that we know if we need to
+				 * execute non-strict functions with a null lhs value if no
+				 * match is found.
+				 */
+				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;
+				/*
+				 * 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;
+					fcinfo->args[0].value = (Datum) 0;
+					fcinfo->args[0].isnull = true;
 
-			ExecEvalArrayCompareInternal(fcinfo, arr, typlen, typbyval,
-										 typalign, true, &result,
-										 &resultnull);
+					ExecEvalArrayCompareInternal(fcinfo, arr, typlen, typbyval,
+												 typalign, true, &result,
+												 &resultnull);
 
-			null_lhs_result = DatumGetBool(result);
+					null_lhs_result = DatumGetBool(result);
 
-			/* invert non-NULL results for NOT IN */
-			if (!resultnull && !inclause)
-				null_lhs_result = !null_lhs_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;
+					op->d.hashedscalararrayop.null_lhs_isnull = resultnull;
+					op->d.hashedscalararrayop.null_lhs_result = null_lhs_result;
+				}
+			}
+			else
+			{
+				/* too short to be worth hashing: linear search each row */
+				elements_tab->hashtab = NULL;
+			}
 		}
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	/* A NULL array yields NULL. */
+	if (elements_tab->cache_isnull)
+	{
+		*op->resnull = true;
+		return;
+	}
+
+	arr = DatumGetArrayTypeP(elements_tab->cached_array);
+
+	/*
+	 * Linear-search mode: the array was too short to be worth a hash table.
+	 * Compare against every element on each call, exactly as the non-hashed
+	 * ExecEvalScalarArrayOp() would.
+	 */
+	if (elements_tab->hashtab == NULL)
+	{
+		ExecEvalArrayCompareInternal(fcinfo, arr,
+									 elements_tab->typlen,
+									 elements_tab->typbyval,
+									 elements_tab->typalign,
+									 true, &result, &resultnull);
+
+		/* ExecEvalArrayCompareInternal computes ANY; invert for NOT IN */
+		if (!inclause && !resultnull)
+			result = BoolGetDatum(!DatumGetBool(result));
+
+		*op->resvalue = result;
+		*op->resnull = resultnull;
+		return;
 	}
 
 	/*
@@ -4461,7 +4561,7 @@ ExecEvalHashedScalarArrayOp(ExprState *state, ExprEvalStep *op, ExprContext *eco
 			fcinfo->args[1].value = (Datum) 0;
 			fcinfo->args[1].isnull = true;
 
-			result = op->d.hashedscalararrayop.finfo->fn_addr(fcinfo);
+			result = fcinfo->flinfo->fn_addr(fcinfo);
 			resultnull = fcinfo->isnull;
 
 			/*
diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 55cebe4a74b..0b67643fc28 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_arg_has_unstable_node_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);
@@ -2629,7 +2630,6 @@ eval_const_expressions(PlannerInfo *root, Node *node)
 	return eval_const_expressions_mutator(node, &context);
 }
 
-#define MIN_ARRAY_SIZE_FOR_HASHED_SAOP 9
 /*--------------------
  * convert_saop_to_hashed_saop
  *
@@ -2638,13 +2638,18 @@ 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 (no Vars, no
+ *	  volatile functions, no aggregate/grouping/window functions, no
+ *	  sub-selects).  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)
@@ -2665,9 +2670,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 with no volatile function and nothing
+		 * rejected by saop_array_arg_has_unstable_node_walker().  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 &&
+				 !contain_volatile_functions(arrayarg) &&
+				 !saop_array_arg_has_unstable_node_walker(arrayarg, NULL))
+		{
+			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)
 			{
@@ -2675,23 +2712,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 */
@@ -2708,29 +2730,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);
 				}
 			}
 		}
@@ -2739,6 +2746,34 @@ convert_saop_to_hashed_saop_walker(Node *node, void *context)
 	return expression_tree_walker(node, convert_saop_to_hashed_saop_walker, NULL);
 }
 
+/*
+ * saop_array_arg_has_unstable_node_walker
+ *		True if 'node' contains something that keeps a ScalarArrayOpExpr's
+ *		array argument from being evaluated once and reused for the whole
+ *		execution: a Var or PlaceHolderVar (any level -- this runs before
+ *		SS_replace_correlation_vars, so an outer reference is still a Var, not
+ *		a Param), an aggregate/grouping/window function, a sub-select, or a
+ *		non-PARAM_EXTERN Param.  Volatile functions are checked by the caller.
+ */
+static bool
+saop_array_arg_has_unstable_node_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+	if (IsA(node, Var) || IsA(node, PlaceHolderVar))
+		return true;
+	if (IsA(node, Param))
+		return ((Param *) node)->paramkind != PARAM_EXTERN;
+	if (IsA(node, Aggref) ||
+		IsA(node, GroupingFunc) ||
+		IsA(node, WindowFunc) ||
+		IsA(node, SubLink) ||
+		IsA(node, SubPlan) ||
+		IsA(node, AlternativeSubPlan))
+		return true;
+	return expression_tree_walker(node, saop_array_arg_has_unstable_node_walker,
+								  context);
+}
 
 /*--------------------
  * estimate_expression_value
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index c61b3d624d5..056a491b5e0 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -646,7 +646,12 @@ typedef struct ExprEvalStep
 											 * returns. */
 			bool		null_lhs_isnull;
 			struct ScalarArrayOpExprHashTable *elements_tab;
-			FmgrInfo   *finfo;	/* function's lookup data */
+
+			/*
+			 * 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 09b0c29408c..12de419fdcf 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -890,12 +890,13 @@ typedef OpExpr NullIfExpr;
  * flag to remember whether it's ANY or ALL, and we don't have to store
  * 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.
- * 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.
+ * A ScalarArrayOpExpr with a valid hashfuncid is evaluated by building a hash
+ * table from the array argument and probing it once per row; hashfuncid is the
+ * hash function used to build and probe it.  The planner sets hashfuncid when
+ * the array is a Const, or a non-Const expression it proves stays fixed for the
+ * duration of one execution (see convert_saop_to_hashed_saop()) -- in that case
+ * the executor evaluates the array once and builds the table from the result.
+ * With no valid hashfuncid the executor falls back to a linear scan.
  *
  * 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
@@ -937,6 +938,13 @@ typedef struct ScalarArrayOpExpr
 	ParseLoc	location;
 } ScalarArrayOpExpr;
 
+/*
+ * Minimum array length for which hashing a ScalarArrayOpExpr beats a linear
+ * search.  Applied by the planner when the length is known then, otherwise by
+ * the executor once the run-time array is in hand.
+ */
+#define MIN_ARRAY_SIZE_FOR_HASHED_SAOP 9
+
 /*
  * BoolExpr - expression node for the basic Boolean operators AND, OR, NOT
  *
diff --git a/src/test/regress/expected/expressions.out b/src/test/regress/expected/expressions.out
index 730f7bc7eba..8d77badd152 100644
--- a/src/test/regress/expected/expressions.out
+++ b/src/test/regress/expected/expressions.out
@@ -327,6 +327,213 @@ 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, r int4range);
+insert into saop_stab
+  select g, int4range(g, g + 1) 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)
+
+-- 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;
+-- 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)
+
 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..870f371eb5e 100644
--- a/src/test/regress/sql/expressions.sql
+++ b/src/test/regress/sql/expressions.sql
@@ -134,6 +134,119 @@ 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, r int4range);
+insert into saop_stab
+  select g, int4range(g, g + 1) 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}'));
+
+-- 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;
+
+-- 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;
+
+rollback;
+
 -- Test with non-strict equality function.
 -- We need to create our own type for this.
 

base-commit: a12600b762c36d91450ce085fa25ef75250bc1c2
-- 
2.53.0

