From fb7e709db0a90117bb1a067d591d655ce8f8154d Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Tue, 20 Oct 2026 16:33:05 -0400
Subject: [PATCH v9 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 | 249 ++++++++++++++++++++++----
 src/backend/optimizer/plan/setrefs.c  | 119 ++++++++++++
 src/backend/optimizer/util/clauses.c  |  44 +++++
 src/backend/optimizer/util/pathnode.c | 219 +++++++++++++++++++---
 src/include/optimizer/clauses.h       |   1 +
 src/test/regress/expected/gist.out    | 207 +++++++++++++++++++--
 src/test/regress/sql/gist.sql         |  87 ++++++++-
 src/tools/pgindent/typedefs.list      |   1 +
 8 files changed, 850 insertions(+), 77 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index ae043fb3cad..990a98850d0 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 ||
@@ -1440,6 +1473,8 @@ add_extras_append_path(PlannerInfo *root, RelOptInfo *rel,
 		return;
 
 	tlist_exprs = get_tlist_exprs(root->processed_tlist, true);
+	if (!bms_is_empty(root->outer_join_rels))
+		tlist_exprs = (List *) strip_nullingrels((Node *) tlist_exprs);
 	foreach(lc, rel->indexlist)
 	{
 		IndexOptInfo *index = (IndexOptInfo *) lfirst(lc);
@@ -1465,39 +1500,146 @@ 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 */
+	{
+		AppendPathInput input = {0};
+		AppendPath *apath;
+
+		foreach(lc, live_childrels)
+		{
+			RelOptInfo *childrel = (RelOptInfo *) lfirst(lc);
+			Path	   *best = extras_child_path(root, childrel,
+												 childrel->pathlist,
+												 extras, NIL);
+			ListCell   *lp;
+
+			if (best == NULL)
+				return;
+			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);
+			}
+		}
+		apath = create_append_path(root, rel, input, NIL, NULL, 0, false, -1);
+		apath->path.pathtarget = 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)
 	{
-		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)
+		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)
 		{
-			Path	   *path = (Path *) lfirst(lp);
+			RelOptInfo *childrel = (RelOptInfo *) lfirst(lcr);
+			Path	   *best = extras_child_path(root, childrel,
+												 childrel->pathlist,
+												 extras, pathkeys);
 
-			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;
 				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);
 		}
-		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);
+	/* 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 +3521,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 +3560,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 +3773,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 12a008568a9..d2abc92bbe9 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,7 @@ 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? */
+	List	   *nulled_tles;	/* see search_indexed_tlist_for_nulled_expr() */
 	tlist_vinfo vars[FLEXIBLE_ARRAY_MEMBER];	/* has num_vars entries */
 } indexed_tlist;
 
@@ -180,6 +182,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,
@@ -2940,6 +2946,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 +2998,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 +3185,107 @@ 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.
+ */
+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;
+
+	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));
+		}
+		if (itlist->nulled_tles == NIL)
+			return NULL;
+	}
+
+	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 +3493,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 +3506,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..7f026089c2f 100644
--- a/src/backend/optimizer/util/clauses.c
+++ b/src/backend/optimizer/util/clauses.c
@@ -397,6 +397,50 @@ 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 relabel_extra_for_join() in pathnode.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 59b986a632c..21a7ed9bcd2 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -402,7 +402,8 @@ 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
+ * 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.
  */
@@ -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,
@@ -2783,9 +2887,9 @@ join_path_target(PlannerInfo *root, RelOptInfo *joinrel,
 	target = copy_pathtarget(joinrel->reltarget);
 	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;
@@ -3164,9 +3268,39 @@ typedef struct
 {
 	PlannerInfo *root;
 	List	   *free;
+	bool		nulled_ok;		/* see search_indexed_tlist_for_nulled_expr() */
+	List	   *free_stripped;	/* 'free' without nullingrels, made 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
@@ -3183,7 +3317,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;
 
@@ -3201,12 +3335,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);
@@ -3255,6 +3392,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;
@@ -3295,6 +3434,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
@@ -3314,12 +3454,35 @@ 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;
+		Relids		ojrelids;
+
+		/*
+		 * A join that forms outer joins matches nulled copies too, as
+		 * set_join_references() does when the Join's ojrelids isn't empty.
+		 */
+		ojrelids = bms_difference(path->parent->relids,
+								  bms_union(jpath->outerjoinpath->parent->relids,
+											jpath->innerjoinpath->parent->relids));
+		return free_exprs_cost(root, target, join_free_exprs(jpath),
+							   !bms_is_empty(ojrelids));
+	}
 
 	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 c9b98489851..ebb91bdbaca 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -991,16 +991,68 @@ select gist_ios_costly(w.i), d.tag from gist_ios_wide w
 
 reset enable_mergejoin;
 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.
 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)
+
+-- And from both sides' nullable rows of a FULL JOIN.
+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)
+
+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;
+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
@@ -1010,6 +1062,119 @@ select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d
                Output: a.i
 (9 rows)
 
+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;
+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;
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+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)
+
+reset enable_seqscan;
+reset enable_bitmapscan;
+drop table gist_ios_gm;
+-- And through a Parallel Append over partitions that all have the index.
+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 (5001);
+create table gist_ios_ppart2 partition of gist_ios_ppart
+  for values from (5001) 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_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))
+         ->  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))
+(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 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.
@@ -1027,19 +1192,35 @@ select gist_ios_costly(p.i), d.tag from gist_ios_part p
   join gist_ios_dim d on d.i = p.i;
                                                QUERY PLAN                                               
 --------------------------------------------------------------------------------------------------------
- Hash Join
+ Merge Join
    Output: (gist_ios_costly(p.i)), d.tag
-   Hash Cond: (p.i = d.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))
-   ->  Hash
-         Output: d.tag, d.i
-         ->  Seq Scan on pg_temp.gist_ios_dim d
-               Output: d.tag, d.i
-(12 rows)
+   ->  Index Scan using gist_ios_dim_i_idx on pg_temp.gist_ios_dim d
+         Output: d.i, d.tag
+(10 rows)
+
+-- An ordered Append passes the value up as well, here into a merge join.
+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)
 
 -- An index created on only one partition doesn't count: every child must
 -- emit the value for the Append to.
diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql
index 516ea5115c2..694ddbde340 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -489,12 +489,89 @@ select gist_ios_costly(w.i), d.tag from gist_ios_wide w
   join gist_ios_dim d on d.i = w.i;
 reset enable_mergejoin;
 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.
 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;
+-- And from both sides' nullable rows of a FULL JOIN.
+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;
+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;
+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;
+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;
+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;
+set enable_seqscan = off;
+set enable_bitmapscan = off;
+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;
+reset enable_seqscan;
+reset enable_bitmapscan;
+drop table gist_ios_gm;
+-- And through a Parallel Append over partitions that all have the index.
+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 (5001);
+create table gist_ios_ppart2 partition of gist_ios_ppart
+  for values from (5001) 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 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.
@@ -510,6 +587,10 @@ vacuum analyze gist_ios_part;
 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;
+-- An ordered Append passes the value up as well, here into a merge join.
+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;
 -- 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

