From 5ec21b16ca02426e7535f93e5ff7d2eeb63d1292 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Thu, 8 Oct 2026 11:46:55 -0400
Subject: [PATCH v8 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 four 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 ignoring
  nullingrels at an outer join, as it already does for Vars there
  (search_indexed_tlist_for_nulled_expr()); path_target_cost() credits
  it the same way.  A non-strict expression is still computed above
  the join.  A scan matches the query's targetlist without nullingrels,
  so it emits the value in the first place.

* 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.

The expression also has to be compared without nullingrels when a scan
decides what to emit; strip_nullingrels() does that, tolerating the
special varnos (ROWID_VAR in a MERGE) that remove_nulling_relids()
doesn't.

Author: Greg Burd <greg@burd.me>
Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me
---
 src/backend/optimizer/path/allpaths.c | 241 +++++++++++++++++++++----
 src/backend/optimizer/plan/setrefs.c  |  66 +++++++
 src/backend/optimizer/util/pathnode.c | 246 ++++++++++++++++++++++----
 src/include/optimizer/pathnode.h      |   1 +
 src/test/regress/expected/gist.out    | 116 ++++++++++--
 src/test/regress/sql/gist.sql         |  50 +++++-
 src/tools/pgindent/typedefs.list      |   2 +
 7 files changed, 643 insertions(+), 79 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 417f9b1c544..9480ee83ded 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,
@@ -1397,10 +1398,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
@@ -1412,8 +1442,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,
@@ -1421,9 +1453,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 ||
@@ -1431,6 +1464,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);
@@ -1456,39 +1491,138 @@ 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 */
 	{
-		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;
+
+		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)
+				return;
+			accumulate_append_subpath(best, &input.subpaths, NULL,
+									  &input.child_append_relid_sets);
+			foreach(lp, childrel->pathlist)
 			{
-				best = path;	/* pathlist is sorted by total cost */
+				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)
+	{
+		List	   *pathkeys = (List *) lfirst(lc);
+		List	   *subpaths = NIL;
+		List	   *relid_sets = NIL;
+		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;
+
+		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;
 			}
+			subpaths = lappend(subpaths, best);
+			relid_sets = lappend(relid_sets, childrel->relids);
+		}
+		if (!ok)
+			continue;
+
+		if (pathkeys_contained_in(pathkeys, partition_pathkeys) ||
+			(!partition_pathkeys_partial &&
+			 pathkeys_contained_in(partition_pathkeys, pathkeys)))
+		{
+			AppendPathInput input = {0};
+			AppendPath *apath;
+
+			input.subpaths = subpaths;
+			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, subpaths, 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);
+	}
 }
 
 /*
@@ -3370,10 +3504,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.
@@ -3387,12 +3543,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.
@@ -3581,7 +3756,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 df5777df900..e0b27d49421 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"
@@ -179,6 +180,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,
@@ -3148,6 +3153,57 @@ search_indexed_tlist_for_non_var(Expr *node,
 	return NULL;				/* no match */
 }
 
+/*
+ * 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 ignoring nullingrels, as we match Vars
+ * here (NRM_SUPERSET).  The planner relies on this when it carries such a
+ * value up through an outer join (see join_path_target()).
+ */
+static Var *
+search_indexed_tlist_for_nulled_expr(PlannerInfo *root, Expr *node,
+									 indexed_tlist *itlist, int newvarno)
+{
+	Node	   *stripped = NULL;
+	ListCell   *lc;
+
+	if (IsA(node, Const) || IsA(node, Param) ||
+		bms_is_empty(root->outer_join_rels))
+		return NULL;
+
+	foreach(lc, itlist->tlist)
+	{
+		TargetEntry *tle = (TargetEntry *) lfirst(lc);
+
+		if (IsA(tle->expr, Var) || IsA(tle->expr, PlaceHolderVar))
+			continue;
+		if (stripped == NULL)
+		{
+			if (contain_nonstrict_functions((Node *) node) ||
+				!contain_var_clause((Node *) node))
+				return NULL;
+			stripped = strip_nullingrels((Node *) node);
+		}
+		if (equal(stripped, strip_nullingrels((Node *) tle->expr)))
+		{
+			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
  *
@@ -3355,6 +3411,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;
 	}
@@ -3363,6 +3424,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/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 1c79eb62be9..ca29f005d9a 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -401,7 +401,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;
@@ -1379,6 +1387,49 @@ expr_worth_emitting(PlannerInfo *root, Node *expr)
 	return cost.per_tuple > 10 * cpu_operator_cost;
 }
 
+/*
+ * 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()).  Unlike
+ * remove_nulling_relids() this tolerates 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);
+}
+
 /*
  * query_tlist_exprs
  *	  The query's final targetlist expressions, in terms of 'rel'.
@@ -1395,6 +1446,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;
 }
 
@@ -1417,16 +1476,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;
@@ -1447,8 +1506,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;
 
@@ -1511,7 +1568,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,
@@ -2746,6 +2803,98 @@ calc_non_nestloop_required_outer(Path *outer_path, Path *inner_path)
 	return required_outer;
 }
 
+/*
+ * 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.
+ */
+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);
+}
+
+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.
@@ -2758,16 +2907,13 @@ 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.
- *
- * And only values worth it; see expr_worth_emitting().
+ * A value needn't be parallel-safe for a partial join path: the join only
+ * references its input's output column.  But it must be worth carrying; see
+ * expr_worth_emitting().
  */
 static PathTarget *
 join_path_target(PlannerInfo *root, RelOptInfo *joinrel,
@@ -2785,10 +2931,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 (expr_worth_emitting(root, expr) &&
-			(!joinrel->consider_parallel || is_parallel_safe(root, expr)))
+		if (expr != NULL && expr_worth_emitting(root, expr))
 			add_new_column_to_pathtarget(target, (Expr *) expr);
 		else
 			all_kept = false;
@@ -3176,15 +3321,36 @@ typedef struct
 {
 	PlannerInfo *root;
 	List	   *free;
+	bool		nulled_ok;		/* see search_indexed_tlist_for_nulled_expr() */
 	QualCost	cost;
-}			credit_free_context;
+} credit_free_context;
 
 static bool
-credit_free_exprs_walker(Node *node, credit_free_context * context)
+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) ||
+		contain_nonstrict_functions(node))
+		return false;
+	stripped = strip_nullingrels(node);
+	foreach(lc, context->free)
+	{
+		if (equal(stripped, strip_nullingrels((Node *) lfirst(lc))))
+			return true;
+	}
+	return false;
+}
+
+static bool
+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;
 
@@ -3202,12 +3368,14 @@ 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.cost = target->cost;
 	if (free != NIL)
 		(void) credit_free_exprs_walker((Node *) target->exprs, &context);
@@ -3256,6 +3424,7 @@ indexonly_qual_cost(PlannerInfo *root, IndexPath *ipath, List *qpquals,
 
 	context.root = root;
 	context.free = indexonly_free_exprs(ipath);
+	context.nulled_ok = false;
 	context.cost = cost;
 	if (context.free == NIL)
 		return cost;
@@ -3296,6 +3465,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
@@ -3313,12 +3483,28 @@ 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;
+
+		/* an outer join matches nulled copies too; see setrefs.c */
+		return free_exprs_cost(root, target, join_free_exprs(jpath),
+							   IS_OUTER_JOIN(jpath->jointype));
+	}
 
 	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/pathnode.h b/src/include/optimizer/pathnode.h
index b13792b24b3..925541c08e4 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -234,6 +234,7 @@ extern QualCost path_target_cost(PlannerInfo *root, Path *path,
 extern List *path_extra_exprs(Path *path);
 extern bool path_emits_extras(Path *path);
 extern bool expr_contains(Node *expr, Node *sub);
+extern Node *strip_nullingrels(Node *node);
 extern bool expr_worth_emitting(PlannerInfo *root, Node *expr);
 extern QualCost indexonly_qual_cost(PlannerInfo *root, IndexPath *ipath,
 									List *qpquals, QualCost cost);
diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out
index 9ecde5b6205..4a9865b1517 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -843,16 +843,41 @@ select gist_ios_costly(a.i), d.tag, n.k from gist_ios_cost a
 
 reset enable_hashjoin;
 reset enable_mergejoin;
--- 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)
+
+-- 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
@@ -862,6 +887,55 @@ 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)
+
+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.
@@ -879,19 +953,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 11d191299dd..645938779a3 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -406,12 +406,52 @@ select gist_ios_costly(a.i), d.tag, n.k from gist_ios_cost a
   join gist_ios_dim d on d.i = a.i join gist_ios_dim2 n on n.i = a.i;
 reset enable_hashjoin;
 reset enable_mergejoin;
--- 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;
+-- 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;
+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.
@@ -427,6 +467,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 d534a8f5bb3..2c9d51a93c7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3686,6 +3686,7 @@ count_param_references_context
 cp_hash_func
 create_upper_paths_hook_type
 createdb_failure_params
+credit_free_context
 crosstab_HashEnt
 crosstab_cat_desc
 curl_infotype
@@ -4219,6 +4220,7 @@ regexp
 regexp_matches_ctx
 registered_buffer
 regproc
+relabel_context
 relopt_bool
 relopt_enum
 relopt_enum_elt_def
-- 
2.50.1

