From cf39c9ca76e40b48abad55d1c51c6707265b58af Mon Sep 17 00:00:00 2001
From: Robert Haas <rhaas@postgresql.org>
Date: Tue, 16 Sep 2025 09:32:15 -0400
Subject: [PATCH v6 1/3] Keep track of what RTIs a Result node is scanning.

Result nodes now include an RTI set, which is only non-NULL when they
have no subplan, and is taken from the relid set of the RelOptInfo
that the Result is generating. They also now include a result_reason,
which tells us something about why the Result node was inserted.

Using that information, EXPLAIN now emits, where relevant, a "Replaces"
line describing the origin of the Result node.

Likewise, pg_overexplain's EXPLAIN (RANGE_TABLE) now displays the RTIs
stored in a Result node just as it already does for other RTI-bearing
node types.
---
 contrib/file_fdw/expected/file_fdw.out        |   1 +
 .../expected/pg_overexplain.out               |   6 +-
 contrib/pg_overexplain/pg_overexplain.c       |  12 ++
 .../postgres_fdw/expected/postgres_fdw.out    |   9 +-
 src/backend/commands/explain.c                |  98 ++++++++++++
 src/backend/optimizer/plan/createplan.c       | 117 ++++++++++-----
 src/backend/optimizer/plan/setrefs.c          |   2 +
 src/include/nodes/plannodes.h                 |  22 +++
 src/test/regress/expected/aggregates.out      |  51 ++++---
 src/test/regress/expected/case.out            |  21 +--
 src/test/regress/expected/explain.out         |   8 +
 .../regress/expected/generated_virtual.out    |  17 ++-
 src/test/regress/expected/groupingsets.out    |   3 +-
 src/test/regress/expected/inherit.out         |  28 ++--
 src/test/regress/expected/join.out            | 113 ++++++++------
 src/test/regress/expected/merge.out           |   3 +-
 .../regress/expected/partition_aggregate.out  |  21 +--
 src/test/regress/expected/partition_join.out  |  23 +--
 src/test/regress/expected/partition_prune.out | 141 ++++++++++--------
 src/test/regress/expected/predicate.out       |  33 ++--
 src/test/regress/expected/rowsecurity.out     |  13 +-
 src/test/regress/expected/rowtypes.out        |   6 +-
 src/test/regress/expected/select.out          |   7 +-
 src/test/regress/expected/subselect.out       |   9 +-
 src/test/regress/expected/tsrf.out            |   6 +-
 src/test/regress/sql/explain.sql              |   1 +
 src/tools/pgindent/typedefs.list              |   1 +
 27 files changed, 534 insertions(+), 238 deletions(-)

diff --git a/contrib/file_fdw/expected/file_fdw.out b/contrib/file_fdw/expected/file_fdw.out
index 246e3d3e566..5121e27dce5 100644
--- a/contrib/file_fdw/expected/file_fdw.out
+++ b/contrib/file_fdw/expected/file_fdw.out
@@ -322,6 +322,7 @@ SET constraint_exclusion = 'on';
 SELECT explain_filter('EXPLAIN (VERBOSE, COSTS FALSE) SELECT * FROM agg_csv WHERE a < 0');
  Result
    Output: a, b
+   Replaces: Scan on agg_csv
    One-Time Filter: false
 
 \t off
diff --git a/contrib/pg_overexplain/expected/pg_overexplain.out b/contrib/pg_overexplain/expected/pg_overexplain.out
index 6de02323d7c..55d34666d87 100644
--- a/contrib/pg_overexplain/expected/pg_overexplain.out
+++ b/contrib/pg_overexplain/expected/pg_overexplain.out
@@ -44,9 +44,10 @@ EXPLAIN (RANGE_TABLE) SELECT 1;
                 QUERY PLAN                
 ------------------------------------------
  Result  (cost=0.00..0.01 rows=1 width=4)
+   RTIs: 1
  RTI 1 (result):
    Eref: "*RESULT*" ()
-(3 rows)
+(4 rows)
 
 -- Create a partitioned table.
 CREATE TABLE vegetables (id serial, name text, genus text)
@@ -475,6 +476,7 @@ INSERT INTO vegetables (name, genus) VALUES ('broccoflower', 'brassica');
    Nominal RTI: 1
    Exclude Relation RTI: 0
    ->  Result
+         RTIs: 2
  RTI 1 (relation):
    Eref: vegetables (id, name, genus)
    Relation: vegetables
@@ -485,5 +487,5 @@ INSERT INTO vegetables (name, genus) VALUES ('broccoflower', 'brassica');
    Eref: "*RESULT*" ()
  Unprunable RTIs: 1
  Result RTIs: 1
-(14 rows)
+(15 rows)
 
diff --git a/contrib/pg_overexplain/pg_overexplain.c b/contrib/pg_overexplain/pg_overexplain.c
index de824566f8c..bd70b6d9d5e 100644
--- a/contrib/pg_overexplain/pg_overexplain.c
+++ b/contrib/pg_overexplain/pg_overexplain.c
@@ -236,6 +236,18 @@ overexplain_per_node_hook(PlanState *planstate, List *ancestors,
 									  ((MergeAppend *) plan)->apprelids,
 									  es);
 				break;
+			case T_Result:
+
+				/*
+				 * 'relids' is only meaningful when plan->lefttree is NULL,
+				 * but if somehow it ends up set when plan->lefttree is not
+				 * NULL, print it anyway.
+				 */
+				if (plan->lefttree == NULL ||
+					((Result *) plan)->relids != NULL)
+					overexplain_bitmapset("RTIs",
+										  ((Result *) plan)->relids,
+										  es);
 			default:
 				break;
 		}
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 18d727d7790..7aa61cec5da 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -7157,8 +7157,9 @@ EXPLAIN (VERBOSE, COSTS OFF) SELECT count(*) FROM ft1 WHERE c2 < 0;
  Aggregate
    Output: count(*)
    ->  Result
+         Replaces: Scan on ft1
          One-Time Filter: false
-(4 rows)
+(5 rows)
 
 SELECT count(*) FROM ft1 WHERE c2 < 0;
  count 
@@ -7201,8 +7202,9 @@ EXPLAIN (VERBOSE, COSTS OFF) SELECT count(*) FROM ft1 WHERE c2 >= 0;
  Aggregate
    Output: count(*)
    ->  Result
+         Replaces: Scan on ft1
          One-Time Filter: false
-(4 rows)
+(5 rows)
 
 SELECT count(*) FROM ft1 WHERE c2 >= 0;
  count 
@@ -8030,8 +8032,9 @@ DELETE FROM rem1 WHERE false;     -- currently can't be pushed down
    Remote SQL: DELETE FROM public.loc1 WHERE ctid = $1
    ->  Result
          Output: ctid
+         Replaces: Scan on rem1
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 -- Test with statement-level triggers
 CREATE TRIGGER trig_stmt_before
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 8345bc0264b..0ab1813728a 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -147,6 +147,7 @@ static void show_buffer_usage(ExplainState *es, const BufferUsage *usage);
 static void show_wal_usage(ExplainState *es, const WalUsage *usage);
 static void show_memory_counters(ExplainState *es,
 								 const MemoryContextCounters *mem_counters);
+static void show_result_replacement_info(Result *result, ExplainState *es);
 static void ExplainIndexScanDetails(Oid indexid, ScanDirection indexorderdir,
 									ExplainState *es);
 static void ExplainScanTarget(Scan *plan, ExplainState *es);
@@ -2232,6 +2233,7 @@ ExplainNode(PlanState *planstate, List *ancestors,
 								   ancestors, es);
 			break;
 		case T_Result:
+			show_result_replacement_info(castNode(Result, plan), es);
 			show_upper_qual((List *) ((Result *) plan)->resconstantqual,
 							"One-Time Filter", planstate, ancestors, es);
 			show_upper_qual(plan->qual, "Filter", planstate, ancestors, es);
@@ -4750,6 +4752,102 @@ show_modifytable_info(ModifyTableState *mtstate, List *ancestors,
 		ExplainCloseGroup("Target Tables", "Target Tables", false, es);
 }
 
+/*
+ * Explain what a "Result" node replaced.
+ */
+static void
+show_result_replacement_info(Result *result, ExplainState *es)
+{
+	StringInfoData buf;
+	int			nrels = 0;
+	int			rti = -1;
+	bool		found_non_result = false;
+	char	   *replacement_type;
+
+	/* If the Result node has a subplan, it didn't replace anything. */
+	if (result->plan.lefttree != NULL)
+		return;
+
+	/* Gating result nodes should have a subplan, and we don't. */
+	Assert(result->result_type != RESULT_TYPE_GATING);
+
+	switch (result->result_type)
+	{
+		case RESULT_TYPE_GATING:
+			replacement_type = "Gating";
+			break;
+		case RESULT_TYPE_SCAN:
+			replacement_type = "Scan";
+			break;
+		case RESULT_TYPE_JOIN:
+			replacement_type = "Join";
+			break;
+		case RESULT_TYPE_UPPER:
+			/* a small white lie */
+			replacement_type = "Aggregate";
+			break;
+		case RESULT_TYPE_MINMAX:
+			replacement_type = "MinMaxAggregate";
+			break;
+	}
+
+	/*
+	 * Build up a comma-separated list of user-facing names for the range
+	 * table entries in the relids set.
+	 */
+	initStringInfo(&buf);
+	while ((rti = bms_next_member(result->relids, rti)) >= 0)
+	{
+		RangeTblEntry *rte = rt_fetch(rti, es->rtable);
+		char	   *refname;
+
+		/*
+		 * add_outer_joins_to_relids will add join RTIs to the relids set of a
+		 * join; if that join is then replaced with a Result node, we may see
+		 * such RTIs here. But we want to completely ignore those here,
+		 * because "a LEFT JOIN b ON whatever" is a join between a and b, not
+		 * a join between a, b, and an unnamed join.
+		 */
+		if (rte->rtekind == RTE_JOIN)
+			continue;
+
+		/* Count the number of rels that aren't ignored completely. */
+		++nrels;
+
+		/* Work out what reference name to use and add it to the string. */
+		refname = (char *) list_nth(es->rtable_names, rti - 1);
+		if (refname == NULL)
+			refname = rte->eref->aliasname;
+		if (buf.len > 0)
+			appendStringInfoString(&buf, ", ");
+		appendStringInfoString(&buf, refname);
+
+		/* Keep track of whether we see anything other than RTE_RESULT. */
+		if (rte->rtekind != RTE_RESULT)
+			found_non_result = true;
+	}
+
+	/*
+	 * If this Result node is because of a single RTE that is RTE_RESULT, it
+	 * is not really replacing anything at all, because there's no other
+	 * method for implementing a scan of such an RTE, so we don't display the
+	 * Replaces line in such cases.
+	 */
+	if (nrels <= 1 && !found_non_result &&
+		result->result_type == RESULT_TYPE_SCAN)
+		return;
+
+	/* Say what we replaced, with list of rels if available. */
+	if (buf.len == 0)
+		ExplainPropertyText("Replaces", replacement_type, es);
+	else
+	{
+		char	   *s = psprintf("%s on %s", replacement_type, buf.data);
+
+		ExplainPropertyText("Replaces", s, es);
+	}
+}
+
 /*
  * Explain the constituent plans of an Append, MergeAppend,
  * BitmapAnd, or BitmapOr node.
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 6791cbeb416..c9dba7ff346 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -99,7 +99,8 @@ static Gather *create_gather_plan(PlannerInfo *root, GatherPath *best_path);
 static Plan *create_projection_plan(PlannerInfo *root,
 									ProjectionPath *best_path,
 									int flags);
-static Plan *inject_projection_plan(Plan *subplan, List *tlist, bool parallel_safe);
+static Plan *inject_projection_plan(Plan *subplan, List *tlist,
+									bool parallel_safe);
 static Sort *create_sort_plan(PlannerInfo *root, SortPath *best_path, int flags);
 static IncrementalSort *create_incrementalsort_plan(PlannerInfo *root,
 													IncrementalSortPath *best_path, int flags);
@@ -302,7 +303,10 @@ static SetOp *make_setop(SetOpCmd cmd, SetOpStrategy strategy,
 						 List *tlist, Plan *lefttree, Plan *righttree,
 						 List *groupList, long numGroups);
 static LockRows *make_lockrows(Plan *lefttree, List *rowMarks, int epqParam);
-static Result *make_result(List *tlist, Node *resconstantqual, Plan *subplan);
+static Result *make_gating_result(List *tlist, Node *resconstantqual,
+								  Plan *subplan);
+static Result *make_one_row_result(List *tlist, Node *resconstantqual,
+								   RelOptInfo *rel);
 static ProjectSet *make_project_set(List *tlist, Plan *subplan);
 static ModifyTable *make_modifytable(PlannerInfo *root, Plan *subplan,
 									 CmdType operation, bool canSetTag,
@@ -1012,36 +1016,36 @@ static Plan *
 create_gating_plan(PlannerInfo *root, Path *path, Plan *plan,
 				   List *gating_quals)
 {
-	Plan	   *gplan;
-	Plan	   *splan;
+	Result	   *gplan;
 
 	Assert(gating_quals);
 
 	/*
-	 * We might have a trivial Result plan already.  Stacking one Result atop
-	 * another is silly, so if that applies, just discard the input plan.
+	 * Since we need a Result node anyway, always return the path's requested
+	 * tlist; that's never a wrong choice, even if the parent node didn't ask
+	 * for CP_EXACT_TLIST.
+	 */
+	gplan = make_gating_result(build_path_tlist(root, path),
+							   (Node *) gating_quals, plan);
+
+	/*
+	 * We might have had a trivial Result plan already.  Stacking one Result
+	 * atop another is silly, so if that applies, just discard the input plan.
 	 * (We're assuming its targetlist is uninteresting; it should be either
-	 * the same as the result of build_path_tlist, or a simplified version.)
+	 * the same as the result of build_path_tlist, or a simplified version.
+	 * However, we preserve the set of relids that it purports to scan and
+	 * attribute that to our replacement Result instead, and likewise for the
+	 * result_type.)
 	 */
-	splan = plan;
 	if (IsA(plan, Result))
 	{
 		Result	   *rplan = (Result *) plan;
 
-		if (rplan->plan.lefttree == NULL &&
-			rplan->resconstantqual == NULL)
-			splan = NULL;
+		gplan->plan.lefttree = NULL;
+		gplan->relids = rplan->relids;
+		gplan->result_type = rplan->result_type;
 	}
 
-	/*
-	 * Since we need a Result node anyway, always return the path's requested
-	 * tlist; that's never a wrong choice, even if the parent node didn't ask
-	 * for CP_EXACT_TLIST.
-	 */
-	gplan = (Plan *) make_result(build_path_tlist(root, path),
-								 (Node *) gating_quals,
-								 splan);
-
 	/*
 	 * Notice that we don't change cost or size estimates when doing gating.
 	 * The costs of qual eval were already included in the subplan's cost.
@@ -1054,12 +1058,12 @@ create_gating_plan(PlannerInfo *root, Path *path, Plan *plan,
 	 * in most cases we have only a very bad idea of the probability of the
 	 * gating qual being true.
 	 */
-	copy_plan_costsize(gplan, plan);
+	copy_plan_costsize(&gplan->plan, plan);
 
 	/* Gating quals could be unsafe, so better use the Path's safety flag */
-	gplan->parallel_safe = path->parallel_safe;
+	gplan->plan.parallel_safe = path->parallel_safe;
 
-	return gplan;
+	return &gplan->plan;
 }
 
 /*
@@ -1235,10 +1239,10 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags)
 		/* Generate a Result plan with constant-FALSE gating qual */
 		Plan	   *plan;
 
-		plan = (Plan *) make_result(tlist,
-									(Node *) list_make1(makeBoolConst(false,
-																	  false)),
-									NULL);
+		plan = (Plan *) make_one_row_result(tlist,
+											(Node *) list_make1(makeBoolConst(false,
+																			  false)),
+											best_path->path.parent);
 
 		copy_generic_path_info(plan, (Path *) best_path);
 
@@ -1636,7 +1640,7 @@ create_group_result_plan(PlannerInfo *root, GroupResultPath *best_path)
 	/* best_path->quals is just bare clauses */
 	quals = order_qual_clauses(root, best_path->quals);
 
-	plan = make_result(tlist, (Node *) quals, NULL);
+	plan = make_one_row_result(tlist, (Node *) quals, best_path->path.parent);
 
 	copy_generic_path_info(&plan->plan, (Path *) best_path);
 
@@ -1933,8 +1937,7 @@ create_projection_plan(PlannerInfo *root, ProjectionPath *best_path, int flags)
 	}
 	else
 	{
-		/* We need a Result node */
-		plan = (Plan *) make_result(tlist, NULL, subplan);
+		plan = (Plan *) make_gating_result(tlist, NULL, subplan);
 
 		copy_generic_path_info(plan, (Path *) best_path);
 	}
@@ -1958,7 +1961,7 @@ inject_projection_plan(Plan *subplan, List *tlist, bool parallel_safe)
 {
 	Plan	   *plan;
 
-	plan = (Plan *) make_result(tlist, NULL, subplan);
+	plan = (Plan *) make_gating_result(tlist, NULL, subplan);
 
 	/*
 	 * In principle, we should charge tlist eval cost plus cpu_per_tuple per
@@ -2436,7 +2439,9 @@ create_minmaxagg_plan(PlannerInfo *root, MinMaxAggPath *best_path)
 	/* Generate the output plan --- basically just a Result */
 	tlist = build_path_tlist(root, &best_path->path);
 
-	plan = make_result(tlist, (Node *) best_path->quals, NULL);
+	plan = make_one_row_result(tlist, (Node *) best_path->quals,
+							   best_path->path.parent);
+	plan->result_type = RESULT_TYPE_MINMAX;
 
 	copy_generic_path_info(&plan->plan, (Path *) best_path);
 
@@ -3887,7 +3892,8 @@ create_resultscan_plan(PlannerInfo *root, Path *best_path,
 			replace_nestloop_params(root, (Node *) scan_clauses);
 	}
 
-	scan_plan = make_result(tlist, (Node *) scan_clauses, NULL);
+	scan_plan = make_one_row_result(tlist, (Node *) scan_clauses,
+									best_path->parent);
 
 	copy_generic_path_info(&scan_plan->plan, best_path);
 
@@ -6922,22 +6928,57 @@ make_limit(Plan *lefttree, Node *limitOffset, Node *limitCount,
 }
 
 /*
- * make_result
- *	  Build a Result plan node
+ * make_gating_result
+ *	  Build a Result plan node that performs projection of a subplan, and/or
+ *	  applies a one time filter (resconstantqual)
  */
 static Result *
-make_result(List *tlist,
-			Node *resconstantqual,
-			Plan *subplan)
+make_gating_result(List *tlist,
+				   Node *resconstantqual,
+				   Plan *subplan)
 {
 	Result	   *node = makeNode(Result);
 	Plan	   *plan = &node->plan;
 
+	Assert(subplan != NULL);
+
 	plan->targetlist = tlist;
 	plan->qual = NIL;
 	plan->lefttree = subplan;
 	plan->righttree = NULL;
+	node->result_type = RESULT_TYPE_GATING;
+	node->resconstantqual = resconstantqual;
+	node->relids = NULL;
+
+	return node;
+}
+
+/*
+ * make_one_row_result
+ *	  Build a Result plan node that returns a single row (or possibly no rows,
+ *	  if the one-time filtered defined by resconstantqual returns false)
+ *
+ * 'rel' should be this path's RelOptInfo. In essence, we're saying that this
+ * Result node generates all the tuples for that RelOptInfo. Note that the same
+ * consideration can never arise in make_gating_result(), because in that case
+ * the tuples are always coming from some subordinate node.
+ */
+static Result *
+make_one_row_result(List *tlist,
+					Node *resconstantqual,
+					RelOptInfo *rel)
+{
+	Result	   *node = makeNode(Result);
+	Plan	   *plan = &node->plan;
+
+	plan->targetlist = tlist;
+	plan->qual = NIL;
+	plan->lefttree = NULL;
+	plan->righttree = NULL;
+	node->result_type = IS_UPPER_REL(rel) ? RESULT_TYPE_UPPER :
+		IS_JOIN_REL(rel) ? RESULT_TYPE_JOIN : RESULT_TYPE_SCAN;
 	node->resconstantqual = resconstantqual;
+	node->relids = rel->relids;
 
 	return node;
 }
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index d706546f332..6950eff2c5b 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -1056,6 +1056,8 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				/* resconstantqual can't contain any subplan variable refs */
 				splan->resconstantqual =
 					fix_scan_expr(root, splan->resconstantqual, rtoffset, 1);
+				/* adjust the relids set */
+				splan->relids = offset_relid_set(splan->relids, rtoffset);
 			}
 			break;
 		case T_ProjectSet:
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 29d7732d6a0..3d196f5078e 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -252,6 +252,20 @@ typedef struct Plan
 #define outerPlan(node)			(((Plan *)(node))->lefttree)
 
 
+/* ----------------
+ *	 ResultType -
+ *		Classification of Result nodes
+ * ----------------
+ */
+typedef enum ResultType
+{
+	RESULT_TYPE_GATING,			/* project or one-time-filter outer plan */
+	RESULT_TYPE_SCAN,			/* replace empty scan */
+	RESULT_TYPE_JOIN,			/* replace empty join */
+	RESULT_TYPE_UPPER,			/* replace degenerate upper rel */
+	RESULT_TYPE_MINMAX			/* implement minmax aggregate */
+} ResultType;
+
 /* ----------------
  *	 Result node -
  *		If no outer plan, evaluate a variable-free targetlist.
@@ -261,12 +275,20 @@ typedef struct Plan
  * If resconstantqual isn't NULL, it represents a one-time qualification
  * test (i.e., one that doesn't depend on any variables from the outer plan,
  * so needs to be evaluated only once).
+ *
+ * relids identifies the relation for which this Result node is generating the
+ * tuples. When subplan is not NULL, it should be empty: this node is not
+ * generating anything in that case, just acting on tuples generated by the
+ * subplan. Otherwise, it contains the relids of the planner relation that
+ * the Result represents.
  * ----------------
  */
 typedef struct Result
 {
 	Plan		plan;
+	ResultType	result_type;
 	Node	   *resconstantqual;
+	Bitmapset  *relids;
 } Result;
 
 /* ----------------
diff --git a/src/test/regress/expected/aggregates.out b/src/test/regress/expected/aggregates.out
index c35288eecde..1f24f6ffd1f 100644
--- a/src/test/regress/expected/aggregates.out
+++ b/src/test/regress/expected/aggregates.out
@@ -959,11 +959,12 @@ explain (costs off)
                          QUERY PLAN                         
 ------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan using tenk1_unique1 on tenk1
                  Index Cond: (unique1 IS NOT NULL)
-(5 rows)
+(6 rows)
 
 select min(unique1) from tenk1;
  min 
@@ -976,11 +977,12 @@ explain (costs off)
                              QUERY PLAN                              
 ---------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan Backward using tenk1_unique1 on tenk1
                  Index Cond: (unique1 IS NOT NULL)
-(5 rows)
+(6 rows)
 
 select max(unique1) from tenk1;
  max  
@@ -993,11 +995,12 @@ explain (costs off)
                                QUERY PLAN                               
 ------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan Backward using tenk1_unique1 on tenk1
                  Index Cond: ((unique1 IS NOT NULL) AND (unique1 < 42))
-(5 rows)
+(6 rows)
 
 select max(unique1) from tenk1 where unique1 < 42;
  max 
@@ -1010,11 +1013,12 @@ explain (costs off)
                                QUERY PLAN                               
 ------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan Backward using tenk1_unique1 on tenk1
                  Index Cond: ((unique1 IS NOT NULL) AND (unique1 > 42))
-(5 rows)
+(6 rows)
 
 select max(unique1) from tenk1 where unique1 > 42;
  max  
@@ -1033,11 +1037,12 @@ explain (costs off)
                                 QUERY PLAN                                 
 ---------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan Backward using tenk1_unique1 on tenk1
                  Index Cond: ((unique1 IS NOT NULL) AND (unique1 > 42000))
-(5 rows)
+(6 rows)
 
 select max(unique1) from tenk1 where unique1 > 42000;
  max 
@@ -1052,11 +1057,12 @@ explain (costs off)
                                  QUERY PLAN                                 
 ----------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan Backward using tenk1_thous_tenthous on tenk1
                  Index Cond: ((thousand = 33) AND (tenthous IS NOT NULL))
-(5 rows)
+(6 rows)
 
 select max(tenthous) from tenk1 where thousand = 33;
  max  
@@ -1069,11 +1075,12 @@ explain (costs off)
                                 QUERY PLAN                                
 --------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan using tenk1_thous_tenthous on tenk1
                  Index Cond: ((thousand = 33) AND (tenthous IS NOT NULL))
-(5 rows)
+(6 rows)
 
 select min(tenthous) from tenk1 where thousand = 33;
  min 
@@ -1090,11 +1097,12 @@ explain (costs off)
  Seq Scan on int4_tbl
    SubPlan 2
      ->  Result
+           Replaces: MinMaxAggregate
            InitPlan 1
              ->  Limit
                    ->  Index Only Scan using tenk1_unique1 on tenk1
                          Index Cond: ((unique1 IS NOT NULL) AND (unique1 > int4_tbl.f1))
-(7 rows)
+(8 rows)
 
 select f1, (select min(unique1) from tenk1 where unique1 > f1) AS gt
   from int4_tbl;
@@ -1119,7 +1127,8 @@ explain (costs off)
            ->  Index Only Scan Backward using tenk1_unique2 on tenk1
                  Index Cond: (unique2 IS NOT NULL)
    ->  Result
-(7 rows)
+         Replaces: MinMaxAggregate
+(8 rows)
 
 select distinct max(unique2) from tenk1;
  max  
@@ -1138,7 +1147,8 @@ explain (costs off)
            ->  Index Only Scan Backward using tenk1_unique2 on tenk1
                  Index Cond: (unique2 IS NOT NULL)
    ->  Result
-(7 rows)
+         Replaces: MinMaxAggregate
+(8 rows)
 
 select max(unique2) from tenk1 order by 1;
  max  
@@ -1157,7 +1167,8 @@ explain (costs off)
            ->  Index Only Scan Backward using tenk1_unique2 on tenk1
                  Index Cond: (unique2 IS NOT NULL)
    ->  Result
-(7 rows)
+         Replaces: MinMaxAggregate
+(8 rows)
 
 select max(unique2) from tenk1 order by max(unique2);
  max  
@@ -1176,7 +1187,8 @@ explain (costs off)
            ->  Index Only Scan Backward using tenk1_unique2 on tenk1
                  Index Cond: (unique2 IS NOT NULL)
    ->  Result
-(7 rows)
+         Replaces: MinMaxAggregate
+(8 rows)
 
 select max(unique2) from tenk1 order by max(unique2)+1;
  max  
@@ -1196,7 +1208,8 @@ explain (costs off)
                  Index Cond: (unique2 IS NOT NULL)
    ->  ProjectSet
          ->  Result
-(8 rows)
+               Replaces: MinMaxAggregate
+(9 rows)
 
 select max(unique2), generate_series(1,3) as g from tenk1 order by g desc;
  max  | g 
@@ -1212,12 +1225,13 @@ explain (costs off)
                      QUERY PLAN                     
 ----------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Result
                  One-Time Filter: (100 IS NOT NULL)
                  ->  Seq Scan on tenk1
-(6 rows)
+(7 rows)
 
 select max(100) from tenk1;
  max 
@@ -1243,6 +1257,7 @@ explain (costs off)
                                          QUERY PLAN                                          
 ---------------------------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Merge Append
@@ -1265,7 +1280,7 @@ explain (costs off)
                  ->  Index Only Scan using minmaxtest2i on minmaxtest2 minmaxtest_8
                        Index Cond: (f1 IS NOT NULL)
                  ->  Index Only Scan Backward using minmaxtest3i on minmaxtest3 minmaxtest_9
-(23 rows)
+(24 rows)
 
 select min(f1), max(f1) from minmaxtest;
  min | max 
@@ -1304,7 +1319,8 @@ explain (costs off)
    ->  Sort
          Sort Key: ((InitPlan 1).col1), ((InitPlan 2).col1)
          ->  Result
-(26 rows)
+               Replaces: MinMaxAggregate
+(27 rows)
 
 select distinct min(f1), max(f1) from minmaxtest;
  min | max 
@@ -1334,7 +1350,8 @@ explain (costs off)
                    ->  Seq Scan on int4_tbl t1
                          Filter: ((f1 IS NOT NULL) AND (f1 = t0.f1))
            ->  Result
-(9 rows)
+                 Replaces: MinMaxAggregate
+(10 rows)
 
 select f1, (select distinct min(t1.f1) from int4_tbl t1 where t1.f1 = t0.f1)
 from int4_tbl t0;
diff --git a/src/test/regress/expected/case.out b/src/test/regress/expected/case.out
index efee7fc4317..4c77f7e3961 100644
--- a/src/test/regress/expected/case.out
+++ b/src/test/regress/expected/case.out
@@ -266,27 +266,30 @@ SELECT *
 -- Tests for constant subexpression simplification
 explain (costs off)
 SELECT * FROM CASE_TBL WHERE NULLIF(1, 2) = 2;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Scan on case_tbl
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off)
 SELECT * FROM CASE_TBL WHERE NULLIF(1, 1) IS NOT NULL;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Scan on case_tbl
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off)
 SELECT * FROM CASE_TBL WHERE NULLIF(1, null) = 2;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Scan on case_tbl
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 --
 -- Examples of updates involving tables
diff --git a/src/test/regress/expected/explain.out b/src/test/regress/expected/explain.out
index c53bf9c8aa3..7c1f26b182c 100644
--- a/src/test/regress/expected/explain.out
+++ b/src/test/regress/expected/explain.out
@@ -54,6 +54,14 @@ set jit = off;
 -- enabled.
 set track_io_timing = off;
 -- Simple cases
+explain (costs off) select 1 as a, 2 as b having false;
+        QUERY PLAN        
+--------------------------
+ Result
+   Replaces: Aggregate
+   One-Time Filter: false
+(3 rows)
+
 select explain_filter('explain select * from int8_tbl i8');
                      explain_filter                      
 ---------------------------------------------------------
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index d8645192351..b48e6bdbf9d 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -1579,8 +1579,9 @@ select t2.* from gtest32 t1 left join gtest32 t2 on false;
          Output: t1.a, t1.b, t1.c, t1.d, t1.e
    ->  Result
          Output: a, e, 20, COALESCE(a, 100)
+         Replaces: Scan on t2
          One-Time Filter: false
-(8 rows)
+(9 rows)
 
 select t2.* from gtest32 t1 left join gtest32 t2 on false;
  a | b | c | d | e 
@@ -1642,20 +1643,22 @@ set constraint_exclusion to on;
 -- should get a dummy Result, not a seq scan
 explain (costs off)
 select * from gtest33 where b < 10;
-        QUERY PLAN        
---------------------------
+         QUERY PLAN          
+-----------------------------
  Result
+   Replaces: Scan on gtest33
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- should get a dummy Result, not a seq scan
 explain (costs off)
 select * from gtest33 where b is null;
-        QUERY PLAN        
---------------------------
+         QUERY PLAN          
+-----------------------------
  Result
+   Replaces: Scan on gtest33
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 reset constraint_exclusion;
 drop table gtest33;
diff --git a/src/test/regress/expected/groupingsets.out b/src/test/regress/expected/groupingsets.out
index 35e4cb47ebe..210bbe307a7 100644
--- a/src/test/regress/expected/groupingsets.out
+++ b/src/test/regress/expected/groupingsets.out
@@ -591,11 +591,12 @@ explain (costs off)
                          QUERY PLAN                         
 ------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan using tenk1_unique1 on tenk1
                  Index Cond: (unique1 IS NOT NULL)
-(5 rows)
+(6 rows)
 
 -- Views with GROUPING SET queries
 CREATE VIEW gstest_view AS select a, b, grouping(a,b), sum(c), count(*), max(c)
diff --git a/src/test/regress/expected/inherit.out b/src/test/regress/expected/inherit.out
index 5b5055babdc..031dd87424a 100644
--- a/src/test/regress/expected/inherit.out
+++ b/src/test/regress/expected/inherit.out
@@ -580,8 +580,9 @@ update some_tab set a = a + 1 where false;
  Update on public.some_tab
    ->  Result
          Output: (some_tab.a + 1), NULL::oid, NULL::tid
+         Replaces: Scan on some_tab
          One-Time Filter: false
-(4 rows)
+(5 rows)
 
 update some_tab set a = a + 1 where false;
 explain (verbose, costs off)
@@ -592,8 +593,9 @@ update some_tab set a = a + 1 where false returning b, a;
    Output: some_tab.b, some_tab.a
    ->  Result
          Output: (some_tab.a + 1), NULL::oid, NULL::tid
+         Replaces: Scan on some_tab
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 update some_tab set a = a + 1 where false returning b, a;
  b | a 
@@ -699,8 +701,9 @@ explain update parted_tab set a = 2 where false;
 --------------------------------------------------------
  Update on parted_tab  (cost=0.00..0.00 rows=0 width=0)
    ->  Result  (cost=0.00..0.00 rows=0 width=10)
+         Replaces: Scan on parted_tab
          One-Time Filter: false
-(3 rows)
+(4 rows)
 
 drop table parted_tab;
 -- Check UPDATE with multi-level partitioned inherited target
@@ -1756,6 +1759,7 @@ explain (verbose, costs off) select min(1-id) from matest0;
 ---------------------------------------------------------------------------------
  Result
    Output: (InitPlan 1).col1
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            Output: ((1 - matest0.id))
@@ -1779,7 +1783,7 @@ explain (verbose, costs off) select min(1-id) from matest0;
                        ->  Index Scan using matest3i on public.matest3 matest0_4
                              Output: matest0_4.id, (1 - matest0_4.id)
                              Index Cond: ((1 - matest0_4.id) IS NOT NULL)
-(25 rows)
+(26 rows)
 
 select min(1-id) from matest0;
  min 
@@ -1943,6 +1947,7 @@ SELECT min(x) FROM
                              QUERY PLAN                             
 --------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Merge Append
@@ -1951,7 +1956,7 @@ SELECT min(x) FROM
                        Index Cond: (unique1 IS NOT NULL)
                  ->  Index Only Scan using tenk1_unique2 on tenk1 b
                        Index Cond: (unique2 IS NOT NULL)
-(9 rows)
+(10 rows)
 
 explain (costs off)
 SELECT min(y) FROM
@@ -1961,6 +1966,7 @@ SELECT min(y) FROM
                              QUERY PLAN                             
 --------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Merge Append
@@ -1969,7 +1975,7 @@ SELECT min(y) FROM
                        Index Cond: (unique1 IS NOT NULL)
                  ->  Index Only Scan using tenk1_unique2 on tenk1 b
                        Index Cond: (unique2 IS NOT NULL)
-(9 rows)
+(10 rows)
 
 -- XXX planner doesn't recognize that index on unique2 is sufficiently sorted
 explain (costs off)
@@ -3087,11 +3093,12 @@ explain (costs off) select * from range_list_parted where a between 3 and 23 and
 
 /* Should select no rows because range partition key cannot be null */
 explain (costs off) select * from range_list_parted where a is null;
-        QUERY PLAN        
---------------------------
+              QUERY PLAN               
+---------------------------------------
  Result
+   Replaces: Scan on range_list_parted
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 /* Should only select rows from the null-accepting partition */
 explain (costs off) select * from range_list_parted where b is null;
@@ -3252,6 +3259,7 @@ explain (costs off) select min(a), max(a) from parted_minmax where b = '12345';
                                            QUERY PLAN                                           
 ------------------------------------------------------------------------------------------------
  Result
+   Replaces: MinMaxAggregate
    InitPlan 1
      ->  Limit
            ->  Index Only Scan using parted_minmax1i on parted_minmax1 parted_minmax
@@ -3260,7 +3268,7 @@ explain (costs off) select min(a), max(a) from parted_minmax where b = '12345';
      ->  Limit
            ->  Index Only Scan Backward using parted_minmax1i on parted_minmax1 parted_minmax_1
                  Index Cond: ((a IS NOT NULL) AND (b = '12345'::text))
-(9 rows)
+(10 rows)
 
 select min(a), max(a) from parted_minmax where b = '12345';
  min | max 
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 04079268b98..30baa9dc51a 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -2264,11 +2264,12 @@ explain (costs off)
 select aa, bb, unique1, unique1
   from tenk1 right join b_star on aa = unique1
   where bb < bb and bb is null;
-        QUERY PLAN        
---------------------------
+            QUERY PLAN             
+-----------------------------------
  Result
+   Replaces: Join on tenk1, b_star
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 select aa, bb, unique1, unique1
   from tenk1 right join b_star on aa = unique1
@@ -2386,11 +2387,12 @@ order by t1.unique1;
                            Index Cond: (unique1 < 10)
                SubPlan 2
                  ->  Result
+                       Replaces: MinMaxAggregate
                        InitPlan 1
                          ->  Limit
                                ->  Index Only Scan using tenk1_unique1 on tenk1
                                      Index Cond: ((unique1 IS NOT NULL) AND (unique1 = t2.unique1))
-(19 rows)
+(20 rows)
 
 -- Ensure we get the expected result
 select t1.unique1,t2.unique1 from tenk1 t1
@@ -2655,8 +2657,8 @@ select * from int8_tbl t1 left join
     (int8_tbl t2 left join int8_tbl t3 full join int8_tbl t4 on false on false)
     left join int8_tbl t5 on t2.q1 = t5.q1
 on t2.q2 = 123;
-                    QUERY PLAN                    
---------------------------------------------------
+                     QUERY PLAN                     
+----------------------------------------------------
  Nested Loop Left Join
    ->  Seq Scan on int8_tbl t1
    ->  Materialize
@@ -2667,9 +2669,10 @@ on t2.q2 = 123;
                      ->  Seq Scan on int8_tbl t2
                            Filter: (q2 = 123)
                      ->  Result
+                           Replaces: Join on t3, t4
                            One-Time Filter: false
                ->  Seq Scan on int8_tbl t5
-(12 rows)
+(13 rows)
 
 explain (costs off)
 select * from int8_tbl t1
@@ -4164,6 +4167,7 @@ select * from t t1
                            Output: t2.i2, 1
          ->  Result
                Output: i3
+               Replaces: Scan on t3
                One-Time Filter: false
    ->  Memoize
          Output: t4.i4
@@ -4172,7 +4176,7 @@ select * from t t1
          ->  Index Only Scan using t_pkey on pg_temp.t t4
                Output: t4.i4
                Index Cond: (t4.i4 > (1))
-(25 rows)
+(26 rows)
 
 explain (verbose, costs off)
 select * from
@@ -4362,8 +4366,9 @@ from int4_tbl t1
 -------------------------------------------------------------------------------------------------------------------
  Result
    Output: (current_database())::information_schema.sql_identifier, (c.relname)::information_schema.sql_identifier
+   Replaces: Join on t1, t2, a, c, nc, t, nt, bt, nbt
    One-Time Filter: false
-(3 rows)
+(4 rows)
 
 -- Test handling of qual pushdown to appendrel members with non-Var outputs
 explain (verbose, costs off)
@@ -4435,11 +4440,12 @@ select unique1 from tenk1, lateral f_immutable_int4(1) x where x = unique1;
 
 explain (costs off)
 select unique1 from tenk1, lateral f_immutable_int4(1) x where x in (select 17);
-        QUERY PLAN        
---------------------------
+        QUERY PLAN         
+---------------------------
  Result
+   Replaces: Scan on tenk1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off)
 select unique1, x from tenk1 join f_immutable_int4(1) x on unique1 = x;
@@ -4485,11 +4491,12 @@ select unique1, x from tenk1 full join f_immutable_int4(1) x on unique1 = x;
 -- check that pullup of a const function allows further const-folding
 explain (costs off)
 select unique1 from tenk1, f_immutable_int4(1) x where x = 42;
-        QUERY PLAN        
---------------------------
+        QUERY PLAN         
+---------------------------
  Result
+   Replaces: Scan on tenk1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- test inlining of immutable functions with PlaceHolderVars
 explain (costs off)
@@ -5345,8 +5352,9 @@ left join
    Join Filter: false
    ->  Result
    ->  Result
+         Replaces: Join on c, n
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 -- check handling of apparently-commutable outer joins with non-commutable
 -- joins between them
@@ -5540,12 +5548,13 @@ select 1 from
      right join (select 1 as z) as ss2 on true)
   on false,
   lateral (select i4.f1, ss1.n from int8_tbl as i8 limit 1) as ss3;
-        QUERY PLAN        
---------------------------
+                  QUERY PLAN                   
+-----------------------------------------------
  Result
    Output: 1
+   Replaces: Join on i4, ss3, x1, x2, *RESULT*
    One-Time Filter: false
-(3 rows)
+(4 rows)
 
 select 1 from
   int4_tbl as i4
@@ -5574,11 +5583,12 @@ select 1 from t t1
        on false
      where t3.a = coalesce(t5.a,1)) as s2
   on true;
-        QUERY PLAN        
---------------------------
+                 QUERY PLAN                 
+--------------------------------------------
  Result
+   Replaces: Join on t1, s1, t2, t3, t4, t5
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 rollback;
 --
@@ -5975,14 +5985,15 @@ from int4_tbl as t1
                         inner join int8_tbl as t7 on null)
                on t5.q1 = t7.q2)
     on false;
-           QUERY PLAN           
---------------------------------
+                    QUERY PLAN                    
+--------------------------------------------------
  Nested Loop Left Join
    Join Filter: false
    ->  Seq Scan on int4_tbl t1
    ->  Result
+         Replaces: Join on t2, t3, t4, t5, t7, t6
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 -- variant with Var rather than PHV coming from t6
 explain (costs off)
@@ -5997,14 +6008,15 @@ from int4_tbl as t1
                         inner join int8_tbl as t7 on null)
                on t5.q1 = t7.q2)
     on false;
-           QUERY PLAN           
---------------------------------
+                    QUERY PLAN                    
+--------------------------------------------------
  Nested Loop Left Join
    Join Filter: false
    ->  Seq Scan on int4_tbl t1
    ->  Result
+         Replaces: Join on t2, t3, t4, t5, t7, t6
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 -- per further discussion of bug #17781
 explain (costs off)
@@ -6054,15 +6066,16 @@ select * from int8_tbl t1 left join
   (int8_tbl t2 inner join int8_tbl t3 on false
    left join int8_tbl t4 on t2.q2 = t4.q2)
 on t1.q1 = t2.q1;
-              QUERY PLAN              
---------------------------------------
+                 QUERY PLAN                 
+--------------------------------------------
  Hash Left Join
    Hash Cond: (t1.q1 = q1)
    ->  Seq Scan on int8_tbl t1
    ->  Hash
          ->  Result
+               Replaces: Join on t2, t3, t4
                One-Time Filter: false
-(6 rows)
+(7 rows)
 
 -- deduce constant-false from an EquivalenceClass
 explain (costs off)
@@ -6070,15 +6083,16 @@ select * from int8_tbl t1 left join
   (int8_tbl t2 inner join int8_tbl t3 on (t2.q1-t3.q2) = 0 and (t2.q1-t3.q2) = 1
    left join int8_tbl t4 on t2.q2 = t4.q2)
 on t1.q1 = t2.q1;
-              QUERY PLAN              
---------------------------------------
+                 QUERY PLAN                 
+--------------------------------------------
  Hash Left Join
    Hash Cond: (t1.q1 = q1)
    ->  Seq Scan on int8_tbl t1
    ->  Hash
          ->  Result
+               Replaces: Join on t2, t3, t4
                One-Time Filter: false
-(6 rows)
+(7 rows)
 
 -- pseudoconstant based on an outer-level Param
 explain (costs off)
@@ -6317,8 +6331,9 @@ select p.* from
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on p
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 select p.* from
   (parent p left join child c on (p.k = c.k)) join parent x on p.k = x.k
@@ -6334,8 +6349,9 @@ select p.* from
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Join on p, x
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- bug 5255: this is not optimizable by join removal
 begin;
@@ -6407,8 +6423,9 @@ SELECT q2 FROM
          Output: int4_tbl.f1
    ->  Result
          Output: q2, 'constant'::text
+         Replaces: Scan on int8_tbl
          One-Time Filter: false
-(9 rows)
+(10 rows)
 
 -- join removal bug #17786: check that OR conditions are cleaned up
 EXPLAIN (COSTS OFF)
@@ -6427,8 +6444,9 @@ FROM int4_tbl
                Filter: ((tenk1.unique1 = (42)) OR (tenk1.unique2 = (42)))
                ->  Seq Scan on tenk1
                ->  Result
+                     Replaces: Scan on int8_tbl
                      One-Time Filter: false
-(9 rows)
+(10 rows)
 
 rollback;
 -- another join removal bug: we must clean up correctly when removing a PHV
@@ -6878,8 +6896,9 @@ where q1.x = q2.y;
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on sj_1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- We can't use a cross-EC generated self join qual because of current logic of
 -- the generate_join_implied_equalities routine.
@@ -7703,11 +7722,12 @@ select 1 from emp1 full join
         on true
     where false) s on true
 where false;
-        QUERY PLAN        
---------------------------
+            QUERY PLAN            
+----------------------------------
  Result
+   Replaces: Join on emp1, t1, t3
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 select 1 from emp1 full join
     (select * from emp1 t1 join
@@ -7936,8 +7956,9 @@ where false;
 --------------------------
  Result
    Output: 1
+   Replaces: Scan on ss
    One-Time Filter: false
-(3 rows)
+(4 rows)
 
 --
 -- Test LATERAL
@@ -8875,8 +8896,9 @@ select * from int8_tbl i8 left join lateral
          Output: i8.q1, i8.q2
    ->  Result
          Output: f1, i8.q2
+         Replaces: Scan on int4_tbl
          One-Time Filter: false
-(8 rows)
+(9 rows)
 
 explain (verbose, costs off)
 select * from int8_tbl i8 left join lateral
@@ -8889,8 +8911,9 @@ select * from int8_tbl i8 left join lateral
          Output: i8.q1, i8.q2
    ->  Result
          Output: f1, f1, i8.q2
+         Replaces: Join on i1, i2
          One-Time Filter: false
-(7 rows)
+(8 rows)
 
 -- check handling of nested appendrels inside LATERAL
 select * from
diff --git a/src/test/regress/expected/merge.out b/src/test/regress/expected/merge.out
index cf2219df754..44df626c40c 100644
--- a/src/test/regress/expected/merge.out
+++ b/src/test/regress/expected/merge.out
@@ -2426,8 +2426,9 @@ MERGE INTO pa_target t USING pa_source s ON t.tid = s.sid
                Output: t.tid, t.ctid
                ->  Result
                      Output: t.tid, t.ctid
+                     Replaces: Scan on t
                      One-Time Filter: false
-(12 rows)
+(13 rows)
 
 MERGE INTO pa_target t USING pa_source s ON t.tid = s.sid
   WHEN NOT MATCHED THEN INSERT VALUES (s.sid);
diff --git a/src/test/regress/expected/partition_aggregate.out b/src/test/regress/expected/partition_aggregate.out
index 5f2c0cf5786..cb12bf53719 100644
--- a/src/test/regress/expected/partition_aggregate.out
+++ b/src/test/regress/expected/partition_aggregate.out
@@ -146,13 +146,14 @@ SELECT c, a, count(*) FROM pagg_tab GROUP BY a, c;
 -- Test when input relation for grouping is dummy
 EXPLAIN (COSTS OFF)
 SELECT c, sum(a) FROM pagg_tab WHERE 1 = 2 GROUP BY c;
-           QUERY PLAN           
---------------------------------
+             QUERY PLAN             
+------------------------------------
  HashAggregate
    Group Key: c
    ->  Result
+         Replaces: Scan on pagg_tab
          One-Time Filter: false
-(4 rows)
+(5 rows)
 
 SELECT c, sum(a) FROM pagg_tab WHERE 1 = 2 GROUP BY c;
  c | sum 
@@ -161,12 +162,13 @@ SELECT c, sum(a) FROM pagg_tab WHERE 1 = 2 GROUP BY c;
 
 EXPLAIN (COSTS OFF)
 SELECT c, sum(a) FROM pagg_tab WHERE c = 'x' GROUP BY c;
-           QUERY PLAN           
---------------------------------
+             QUERY PLAN             
+------------------------------------
  GroupAggregate
    ->  Result
+         Replaces: Scan on pagg_tab
          One-Time Filter: false
-(3 rows)
+(4 rows)
 
 SELECT c, sum(a) FROM pagg_tab WHERE c = 'x' GROUP BY c;
  c | sum 
@@ -804,15 +806,16 @@ SELECT a.x, b.y, count(*) FROM (SELECT * FROM pagg_tab1 WHERE x < 20) a FULL JOI
 -- Empty join relation because of empty outer side, no partitionwise agg plan
 EXPLAIN (COSTS OFF)
 SELECT a.x, a.y, count(*) FROM (SELECT * FROM pagg_tab1 WHERE x = 1 AND x = 2) a LEFT JOIN pagg_tab2 b ON a.x = b.y GROUP BY a.x, a.y ORDER BY 1, 2;
-              QUERY PLAN              
---------------------------------------
+                  QUERY PLAN                  
+----------------------------------------------
  GroupAggregate
    Group Key: pagg_tab1.y
    ->  Sort
          Sort Key: pagg_tab1.y
          ->  Result
+               Replaces: Join on b, pagg_tab1
                One-Time Filter: false
-(6 rows)
+(7 rows)
 
 SELECT a.x, a.y, count(*) FROM (SELECT * FROM pagg_tab1 WHERE x = 1 AND x = 2) a LEFT JOIN pagg_tab2 b ON a.x = b.y GROUP BY a.x, a.y ORDER BY 1, 2;
  x | y | count 
diff --git a/src/test/regress/expected/partition_join.out b/src/test/regress/expected/partition_join.out
index 24e06845f92..13fda3f32ef 100644
--- a/src/test/regress/expected/partition_join.out
+++ b/src/test/regress/expected/partition_join.out
@@ -1609,19 +1609,21 @@ SELECT avg(t1.a), avg(t2.b), avg(t3.a + t3.b), t1.c, t2.c, t3.c FROM plt1 t1, pl
 -- joins where one of the relations is proven empty
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM prt1 t1, prt2 t2 WHERE t1.a = t2.b AND t1.a = 1 AND t1.a = 2;
-        QUERY PLAN        
---------------------------
+         QUERY PLAN         
+----------------------------
  Result
+   Replaces: Join on t1, t2
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 LEFT JOIN prt2 t2 ON t1.a = t2.b;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Join on t2, prt1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 RIGHT JOIN prt2 t2 ON t1.a = t2.b, prt1 t3 WHERE t2.b = t3.a;
@@ -1647,8 +1649,9 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
                      ->  Seq Scan on prt2_p3 t2_3
    ->  Hash
          ->  Result
+               Replaces: Scan on prt1
                One-Time Filter: false
-(21 rows)
+(22 rows)
 
 EXPLAIN (COSTS OFF)
 SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1 FULL JOIN prt2 t2 ON t1.a = t2.b WHERE t2.a = 0 ORDER BY t1.a, t2.b;
@@ -1667,8 +1670,9 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1 WHERE a = 1 AND a = 2) t1
                      Filter: (a = 0)
          ->  Hash
                ->  Result
+                     Replaces: Scan on prt1
                      One-Time Filter: false
-(14 rows)
+(15 rows)
 
 --
 -- tests for hash partitioned tables.
@@ -2254,8 +2258,9 @@ SELECT t1.a, t1.c, t2.b, t2.c FROM (SELECT * FROM prt1_l WHERE a = 1 AND a = 2)
          ->  Seq Scan on prt2_l_p3_p2 t2_5
    ->  Hash
          ->  Result
+               Replaces: Scan on prt1_l
                One-Time Filter: false
-(11 rows)
+(12 rows)
 
 -- Test case to verify proper handling of subqueries in a partitioned delete.
 -- The weird-looking lateral join is just there to force creation of a
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index 68ecd951809..7499cdb2cdf 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -627,8 +627,9 @@ explain (costs off) select * from rlp3 where a = 20;   /* empty */
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on rlp3
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- redundant clauses are eliminated
 explain (costs off) select * from rlp where a > 1 and a = 10;	/* only default */
@@ -670,8 +671,9 @@ explain (costs off) select * from rlp where a = 1 and a = 3;	/* empty */
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on rlp
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from rlp where (a = 1 and a = 3) or (a > 1 and a = 15);
                             QUERY PLAN                             
@@ -1254,25 +1256,28 @@ select * from boolpart where a is not unknown;
 
 -- check that all partitions are pruned when faced with conflicting clauses
 explain (costs off) select * from boolpart where a is not unknown and a is unknown;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Scan on boolpart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from boolpart where a is false and a is unknown;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Scan on boolpart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from boolpart where a is true and a is unknown;
-        QUERY PLAN        
---------------------------
+          QUERY PLAN          
+------------------------------
  Result
+   Replaces: Scan on boolpart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- inverse boolean partitioning - a seemingly unlikely design, but we've got
 -- code for it, so we'd better test it.
@@ -1568,11 +1573,12 @@ explain (costs off) select * from coercepart where a = any ('{ab,null}');
 (2 rows)
 
 explain (costs off) select * from coercepart where a = any (null::text[]);
-        QUERY PLAN        
---------------------------
+           QUERY PLAN           
+--------------------------------
  Result
+   Replaces: Scan on coercepart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from coercepart where a = all ('{ab}');
                   QUERY PLAN                  
@@ -1582,25 +1588,28 @@ explain (costs off) select * from coercepart where a = all ('{ab}');
 (2 rows)
 
 explain (costs off) select * from coercepart where a = all ('{ab,bc}');
-        QUERY PLAN        
---------------------------
+           QUERY PLAN           
+--------------------------------
  Result
+   Replaces: Scan on coercepart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from coercepart where a = all ('{ab,null}');
-        QUERY PLAN        
---------------------------
+           QUERY PLAN           
+--------------------------------
  Result
+   Replaces: Scan on coercepart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from coercepart where a = all (null::text[]);
-        QUERY PLAN        
---------------------------
+           QUERY PLAN           
+--------------------------------
  Result
+   Replaces: Scan on coercepart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 drop table coercepart;
 CREATE TABLE part (a INT, b INT) PARTITION BY LIST (a);
@@ -1772,8 +1781,9 @@ explain (costs off) select * from lp where a <> 'a' and a is null;
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on lp
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from lp where (a <> 'a' and a <> 'd') or a is null;
                                   QUERY PLAN                                  
@@ -1866,22 +1876,24 @@ create table lparted_by_int2 (a smallint) partition by list (a);
 create table lparted_by_int2_1 partition of lparted_by_int2 for values in (1);
 create table lparted_by_int2_16384 partition of lparted_by_int2 for values in (16384);
 explain (costs off) select * from lparted_by_int2 where a = 100_000_000_000_000;
-        QUERY PLAN        
---------------------------
+             QUERY PLAN              
+-------------------------------------
  Result
+   Replaces: Scan on lparted_by_int2
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 create table rparted_by_int2 (a smallint) partition by range (a);
 create table rparted_by_int2_1 partition of rparted_by_int2 for values from (1) to (10);
 create table rparted_by_int2_16384 partition of rparted_by_int2 for values from (10) to (16384);
 -- all partitions pruned
 explain (costs off) select * from rparted_by_int2 where a > 100_000_000_000_000;
-        QUERY PLAN        
---------------------------
+             QUERY PLAN              
+-------------------------------------
  Result
+   Replaces: Scan on rparted_by_int2
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 create table rparted_by_int2_maxvalue partition of rparted_by_int2 for values from (16384) to (maxvalue);
 -- all partitions but rparted_by_int2_maxvalue pruned
@@ -2131,8 +2143,9 @@ explain (costs off) select * from hp where a = 1 and b = 'abcde' and
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on hp
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 --
 -- Test runtime partition pruning
@@ -3390,11 +3403,12 @@ select * from stable_qual_pruning where a < '2000-02-01'::timestamptz;
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from stable_qual_pruning
   where a = any(array['2010-02-01', '2020-01-01']::timestamp[]);
-            QUERY PLAN             
------------------------------------
+               QUERY PLAN                
+-----------------------------------------
  Result (actual rows=0.00 loops=1)
+   Replaces: Scan on stable_qual_pruning
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (analyze, costs off, summary off, timing off, buffers off)
 select * from stable_qual_pruning
@@ -3642,6 +3656,7 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
    Sort Key: ma_test.b
    InitPlan 2
      ->  Result (actual rows=1.00 loops=1)
+           Replaces: MinMaxAggregate
            InitPlan 1
              ->  Limit (actual rows=1.00 loops=1)
                    ->  Index Scan using ma_test_p2_b_idx on ma_test_p2 (actual rows=1.00 loops=1)
@@ -3656,7 +3671,7 @@ explain (analyze, costs off, summary off, timing off, buffers off) select * from
    ->  Index Scan using ma_test_p3_b_idx on ma_test_p3 ma_test_3 (actual rows=10.00 loops=1)
          Filter: (a >= (InitPlan 2).col1)
          Index Searches: 1
-(18 rows)
+(19 rows)
 
 reset enable_seqscan;
 reset enable_sort;
@@ -3678,11 +3693,12 @@ explain (costs off) select * from pp_arrpart where a = '{1}';
 (2 rows)
 
 explain (costs off) select * from pp_arrpart where a = '{1, 2}';
-        QUERY PLAN        
---------------------------
+           QUERY PLAN           
+--------------------------------
  Result
+   Replaces: Scan on pp_arrpart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from pp_arrpart where a in ('{4, 5}', '{1}');
                               QUERY PLAN                              
@@ -3764,11 +3780,12 @@ explain (costs off) select * from pp_enumpart where a = 'blue';
 (2 rows)
 
 explain (costs off) select * from pp_enumpart where a = 'black';
-        QUERY PLAN        
---------------------------
+           QUERY PLAN            
+---------------------------------
  Result
+   Replaces: Scan on pp_enumpart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 drop table pp_enumpart;
 drop type pp_colors;
@@ -3785,11 +3802,12 @@ explain (costs off) select * from pp_recpart where a = '(1,1)'::pp_rectype;
 (2 rows)
 
 explain (costs off) select * from pp_recpart where a = '(1,2)'::pp_rectype;
-        QUERY PLAN        
---------------------------
+           QUERY PLAN           
+--------------------------------
  Result
+   Replaces: Scan on pp_recpart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 drop table pp_recpart;
 drop type pp_rectype;
@@ -3805,11 +3823,12 @@ explain (costs off) select * from pp_intrangepart where a = '[1,2]'::int4range;
 (2 rows)
 
 explain (costs off) select * from pp_intrangepart where a = '(1,2)'::int4range;
-        QUERY PLAN        
---------------------------
+             QUERY PLAN              
+-------------------------------------
  Result
+   Replaces: Scan on pp_intrangepart
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 drop table pp_intrangepart;
 --
@@ -4125,19 +4144,21 @@ explain (costs off) update listp1 set a = 1 where a = 2;
 -- constraint exclusion enabled
 set constraint_exclusion to 'on';
 explain (costs off) select * from listp1 where a = 2;
-        QUERY PLAN        
---------------------------
+         QUERY PLAN         
+----------------------------
  Result
+   Replaces: Scan on listp1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) update listp1 set a = 1 where a = 2;
-           QUERY PLAN           
---------------------------------
+            QUERY PLAN            
+----------------------------------
  Update on listp1
    ->  Result
+         Replaces: Scan on listp1
          One-Time Filter: false
-(3 rows)
+(4 rows)
 
 reset constraint_exclusion;
 reset enable_partition_pruning;
@@ -4524,18 +4545,20 @@ create table hp_contradict_test (a int, b int) partition by hash (a part_test_in
 create table hp_contradict_test_p1 partition of hp_contradict_test for values with (modulus 2, remainder 0);
 create table hp_contradict_test_p2 partition of hp_contradict_test for values with (modulus 2, remainder 1);
 explain (costs off) select * from hp_contradict_test where a is null and a === 1 and b === 1;
-        QUERY PLAN        
---------------------------
+               QUERY PLAN               
+----------------------------------------
  Result
+   Replaces: Scan on hp_contradict_test
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 explain (costs off) select * from hp_contradict_test where a === 1 and b === 1 and a is null;
-        QUERY PLAN        
---------------------------
+               QUERY PLAN               
+----------------------------------------
  Result
+   Replaces: Scan on hp_contradict_test
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 drop table hp_contradict_test;
 drop operator class part_test_int4_ops2 using hash;
diff --git a/src/test/regress/expected/predicate.out b/src/test/regress/expected/predicate.out
index 1aff0b59ff8..304b6868b90 100644
--- a/src/test/regress/expected/predicate.out
+++ b/src/test/regress/expected/predicate.out
@@ -36,8 +36,9 @@ SELECT * FROM pred_tab t WHERE t.a IS NULL;
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on t
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- Ensure the IS_NULL qual is not reduced to constant-FALSE on nullable
 -- columns
@@ -77,8 +78,9 @@ SELECT * FROM pred_tab t WHERE t.a IS NULL OR t.c IS NULL;
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on t
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- Ensure the OR clause is not reduced to constant-FALSE when not all branches
 -- are provably false
@@ -139,8 +141,9 @@ SELECT * FROM pred_tab t1
    Join Filter: false
    ->  Seq Scan on pred_tab t1
    ->  Result
+         Replaces: Scan on t2
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 -- Ensure the IS_NULL qual is not reduced to constant-FALSE when the column is
 -- nullable by an outer join
@@ -209,8 +212,9 @@ SELECT * FROM pred_tab t1
    Join Filter: false
    ->  Seq Scan on pred_tab t1
    ->  Result
+         Replaces: Scan on t2
          One-Time Filter: false
-(5 rows)
+(6 rows)
 
 -- Ensure the OR clause is not reduced to constant-FALSE when a column is
 -- made nullable from an outer join
@@ -267,17 +271,18 @@ SELECT * FROM pred_tab t1
     LEFT JOIN pred_tab t2 ON EXISTS
         (SELECT 1 FROM pred_tab t3, pred_tab t4, pred_tab t5, pred_tab t6
          WHERE t1.a = t3.a AND t6.a IS NULL);
-             QUERY PLAN              
--------------------------------------
+                 QUERY PLAN                 
+--------------------------------------------
  Nested Loop Left Join
    Join Filter: (InitPlan 1).col1
    InitPlan 1
      ->  Result
+           Replaces: Join on t3, t4, t5, t6
            One-Time Filter: false
    ->  Seq Scan on pred_tab t1
    ->  Materialize
          ->  Seq Scan on pred_tab t2
-(8 rows)
+(9 rows)
 
 DROP TABLE pred_tab;
 -- Validate we handle IS NULL and IS NOT NULL quals correctly with inheritance
@@ -418,20 +423,22 @@ SET constraint_exclusion TO ON;
 -- Ensure that we get a dummy plan
 EXPLAIN (COSTS OFF)
 SELECT * FROM pred_tab1, pred_tab2 WHERE pred_tab2.a IS NULL;
-        QUERY PLAN        
---------------------------
+                QUERY PLAN                
+------------------------------------------
  Result
+   Replaces: Join on pred_tab1, pred_tab2
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 -- Ensure that we get a dummy plan
 EXPLAIN (COSTS OFF)
 SELECT * FROM pred_tab2, pred_tab1 WHERE pred_tab1.a IS NULL OR pred_tab1.b < 2;
-        QUERY PLAN        
---------------------------
+                QUERY PLAN                
+------------------------------------------
  Result
+   Replaces: Join on pred_tab2, pred_tab1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 RESET constraint_exclusion;
 DROP TABLE pred_tab1;
diff --git a/src/test/regress/expected/rowsecurity.out b/src/test/regress/expected/rowsecurity.out
index 8c879509313..7153ebba521 100644
--- a/src/test/regress/expected/rowsecurity.out
+++ b/src/test/regress/expected/rowsecurity.out
@@ -3606,8 +3606,9 @@ EXPLAIN (COSTS OFF) SELECT * FROM t1;
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on t1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 SET SESSION AUTHORIZATION regress_rls_bob;
 SELECT * FROM t1;
@@ -3619,8 +3620,9 @@ EXPLAIN (COSTS OFF) SELECT * FROM t1;
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on t1
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 --
 -- COPY TO/FROM
@@ -4524,11 +4526,12 @@ SELECT * FROM rls_tbl WHERE a <<< 1000;
 (0 rows)
 
 EXPLAIN (COSTS OFF) SELECT * FROM rls_tbl WHERE a <<< 1000 or a <<< 900;
-        QUERY PLAN        
---------------------------
+         QUERY PLAN          
+-----------------------------
  Result
+   Replaces: Scan on rls_tbl
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 RESET SESSION AUTHORIZATION;
 CREATE TABLE rls_child_tbl () INHERITS (rls_tbl);
diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out
index 9168979a620..40b94a82ad9 100644
--- a/src/test/regress/expected/rowtypes.out
+++ b/src/test/regress/expected/rowtypes.out
@@ -1323,8 +1323,9 @@ where false;
 --------------------------
  Result
    Output: (a).f1, (a).f2
+   Replaces: Scan on ss
    One-Time Filter: false
-(3 rows)
+(4 rows)
 
 explain (verbose, costs off)
 with cte(c) as materialized (select row(1, 2)),
@@ -1348,11 +1349,12 @@ where false;
 -----------------------------------
  Result
    Output: (cte.c).f1
+   Replaces: Scan on cte
    One-Time Filter: false
    CTE cte
      ->  Result
            Output: '(1,2)'::record
-(6 rows)
+(7 rows)
 
 --
 -- Tests for component access / FieldSelect
diff --git a/src/test/regress/expected/select.out b/src/test/regress/expected/select.out
index bab0cc93ff5..2cdb78d1e9a 100644
--- a/src/test/regress/expected/select.out
+++ b/src/test/regress/expected/select.out
@@ -962,10 +962,11 @@ create table list_parted_tbl (a int,b int) partition by list (a);
 create table list_parted_tbl1 partition of list_parted_tbl
   for values in (1) partition by list(b);
 explain (costs off) select * from list_parted_tbl;
-        QUERY PLAN        
---------------------------
+             QUERY PLAN              
+-------------------------------------
  Result
+   Replaces: Scan on list_parted_tbl
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 drop table list_parted_tbl;
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index 307e5ca1f3d..065ae550918 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -2006,8 +2006,9 @@ select * from json_tab t1 left join (select json_array(1, a) from json_tab t2) s
          Output: t1.a
    ->  Result
          Output: JSON_ARRAY(1, a RETURNING json)
+         Replaces: Scan on t2
          One-Time Filter: false
-(8 rows)
+(9 rows)
 
 select * from json_tab t1 left join (select json_array(1, a) from json_tab t2) s on false;
  a | json_array 
@@ -2898,12 +2899,13 @@ ON B.hundred in (SELECT min(c.hundred) FROM tenk2 C WHERE c.odd = b.odd);
                      ->  Subquery Scan on unnamed_subquery
                            Filter: (b.hundred = unnamed_subquery.min)
                            ->  Result
+                                 Replaces: MinMaxAggregate
                                  InitPlan 1
                                    ->  Limit
                                          ->  Index Scan using tenk2_hundred on tenk2 c
                                                Index Cond: (hundred IS NOT NULL)
                                                Filter: (odd = b.odd)
-(16 rows)
+(17 rows)
 
 --
 -- Test VALUES to ARRAY (VtA) transformation
@@ -3066,8 +3068,9 @@ EXPLAIN (COSTS OFF) EXECUTE test(NULL, 3.14, NULL);
         QUERY PLAN        
 --------------------------
  Result
+   Replaces: Scan on onek
    One-Time Filter: false
-(2 rows)
+(3 rows)
 
 EXPLAIN (COSTS OFF) EXECUTE test(NULL, 3.14, '-1.5');
                                                                      QUERY PLAN                                                                      
diff --git a/src/test/regress/expected/tsrf.out b/src/test/regress/expected/tsrf.out
index d47b5f6ec57..c4f7b187f5b 100644
--- a/src/test/regress/expected/tsrf.out
+++ b/src/test/regress/expected/tsrf.out
@@ -91,8 +91,9 @@ SELECT unnest(ARRAY[1, 2]) FROM few WHERE false;
  ProjectSet
    Output: unnest('{1,2}'::integer[])
    ->  Result
+         Replaces: Scan on few
          One-Time Filter: false
-(4 rows)
+(5 rows)
 
 SELECT unnest(ARRAY[1, 2]) FROM few WHERE false;
  unnest 
@@ -107,8 +108,9 @@ SELECT * FROM few f1,
 ------------------------------------------------
  Result
    Output: f1.id, f1.dataa, f1.datab, ss.unnest
+   Replaces: Join on f1, ss
    One-Time Filter: false
-(3 rows)
+(4 rows)
 
 SELECT * FROM few f1,
   (SELECT unnest(ARRAY[1,2]) FROM few f2 WHERE false OFFSET 0) ss;
diff --git a/src/test/regress/sql/explain.sql b/src/test/regress/sql/explain.sql
index 7842d25ded3..ebdab42604b 100644
--- a/src/test/regress/sql/explain.sql
+++ b/src/test/regress/sql/explain.sql
@@ -61,6 +61,7 @@ set track_io_timing = off;
 
 -- Simple cases
 
+explain (costs off) select 1 as a, 2 as b having false;
 select explain_filter('explain select * from int8_tbl i8');
 select explain_filter('explain (analyze, buffers off) select * from int8_tbl i8');
 select explain_filter('explain (analyze, buffers off, verbose) select * from int8_tbl i8');
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index e90af5b2ad3..3c80d49b67e 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2568,6 +2568,7 @@ RestrictInfo
 Result
 ResultRelInfo
 ResultState
+ResultType
 RetainDeadTuplesData
 RetainDeadTuplesPhase
 ReturnSetInfo
-- 
2.39.5 (Apple Git-154)

