From 1c2ff55d92802d0419940bcf0963568f0981ca09 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Thu, 1 Oct 2026 10:19:05 -0400
Subject: [PATCH v1] Let an ordering index scan hand its ORDER BY values to the
 target list

An Index Scan that orders by an operator (ORDER BY col <-> const)
computes each returned row's ORDER BY value inside the index AM and
reports it in xs_orderbyvals.  If the same expression also appears in
the target list, the executor then evaluates it a second time in the
scan's projection.  For an expensive distance operator that is wasted
work; for an AM whose ordering value depends on index-wide state the
displayed number is not the number the rows were ordered by.

Add IndexAmRoutine.amorderbyvalsexact.  When an AM sets it, setrefs.c
replaces each top-level target list expression that is equal() to the
i'th indexorderbyorig expression with Var(INNER_VAR, i), and
nodeIndexscan.c stores the returned row's ORDER BY values in a virtual
slot it installs as the projection's inner tuple (a scan node has no
inner plan, so INNER_VAR is otherwise unused there).  The values stored
are the AM's for a row it reported exact, the recomputed ones for a row
it asked to recheck, a reorder queue entry's copy for a queued row, and
freshly recomputed ones for an EvalPlanQual test tuple.  A lossy AM's
lower bound is never displayed.  Only float4/float8 ORDER BY
expressions qualify, as those are the only types
index_store_float8_orderby_distances() can deliver.

EXPLAIN VERBOSE deparses the INNER_VAR back to the ORDER BY expression
and reports "Order By Values Used: N".  Above such a scan, an upper
node's reference to the value now prints with one more pair of
parentheses, the same way any reference to a computed scan column does
(box, polygon expected output).

GiST and SP-GiST set the flag; their non-rechecked distances are the
operator's result, so for them this is a pure optimization.  Other AMs
default to false and are unaffected.
---
 doc/src/sgml/indexam.sgml               |  19 ++++
 src/backend/access/gist/gist.c          |   1 +
 src/backend/access/spgist/spgutils.c    |   1 +
 src/backend/commands/explain.c          |   4 +
 src/backend/executor/nodeIndexscan.c    |  91 ++++++++++++++++-
 src/backend/optimizer/plan/createplan.c |   4 +
 src/backend/optimizer/plan/setrefs.c    |  56 ++++++++++
 src/backend/optimizer/util/plancat.c    |   2 +
 src/backend/utils/adt/ruleutils.c       |  31 ++++++
 src/include/access/amapi.h              |   9 ++
 src/include/nodes/execnodes.h           |   7 ++
 src/include/nodes/pathnodes.h           |   1 +
 src/include/nodes/plannodes.h           |   9 ++
 src/test/regress/expected/box.out       |  12 +--
 src/test/regress/expected/gist.out      | 130 ++++++++++++++++++++++++
 src/test/regress/expected/polygon.out   |   6 +-
 src/test/regress/sql/gist.sql           |  60 +++++++++++
 17 files changed, 432 insertions(+), 11 deletions(-)

diff --git a/doc/src/sgml/indexam.sgml b/doc/src/sgml/indexam.sgml
index f48da318530..a263f85c003 100644
--- a/doc/src/sgml/indexam.sgml
+++ b/doc/src/sgml/indexam.sgml
@@ -109,6 +109,8 @@ typedef struct IndexAmRoutine
     bool        amcanorder;
     /* does AM support ORDER BY result of an operator on indexed column? */
     bool        amcanorderbyop;
+    /* are the AM's non-rechecked ORDER BY values exact enough to display? */
+    bool        amorderbyvalsexact;
     /* does AM support hashing using API consistent with the hash AM? */
     bool        amcanhash;
     /* do operators within an opfamily have consistent equality semantics? */
@@ -989,6 +991,23 @@ amtranslatecmptype (CompareType cmptype, Oid opfamily, Oid opcintype);
        of that form can be passed to <function>amrescan</function> as described
        previously.
       </para>
+      <para>
+       Such an access method returns each entry's ordering values in
+       <literal>scan-&gt;xs_orderbyvals</literal>, and sets
+       <literal>scan-&gt;xs_recheckorderby</literal> to true when those values are
+       only approximations that the executor must recompute from the heap
+       tuple.  If the access method also sets
+       <structfield>amorderbyvalsexact</structfield> to true, it promises that
+       whenever <literal>xs_recheckorderby</literal> is false, the values in
+       <literal>xs_orderbyvals</literal> are the values the query should see;
+       the executor may then return them directly for a target list entry
+       that is the same expression as the <literal>ORDER BY</literal> key,
+       instead of evaluating the operator again.  An access method whose
+       ordering values differ from what the operator would compute on the
+       heap tuple (because they are approximate, or depend on index-wide
+       state) must leave this false unless it intends that difference to be
+       visible.
+      </para>
      </listitem>
     </itemizedlist>
   </para>
diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c
index 8565e225be7..1a43314aaf7 100644
--- a/src/backend/access/gist/gist.c
+++ b/src/backend/access/gist/gist.c
@@ -65,6 +65,7 @@ gisthandler(PG_FUNCTION_ARGS)
 		.amoptsprocnum = GIST_OPTIONS_PROC,
 		.amcanorder = false,
 		.amcanorderbyop = true,
+		.amorderbyvalsexact = true,
 		.amcanhash = false,
 		.amconsistentequality = false,
 		.amconsistentordering = false,
diff --git a/src/backend/access/spgist/spgutils.c b/src/backend/access/spgist/spgutils.c
index f2ee333f60d..1d8f0cffec0 100644
--- a/src/backend/access/spgist/spgutils.c
+++ b/src/backend/access/spgist/spgutils.c
@@ -50,6 +50,7 @@ spghandler(PG_FUNCTION_ARGS)
 		.amoptsprocnum = SPGIST_OPTIONS_PROC,
 		.amcanorder = false,
 		.amcanorderbyop = true,
+		.amorderbyvalsexact = true,
 		.amcanhash = false,
 		.amconsistentequality = false,
 		.amconsistentordering = false,
diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c
index 96f2f0e2e74..924f70af2e4 100644
--- a/src/backend/commands/explain.c
+++ b/src/backend/commands/explain.c
@@ -1974,6 +1974,10 @@ ExplainNode(PlanState *planstate, List *ancestors,
 										   planstate, es);
 			show_scan_qual(((IndexScan *) plan)->indexorderbyorig,
 						   "Order By", planstate, ancestors, es);
+			if (es->verbose && ((IndexScan *) plan)->indexorderbytlist > 0)
+				ExplainPropertyInteger("Order By Values Used", NULL,
+									   ((IndexScan *) plan)->indexorderbytlist,
+									   es);
 			show_scan_qual(plan->qual, "Filter", planstate, ancestors, es);
 			if (plan->qual)
 				show_instrumentation_count("Rows Removed by Filter", 1,
diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c
index 129d005f187..6504fed2bf1 100644
--- a/src/backend/executor/nodeIndexscan.c
+++ b/src/backend/executor/nodeIndexscan.c
@@ -163,6 +163,33 @@ IndexNext(IndexScanState *node)
 	return ExecClearTuple(slot);
 }
 
+/*
+ * Hand the ORDER BY values of the row about to be returned to the projection,
+ * when the targetlist asks for them (indexorderbytlist > 0, set by setrefs.c
+ * only for an AM with amorderbyvalsexact).  `vals`/`nulls` are what the
+ * caller established as this row's ordering values: the AM's when it reported
+ * them exact, the recomputed ones when it asked for a recheck, or a reorder
+ * queue entry's copy.  Never a lossy AM's lower bound.
+ */
+static inline void
+IndexStoreOrderByValues(IndexScanState *node, const Datum *vals,
+						const bool *nulls)
+{
+	TupleTableSlot *oslot = node->iss_OrderBySlot;
+	int			i;
+
+	if (oslot == NULL)
+		return;
+	ExecClearTuple(oslot);
+	for (i = 0; i < node->iss_NumOrderByKeys; i++)
+	{
+		oslot->tts_values[i] = vals[i];
+		oslot->tts_isnull[i] = nulls[i];
+	}
+	ExecStoreVirtualTuple(oslot);
+	node->ss.ps.ps_ExprContext->ecxt_innertuple = oslot;
+}
+
 /* ----------------------------------------------------------------
  *		IndexNextWithReorder
  *
@@ -251,6 +278,9 @@ IndexNextWithReorder(IndexScanState *node)
 			{
 				HeapTuple	tuple;
 
+				/* before the pop frees the entry's copies */
+				IndexStoreOrderByValues(node, topmost->orderbyvals,
+										topmost->orderbynulls);
 				tuple = reorderqueue_pop(node);
 
 				/* Pass 'true', as the tuple in the queue is a palloc'd copy */
@@ -352,6 +382,7 @@ next_indextuple:
 		else
 		{
 			/* Can return this tuple immediately. */
+			IndexStoreOrderByValues(node, lastfetched_vals, lastfetched_nulls);
 			return slot;
 		}
 	}
@@ -404,7 +435,22 @@ IndexRecheck(IndexScanState *node, TupleTableSlot *slot)
 
 	/* Does the tuple meet the indexqual condition? */
 	econtext->ecxt_scantuple = slot;
-	return ExecQualAndReset(node->indexqualorig, econtext);
+	if (!ExecQualAndReset(node->indexqualorig, econtext))
+		return false;
+
+	/*
+	 * The EPQ test tuple never went through IndexNext, so the ORDER BY values
+	 * the targetlist reads (if any) are not those of this row.  Recompute
+	 * them from the tuple, as we would for a rechecked row.
+	 */
+	if (node->iss_OrderBySlot != NULL)
+	{
+		EvalOrderByExpressions(node, econtext);
+		IndexStoreOrderByValues(node, node->iss_OrderByValues,
+								node->iss_OrderByNulls);
+	}
+
+	return true;
 }
 
 
@@ -954,7 +1000,24 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags)
 	 * Initialize result type and projection.
 	 */
 	ExecInitResultTypeTL(&indexstate->ss.ps);
-	ExecAssignScanProjectionInfo(&indexstate->ss);
+
+	/*
+	 * A targetlist that reads the ORDER BY values (INNER_VAR Vars, see
+	 * setrefs.c) is never the scan tuple's physical layout, so it always
+	 * projects; ExecConditionalAssignProjectionInfo would assert on the
+	 * INNER_VAR while testing whether it can skip projection.
+	 */
+	if (node->indexorderbytlist > 0)
+	{
+		ExecInitResultSlot(&indexstate->ss.ps, &TTSOpsVirtual);
+		indexstate->ss.ps.resultops = &TTSOpsVirtual;
+		indexstate->ss.ps.resultopsfixed = true;
+		indexstate->ss.ps.resultopsset = true;
+		ExecAssignProjectionInfo(&indexstate->ss.ps,
+								 indexstate->ss.ss_ScanTupleSlot->tts_tupleDescriptor);
+	}
+	else
+		ExecAssignScanProjectionInfo(&indexstate->ss);
 
 	/*
 	 * initialize child expressions
@@ -1024,6 +1087,30 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags)
 						   NULL,	/* no ArrayKeys */
 						   NULL);
 
+	/*
+	 * A targetlist that reads the ORDER BY values needs a slot to read them
+	 * from, typed as the ORDER BY expressions (setrefs.c built it so).
+	 */
+	if (node->indexorderbytlist > 0)
+	{
+		TupleDesc	odesc;
+		ListCell   *lc;
+		AttrNumber	attno = 0;
+
+		odesc = CreateTemplateTupleDesc(list_length(node->indexorderbyorig));
+		foreach(lc, node->indexorderbyorig)
+		{
+			Node	   *expr = (Node *) lfirst(lc);
+
+			attno++;
+			TupleDescInitEntry(odesc, attno, NULL, exprType(expr),
+							   exprTypmod(expr), 0);
+		}
+		TupleDescFinalize(odesc);
+		indexstate->iss_OrderBySlot =
+			ExecAllocTableSlot(&estate->es_tupleTable, odesc, &TTSOpsVirtual, 0);
+	}
+
 	/* Initialize sort support, if we need to re-check ORDER BY exprs */
 	if (indexstate->iss_NumOrderByKeys > 0)
 	{
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 2f8a08ad78e..7635707d300 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -3033,6 +3033,10 @@ create_indexscan_plan(PlannerInfo *root,
 											indexorderbyops,
 											best_path->indexscandir);
 
+	if (!indexonly && indexorderbys != NIL)
+		((IndexScan *) scan_plan)->indexorderbyexact =
+			indexinfo->amorderbyvalsexact;
+
 	copy_generic_path_info(&scan_plan->plan, &best_path->path);
 
 	return scan_plan;
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index 8a641402a96..3ecea955800 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -134,6 +134,7 @@ static bool flatten_rtes_walker(Node *node, flatten_rtes_walker_context *cxt);
 static void add_rte_to_flat_rtable(PlannerGlobal *glob, List *rteperminfos,
 								   RangeTblEntry *rte);
 static Plan *set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset);
+static void replace_orderby_tlist_refs(IndexScan *splan);
 static Plan *set_indexonlyscan_references(PlannerInfo *root,
 										  IndexOnlyScan *plan,
 										  int rtoffset);
@@ -701,6 +702,8 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 				splan->indexorderbyorig =
 					fix_scan_list(root, splan->indexorderbyorig,
 								  rtoffset, NUM_EXEC_QUAL(plan));
+				/* after both are fixed, so equal() compares like with like */
+				replace_orderby_tlist_refs(splan);
 			}
 			break;
 		case T_IndexOnlyScan:
@@ -1360,6 +1363,59 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 	return plan;
 }
 
+/*
+ * replace_orderby_tlist_refs
+ *		Let an ordering IndexScan hand its ORDER BY values to its targetlist.
+ *
+ * If the index AM promises that the ORDER BY values it reports for an exact
+ * (non-rechecked) row are the values the query should see
+ * (amorderbyvalsexact), replace each top-level targetlist expression that is
+ * equal() to the i'th indexorderbyorig expression with
+ * Var(INNER_VAR, i + 1).  nodeIndexscan.c then fills a virtual slot with the
+ * ORDER BY values of every returned row -- the AM's for an exact row, the
+ * recomputed ones for a rechecked row -- and installs it as the projection's
+ * inner tuple.  A scan node has no inner plan, so INNER_VAR is otherwise
+ * unused here.
+ *
+ * Only float8/float4 expressions qualify: those are the only types
+ * index_store_float8_orderby_distances() can deliver.
+ */
+static void
+replace_orderby_tlist_refs(IndexScan *splan)
+{
+	ListCell   *lc;
+	int			nrepl = 0;
+
+	if (!splan->indexorderbyexact || splan->indexorderbyorig == NIL)
+		return;
+
+	foreach(lc, splan->scan.plan.targetlist)
+	{
+		TargetEntry *tle = lfirst_node(TargetEntry, lc);
+		ListCell   *lo;
+		int			i = 0;
+
+		foreach(lo, splan->indexorderbyorig)
+		{
+			Expr	   *orig = (Expr *) lfirst(lo);
+			Oid			typ = exprType((Node *) orig);
+
+			i++;
+			if ((typ == FLOAT8OID || typ == FLOAT4OID) &&
+				equal(tle->expr, orig))
+			{
+				tle->expr = (Expr *) makeVar(INNER_VAR, i, typ,
+											 exprTypmod((Node *) orig),
+											 exprCollation((Node *) orig),
+											 0);
+				nrepl++;
+				break;
+			}
+		}
+	}
+	splan->indexorderbytlist = nrepl;
+}
+
 /*
  * set_indexonlyscan_references
  *		Do set_plan_references processing on an IndexOnlyScan
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index d067368cfc1..5e691a7fb65 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -305,6 +305,7 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 				/* We copy just the fields we need, not all of rd_indam */
 				amroutine = indexRelation->rd_indam;
 				info->amcanorderbyop = amroutine->amcanorderbyop;
+				info->amorderbyvalsexact = amroutine->amorderbyvalsexact;
 				info->amoptionalkey = amroutine->amoptionalkey;
 				info->amsearcharray = amroutine->amsearcharray;
 				info->amsearchnulls = amroutine->amsearchnulls;
@@ -406,6 +407,7 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent,
 			else
 			{
 				info->amcanorderbyop = false;
+				info->amorderbyvalsexact = false;
 				info->amoptionalkey = false;
 				info->amsearcharray = false;
 				info->amsearchnulls = false;
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 5e8e1683db0..dca0d43e9ce 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -180,6 +180,8 @@ typedef struct
 	List	   *outer_tlist;	/* referent for OUTER_VAR Vars */
 	List	   *inner_tlist;	/* referent for INNER_VAR Vars */
 	List	   *index_tlist;	/* referent for INDEX_VAR Vars */
+	List	   *orderby_tlist;	/* IndexScan: referent for INNER_VAR Vars that
+								 * stand for its ORDER BY values */
 	/* Special namespace representing a function signature: */
 	char	   *funcname;
 	int			numargs;
@@ -5227,6 +5229,23 @@ set_deparse_plan(deparse_namespace *dpns, Plan *plan)
 		dpns->index_tlist = ((CustomScan *) plan)->custom_scan_tlist;
 	else
 		dpns->index_tlist = NIL;
+
+	/*
+	 * An IndexScan whose targetlist reads its own ORDER BY values (see
+	 * replace_orderby_tlist_refs in setrefs.c) uses INNER_VAR Vars numbered
+	 * by ORDER BY key; they deparse as the ORDER BY expressions.
+	 */
+	dpns->orderby_tlist = NIL;
+	if (IsA(plan, IndexScan) && ((IndexScan *) plan)->indexorderbytlist > 0)
+	{
+		ListCell   *lc;
+		AttrNumber	i = 0;
+
+		foreach(lc, ((IndexScan *) plan)->indexorderbyorig)
+			dpns->orderby_tlist = lappend(dpns->orderby_tlist,
+										  makeTargetEntry((Expr *) lfirst(lc),
+														  ++i, NULL, false));
+	}
 }
 
 /*
@@ -7988,6 +8007,18 @@ resolve_special_varno(Node *node, deparse_context *context,
 		context->appendparents = save_appendparents;
 		return;
 	}
+	else if (var->varno == INNER_VAR && dpns->orderby_tlist)
+	{
+		TargetEntry *tle;
+
+		tle = get_tle_by_resno(dpns->orderby_tlist, var->varattno);
+		if (!tle)
+			elog(ERROR, "bogus varattno for ORDER BY value var: %d", var->varattno);
+
+		resolve_special_varno((Node *) tle->expr, context,
+							  callback, callback_arg);
+		return;
+	}
 	else if (var->varno == INNER_VAR && dpns->inner_tlist)
 	{
 		TargetEntry *tle;
diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h
index 79240333530..e19f7df2454 100644
--- a/src/include/access/amapi.h
+++ b/src/include/access/amapi.h
@@ -247,6 +247,15 @@ typedef struct IndexAmRoutine
 	bool		amcanorder;
 	/* does AM support ORDER BY result of an operator on indexed column? */
 	bool		amcanorderbyop;
+
+	/*
+	 * When an ordering scan reports xs_recheckorderby = false, may the
+	 * executor substitute the AM's xs_orderbyvals for an identical ORDER BY
+	 * expression in the scan's target list, rather than re-evaluating it?
+	 * Setting this asserts that the value the AM reports IS the value the
+	 * query should display.  See nodeIndexscan.c.
+	 */
+	bool		amorderbyvalsexact;
 	/* does AM support hashing using API consistent with the hash AM? */
 	bool		amcanhash;
 	/* do operators within an opfamily have consistent equality semantics? */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 91bb0bd2e13..07a404e665b 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1751,6 +1751,13 @@ typedef struct IndexScanState
 	bool	   *iss_OrderByTypByVals;
 	int16	   *iss_OrderByTypLens;
 	Size		iss_PscanLen;
+
+	/*
+	 * When the plan's targetlist reads the ORDER BY values (indexorderbytlist
+	 * > 0), the values of the row being returned, as a virtual slot installed
+	 * as the projection's inner tuple.
+	 */
+	TupleTableSlot *iss_OrderBySlot;
 } IndexScanState;
 
 /* ----------------
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 1c6d1fe3d04..1e4f2bd64e7 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -1434,6 +1434,7 @@ typedef struct IndexOptInfo
 	 * (IndexAmRoutine).  These fields are not set for partitioned indexes.
 	 */
 	bool		amcanorderbyop;
+	bool		amorderbyvalsexact;
 	bool		amoptionalkey;
 	bool		amsearcharray;
 	bool		amsearchnulls;
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 09a1ec73180..295d2b9db69 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -616,6 +616,15 @@ typedef struct IndexScan
 	List	   *indexorderbyops;
 	/* forward or backward or don't care */
 	ScanDirection indexorderdir;
+
+	/*
+	 * May setrefs.c replace target-list occurrences of an indexorderbyorig
+	 * expression with INNER_VAR references to the scan's ORDER BY values?
+	 * Copied from the AM's amorderbyvalsexact.  indexorderbytlist counts the
+	 * replacements actually made (0 if none), for EXPLAIN.
+	 */
+	bool		indexorderbyexact;
+	int			indexorderbytlist;
 } IndexScan;
 
 /* ----------------
diff --git a/src/test/regress/expected/box.out b/src/test/regress/expected/box.out
index 10760870ce7..60d92a26e1e 100644
--- a/src/test/regress/expected/box.out
+++ b/src/test/regress/expected/box.out
@@ -594,10 +594,10 @@ SET enable_bitmapscan = OFF;
 EXPLAIN (COSTS OFF)
 SELECT rank() OVER (ORDER BY b <-> point '123,456') n, b <-> point '123,456' dist, id
 FROM quad_box_tbl;
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
  WindowAgg
-   Window: w1 AS (ORDER BY (b <-> '(123,456)'::point) ROWS UNBOUNDED PRECEDING)
+   Window: w1 AS (ORDER BY ((b <-> '(123,456)'::point)) ROWS UNBOUNDED PRECEDING)
    ->  Index Scan using quad_box_tbl_idx on quad_box_tbl
          Order By: (b <-> '(123,456)'::point)
 (4 rows)
@@ -617,10 +617,10 @@ WHERE seq.id IS NULL OR idx.id IS NULL;
 EXPLAIN (COSTS OFF)
 SELECT rank() OVER (ORDER BY b <-> point '123,456') n, b <-> point '123,456' dist, id
 FROM quad_box_tbl WHERE b <@ box '((200,300),(500,600))';
-                                   QUERY PLAN                                   
---------------------------------------------------------------------------------
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
  WindowAgg
-   Window: w1 AS (ORDER BY (b <-> '(123,456)'::point) ROWS UNBOUNDED PRECEDING)
+   Window: w1 AS (ORDER BY ((b <-> '(123,456)'::point)) ROWS UNBOUNDED PRECEDING)
    ->  Index Scan using quad_box_tbl_idx on quad_box_tbl
          Index Cond: (b <@ '(500,600),(200,300)'::box)
          Order By: (b <-> '(123,456)'::point)
diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out
index ac79f94aa80..dd52c687535 100644
--- a/src/test/regress/expected/gist.out
+++ b/src/test/regress/expected/gist.out
@@ -387,6 +387,136 @@ select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1;
 
 select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1;
 ERROR:  lossy distance functions are not supported in index-only scans
+drop index gist_tbl_multi_index;
+-- Test that an ordering Index Scan returns the AM's ORDER BY values to a
+-- targetlist entry equal to the ORDER BY expression, instead of evaluating
+-- the expression again (amorderbyvalsexact).
+set enable_indexonlyscan = off;
+-- An expression index over a counting function proves the operator is not
+-- re-evaluated: the count stays at zero for the rows returned.
+create sequence gist_cnt_seq;
+create function gist_cnt_pt(point) returns point language plpgsql immutable
+  as $$ begin perform nextval('public.gist_cnt_seq'); return $1; end $$;
+create index gist_tbl_cnt_index on gist_tbl using gist (gist_cnt_pt(p));
+explain (verbose, costs off)
+select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist
+  from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3;
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Limit
+   Output: p, ((gist_cnt_pt(p) <-> '(0.201,0.201)'::point))
+   ->  Index Scan using gist_tbl_cnt_index on public.gist_tbl
+         Output: p, ((gist_cnt_pt(p) <-> '(0.201,0.201)'::point))
+         Order By: (gist_cnt_pt(gist_tbl.p) <-> '(0.201,0.201)'::point)
+         Order By Values Used: 1
+(6 rows)
+
+select setval('gist_cnt_seq', 1, false);
+ setval 
+--------
+      1
+(1 row)
+
+select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist
+  from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3;
+      p      |         dist          
+-------------+-----------------------
+ (0.2,0.2)   | 0.0014142135623730963
+ (0.25,0.25) |   0.06929646455628163
+ (0.15,0.15) |   0.07212489168102787
+(3 rows)
+
+select nextval('gist_cnt_seq') - 1 as calls_during_scan;
+ calls_during_scan 
+-------------------
+                 0
+(1 row)
+
+-- Every copy of the ORDER BY expression is served from the index; an
+-- expression that merely contains it is not.
+explain (verbose, costs off)
+select gist_cnt_pt(p) <-> point(0,0) as d1,
+       (gist_cnt_pt(p) <-> point(0,0)) * 2 as d2,
+       gist_cnt_pt(p) <-> point(0,0) as d3
+  from gist_tbl order by gist_cnt_pt(p) <-> point(0,0) limit 2;
+                                                                         QUERY PLAN                                                                          
+-------------------------------------------------------------------------------------------------------------------------------------------------------------
+ Limit
+   Output: ((gist_cnt_pt(p) <-> '(0,0)'::point)), (((gist_cnt_pt(p) <-> '(0,0)'::point) * '2'::double precision)), ((gist_cnt_pt(p) <-> '(0,0)'::point))
+   ->  Index Scan using gist_tbl_cnt_index on public.gist_tbl
+         Output: ((gist_cnt_pt(p) <-> '(0,0)'::point)), ((gist_cnt_pt(p) <-> '(0,0)'::point) * '2'::double precision), ((gist_cnt_pt(p) <-> '(0,0)'::point))
+         Order By: (gist_cnt_pt(gist_tbl.p) <-> '(0,0)'::point)
+         Order By Values Used: 2
+(6 rows)
+
+drop index gist_tbl_cnt_index;
+drop function gist_cnt_pt(point);
+drop sequence gist_cnt_seq;
+-- The box opclass's distance to a point is lossy, so this exercises the
+-- recheck and reorder-queue paths; the displayed values must match the
+-- operator's.
+create index gist_tbl_box_index on gist_tbl using gist (b);
+explain (verbose, costs off)
+select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+  where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91);
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using gist_tbl_box_index on public.gist_tbl
+   Output: b, ((b <-> '(5.2,5.91)'::point))
+   Index Cond: (gist_tbl.b <@ '(6,6),(5,5)'::box)
+   Order By: (gist_tbl.b <-> '(5.2,5.91)'::point)
+   Order By Values Used: 1
+(5 rows)
+
+select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+  where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91);
+            b            |        dist        
+-------------------------+--------------------
+ (5.55,5.55),(5.55,5.55) | 0.5020956084253276
+ (5.6,5.6),(5.6,5.6)     | 0.5060632371551997
+ (5.5,5.5),(5.5,5.5)     | 0.5080354318352215
+ (5.65,5.65),(5.65,5.65) | 0.5197114584074514
+ (5.45,5.45),(5.45,5.45) |  0.523545604508337
+ (5.7,5.7),(5.7,5.7)     |   0.54230987451825
+ (5.4,5.4),(5.4,5.4)     | 0.5478138369920934
+ (5.75,5.75),(5.75,5.75) | 0.5728001396647873
+ (5.35,5.35),(5.35,5.35) | 0.5797413216254301
+ (5.8,5.8),(5.8,5.8)     | 0.6099999999999997
+ (5.3,5.3),(5.3,5.3)     | 0.6181423784210239
+ (5.85,5.85),(5.85,5.85) | 0.6527633568147031
+ (5.25,5.25),(5.25,5.25) | 0.6618912297349167
+ (5.9,5.9),(5.9,5.9)     | 0.7000714249274858
+ (5.2,5.2),(5.2,5.2)     |               0.71
+ (5.95,5.95),(5.95,5.95) |  0.751065909225016
+ (5.15,5.15),(5.15,5.15) | 0.7616429609731844
+ (6,6),(6,6)             | 0.8050465825031491
+ (5.1,5.1),(5.1,5.1)     | 0.8161494961096287
+ (5.05,5.05),(5.05,5.05) | 0.8729833904490971
+ (5,5),(5,5)             | 0.9317188417113825
+(21 rows)
+
+select count(*) filter (where dist = b <-> point(5.2, 5.91)) as same,
+       count(*) as total
+  from (select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+          order by b <-> point(5.2, 5.91) limit 200) ss;
+ same | total 
+------+-------
+  200 |   200
+(1 row)
+
+-- Same, under row locking (the tuple is re-fetched by LockRows).
+select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+  where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91)
+  limit 3 for update;
+            b            |        dist        
+-------------------------+--------------------
+ (5.55,5.55),(5.55,5.55) | 0.5020956084253276
+ (5.6,5.6),(5.6,5.6)     | 0.5060632371551997
+ (5.5,5.5),(5.5,5.5)     | 0.5080354318352215
+(3 rows)
+
+drop index gist_tbl_box_index;
+reset enable_indexonlyscan;
 -- Test that an index-only scan deforms the tuple it reconstructs with the
 -- descriptor the AM formed it with, not the scan slot's descriptor.
 create temp table gist_ios_tupdesc (a inet, r numrange);
diff --git a/src/test/regress/expected/polygon.out b/src/test/regress/expected/polygon.out
index c01848f103e..e9560664870 100644
--- a/src/test/regress/expected/polygon.out
+++ b/src/test/regress/expected/polygon.out
@@ -283,10 +283,10 @@ SET enable_bitmapscan = OFF;
 EXPLAIN (COSTS OFF)
 SELECT rank() OVER (ORDER BY p <-> point '123,456') n, p <-> point '123,456' dist, id
 FROM quad_poly_tbl WHERE p <@ polygon '((300,300),(400,600),(600,500),(700,200))';
-                                   QUERY PLAN                                    
----------------------------------------------------------------------------------
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
  WindowAgg
-   Window: w1 AS (ORDER BY (p <-> '(123,456)'::point) ROWS UNBOUNDED PRECEDING)
+   Window: w1 AS (ORDER BY ((p <-> '(123,456)'::point)) ROWS UNBOUNDED PRECEDING)
    ->  Index Scan using quad_poly_tbl_idx on quad_poly_tbl
          Index Cond: (p <@ '((300,300),(400,600),(600,500),(700,200))'::polygon)
          Order By: (p <-> '(123,456)'::point)
diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql
index 57dcc082450..e1f4e0b9685 100644
--- a/src/test/regress/sql/gist.sql
+++ b/src/test/regress/sql/gist.sql
@@ -169,6 +169,66 @@ explain (verbose, costs off)
 select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1;
 select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1;
 
+drop index gist_tbl_multi_index;
+
+-- Test that an ordering Index Scan returns the AM's ORDER BY values to a
+-- targetlist entry equal to the ORDER BY expression, instead of evaluating
+-- the expression again (amorderbyvalsexact).
+set enable_indexonlyscan = off;
+
+-- An expression index over a counting function proves the operator is not
+-- re-evaluated: the count stays at zero for the rows returned.
+create sequence gist_cnt_seq;
+create function gist_cnt_pt(point) returns point language plpgsql immutable
+  as $$ begin perform nextval('public.gist_cnt_seq'); return $1; end $$;
+create index gist_tbl_cnt_index on gist_tbl using gist (gist_cnt_pt(p));
+
+explain (verbose, costs off)
+select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist
+  from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3;
+
+select setval('gist_cnt_seq', 1, false);
+select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist
+  from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3;
+select nextval('gist_cnt_seq') - 1 as calls_during_scan;
+
+-- Every copy of the ORDER BY expression is served from the index; an
+-- expression that merely contains it is not.
+explain (verbose, costs off)
+select gist_cnt_pt(p) <-> point(0,0) as d1,
+       (gist_cnt_pt(p) <-> point(0,0)) * 2 as d2,
+       gist_cnt_pt(p) <-> point(0,0) as d3
+  from gist_tbl order by gist_cnt_pt(p) <-> point(0,0) limit 2;
+
+drop index gist_tbl_cnt_index;
+drop function gist_cnt_pt(point);
+drop sequence gist_cnt_seq;
+
+-- The box opclass's distance to a point is lossy, so this exercises the
+-- recheck and reorder-queue paths; the displayed values must match the
+-- operator's.
+create index gist_tbl_box_index on gist_tbl using gist (b);
+
+explain (verbose, costs off)
+select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+  where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91);
+
+select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+  where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91);
+
+select count(*) filter (where dist = b <-> point(5.2, 5.91)) as same,
+       count(*) as total
+  from (select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+          order by b <-> point(5.2, 5.91) limit 200) ss;
+
+-- Same, under row locking (the tuple is re-fetched by LockRows).
+select b, b <-> point(5.2, 5.91) as dist from gist_tbl
+  where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91)
+  limit 3 for update;
+
+drop index gist_tbl_box_index;
+reset enable_indexonlyscan;
+
 -- Test that an index-only scan deforms the tuple it reconstructs with the
 -- descriptor the AM formed it with, not the scan slot's descriptor.
 create temp table gist_ios_tupdesc (a inet, r numrange);
-- 
2.50.1

