From 289175dab0fd993a0d2f48838ceabff133a4a84c Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Tue, 20 Oct 2026 16:33:05 -0400
Subject: [PATCH v10 7/7] Carry index-computed values through outer joins,
 Gather and ordered Appends

The previous commit passes an index's extra values (see
path_extra_exprs()) up through inner joins and an unordered Append, but
stops at three places, where the expression is then computed above them:

* The nullable side of an outer join.  Above the join, the query's copy
  of the expression has the join's bit in its Vars' nullingrels, and the
  scan's copy doesn't.  The join null-extends the scan's value along
  with the Vars, so the two agree on every row exactly when the
  expression yields NULL for NULL inputs.  So for a strict expression,
  join_path_target() now relabels the value with the joinrel's Vars
  (their nullingrels) as it passes it up, and setrefs.c's
  fix_join_expr() matches it against the input's copy at an outer join
  the way it already matches Vars there: equal but for nullingrels, with
  the upper copy's a superset of the input's
  (search_indexed_tlist_for_nulled_expr()); path_target_cost() credits
  it the same way.  A non-strict expression is still computed above
  the join.  An index-only scan matches the query's targetlist without
  nullingrels, so it emits the value in the first place; an ordering
  IndexScan's ORDER BY values still aren't emitted on the nullable side.

* Partial paths, which emitted only parallel-safe values.  A worker
  reads the stored value; nothing evaluates the expression, so a
  parallel-restricted one is fine.  Gather and Gather Merge now pass a
  partial path's values up (gather_target()), and a Gather over the
  cheapest partial path that emits values is considered as well as one
  over the cheapest partial path.

* An ordered Append or MergeAppend, and a parallel Append.
  add_extras_append_path() now also builds those, over child paths that
  emit the values and have the ordering, and over the children's
  partial paths, collecting the children as
  generate_orderedappend_paths() does.

The expression also has to be compared without nullingrels when a scan
decides what to emit.  The new strip_nullingrels() in clauses.c does
that; remove_nulling_relids() can't, as it errors out on the negative
special varnos (ROWID_VAR in a MERGE).

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
---
 src/backend/optimizer/path/allpaths.c | 260 ++++++++++++++++++++++----
 src/backend/optimizer/plan/setrefs.c  | 128 ++++++++++++-
 src/backend/optimizer/util/clauses.c  |  45 +++++
 src/backend/optimizer/util/pathnode.c | 225 +++++++++++++++++++---
 src/include/optimizer/clauses.h       |   1 +
 src/test/regress/expected/gist.out    | 214 ++++++++++++++++++++-
 src/test/regress/sql/gist.sql         | 106 ++++++++++-
 src/tools/pgindent/typedefs.list      |   1 +
 8 files changed, 907 insertions(+), 73 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index a6b1b15208b..89543166572 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -103,6 +103,7 @@ static void set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel,
 static void set_plain_rel_size(PlannerInfo *root, RelOptInfo *rel,
 							   RangeTblEntry *rte);
 static void create_plain_partial_paths(PlannerInfo *root, RelOptInfo *rel);
+static PathTarget *gather_target(RelOptInfo *rel, Path *subpath);
 static void add_extras_append_path(PlannerInfo *root, RelOptInfo *rel,
 								   List *live_childrels);
 static void set_rel_consider_parallel(PlannerInfo *root, RelOptInfo *rel,
@@ -1406,10 +1407,39 @@ set_grouped_rel_pathlist(PlannerInfo *root, RelOptInfo *rel)
 }
 
 
+/*
+ * extras_child_path
+ *	  The cheapest path of 'childrel' from 'paths' that emits every value
+ *	  in 'extras' (in the parent's terms) and is ordered by 'pathkeys'
+ *	  (NIL for no requirement), or NULL.  Partial paths are sorted by total
+ *	  cost too, so the same search serves partial_pathlist.
+ */
+static Path *
+extras_child_path(PlannerInfo *root, RelOptInfo *childrel, List *paths,
+				  List *extras, List *pathkeys)
+{
+	List	   *child_extras;
+	ListCell   *lp;
+
+	child_extras = (List *)
+		adjust_appendrel_attrs_multilevel(root, (Node *) extras,
+										  childrel, childrel->top_parent);
+	foreach(lp, paths)
+	{
+		Path	   *path = (Path *) lfirst(lp);
+
+		if (path->param_info == NULL &&
+			pathkeys_contained_in(pathkeys, path->pathkeys) &&
+			list_difference(child_extras, path_extra_exprs(path)) == NIL)
+			return path;
+	}
+	return NULL;
+}
+
 /*
  * add_extras_append_path
- *	  Build an unordered, unparameterized Append over child paths that all
- *	  emit the same extra values, if there are any worth emitting.
+ *	  Build Appends over child paths that all emit the same extra values,
+ *	  if there are any worth emitting.
  *
  * A partitioned table's index on an expression is an index on that
  * expression in every partition, so each child may have an index-only scan
@@ -1421,8 +1451,10 @@ set_grouped_rel_pathlist(PlannerInfo *root, RelOptInfo *rel)
  * emits it.  create_append_child_plan() puts each child's values in the
  * Append's order.
  *
- * Only an unordered, non-partial Append is built this way; an ordered or
- * parallel Append still computes the expression above it.
+ * We build an unordered Append, a MergeAppend for each ordering some child
+ * provides (or an ordered Append if the partitions are ordered that way, as
+ * generate_orderedappend_paths() does), and a parallel Append over the
+ * children's partial paths.
  */
 static void
 add_extras_append_path(PlannerInfo *root, RelOptInfo *rel,
@@ -1430,9 +1462,10 @@ add_extras_append_path(PlannerInfo *root, RelOptInfo *rel,
 {
 	List	   *extras = NIL;
 	List	   *tlist_exprs;
-	AppendPathInput input = {0};
+	List	   *orderings = NIL;
+	List	   *partition_pathkeys = NIL;
+	bool		partition_pathkeys_partial = true;
 	PathTarget *target;
-	AppendPath *apath;
 	ListCell   *lc;
 
 	if (rel->reloptkind != RELOPT_BASEREL || live_childrels == NIL ||
@@ -1465,39 +1498,159 @@ add_extras_append_path(PlannerInfo *root, RelOptInfo *rel,
 	if (extras == NIL)
 		return;
 
-	foreach(lc, live_childrels)
+	target = copy_pathtarget(rel->reltarget);
+	foreach(lc, extras)
+		add_new_column_to_pathtarget(target, (Expr *) lfirst(lc));
+	target = set_pathtarget_cost_width(root, target);
+
+	/*
+	 * Unordered Append; also collect the orderings children offer.  If some
+	 * child has no path that emits the values, we can't build this or an
+	 * ordered one, but may still build the parallel Append below.
+	 */
 	{
-		RelOptInfo *childrel = (RelOptInfo *) lfirst(lc);
-		List	   *child_extras;
-		Path	   *best = NULL;
-		ListCell   *lp;
-
-		child_extras = (List *)
-			adjust_appendrel_attrs_multilevel(root, (Node *) extras,
-											  childrel, childrel->top_parent);
-		foreach(lp, childrel->pathlist)
+		AppendPathInput input = {0};
+		AppendPath *apath;
+		bool		ok = true;
+
+		foreach(lc, live_childrels)
 		{
-			Path	   *path = (Path *) lfirst(lp);
+			RelOptInfo *childrel = (RelOptInfo *) lfirst(lc);
+			Path	   *best = extras_child_path(root, childrel,
+												 childrel->pathlist,
+												 extras, NIL);
+			ListCell   *lp;
 
-			if (path->param_info == NULL &&
-				list_difference(child_extras, path_extra_exprs(path)) == NIL)
+			if (best == NULL)
 			{
-				best = path;	/* pathlist is sorted by total cost */
+				ok = false;
+				orderings = NIL;
 				break;
 			}
+			accumulate_append_subpath(best, &input.subpaths, NULL,
+									  &input.child_append_relid_sets);
+			foreach(lp, childrel->pathlist)
+			{
+				Path	   *path = (Path *) lfirst(lp);
+
+				if (path->pathkeys != NIL && path_emits_extras(path) &&
+					!list_member(orderings, path->pathkeys))
+					orderings = lappend(orderings, path->pathkeys);
+			}
+		}
+		if (ok)
+		{
+			apath = create_append_path(root, rel, input, NIL, NULL, 0, false,
+									   -1);
+			apath->path.pathtarget = target;
+			add_path(rel, (Path *) apath);
 		}
-		if (best == NULL)
-			return;
-		accumulate_append_subpath(best, &input.subpaths, NULL,
-								  &input.child_append_relid_sets);
 	}
 
-	apath = create_append_path(root, rel, input, NIL, NULL, 0, false, -1);
-	target = copy_pathtarget(rel->reltarget);
-	foreach(lc, extras)
-		add_new_column_to_pathtarget(target, (Expr *) lfirst(lc));
-	apath->path.pathtarget = set_pathtarget_cost_width(root, target);
-	add_path(rel, (Path *) apath);
+	/* Ordered: MergeAppend, or Append if the partitions are in that order */
+	if (rel->part_scheme != NULL &&
+		partitions_are_ordered(rel->boundinfo, rel->live_parts))
+		partition_pathkeys = build_partition_pathkeys(root, rel,
+													  ForwardScanDirection,
+													  &partition_pathkeys_partial);
+	foreach(lc, orderings)
+	{
+		List	   *pathkeys = (List *) lfirst(lc);
+		AppendPathInput input = {0};
+		bool		match_partition_order;
+		bool		ok = true;
+		ListCell   *lcr;
+
+		/* only orderings the parent's paths could be using */
+		pathkeys = truncate_useless_pathkeys(root, rel, pathkeys);
+		if (pathkeys == NIL)
+			continue;
+		match_partition_order =
+			pathkeys_contained_in(pathkeys, partition_pathkeys) ||
+			(!partition_pathkeys_partial &&
+			 pathkeys_contained_in(partition_pathkeys, pathkeys));
+
+		foreach(lcr, live_childrels)
+		{
+			RelOptInfo *childrel = (RelOptInfo *) lfirst(lcr);
+			Path	   *best = extras_child_path(root, childrel,
+												 childrel->pathlist,
+												 extras, pathkeys);
+
+			if (best == NULL)
+			{
+				ok = false;
+				break;
+			}
+
+			/* as generate_orderedappend_paths() collects them */
+			if (match_partition_order)
+				input.subpaths = lappend(input.subpaths,
+										 get_singleton_append_subpath(best,
+																	  &input.child_append_relid_sets));
+			else
+				accumulate_append_subpath(best, &input.subpaths, NULL,
+										  &input.child_append_relid_sets);
+		}
+		if (!ok)
+			continue;
+
+		if (match_partition_order)
+		{
+			AppendPath *apath;
+
+			apath = create_append_path(root, rel, input, pathkeys, NULL, 0,
+									   false, -1);
+			apath->path.pathtarget = target;
+			add_path(rel, (Path *) apath);
+		}
+		else
+		{
+			MergeAppendPath *mapath;
+
+			mapath = create_merge_append_path(root, rel, input.subpaths,
+											  input.child_append_relid_sets,
+											  pathkeys, NULL);
+			mapath->path.pathtarget = target;
+			add_path(rel, (Path *) mapath);
+		}
+	}
+
+	/* Parallel Append over the children's partial paths */
+	if (rel->consider_parallel)
+	{
+		AppendPathInput input = {0};
+		AppendPath *apath;
+		int			parallel_workers = 0;
+
+		foreach(lc, live_childrels)
+		{
+			RelOptInfo *childrel = (RelOptInfo *) lfirst(lc);
+			Path	   *best = extras_child_path(root, childrel,
+												 childrel->partial_pathlist,
+												 extras, NIL);
+
+			if (best == NULL)
+				return;
+			parallel_workers = Max(parallel_workers, best->parallel_workers);
+			accumulate_append_subpath(best, &input.partial_subpaths, NULL,
+									  &input.child_append_relid_sets);
+		}
+		if (enable_parallel_append)
+		{
+			parallel_workers = Max(parallel_workers,
+								   pg_leftmost_one_pos32(list_length(live_childrels)) + 1);
+			parallel_workers = Min(parallel_workers,
+								   max_parallel_workers_per_gather);
+		}
+		if (parallel_workers <= 0)
+			return;
+		apath = create_append_path(root, rel, input, NIL, NULL,
+								   parallel_workers, enable_parallel_append,
+								   -1);
+		apath->path.pathtarget = target;
+		add_partial_path(rel, (Path *) apath);
+	}
 }
 
 /*
@@ -3379,10 +3532,32 @@ generate_gather_paths(PlannerInfo *root, RelOptInfo *rel, bool override_rows)
 	cheapest_partial_path = linitial(rel->partial_pathlist);
 	rows = compute_gather_rows(cheapest_partial_path);
 	simple_gather_path = (Path *)
-		create_gather_path(root, rel, cheapest_partial_path, rel->reltarget,
+		create_gather_path(root, rel, cheapest_partial_path,
+						   gather_target(rel, cheapest_partial_path),
 						   NULL, rowsp);
 	add_path(rel, simple_gather_path);
 
+	/*
+	 * A partial path that emits extra values (see path_extra_exprs()) is
+	 * rarely the cheapest; gather the cheapest such path too, passing its
+	 * values up, so whatever is above can use them.
+	 */
+	foreach(lc, rel->partial_pathlist)
+	{
+		Path	   *subpath = (Path *) lfirst(lc);
+
+		if (subpath == cheapest_partial_path)
+			continue;
+		if (path_emits_extras(subpath))
+		{
+			rows = compute_gather_rows(subpath);
+			add_path(rel, (Path *)
+					 create_gather_path(root, rel, subpath,
+										subpath->pathtarget, NULL, rowsp));
+			break;
+		}
+	}
+
 	/*
 	 * For each useful ordering, we can consider an order-preserving Gather
 	 * Merge.
@@ -3396,12 +3571,31 @@ generate_gather_paths(PlannerInfo *root, RelOptInfo *rel, bool override_rows)
 			continue;
 
 		rows = compute_gather_rows(subpath);
-		path = create_gather_merge_path(root, rel, subpath, rel->reltarget,
+		path = create_gather_merge_path(root, rel, subpath,
+										gather_target(rel, subpath),
 										subpath->pathkeys, NULL, rowsp);
 		add_path(rel, &path->path);
 	}
 }
 
+/*
+ * gather_target
+ *	  The target for a Gather or Gather Merge over 'subpath': the rel's
+ *	  reltarget, or the subpath's own if it emits extra values (see
+ *	  path_extra_exprs()), so they pass up through the Gather.
+ *
+ * A Gather over a projection to the final scan/join target, as built by
+ * apply_scanjoin_target_to_paths(), takes rel->reltarget, which by then is
+ * that target.
+ */
+static PathTarget *
+gather_target(RelOptInfo *rel, Path *subpath)
+{
+	if (subpath->pathtarget != rel->reltarget && path_emits_extras(subpath))
+		return subpath->pathtarget;
+	return rel->reltarget;
+}
+
 /*
  * get_useful_pathkeys_for_relation
  *		Determine which orderings of a relation might be useful.
@@ -3590,7 +3784,7 @@ generate_useful_gather_paths(PlannerInfo *root, RelOptInfo *rel, bool override_r
 			rows = compute_gather_rows(subpath);
 			path = create_gather_merge_path(root, rel,
 											subpath,
-											rel->reltarget,
+											gather_target(rel, subpath),
 											subpath->pathkeys,
 											NULL,
 											rowsp);
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index e6b6439ff2d..86e28971707 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -19,6 +19,7 @@
 #include "catalog/pg_type.h"
 #include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
+#include "optimizer/clauses.h"
 #include "optimizer/optimizer.h"
 #include "optimizer/pathnode.h"
 #include "optimizer/planmain.h"
@@ -51,6 +52,12 @@ typedef struct
 	int			num_vars;		/* number of plain Var tlist entries */
 	bool		has_ph_vars;	/* are there PlaceHolderVar entries? */
 	bool		has_non_vars;	/* are there other entries? */
+
+	/*
+	 * non-Var entries without nullingrels, built on demand by
+	 * search_indexed_tlist_for_nulled_expr(); not freed with the struct
+	 */
+	List	   *nulled_tles;
 	tlist_vinfo vars[FLEXIBLE_ARRAY_MEMBER];	/* has num_vars entries */
 } indexed_tlist;
 
@@ -180,6 +187,10 @@ static Var *search_indexed_tlist_for_phv(PlaceHolderVar *phv,
 static Var *search_indexed_tlist_for_non_var(Expr *node,
 											 indexed_tlist *itlist,
 											 int newvarno);
+static Var *search_indexed_tlist_for_nulled_expr(PlannerInfo *root,
+												 Expr *node,
+												 indexed_tlist *itlist,
+												 int newvarno);
 static Var *search_indexed_tlist_for_sortgroupref(Expr *node,
 												  Index sortgroupref,
 												  indexed_tlist *itlist,
@@ -2923,7 +2934,8 @@ set_dummy_tlist_references(Plan *plan, int rtoffset)
  *
  * The result of this function is an indexed_tlist struct to pass to
  * search_indexed_tlist_for_var() and siblings.
- * When done, the indexed_tlist may be freed with a single pfree().
+ * When done, the indexed_tlist may be freed with a single pfree(); a
+ * nulled_tles list built for it later stays in the planner's context.
  */
 static indexed_tlist *
 build_tlist_index(List *tlist)
@@ -2940,6 +2952,7 @@ build_tlist_index(List *tlist)
 	itlist->tlist = tlist;
 	itlist->has_ph_vars = false;
 	itlist->has_non_vars = false;
+	itlist->nulled_tles = NIL;
 
 	/* Find the Vars and fill in the index array */
 	vinfo = itlist->vars;
@@ -2991,6 +3004,7 @@ build_tlist_index_other_vars(List *tlist, int ignore_rel)
 	itlist->tlist = tlist;
 	itlist->has_ph_vars = false;
 	itlist->has_non_vars = false;
+	itlist->nulled_tles = NIL;
 
 	/* Find the desired Vars and fill in the index array */
 	vinfo = itlist->vars;
@@ -3177,6 +3191,108 @@ search_indexed_tlist_for_non_var(Expr *node,
 	return NULL;				/* no match */
 }
 
+/*
+ * expr_nullingrels
+ *	  The union of the nullingrels of the Vars and PlaceHolderVars in 'node'.
+ */
+static Relids
+expr_nullingrels(Node *node)
+{
+	Relids		result = NULL;
+	ListCell   *lc;
+
+	foreach(lc, pull_var_clause(node, PVC_INCLUDE_PLACEHOLDERS |
+								PVC_RECURSE_AGGREGATES |
+								PVC_RECURSE_WINDOWFUNCS))
+	{
+		Node	   *v = (Node *) lfirst(lc);
+
+		if (IsA(v, Var))
+			result = bms_add_members(result, ((Var *) v)->varnullingrels);
+		else
+			result = bms_add_members(result,
+									 ((PlaceHolderVar *) v)->phnullingrels);
+	}
+	return result;
+}
+
+/*
+ * search_indexed_tlist_for_nulled_expr
+ *	  Like search_indexed_tlist_for_non_var(), for an expression above an
+ *	  outer join whose Vars may carry nullingrels the input's don't.
+ *
+ * An input of an outer join may emit a non-Var expression it read from an
+ * index (see path_extra_exprs()).  Above the join, the query's copy of that
+ * expression has the join's bit added to its Vars' nullingrels, so equal()
+ * won't match it; the join adds the bit because it null-extends those Vars.
+ * It null-extends the input's copy of the expression too, so the two agree
+ * on every row if the expression yields NULL when its Vars are NULL, that
+ * is if it is strict.  Then match it as we match Vars here (NRM_SUPERSET):
+ * equal but for nullingrels, with the query's copy's nullingrels a superset
+ * of the input's.  The planner relies on this when it carries such a value
+ * up through an outer join (see join_path_target()).
+ *
+ * The first call for an itlist saves its non-Var entries without
+ * nullingrels, each paired with the nullingrels it had, so that later calls
+ * don't strip them again.  Callers only search an itlist that has_non_vars,
+ * so that list is never empty once built.
+ */
+static Var *
+search_indexed_tlist_for_nulled_expr(PlannerInfo *root, Expr *node,
+									 indexed_tlist *itlist, int newvarno)
+{
+	Node	   *stripped = NULL;
+	Relids		nullingrels = NULL;
+	ListCell   *lc;
+
+	Assert(itlist->has_non_vars);
+	if (IsA(node, Const) || IsA(node, Param) ||
+		bms_is_empty(root->outer_join_rels))
+		return NULL;
+
+	if (itlist->nulled_tles == NIL)
+	{
+		foreach(lc, itlist->tlist)
+		{
+			TargetEntry *tle = (TargetEntry *) lfirst(lc);
+			TargetEntry *ntle;
+
+			if (IsA(tle->expr, Var) || IsA(tle->expr, PlaceHolderVar))
+				continue;
+			ntle = flatCopyTargetEntry(tle);
+			ntle->expr = (Expr *) strip_nullingrels((Node *) tle->expr);
+			itlist->nulled_tles = lappend(itlist->nulled_tles, ntle);
+			itlist->nulled_tles = lappend(itlist->nulled_tles,
+										  expr_nullingrels((Node *) tle->expr));
+		}
+		Assert(itlist->nulled_tles != NIL);
+	}
+
+	for (int i = 0; i < list_length(itlist->nulled_tles); i += 2)
+	{
+		TargetEntry *tle = list_nth_node(TargetEntry, itlist->nulled_tles, i);
+
+		if (stripped == NULL)
+		{
+			if (contain_nonstrict_functions((Node *) node) ||
+				!contain_var_clause((Node *) node))
+				return NULL;
+			stripped = strip_nullingrels((Node *) node);
+			nullingrels = expr_nullingrels((Node *) node);
+		}
+		if (equal(stripped, tle->expr) &&
+			bms_is_subset(list_nth(itlist->nulled_tles, i + 1), nullingrels))
+		{
+			Var		   *newvar = makeVarFromTargetEntry(newvarno, tle);
+
+			newvar->varnosyn = 0;	/* wasn't ever a plain Var */
+			newvar->varattnosyn = 0;
+			return newvar;
+		}
+	}
+	return NULL;
+}
+
 /*
  * search_indexed_tlist_for_sortgroupref --- find a sort/group expression
  *
@@ -3384,6 +3500,11 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context)
 		newvar = search_indexed_tlist_for_non_var((Expr *) node,
 												  context->outer_itlist,
 												  OUTER_VAR);
+		if (newvar == NULL && context->nrm_match == NRM_SUPERSET)
+			newvar = search_indexed_tlist_for_nulled_expr(context->root,
+														  (Expr *) node,
+														  context->outer_itlist,
+														  OUTER_VAR);
 		if (newvar)
 			return (Node *) newvar;
 	}
@@ -3392,6 +3513,11 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context)
 		newvar = search_indexed_tlist_for_non_var((Expr *) node,
 												  context->inner_itlist,
 												  INNER_VAR);
+		if (newvar == NULL && context->nrm_match == NRM_SUPERSET)
+			newvar = search_indexed_tlist_for_nulled_expr(context->root,
+														  (Expr *) node,
+														  context->inner_itlist,
+														  INNER_VAR);
 		if (newvar)
 			return (Node *) newvar;
 	}
diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c
index 5356beaf8a5..86a979982d3 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -397,6 +397,51 @@ expr_contains(Node *expr, Node *sub)
 	return expr_contains_walker(expr, sub);
 }
 
+/*
+ * strip_nullingrels
+ *	  A copy of 'node' with every Var's and PlaceHolderVar's nullingrels
+ *	  cleared.
+ *
+ * Used to compare an expression read below an outer join with the query's
+ * copy above it (see query_tlist_exprs() in pathnode.c and
+ * search_indexed_tlist_for_nulled_expr() in setrefs.c).
+ * remove_nulling_relids() can't do this: it tests each Var's varno with
+ * bms_is_member(), which errors out on the negative special varnos, such as
+ * the ROWID_VAR a MERGE's targetlist contains.
+ */
+static Node *
+strip_nullingrels_mutator(Node *node, void *context)
+{
+	if (node == NULL)
+		return NULL;
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+
+		if (bms_is_empty(var->varnullingrels))
+			return node;
+		var = copyObject(var);
+		var->varnullingrels = NULL;
+		return (Node *) var;
+	}
+	if (IsA(node, PlaceHolderVar))
+	{
+		PlaceHolderVar *phv;
+
+		phv = (PlaceHolderVar *)
+			expression_tree_mutator(node, strip_nullingrels_mutator, context);
+		phv->phnullingrels = NULL;
+		return (Node *) phv;
+	}
+	return expression_tree_mutator(node, strip_nullingrels_mutator, context);
+}
+
+Node *
+strip_nullingrels(Node *node)
+{
+	return strip_nullingrels_mutator(node, NULL);
+}
+
 /*****************************************************************************
  *		Check clauses for mutable functions
  *****************************************************************************/
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 06e9eea685f..062c8d7476e 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -402,9 +402,10 @@ set_cheapest(RelOptInfo *parent_rel)
  * rel->reltarget with the extra expressions appended, so they're its tail.
  *
  * Sort, Material and Memoize don't project and share their input's target,
- * so they emit what it emits.  Nothing else emits extra values: other path
- * types build their own targets, and an upper rel's targets differ from its
- * reltarget for reasons of their own.
+ * so they emit what it emits; so may a Gather or Gather Merge given its
+ * input's target (see generate_gather_paths()).  Nothing else emits extra
+ * values: other path types build their own targets, and an upper rel's
+ * targets differ from its reltarget for reasons of their own.
  */
 static ListCell *
 path_extras_start(Path *path, int *nextras)
@@ -423,6 +424,8 @@ path_extras_start(Path *path, int *nextras)
 		case T_IncrementalSortPath:
 		case T_MaterialPath:
 		case T_MemoizePath:
+		case T_GatherPath:
+		case T_GatherMergePath:
 			{
 				Path	   *subpath;
 
@@ -430,6 +433,10 @@ path_extras_start(Path *path, int *nextras)
 					subpath = ((MaterialPath *) path)->subpath;
 				else if (IsA(path, MemoizePath))
 					subpath = ((MemoizePath *) path)->subpath;
+				else if (IsA(path, GatherPath))
+					subpath = ((GatherPath *) path)->subpath;
+				else if (IsA(path, GatherMergePath))
+					subpath = ((GatherMergePath *) path)->subpath;
 				else
 					subpath = ((SortPath *) path)->subpath;
 				if (subpath->parent != rel ||
@@ -448,6 +455,7 @@ path_extras_start(Path *path, int *nextras)
 				return NULL;
 			break;
 		case T_AppendPath:
+		case T_MergeAppendPath:
 			/* see add_extras_append_path() */
 			if (rel->reloptkind != RELOPT_BASEREL)
 				return NULL;
@@ -1392,6 +1400,14 @@ query_tlist_exprs(PlannerInfo *root, RelOptInfo *rel)
 		exprs = (List *) adjust_appendrel_attrs_multilevel(root, (Node *) exprs,
 														   rel,
 														   rel->top_parent);
+
+	/*
+	 * Above an outer join that null-extends the rel, the query's Vars carry
+	 * nullingrels that a scan's don't.  The scan may still emit the value;
+	 * whether it can be used above the join is join_path_target()'s call.
+	 */
+	if (!bms_is_empty(root->outer_join_rels))
+		exprs = (List *) strip_nullingrels((Node *) exprs);
 	return exprs;
 }
 
@@ -1414,16 +1430,16 @@ query_tlist_exprs(PlannerInfo *root, RelOptInfo *rel)
  * at any depth, with a reference to it; the paths that must compute it
  * instead pay for it when the expression is charged.
  *
- * An expression is matched with equal(), so a Var of a rel on the nullable
- * side of an outer join, which carries varnullingrels in the targetlist,
- * never matches the index's own: the value read below the join isn't the
- * one the query asks for above it.
+ * The query's targetlist is compared without nullingrels (see
+ * query_tlist_exprs()); whether a value read below an outer join can be used
+ * above it is decided as it's passed up (see relabel_extra_for_join()).
  *
- * A partial path gets the extra values only if they're parallel-safe.
+ * A partial path may emit a value that isn't parallel-safe: a worker reads
+ * the stored value, it never evaluates the expression, and nothing above
+ * the scan does either, since it references the scan's output column.
  */
 static PathTarget *
-indexonly_path_target(PlannerInfo *root, IndexOptInfo *index,
-					  bool partial_path)
+indexonly_path_target(PlannerInfo *root, IndexOptInfo *index)
 {
 	RelOptInfo *rel = index->rel;
 	PathTarget *target = NULL;
@@ -1444,8 +1460,6 @@ indexonly_path_target(PlannerInfo *root, IndexOptInfo *index,
 
 		if (!index->canreturn[i++] || IsA(itle->expr, Var))
 			continue;
-		if (partial_path && !is_parallel_safe(root, (Node *) itle->expr))
-			continue;
 		if (!expr_worth_emitting(root, (Node *) itle->expr))
 			continue;
 
@@ -1508,7 +1522,7 @@ create_index_path(PlannerInfo *root,
 	pathnode->path.pathtype = indexonly ? T_IndexOnlyScan : T_IndexScan;
 	pathnode->path.parent = rel;
 	pathnode->path.pathtarget = indexonly ?
-		indexonly_path_target(root, index, partial_path) :
+		indexonly_path_target(root, index) :
 		index_path_orderby_target(root, index, indexorderbys,
 								  indexorderbycols);
 	pathnode->path.param_info = get_baserel_parampathinfo(root, rel,
@@ -2743,6 +2757,99 @@ calc_non_nestloop_required_outer(Path *outer_path, Path *inner_path)
 	return required_outer;
 }
 
+/* state for relabel_extra_for_join()'s walker and mutator */
+typedef struct
+{
+	List	   *joinvars;
+	bool		changed;
+	bool		missing;
+} relabel_context;
+
+static Var *
+relabel_find_joinvar(Var *var, List *joinvars)
+{
+	ListCell   *lc;
+
+	foreach(lc, joinvars)
+	{
+		Var		   *jv = (Var *) lfirst(lc);
+
+		if (IsA(jv, Var) && jv->varno == var->varno &&
+			jv->varattno == var->varattno && jv->varlevelsup == 0)
+			return jv;
+	}
+	return NULL;
+}
+
+/* First pass: does anything need relabeling, or is a Var missing? */
+static bool
+relabel_check_walker(Node *node, relabel_context *context)
+{
+	if (node == NULL)
+		return false;
+	if (IsA(node, Var) && ((Var *) node)->varlevelsup == 0)
+	{
+		Var		   *var = (Var *) node;
+		Var		   *jv = relabel_find_joinvar(var, context->joinvars);
+
+		if (jv == NULL)
+			context->missing = true;
+		else if (!bms_equal(jv->varnullingrels, var->varnullingrels))
+			context->changed = true;
+		return context->missing;
+	}
+	return expression_tree_walker(node, relabel_check_walker, context);
+}
+
+static Node *
+relabel_extra_mutator(Node *node, relabel_context *context)
+{
+	if (node == NULL)
+		return NULL;
+	if (IsA(node, Var) && ((Var *) node)->varlevelsup == 0)
+	{
+		Var		   *var = copyObject((Var *) node);
+		Var		   *jv = relabel_find_joinvar(var, context->joinvars);
+
+		var->varnullingrels = bms_copy(jv->varnullingrels);
+		return (Node *) var;
+	}
+	return expression_tree_mutator(node, relabel_extra_mutator, context);
+}
+
+/*
+ * relabel_extra_for_join
+ *	  Express an input's extra value in terms of the joinrel's Vars.
+ *
+ * Above an outer join, the joinrel's reltarget has each Var of the nullable
+ * side with the join's bit added to its nullingrels, and the query's copy of
+ * an expression over those Vars has it too.  The input's copy doesn't.  The
+ * join null-extends the input's value along with the Vars, which gives the
+ * expression's value for null-extended rows only if the expression is
+ * strict, so for a non-strict one we can't pass the value up (return NULL).
+ * Otherwise return the expression with each Var replaced by the joinrel's
+ * version of it, which setrefs.c matches with the input's
+ * (search_indexed_tlist_for_nulled_expr()).  If some Var isn't in the
+ * joinrel's reltarget, the value isn't needed above, so return NULL too.
+ */
+static Node *
+relabel_extra_for_join(RelOptInfo *joinrel, Node *expr)
+{
+	relabel_context context;
+
+	context.joinvars = joinrel->reltarget->exprs;
+	context.changed = false;
+	context.missing = false;
+	(void) relabel_check_walker(expr, &context);
+	if (context.missing)
+		return NULL;
+	if (!context.changed)
+		return expr;
+	if (contain_nonstrict_functions(expr))
+		return NULL;
+	return relabel_extra_mutator(expr, &context);
+}
+
 /*
  * join_path_target
  *	  Choose the PathTarget for a join path.
@@ -2755,17 +2862,14 @@ calc_non_nestloop_required_outer(Path *outer_path, Path *inner_path)
  * ordering IndexScan below the topmost join would read the value for
  * nothing.
  *
- * Only values the query still needs above this join are passed up: a
- * value that refers to a rel on the nullable side of an outer join this
- * join forms would be wrong above it, but those never match the query's
- * targetlist (its Vars carry varnullingrels the index's don't), so no input
- * emits one; and every value we emit came from the final targetlist.
+ * Above an outer join a value from the nullable side is relabeled with the
+ * join's nullingrels, and passed up only if that gives the right answer for
+ * null-extended rows; see relabel_extra_for_join().
  *
- * Values must be parallel-safe if the join is, since a partial join path's
- * target is computed in workers.
- *
- * Every value a scan emits passed expr_worth_emitting() there, so we don't
- * look up its cost again for each join path.
+ * A value needn't be parallel-safe for a partial join path: the join only
+ * references its input's output column.  Every value a scan emits passed
+ * expr_worth_emitting() there, so we don't look up its cost again for each
+ * join path.
  */
 static PathTarget *
 join_path_target(PlannerInfo *root, RelOptInfo *joinrel,
@@ -2785,9 +2889,9 @@ join_path_target(PlannerInfo *root, RelOptInfo *joinrel,
 	nextras = list_length(outer_extras) + list_length(inner_extras);
 	foreach(lc, list_concat(outer_extras, inner_extras))
 	{
-		Node	   *expr = (Node *) lfirst(lc);
+		Node	   *expr = relabel_extra_for_join(joinrel, (Node *) lfirst(lc));
 
-		if (!joinrel->consider_parallel || is_parallel_safe(root, expr))
+		if (expr != NULL)
 			add_new_column_to_pathtarget(target, (Expr *) expr);
 		else
 			all_kept = false;
@@ -3175,9 +3279,39 @@ typedef struct
 {
 	PlannerInfo *root;
 	List	   *free;
+	bool		nulled_ok;		/* see free_expr_match() */
+	List	   *free_stripped;	/* 'free' without nullingrels, on demand */
 	QualCost	cost;
 } credit_free_context;
 
+/*
+ * free_expr_match
+ *	  Is 'node' one of context->free?
+ *
+ * At an outer join (context->nulled_ok), also accept a strict 'node' that is
+ * one of them but for nullingrels, as setrefs.c does.
+ */
+static bool
+free_expr_match(Node *node, credit_free_context *context)
+{
+	Node	   *stripped;
+	ListCell   *lc;
+
+	if (list_member(context->free, node))
+		return true;
+	if (!context->nulled_ok || bms_is_empty(context->root->outer_join_rels))
+		return false;
+	if (context->free_stripped == NIL)
+		context->free_stripped = (List *) strip_nullingrels((Node *) context->free);
+	stripped = strip_nullingrels(node);
+	foreach(lc, context->free_stripped)
+	{
+		if (equal(stripped, lfirst(lc)))
+			return !contain_nonstrict_functions(node);
+	}
+	return false;
+}
+
 /*
  * credit_free_exprs_walker
  *	  Subtract from context->cost the cost of each outermost subexpression
@@ -3194,7 +3328,7 @@ credit_free_exprs_walker(Node *node, credit_free_context *context)
 {
 	if (node == NULL || IsA(node, Var) || IsA(node, Const))
 		return false;
-	if (list_member(context->free, node))
+	if (free_expr_match(node, context))
 	{
 		QualCost	ecost;
 
@@ -3212,12 +3346,15 @@ credit_free_exprs_walker(Node *node, credit_free_context *context)
  *	  uses (see credit_free_exprs_walker()).
  */
 static QualCost
-free_exprs_cost(PlannerInfo *root, PathTarget *target, List *free)
+free_exprs_cost(PlannerInfo *root, PathTarget *target, List *free,
+				bool nulled_ok)
 {
 	credit_free_context context;
 
 	context.root = root;
 	context.free = free;
+	context.nulled_ok = nulled_ok;
+	context.free_stripped = NIL;
 	context.cost = target->cost;
 	if (free != NIL)
 		(void) credit_free_exprs_walker((Node *) target->exprs, &context);
@@ -3268,6 +3405,8 @@ indexonly_qual_cost(PlannerInfo *root, IndexPath *ipath, List *qpquals,
 
 	context.root = root;
 	context.free = indexonly_free_exprs(ipath);
+	context.nulled_ok = false;
+	context.free_stripped = NIL;
 	context.cost = cost;
 	if (context.free == NIL)
 		return cost;
@@ -3308,6 +3447,7 @@ join_free_exprs(JoinPath *jpath)
  * - a join, for values one of its inputs emits (join_free_exprs());
  * - an index-only scan, for returnable index expression columns
  *   (indexonly_free_exprs());
+ * - a Gather or Gather Merge, for values its input emits;
  *
  * in either case wherever the value appears in the target, since setrefs.c
  * replaces every occurrence (see credit_free_exprs_walker()).  And
@@ -3327,12 +3467,37 @@ path_target_cost(PlannerInfo *root, Path *path, PathTarget *target)
 	ListCell   *lc;
 
 	if (IsA(path, NestPath) || IsA(path, MergePath) || IsA(path, HashPath))
-		return free_exprs_cost(root, target,
-							   join_free_exprs((JoinPath *) path));
+	{
+		JoinPath   *jpath = (JoinPath *) path;
+		bool		forms_oj;
+
+		/*
+		 * A join that forms outer joins matches nulled copies too, as
+		 * set_join_references() does when the Join's ojrelids isn't empty.
+		 * The inputs' relids are disjoint and both within the joinrel's, so
+		 * it forms one exactly when the joinrel has more relids than they do.
+		 */
+		forms_oj = bms_num_members(path->parent->relids) >
+			bms_num_members(jpath->outerjoinpath->parent->relids) +
+			bms_num_members(jpath->innerjoinpath->parent->relids);
+		return free_exprs_cost(root, target, join_free_exprs(jpath),
+							   forms_oj);
+	}
 
 	if (IsA(path, IndexPath) && path->pathtype == T_IndexOnlyScan)
 		return free_exprs_cost(root, target,
-							   indexonly_free_exprs((IndexPath *) path));
+							   indexonly_free_exprs((IndexPath *) path),
+							   false);
+
+	/* set_upper_references() matches them in a Gather's subplan's tlist */
+	if (IsA(path, GatherPath))
+		return free_exprs_cost(root, target,
+							   path_extra_exprs(((GatherPath *) path)->subpath),
+							   false);
+	if (IsA(path, GatherMergePath))
+		return free_exprs_cost(root, target,
+							   path_extra_exprs(((GatherMergePath *) path)->subpath),
+							   false);
 
 	if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan)
 		return cost;
diff --git a/src/include/optimizer/clauses.h b/src/include/optimizer/clauses.h
index e0196edf109..f88278f10d9 100644
--- a/src/include/optimizer/clauses.h
+++ b/src/include/optimizer/clauses.h
@@ -46,6 +46,7 @@ extern char max_parallel_hazard(Query *parse);
 extern bool is_parallel_safe(PlannerInfo *root, Node *node);
 extern bool contain_nonstrict_functions(Node *clause);
 extern bool expr_contains(Node *expr, Node *sub);
+extern Node *strip_nullingrels(Node *node);
 extern bool contain_exec_param(Node *clause, List *param_ids);
 extern bool contain_leaked_vars(Node *clause);
 
diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out
index 40f00e435dc..be68194751f 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -1010,16 +1010,76 @@ select gist_ios_costly(w.i), d.tag from gist_ios_wide w
 reset enable_mergejoin;
 reset enable_nestloop;
 drop table gist_ios_wide;
--- But not from the nullable side of an outer join: there the query's Var
--- differs from the index's (it may be null), so the value read below the
--- join isn't the one asked for.
+-- Also from the nullable side of an outer join, because the function is
+-- strict: a null-extended row gets NULL for the value, which is what the
+-- function returns for a NULL argument.  The value is used inside a larger,
+-- non-strict expression above the join.
+set enable_hashjoin = off;
+set enable_nestloop = off;
 explain (verbose, costs off)
 select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d
   left join gist_ios_cost a on a.i = d.i * 2;
-                       QUERY PLAN                        
----------------------------------------------------------
+                                          QUERY PLAN                                          
+----------------------------------------------------------------------------------------------
+ Merge Left Join
+   Output: COALESCE((gist_ios_costly(a.i)), '-1'::integer)
+   Merge Cond: (((d.i * 2)) = a.i)
+   ->  Sort
+         Output: d.i, ((d.i * 2))
+         Sort Key: ((d.i * 2))
+         ->  Seq Scan on pg_temp.gist_ios_dim d
+               Output: d.i, (d.i * 2)
+   ->  Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost a
+         Output: a.i, (gist_ios_costly(a.i))
+(10 rows)
+
+reset enable_hashjoin;
+reset enable_nestloop;
+-- And from both sides' nullable rows of a FULL JOIN.
+set enable_hashjoin = off;
+explain (verbose, costs off)
+select gist_ios_costly(a.i), d.tag from gist_ios_dim d
+  full join gist_ios_cost a on a.i = d.i * 2;
+                                          QUERY PLAN                                          
+----------------------------------------------------------------------------------------------
+ Merge Full Join
+   Output: (gist_ios_costly(a.i)), d.tag
+   Merge Cond: (((d.i * 2)) = a.i)
+   ->  Sort
+         Output: d.tag, d.i, ((d.i * 2))
+         Sort Key: ((d.i * 2))
+         ->  Seq Scan on pg_temp.gist_ios_dim d
+               Output: d.tag, d.i, (d.i * 2)
+   ->  Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost a
+         Output: a.i, (gist_ios_costly(a.i))
+(10 rows)
+
+reset enable_hashjoin;
+select count(*) as total, count(v) as with_value,
+       count(*) filter (where v = i) as same
+  from (select gist_ios_costly(a.i) as v, a.i from gist_ios_dim d
+        full join gist_ios_cost a on a.i = d.i * 2) s;
+ total | with_value | same  
+-------+------------+-------
+ 10714 |      10000 | 10000
+(1 row)
+
+-- A function that isn't strict may return something else for NULL, so it's
+-- computed above the join.
+create function gist_ios_nonstrict(int) returns int
+  language plpgsql immutable called on null input cost 100000
+  as $$ begin return coalesce($1, -1); end $$;
+create index on gist_ios_cost (i, gist_ios_nonstrict(i));
+vacuum analyze gist_ios_cost;
+set enable_mergejoin = off;
+set enable_nestloop = off;
+explain (verbose, costs off)
+select gist_ios_nonstrict(a.i) from gist_ios_dim d
+  left join gist_ios_cost a on a.i = d.i * 2;
+                   QUERY PLAN                    
+-------------------------------------------------
  Hash Left Join
-   Output: COALESCE(gist_ios_costly(a.i), '-1'::integer)
+   Output: gist_ios_nonstrict(a.i)
    Hash Cond: ((d.i * 2) = a.i)
    ->  Seq Scan on pg_temp.gist_ios_dim d
          Output: d.i, d.tag
@@ -1029,6 +1089,126 @@ select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d
                Output: a.i
 (9 rows)
 
+reset enable_mergejoin;
+reset enable_nestloop;
+select count(*) filter (where v = -1) as null_extended
+  from (select gist_ios_nonstrict(a.i) as v from gist_ios_dim d
+        left join gist_ios_cost a on a.i = d.i * 2) s;
+ null_extended 
+---------------
+           714
+(1 row)
+
+drop function gist_ios_nonstrict(int) cascade;
+NOTICE:  drop cascades to index gist_ios_cost_i_gist_ios_nonstrict_i_idx
+-- A parallel-restricted function: the value is read from the index inside
+-- the workers, not evaluated there, and passed up through the Gather.  (Not
+-- a temp table, which can't be scanned in parallel.)
+create function gist_ios_restricted(int) returns int
+  language plpgsql immutable strict parallel restricted cost 100000
+  as $$ begin return $1; end $$;
+create table gist_ios_par (i int);
+insert into gist_ios_par select g from generate_series(1, 10000) g;
+create index on gist_ios_par (i, gist_ios_restricted(i));
+vacuum analyze gist_ios_par;
+set parallel_setup_cost = 0;
+set parallel_tuple_cost = 0;
+set min_parallel_index_scan_size = 0;
+set max_parallel_workers_per_gather = 2;
+set parallel_leader_participation = off;
+-- (Pin the scan type: these tables aren't temp, so VACUUM may not get to
+-- mark them all-visible while other sessions hold snapshots.)
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+explain (verbose, costs off)
+select gist_ios_restricted(i) from gist_ios_par;
+                                              QUERY PLAN                                              
+------------------------------------------------------------------------------------------------------
+ Gather
+   Output: (gist_ios_restricted(i))
+   Workers Planned: 2
+   ->  Parallel Index Only Scan using gist_ios_par_i_gist_ios_restricted_i_idx on public.gist_ios_par
+         Output: i, (gist_ios_restricted(i))
+(5 rows)
+
+select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_par) s;
+ count |   sum    
+-------+----------
+ 10000 | 50005000
+(1 row)
+
+-- Also through a Gather Merge, which sorts the workers' rows by a column
+-- the index doesn't lead with.
+create table gist_ios_gm (k int, i int);
+insert into gist_ios_gm select g % 97, g from generate_series(1, 20000) g;
+create index on gist_ios_gm (i, gist_ios_restricted(i)) include (k);
+vacuum analyze gist_ios_gm;
+explain (verbose, costs off)
+select k, gist_ios_restricted(i) from gist_ios_gm where i > 100 order by k;
+                                                 QUERY PLAN                                                 
+------------------------------------------------------------------------------------------------------------
+ Gather Merge
+   Output: k, (gist_ios_restricted(i))
+   Workers Planned: 2
+   ->  Sort
+         Output: k, i, (gist_ios_restricted(i))
+         Sort Key: gist_ios_gm.k
+         ->  Parallel Index Only Scan using gist_ios_gm_i_gist_ios_restricted_i_k_idx on public.gist_ios_gm
+               Output: k, i, (gist_ios_restricted(i))
+               Index Cond: (gist_ios_gm.i > 100)
+(9 rows)
+
+select count(*) filter (where v = i) as same, count(*) as total
+  from (select k, i, gist_ios_restricted(i) as v from gist_ios_gm
+        where i > 100 order by k) s;
+ same  | total 
+-------+-------
+ 19900 | 19900
+(1 row)
+
+drop table gist_ios_gm;
+-- And through a Parallel Append over partitions that all have the index.
+-- (Different sizes, so that the children's cost order, which is the plan's
+-- order, doesn't depend on how many pages VACUUM could mark all-visible
+-- when other sessions hold snapshots.)
+create table gist_ios_ppart (i int) partition by range (i);
+create table gist_ios_ppart1 partition of gist_ios_ppart
+  for values from (1) to (2001);
+create table gist_ios_ppart2 partition of gist_ios_ppart
+  for values from (2001) to (10001);
+insert into gist_ios_ppart select g from generate_series(1, 10000) g;
+create index on gist_ios_ppart (i, gist_ios_restricted(i));
+vacuum analyze gist_ios_ppart;
+explain (verbose, costs off)
+select gist_ios_restricted(i) from gist_ios_ppart;
+                                                            QUERY PLAN                                                             
+-----------------------------------------------------------------------------------------------------------------------------------
+ Gather
+   Output: (gist_ios_restricted(gist_ios_ppart.i))
+   Workers Planned: 2
+   ->  Parallel Append
+         ->  Parallel Index Only Scan using gist_ios_ppart2_i_gist_ios_restricted_i_idx on public.gist_ios_ppart2 gist_ios_ppart_2
+               Output: gist_ios_ppart_2.i, (gist_ios_restricted(gist_ios_ppart_2.i))
+         ->  Parallel Index Only Scan using gist_ios_ppart1_i_gist_ios_restricted_i_idx on public.gist_ios_ppart1 gist_ios_ppart_1
+               Output: gist_ios_ppart_1.i, (gist_ios_restricted(gist_ios_ppart_1.i))
+(8 rows)
+
+select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_ppart) s;
+ count |   sum    
+-------+----------
+ 10000 | 50005000
+(1 row)
+
+drop table gist_ios_ppart;
+reset enable_seqscan;
+reset enable_bitmapscan;
+reset parallel_setup_cost;
+reset parallel_tuple_cost;
+reset min_parallel_index_scan_size;
+reset max_parallel_workers_per_gather;
+reset parallel_leader_participation;
+drop table gist_ios_par;
+drop function gist_ios_restricted(int);
 -- A partitioned table whose partitions all have the index: each child reads
 -- the value and the Append passes it up, even when a partition's columns
 -- are in a different order.
@@ -1064,6 +1244,28 @@ select gist_ios_costly(p.i), d.tag from gist_ios_part p
 
 reset enable_mergejoin;
 reset enable_nestloop;
+-- An ordered Append passes the value up as well, here into a merge join.
+set enable_hashjoin = off;
+set enable_nestloop = off;
+explain (verbose, costs off)
+select gist_ios_costly(p.i), d.tag from gist_ios_part p
+  join gist_ios_dim d on d.i = p.i order by p.i;
+                                               QUERY PLAN                                               
+--------------------------------------------------------------------------------------------------------
+ Merge Join
+   Output: (gist_ios_costly(p.i)), d.tag, p.i
+   Merge Cond: (p.i = d.i)
+   ->  Append
+         ->  Index Only Scan using gist_ios_part1_i_gist_ios_costly_i_idx on pg_temp.gist_ios_part1 p_1
+               Output: p_1.i, (gist_ios_costly(p_1.i))
+         ->  Index Only Scan using gist_ios_part2_i_gist_ios_costly_i_idx on pg_temp.gist_ios_part2 p_2
+               Output: p_2.i, (gist_ios_costly(p_2.i))
+   ->  Index Scan using gist_ios_dim_i_idx on pg_temp.gist_ios_dim d
+         Output: d.i, d.tag
+(10 rows)
+
+reset enable_hashjoin;
+reset enable_nestloop;
 -- An index created on only one partition doesn't count: every child must
 -- emit the value for the Append to.
 create temp table gist_ios_part_x (i int) partition by range (i);
diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql
index ddd7001346e..9c62f38ddce 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -508,12 +508,104 @@ select gist_ios_costly(w.i), d.tag from gist_ios_wide w
 reset enable_mergejoin;
 reset enable_nestloop;
 drop table gist_ios_wide;
--- But not from the nullable side of an outer join: there the query's Var
--- differs from the index's (it may be null), so the value read below the
--- join isn't the one asked for.
+-- Also from the nullable side of an outer join, because the function is
+-- strict: a null-extended row gets NULL for the value, which is what the
+-- function returns for a NULL argument.  The value is used inside a larger,
+-- non-strict expression above the join.
+set enable_hashjoin = off;
+set enable_nestloop = off;
 explain (verbose, costs off)
 select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d
   left join gist_ios_cost a on a.i = d.i * 2;
+reset enable_hashjoin;
+reset enable_nestloop;
+-- And from both sides' nullable rows of a FULL JOIN.
+set enable_hashjoin = off;
+explain (verbose, costs off)
+select gist_ios_costly(a.i), d.tag from gist_ios_dim d
+  full join gist_ios_cost a on a.i = d.i * 2;
+reset enable_hashjoin;
+select count(*) as total, count(v) as with_value,
+       count(*) filter (where v = i) as same
+  from (select gist_ios_costly(a.i) as v, a.i from gist_ios_dim d
+        full join gist_ios_cost a on a.i = d.i * 2) s;
+-- A function that isn't strict may return something else for NULL, so it's
+-- computed above the join.
+create function gist_ios_nonstrict(int) returns int
+  language plpgsql immutable called on null input cost 100000
+  as $$ begin return coalesce($1, -1); end $$;
+create index on gist_ios_cost (i, gist_ios_nonstrict(i));
+vacuum analyze gist_ios_cost;
+set enable_mergejoin = off;
+set enable_nestloop = off;
+explain (verbose, costs off)
+select gist_ios_nonstrict(a.i) from gist_ios_dim d
+  left join gist_ios_cost a on a.i = d.i * 2;
+reset enable_mergejoin;
+reset enable_nestloop;
+select count(*) filter (where v = -1) as null_extended
+  from (select gist_ios_nonstrict(a.i) as v from gist_ios_dim d
+        left join gist_ios_cost a on a.i = d.i * 2) s;
+drop function gist_ios_nonstrict(int) cascade;
+-- A parallel-restricted function: the value is read from the index inside
+-- the workers, not evaluated there, and passed up through the Gather.  (Not
+-- a temp table, which can't be scanned in parallel.)
+create function gist_ios_restricted(int) returns int
+  language plpgsql immutable strict parallel restricted cost 100000
+  as $$ begin return $1; end $$;
+create table gist_ios_par (i int);
+insert into gist_ios_par select g from generate_series(1, 10000) g;
+create index on gist_ios_par (i, gist_ios_restricted(i));
+vacuum analyze gist_ios_par;
+set parallel_setup_cost = 0;
+set parallel_tuple_cost = 0;
+set min_parallel_index_scan_size = 0;
+set max_parallel_workers_per_gather = 2;
+set parallel_leader_participation = off;
+-- (Pin the scan type: these tables aren't temp, so VACUUM may not get to
+-- mark them all-visible while other sessions hold snapshots.)
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+explain (verbose, costs off)
+select gist_ios_restricted(i) from gist_ios_par;
+select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_par) s;
+-- Also through a Gather Merge, which sorts the workers' rows by a column
+-- the index doesn't lead with.
+create table gist_ios_gm (k int, i int);
+insert into gist_ios_gm select g % 97, g from generate_series(1, 20000) g;
+create index on gist_ios_gm (i, gist_ios_restricted(i)) include (k);
+vacuum analyze gist_ios_gm;
+explain (verbose, costs off)
+select k, gist_ios_restricted(i) from gist_ios_gm where i > 100 order by k;
+select count(*) filter (where v = i) as same, count(*) as total
+  from (select k, i, gist_ios_restricted(i) as v from gist_ios_gm
+        where i > 100 order by k) s;
+drop table gist_ios_gm;
+-- And through a Parallel Append over partitions that all have the index.
+-- (Different sizes, so that the children's cost order, which is the plan's
+-- order, doesn't depend on how many pages VACUUM could mark all-visible
+-- when other sessions hold snapshots.)
+create table gist_ios_ppart (i int) partition by range (i);
+create table gist_ios_ppart1 partition of gist_ios_ppart
+  for values from (1) to (2001);
+create table gist_ios_ppart2 partition of gist_ios_ppart
+  for values from (2001) to (10001);
+insert into gist_ios_ppart select g from generate_series(1, 10000) g;
+create index on gist_ios_ppart (i, gist_ios_restricted(i));
+vacuum analyze gist_ios_ppart;
+explain (verbose, costs off)
+select gist_ios_restricted(i) from gist_ios_ppart;
+select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_ppart) s;
+drop table gist_ios_ppart;
+reset enable_seqscan;
+reset enable_bitmapscan;
+reset parallel_setup_cost;
+reset parallel_tuple_cost;
+reset min_parallel_index_scan_size;
+reset max_parallel_workers_per_gather;
+reset parallel_leader_participation;
+drop table gist_ios_par;
+drop function gist_ios_restricted(int);
 -- A partitioned table whose partitions all have the index: each child reads
 -- the value and the Append passes it up, even when a partition's columns
 -- are in a different order.
@@ -533,6 +625,14 @@ select gist_ios_costly(p.i), d.tag from gist_ios_part p
   join gist_ios_dim d on d.i = p.i;
 reset enable_mergejoin;
 reset enable_nestloop;
+-- An ordered Append passes the value up as well, here into a merge join.
+set enable_hashjoin = off;
+set enable_nestloop = off;
+explain (verbose, costs off)
+select gist_ios_costly(p.i), d.tag from gist_ios_part p
+  join gist_ios_dim d on d.i = p.i order by p.i;
+reset enable_hashjoin;
+reset enable_nestloop;
 -- An index created on only one partition doesn't count: every child must
 -- emit the value for the Append to.
 create temp table gist_ios_part_x (i int) partition by range (i);
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index ad69854ccd4..702b19b97be 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -4223,6 +4223,7 @@ regexp
 regexp_matches_ctx
 registered_buffer
 regproc
+relabel_context
 relopt_bool
 relopt_enum
 relopt_enum_elt_def
-- 
2.50.1

