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

Since PG 14 (50e17ad281) a ScalarArrayOpExpr with "useOr" can be
evaluated with a hash table instead of a linear scan of the array, but
only when the array argument is a Const.  In a generic plan, a
parameterised list -- "col = ANY($1)", "col IN ($1, $2, ..., $N)",
"col = ANY($1::int[])", "col = ANY(string_to_array($1, ','))" -- keeps
a Param / ArrayExpr / FuncExpr in the plan and falls back to the
O(rows * N) linear path; so does an array computed by a stable function,
in any plan.  That is the shape emitted by JDBC setArray, psycopg and
asyncpg, and by any driver that expands an IN list into bind
parameters, and a prepared statement switches to its generic plan after
five executions when that plan looks cheaper.  MySQL and MariaDB
binary-search their sorted in_vector for the same query and stay
sub-linear.

Treat "is this array fixed for the execution?" as the property that
matters, not the node type.

* clauses.c: convert_saop_to_hashed_saop_walker() accepts, besides a
  non-null Const, any non-Const array argument that cannot vary from one
  row to the next (nor from group to group, nor from one window frame to
  the next).  saop_array_may_vary_walker() decides that with an
  allowlist: it accepts only Consts, external Params, and node types
  that compute their result from their inputs alone -- function and
  operator calls that don't return a set, casts, array and row
  constructors, CASE, COALESCE and the like -- and treats everything
  else as varying: a Var or PlaceHolderVar of any level, an aggregate,
  grouping or window function, merge_action(), a ReturningExpr (OLD/NEW
  of a view column that is an expression, which is NULL when that row
  doesn't exist), a sub-select, a non-external Param, a CaseTestExpr or
  CoerceToDomainValue placeholder, and any node type added later.  An
  ArrayCoerceExpr's per-element expression is not examined, as it reads
  each element through its own CaseTestExpr.  Volatile functions are
  rejected too.  A Var of any level must be rejected because this runs
  in preprocess_expression() before SS_replace_correlation_vars(), so an
  outer-query reference is still a Var, not a Param.  The element count
  is known at plan time for a Const and for a one-dimensional ArrayExpr,
  so the MIN_ARRAY_SIZE_FOR_HASHED_SAOP cutoff is applied there; for
  anything else the executor applies it once the run-time array is
  known.  The walker now also descends into the arguments of a
  ScalarArrayOpExpr it has just handled, as it already did for a
  non-Const array, so nested ones are considered too.

* execExpr.c: for a non-Const stable array in a plan node the hashed
  step no longer emits the array sub-expression inline (which would
  rebuild it every row); it is compiled into an independent ExprState
  (array_expr) that the step owns and evaluates once.  A standalone
  ExprState -- a PL/pgSQL "simple expression", whose compiled state is
  reused across calls with different parameter values -- gets a plain
  EEOP_SCALARARRAYOP instead, which must then use the operator's own
  function rather than the equality function a hashed NOT IN uses.  So
  does an expression compiled to report errors softly (a JSON DEFAULT
  ... ON ERROR expression), since the separately compiled array would
  raise them.

* execExprInterp.c: ExecEvalHashedScalarArrayOp() evaluates array_expr
  once, keeps a detoasted copy of the result in the run-time state, and
  builds the hash table from it with saop_build_hashtable().  A run-time
  array shorter than the threshold is searched linearly through
  ExecEvalArrayCompareInternal() instead, using element type metadata
  cached in the run-time state.  As in ExecEvalScalarArrayOp(), a NULL
  array yields NULL and an empty one false (true for NOT IN) even for a
  NULL scalar, so the NULL-scalar shortcut for a strict function now
  comes after the array is known.  array_expr takes the place of the
  finfo field removed by the previous patch, so ExprEvalStep does not
  grow.

Regression tests for the new array shapes -- external Param, IN
($1,...,$N), $1::int[], string_to_array(), a stable function, a NULL
scalar with an empty array, a JSON DEFAULT ... ON ERROR expression,
the correlated-sub-select exclusion, merge_action() in MERGE ...
RETURNING, a set-returning function, OLD/NEW of a view column, and NOT
IN in a PL/pgSQL simple expression -- are added to
src/test/regress/sql/expressions.sql next to the existing hashed-SAOP
tests.

Not handled: a correlated array (a Var / PlaceHolderVar / PARAM_EXEC,
or an ARRAY(SELECT ...) sub-select), which is deliberately excluded and
stays on the linear path.

Discussion: https://postgr.es/m/TY6PR01MB17327691C30B403369B16B02AF4B02@TY6PR01MB17327.jpnprd01.prod.outlook.com
---
 src/backend/executor/execExpr.c           |  61 +++--
 src/backend/executor/execExprInterp.c     | 153 ++++++++---
 src/backend/optimizer/plan/planner.c      |   6 +-
 src/backend/optimizer/util/clauses.c      | 177 ++++++++++---
 src/include/executor/execExpr.h           |   6 +
 src/include/nodes/primnodes.h             |   5 +-
 src/test/regress/expected/expressions.out | 295 ++++++++++++++++++++++
 src/test/regress/sql/expressions.sql      | 169 +++++++++++++
 8 files changed, 782 insertions(+), 90 deletions(-)

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

