From 04b4e8736101fe6ef3a8eb9337ca832fb1472c72 Mon Sep 17 00:00:00 2001
From: Maxime Schoemans <maxime.schoemans@enterprisedb.com>
Date: Thu, 2 Apr 2026 18:11:44 +0200
Subject: [PATCH v2 1/6] Add multi-entry support to GiST

Add infrastructure for GiST indexes to store multiple index entries per
heap tuple, similar to how GIN decomposes values via extractValue but
within GiST's R-tree framework.

A new optional support function, extractValue (support procedure 13), is
added.  When an opclass provides it, the function is called during insert
and index build to decompose each datum into multiple sub-entries, each
stored as a separate index tuple pointing to the same heap TID.

On the scan side, a simplehash-based TID deduplication hash table ensures
each heap tuple is returned only once despite having multiple index
entries.  Three scan modes are handled:

  - Bitmap scans: the TIDBitmap handles deduplication inherently.
  - Non-ordered scans: the hash table filters duplicates in pageData.
  - Ordered (KNN) scans: the hash table filters duplicates both when
    enqueuing leaf items and when dequeuing from the pairing heap,
    ensuring the first (nearest) distance wins.

Other changes:

  - gistcanreturn() disables index-only scans on key columns that use
    extractValue, since the original datum cannot be reconstructed from
    a single component.
  - Multi-entry is restricted to single-key-column indexes (INCLUDE
    columns are allowed).  Multi-column support is left for future work.
  - gistvalidate.c marks extractValue as optional and validates its
    signature (internal, internal, internal) -> internal.
---
 src/backend/access/gist/gist.c         |  61 +++++++++++-
 src/backend/access/gist/gistbuild.c    |  84 +++++++++++-----
 src/backend/access/gist/gistget.c      | 131 ++++++++++++++++++++++++-
 src/backend/access/gist/gistscan.c     |   4 +
 src/backend/access/gist/gistutil.c     |  84 ++++++++++++++++
 src/backend/access/gist/gistvalidate.c |  25 ++++-
 src/include/access/gist.h              |   3 +-
 src/include/access/gist_private.h      |  31 ++++++
 8 files changed, 392 insertions(+), 31 deletions(-)

diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c
index 8565e225be7..39275354286 100644
--- a/src/backend/access/gist/gist.c
+++ b/src/backend/access/gist/gist.c
@@ -161,6 +161,10 @@ gistbuildempty(Relation index)
  *
  *	  This is the public interface routine for tuple insertion in GiSTs.
  *	  It doesn't do any work; just locks the relation and passes the buck.
+ *
+ *	  If the opclass provides an extractValue function (multi-entry mode),
+ *	  a single heap tuple may produce multiple index entries.  Each entry
+ *	  is inserted separately, all pointing to the same heap TID.
  */
 bool
 gistinsert(Relation r, Datum *values, bool *isnull,
@@ -170,7 +174,6 @@ gistinsert(Relation r, Datum *values, bool *isnull,
 		   IndexInfo *indexInfo)
 {
 	GISTSTATE  *giststate = (GISTSTATE *) indexInfo->ii_AmCache;
-	IndexTuple	itup;
 	MemoryContext oldCxt;
 
 	/* Initialize GISTSTATE cache if first call in this statement */
@@ -185,10 +188,31 @@ gistinsert(Relation r, Datum *values, bool *isnull,
 
 	oldCxt = MemoryContextSwitchTo(giststate->tempCxt);
 
-	itup = gistFormTuple(giststate, r, values, isnull, true);
-	itup->t_tid = *ht_ctid;
+	/*
+	 * If the opclass provides an extractValue function, extract multiple
+	 * entries and insert each one separately.
+	 */
+	if (giststate->multiEntryColumn >= 0)
+	{
+		IndexTuple *itups;
+		int32		nitups;
+		int			i;
+
+		itups = gistExtractEntries(giststate, r, values, isnull, &nitups);
+		for (i = 0; i < nitups; i++)
+		{
+			itups[i]->t_tid = *ht_ctid;
+			gistdoinsert(r, itups[i], 0, giststate, heapRel, false);
+		}
+	}
+	else
+	{
+		IndexTuple	itup;
 
-	gistdoinsert(r, itup, 0, giststate, heapRel, false);
+		itup = gistFormTuple(giststate, r, values, isnull, true);
+		itup->t_tid = *ht_ctid;
+		gistdoinsert(r, itup, 0, giststate, heapRel, false);
+	}
 
 	/* cleanup */
 	MemoryContextSwitchTo(oldCxt);
@@ -1623,6 +1647,14 @@ initGISTstate(Relation index)
 		else
 			giststate->fetchFn[i].fn_oid = InvalidOid;
 
+		/* opclasses are not required to provide an ExtractValue method */
+		if (OidIsValid(index_getprocid(index, i + 1, GIST_EXTRACTVALUE_PROC)))
+			fmgr_info_copy(&(giststate->extractValueFn[i]),
+						   index_getprocinfo(index, i + 1, GIST_EXTRACTVALUE_PROC),
+						   scanCxt);
+		else
+			giststate->extractValueFn[i].fn_oid = InvalidOid;
+
 		/*
 		 * If the index column has a specified collation, we should honor that
 		 * while doing comparisons.  However, we may have a collatable storage
@@ -1640,6 +1672,26 @@ initGISTstate(Relation index)
 			giststate->supportCollation[i] = DEFAULT_COLLATION_OID;
 	}
 
+	/*
+	 * Record which key column is multi-entry (has an extractValue function),
+	 * if any.  Other key columns are allowed alongside it: their values are
+	 * duplicated across the extracted entries, just as INCLUDE columns are.
+	 * At most one key column may be multi-entry, though; decomposing two
+	 * columns at once would require a cross product, which we don't support.
+	 */
+	giststate->multiEntryColumn = -1;
+	for (i = 0; i < IndexRelationGetNumberOfKeyAttributes(index); i++)
+	{
+		if (OidIsValid(giststate->extractValueFn[i].fn_oid))
+		{
+			if (giststate->multiEntryColumn >= 0)
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("multi-entry GiST indexes support at most one multi-entry key column")));
+			giststate->multiEntryColumn = i;
+		}
+	}
+
 	/* No opclass information for INCLUDE attributes */
 	for (; i < index->rd_att->natts; i++)
 	{
@@ -1652,6 +1704,7 @@ initGISTstate(Relation index)
 		giststate->equalFn[i].fn_oid = InvalidOid;
 		giststate->distanceFn[i].fn_oid = InvalidOid;
 		giststate->fetchFn[i].fn_oid = InvalidOid;
+		giststate->extractValueFn[i].fn_oid = InvalidOid;
 		giststate->supportCollation[i] = InvalidOid;
 	}
 
diff --git a/src/backend/access/gist/gistbuild.c b/src/backend/access/gist/gistbuild.c
index 7f57c787f4c..ceb8d13ec91 100644
--- a/src/backend/access/gist/gistbuild.c
+++ b/src/backend/access/gist/gistbuild.c
@@ -242,6 +242,18 @@ gistbuild(Relation heap, Relation index, IndexInfo *indexInfo)
 				hasallsortsupports = false;
 				break;
 			}
+
+			/*
+			 * The sorted build path forms a single index tuple per heap tuple
+			 * straight from the tuplesort and never calls extractValue, so it
+			 * cannot be used for a multi-entry key column.
+			 */
+			if (OidIsValid(index_getprocid(index, i + 1,
+										   GIST_EXTRACTVALUE_PROC)))
+			{
+				hasallsortsupports = false;
+				break;
+			}
 		}
 		if (hasallsortsupports)
 			buildstate.buildMode = GIST_SORTED_BUILD;
@@ -827,21 +839,8 @@ gistBuildCallback(Relation index,
 				  void *state)
 {
 	GISTBuildState *buildstate = (GISTBuildState *) state;
-	IndexTuple	itup;
 	MemoryContext oldCtx;
 
-	oldCtx = MemoryContextSwitchTo(buildstate->giststate->tempCxt);
-
-	/* form an index tuple and point it at the heap tuple */
-	itup = gistFormTuple(buildstate->giststate, index,
-						 values, isnull,
-						 true);
-	itup->t_tid = *tid;
-
-	/* Update tuple count and total size. */
-	buildstate->indtuples += 1;
-	buildstate->indtuplesSize += IndexTupleSize(itup);
-
 	/*
 	 * XXX In buffering builds, the tempCxt is also reset down inside
 	 * gistProcessEmptyingQueue().  This is not great because it risks
@@ -850,20 +849,61 @@ gistBuildCallback(Relation index,
 	 * better that a memory context be "owned" by only one function.  However,
 	 * currently this isn't causing issues so it doesn't seem worth the amount
 	 * of refactoring that would be needed to avoid it.
+	 *
+	 * If the opclass provides an extractValue function, extract multiple
+	 * entries and insert each one.  Otherwise, form a single index tuple.
+	 *
+	 * We extract entries in the caller's memory context so that the itups
+	 * array survives MemoryContextReset(tempCxt) inside
+	 * gistProcessEmptyingQueue during buffering builds.
 	 */
-	if (buildstate->buildMode == GIST_BUFFERING_ACTIVE)
+	if (buildstate->giststate->multiEntryColumn >= 0)
 	{
-		/* We have buffers, so use them. */
-		gistBufferingBuildInsert(buildstate, itup);
+		IndexTuple *itups;
+		int32		nitups;
+		int			i;
+
+		itups = gistExtractEntries(buildstate->giststate, index,
+								  values, isnull, &nitups);
+
+		oldCtx = MemoryContextSwitchTo(buildstate->giststate->tempCxt);
+
+		for (i = 0; i < nitups; i++)
+		{
+			itups[i]->t_tid = *tid;
+
+			/* Update tuple count and total size */
+			buildstate->indtuples += 1;
+			buildstate->indtuplesSize += IndexTupleSize(itups[i]);
+
+			if (buildstate->buildMode == GIST_BUFFERING_ACTIVE)
+				gistBufferingBuildInsert(buildstate, itups[i]);
+			else
+				gistdoinsert(index, itups[i], buildstate->freespace,
+							 buildstate->giststate, buildstate->heaprel, true);
+		}
 	}
 	else
 	{
-		/*
-		 * There's no buffers (yet). Since we already have the index relation
-		 * locked, we call gistdoinsert directly.
-		 */
-		gistdoinsert(index, itup, buildstate->freespace,
-					 buildstate->giststate, buildstate->heaprel, true);
+		IndexTuple	itup;
+
+		oldCtx = MemoryContextSwitchTo(buildstate->giststate->tempCxt);
+
+		/* form an index tuple and point it at the heap tuple */
+		itup = gistFormTuple(buildstate->giststate, index,
+							 values, isnull,
+							 true);
+		itup->t_tid = *tid;
+
+		/* Update tuple count and total size. */
+		buildstate->indtuples += 1;
+		buildstate->indtuplesSize += IndexTupleSize(itup);
+
+		if (buildstate->buildMode == GIST_BUFFERING_ACTIVE)
+			gistBufferingBuildInsert(buildstate, itup);
+		else
+			gistdoinsert(index, itup, buildstate->freespace,
+						 buildstate->giststate, buildstate->heaprel, true);
 	}
 
 	MemoryContextSwitchTo(oldCtx);
diff --git a/src/backend/access/gist/gistget.c b/src/backend/access/gist/gistget.c
index 4d7c100d737..3e2f1da36d6 100644
--- a/src/backend/access/gist/gistget.c
+++ b/src/backend/access/gist/gistget.c
@@ -17,6 +17,7 @@
 #include "access/genam.h"
 #include "access/gist_private.h"
 #include "access/relscan.h"
+#include "common/hashfn.h"
 #include "executor/instrument_node.h"
 #include "lib/pairingheap.h"
 #include "miscadmin.h"
@@ -26,6 +27,49 @@
 #include "utils/memutils.h"
 #include "utils/rel.h"
 
+/*
+ * Simplehash implementation for TID deduplication in multi-entry scans.
+ *
+ * When an opclass provides an extractValue function, each heap tuple produces
+ * multiple index entries.  During scans, we must deduplicate results so that
+ * each heap TID is returned only once.
+ */
+
+/* Hash table entry for basic TID dedup */
+typedef struct GISTTIDHashEntry
+{
+	ItemPointerData tid;		/* TID (hashtable key) */
+	uint32		hash;			/* hash value (cached) */
+	char		status;			/* hash status */
+} GISTTIDHashEntry;
+
+static inline uint32
+gist_tid_hash_fn(ItemPointerData tid)
+{
+	uint32		h = murmurhash32(ItemPointerGetBlockNumber(&tid));
+
+	return murmurhash32(h + ItemPointerGetOffsetNumber(&tid));
+}
+
+static inline bool
+gist_tid_match_fn(ItemPointerData a, ItemPointerData b)
+{
+	return ItemPointerEquals(&a, &b);
+}
+
+/* --- gisttid hash table (declare + define) --- */
+#define SH_PREFIX gisttid
+#define SH_ELEMENT_TYPE GISTTIDHashEntry
+#define SH_KEY_TYPE ItemPointerData
+#define SH_KEY tid
+#define SH_HASH_KEY(tb, key) gist_tid_hash_fn(key)
+#define SH_EQUAL(tb, a, b) gist_tid_match_fn(a, b)
+#define SH_SCOPE static inline
+#define SH_DECLARE
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+
 /*
  * gistkillitems() -- set LP_DEAD state for items an indexscan caller has
  * told us were killed.
@@ -456,7 +500,8 @@ gistScanPage(IndexScanDesc scan, GISTSearchItem *pageItem,
 		{
 			/*
 			 * getbitmap scan, so just push heap tuple TIDs into the bitmap
-			 * without worrying about ordering
+			 * without worrying about ordering.  The bitmap itself handles
+			 * deduplication, so no extra work needed for multi-entry.
 			 */
 			tbm_add_tuples(tbm, &it->t_tid, 1, recheck);
 			(*ntids)++;
@@ -464,8 +509,20 @@ gistScanPage(IndexScanDesc scan, GISTSearchItem *pageItem,
 		else if (scan->numberOfOrderBys == 0 && GistPageIsLeaf(page))
 		{
 			/*
-			 * Non-ordered scan, so report tuples in so->pageData[]
+			 * Non-ordered scan, so report tuples in so->pageData[].
+			 *
+			 * For multi-entry indexes, check the TID hash table to avoid
+			 * returning duplicate heap TIDs.
 			 */
+			if (so->tidHash && (it->t_info & GIST_MULTIENTRY_MASK))
+			{
+				bool		found;
+
+				gisttid_insert(so->tidHash, it->t_tid, &found);
+				if (found)
+					continue;	/* already seen this TID */
+			}
+
 			so->pageData[so->nPageData].heapPtr = it->t_tid;
 			so->pageData[so->nPageData].recheck = recheck;
 			so->pageData[so->nPageData].offnum = i;
@@ -495,6 +552,21 @@ gistScanPage(IndexScanDesc scan, GISTSearchItem *pageItem,
 
 			oldcxt = MemoryContextSwitchTo(so->queueCxt);
 
+			/*
+			 * For multi-entry ordered scans, skip heap tuples whose TIDs
+			 * were already returned by getNextNearest.  We use lookup
+			 * (not insert) here: a TID must remain enqueueable until it
+			 * is actually dequeued, so that the pairing heap can pick the
+			 * copy with the smallest distance.
+			 */
+			if (GistPageIsLeaf(page) && so->tidHash &&
+				(it->t_info & GIST_MULTIENTRY_MASK) &&
+				gisttid_lookup(so->tidHash, it->t_tid))
+			{
+				MemoryContextSwitchTo(oldcxt);
+				continue;
+			}
+
 			/* Create new GISTSearchItem for this item */
 			item = palloc(SizeOfGISTSearchItem(scan->numberOfOrderBys));
 
@@ -505,6 +577,8 @@ gistScanPage(IndexScanDesc scan, GISTSearchItem *pageItem,
 				item->data.heap.heapPtr = it->t_tid;
 				item->data.heap.recheck = recheck;
 				item->data.heap.recheckDistances = recheck_distances;
+				item->data.heap.multiEntry =
+					(it->t_info & GIST_MULTIENTRY_MASK) != 0;
 
 				/*
 				 * In an index-only scan, also fetch the data from the tuple.
@@ -587,7 +661,27 @@ getNextNearest(IndexScanDesc scan)
 
 		if (GISTSearchItemIsHeap(*item))
 		{
-			/* found a heap item at currently minimal distance */
+			/*
+			 * Found a heap item at currently minimal distance.
+			 *
+			 * For multi-entry ordered scans, deduplicate using tidHash to
+			 * ensure each TID is returned only once.  Duplicate entries
+			 * for the same TID may exist in the queue with different
+			 * distances; the pairing heap ensures we see the smallest
+			 * distance first, and tidHash skips subsequent duplicates.
+			 */
+			if (so->tidHash && item->data.heap.multiEntry)
+			{
+				bool		found;
+
+				gisttid_insert(so->tidHash, item->data.heap.heapPtr, &found);
+				if (found)
+				{
+					pfree(item);
+					continue;	/* already returned this TID */
+				}
+			}
+
 			scan->xs_heaptid = item->data.heap.heapPtr;
 			scan->xs_recheck = item->data.heap.recheck;
 
@@ -643,6 +737,29 @@ gistgettuple(IndexScanDesc scan, ScanDirection dir)
 		if (so->pageDataCxt)
 			MemoryContextReset(so->pageDataCxt);
 
+		/*
+		 * For multi-entry indexes, set up TID deduplication hash tables.
+		 */
+		if (so->giststate->multiEntryColumn >= 0)
+		{
+			MemoryContext oldHashCxt;
+
+			/*
+			 * Create a dedicated context for the hash tables so they can
+			 * be reset independently.
+			 */
+			if (so->tidHashCxt == so->giststate->scanCxt)
+				so->tidHashCxt = AllocSetContextCreate(so->giststate->scanCxt,
+													   "GiST TID hash context",
+													   ALLOCSET_DEFAULT_SIZES);
+			else
+				MemoryContextReset(so->tidHashCxt);
+
+			oldHashCxt = MemoryContextSwitchTo(so->tidHashCxt);
+			so->tidHash = gisttid_create(so->tidHashCxt, 256, NULL);
+			MemoryContextSwitchTo(oldHashCxt);
+		}
+
 		fakeItem.blkno = GIST_ROOT_BLKNO;
 		memset(&fakeItem.data.parentlsn, 0, sizeof(GistNSN));
 		gistScanPage(scan, &fakeItem, NULL, NULL, NULL);
@@ -805,6 +922,14 @@ gistgetbitmap(IndexScanDesc scan, TIDBitmap *tbm)
 bool
 gistcanreturn(Relation index, int attno)
 {
+	/*
+	 * Multi-entry indexes store decomposed sub-entries in key columns, not the
+	 * original datum, so key columns cannot be returned in an index-only scan.
+	 */
+	if (attno <= IndexRelationGetNumberOfKeyAttributes(index) &&
+		OidIsValid(index_getprocid(index, attno, GIST_EXTRACTVALUE_PROC)))
+		return false;
+
 	if (attno > IndexRelationGetNumberOfKeyAttributes(index) ||
 		OidIsValid(index_getprocid(index, attno, GIST_FETCH_PROC)) ||
 		!OidIsValid(index_getprocid(index, attno, GIST_COMPRESS_PROC)))
diff --git a/src/backend/access/gist/gistscan.c b/src/backend/access/gist/gistscan.c
index c65f93abdae..4d9a4a148cd 100644
--- a/src/backend/access/gist/gistscan.c
+++ b/src/backend/access/gist/gistscan.c
@@ -96,6 +96,10 @@ gistbeginscan(Relation r, int nkeys, int norderbys)
 	so->queue = NULL;
 	so->queueCxt = giststate->scanCxt;	/* see gistrescan */
 
+	/* Initialize multi-entry TID dedup fields (NULL if not multi-entry) */
+	so->tidHash = NULL;
+	so->tidHashCxt = giststate->scanCxt;
+
 	/* workspaces with size dependent on numberOfOrderBys: */
 	so->distances = palloc(sizeof(so->distances[0]) * scan->numberOfOrderBys);
 	so->qual_ok = true;			/* in case there are zero keys */
diff --git a/src/backend/access/gist/gistutil.c b/src/backend/access/gist/gistutil.c
index 0f58f61879f..0052d0c5e93 100644
--- a/src/backend/access/gist/gistutil.c
+++ b/src/backend/access/gist/gistutil.c
@@ -1007,6 +1007,90 @@ gistproperty(Oid index_oid, int attno,
 	return true;
 }
 
+/*
+ * gistExtractEntries -- extract multiple index entries from one heap tuple.
+ *
+ * Calls the opclass's extractValue function to decompose the indexed datum
+ * into multiple sub-entries.  Returns an array of IndexTuples, one per
+ * sub-entry.
+ *
+ * The multi-entry key column is giststate->multiEntryColumn; extractValue is
+ * applied to it, and the resulting sub-entries each become one index tuple.
+ * Any other key columns, as well as INCLUDE columns, keep their original
+ * value on every entry (they are duplicated, just like INCLUDE columns).
+ * If the multi-entry datum is NULL or extractValue returns no entries, a
+ * single index entry with that column NULL is produced.
+ */
+IndexTuple *
+gistExtractEntries(GISTSTATE *giststate, Relation index,
+				   Datum *values, bool *isnull, int32 *nentries)
+{
+	int			mecol = giststate->multiEntryColumn;
+	Datum	   *entries;
+	bool	   *nullFlags;
+	IndexTuple *result;
+	int			i;
+
+	Assert(mecol >= 0);
+
+	/* A NULL in the multi-entry column produces a single NULL entry there */
+	if (isnull[mecol])
+	{
+		*nentries = 1;
+		result = palloc(sizeof(IndexTuple));
+		result[0] = gistFormTuple(giststate, index, values, isnull, true);
+		return result;
+	}
+
+	/* Call the opclass's extractValue function on the multi-entry column */
+	nullFlags = NULL;
+	entries = (Datum *)
+		DatumGetPointer(FunctionCall3Coll(&giststate->extractValueFn[mecol],
+										  giststate->supportCollation[mecol],
+										  values[mecol],
+										  PointerGetDatum(nentries),
+										  PointerGetDatum(&nullFlags)));
+
+	/* Handle empty or NULL result: produce a single NULL entry */
+	if (entries == NULL || *nentries <= 0)
+	{
+		*nentries = 1;
+		values[mecol] = (Datum) 0;
+		isnull[mecol] = true;
+		result = palloc(sizeof(IndexTuple));
+		result[0] = gistFormTuple(giststate, index, values, isnull, true);
+		return result;
+	}
+
+	/* Create nullFlags array if the function didn't */
+	if (nullFlags == NULL)
+		nullFlags = palloc0_array(bool, *nentries);
+
+	/*
+	 * Form one index tuple per extracted entry.  We overwrite only the
+	 * multi-entry column in values[]/isnull[]; the other columns keep their
+	 * original value, so each tuple carries a copy of them.
+	 */
+	result = palloc_array(IndexTuple, *nentries);
+	for (i = 0; i < *nentries; i++)
+	{
+		values[mecol] = entries[i];
+		isnull[mecol] = nullFlags[i];
+		result[i] = gistFormTuple(giststate, index, values, isnull, true);
+
+		/*
+		 * Mark the tuple as multi-entry only when this value produced more
+		 * than one entry, so that its heap TID appears several times in the
+		 * index.  Single-entry values keep a unique TID and let scans skip
+		 * the dedup hash for them.
+		 */
+		if (*nentries > 1)
+			result[i]->t_info |= GIST_MULTIENTRY_MASK;
+	}
+
+	return result;
+}
+
 /*
  * This is a stratnum translation support function for GiST opclasses that use
  * the RT*StrategyNumber constants.
diff --git a/src/backend/access/gist/gistvalidate.c b/src/backend/access/gist/gistvalidate.c
index 56feb8d8400..390bb2b33e2 100644
--- a/src/backend/access/gist/gistvalidate.c
+++ b/src/backend/access/gist/gistvalidate.c
@@ -144,6 +144,11 @@ gistvalidate(Oid opclassoid)
 					procform->amproclefttype == ANYOID &&
 					procform->amprocrighttype == ANYOID;
 				break;
+			case GIST_EXTRACTVALUE_PROC:
+				ok = check_amproc_signature(procform->amproc, INTERNALOID, true,
+											3, 3, INTERNALOID, INTERNALOID,
+											INTERNALOID);
+				break;
 			default:
 				ereport(INFO,
 						(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
@@ -265,7 +270,8 @@ gistvalidate(Oid opclassoid)
 		if (i == GIST_DISTANCE_PROC || i == GIST_FETCH_PROC ||
 			i == GIST_COMPRESS_PROC || i == GIST_DECOMPRESS_PROC ||
 			i == GIST_OPTIONS_PROC || i == GIST_SORTSUPPORT_PROC ||
-			i == GIST_TRANSLATE_CMPTYPE_PROC)
+			i == GIST_TRANSLATE_CMPTYPE_PROC ||
+			i == GIST_EXTRACTVALUE_PROC)
 			continue;			/* optional methods */
 		ereport(INFO,
 				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
@@ -274,6 +280,22 @@ gistvalidate(Oid opclassoid)
 		result = false;
 	}
 
+	/*
+	 * extractValue decomposes a value into several leaf entries, none of which
+	 * is the original datum, so the original cannot be reconstructed by a fetch
+	 * function.  An opclass providing both is contradictory.
+	 */
+	if (opclassgroup &&
+		(opclassgroup->functionset & (((uint64) 1) << GIST_EXTRACTVALUE_PROC)) &&
+		(opclassgroup->functionset & (((uint64) 1) << GIST_FETCH_PROC)))
+	{
+		ereport(INFO,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("operator class \"%s\" of access method %s cannot have both extractValue and fetch support functions",
+						opclassname, "gist")));
+		result = false;
+	}
+
 	ReleaseCatCacheList(proclist);
 	ReleaseCatCacheList(oprlist);
 	ReleaseSysCache(classtup);
@@ -337,6 +359,7 @@ gistadjustmembers(Oid opfamilyoid,
 			case GIST_OPTIONS_PROC:
 			case GIST_SORTSUPPORT_PROC:
 			case GIST_TRANSLATE_CMPTYPE_PROC:
+			case GIST_EXTRACTVALUE_PROC:
 				/* Optional, so force it to be a soft family dependency */
 				op->ref_is_hard = false;
 				op->ref_is_family = true;
diff --git a/src/include/access/gist.h b/src/include/access/gist.h
index 9b385b13a88..68f8eca550e 100644
--- a/src/include/access/gist.h
+++ b/src/include/access/gist.h
@@ -41,7 +41,8 @@
 #define GIST_OPTIONS_PROC				10
 #define GIST_SORTSUPPORT_PROC			11
 #define GIST_TRANSLATE_CMPTYPE_PROC		12
-#define GISTNProcs					12
+#define GIST_EXTRACTVALUE_PROC			13
+#define GISTNProcs					13
 
 /*
  * Page opaque data in a GiST index page.
diff --git a/src/include/access/gist_private.h b/src/include/access/gist_private.h
index 44514f1cb8d..4fc7945f106 100644
--- a/src/include/access/gist_private.h
+++ b/src/include/access/gist_private.h
@@ -38,6 +38,16 @@
  */
 #define GIST_MAX_SPLIT_PAGES		75
 
+/*
+ * A multi-entry opclass (one with an extractValue support function) can map a
+ * single heap tuple to several index tuples that share one heap TID.  We set
+ * this bit in an index tuple's t_info when, and only when, extractValue
+ * produced more than one entry, so scans know that TID may appear more than
+ * once and must be deduplicated.  Index tuples without the bit have a unique
+ * TID and skip the dedup hash entirely.
+ */
+#define GIST_MULTIENTRY_MASK		INDEX_AM_RESERVED_BIT
+
 /* Buffer lock modes */
 #define GIST_SHARE	BUFFER_LOCK_SHARE
 #define GIST_EXCLUSIVE	BUFFER_LOCK_EXCLUSIVE
@@ -92,6 +102,13 @@ typedef struct GISTSTATE
 	FmgrInfo	equalFn[INDEX_MAX_KEYS];
 	FmgrInfo	distanceFn[INDEX_MAX_KEYS];
 	FmgrInfo	fetchFn[INDEX_MAX_KEYS];
+	FmgrInfo	extractValueFn[INDEX_MAX_KEYS];
+
+	/*
+	 * Index of the key column that has an extractValue function, or -1 if the
+	 * index is not multi-entry.  At most one key column may be multi-entry.
+	 */
+	int			multiEntryColumn;
 
 	/* Collations to pass to the support functions */
 	Oid			supportCollation[INDEX_MAX_KEYS];
@@ -120,6 +137,7 @@ typedef struct GISTSearchHeapItem
 	ItemPointerData heapPtr;
 	bool		recheck;		/* T if quals must be rechecked */
 	bool		recheckDistances;	/* T if distances must be rechecked */
+	bool		multiEntry;		/* T if from a multi-entry tuple (needs dedup) */
 	HeapTuple	recontup;		/* data reconstructed from the index, used in
 								 * index-only scans */
 	OffsetNumber offnum;		/* track offset in page to mark tuple as
@@ -156,6 +174,14 @@ typedef struct GISTScanOpaqueData
 	GISTSTATE  *giststate;		/* index information, see above */
 	Oid		   *orderByTypes;	/* datatypes of ORDER BY expressions */
 
+	/*
+	 * For multi-entry indexes: hash table for TID deduplication.  Each heap
+	 * tuple produces multiple index entries, so we track which TIDs have been
+	 * returned.  NULL for standard (non-multi-entry) indexes.
+	 */
+	struct gisttid_hash *tidHash;
+	MemoryContext tidHashCxt;	/* context holding the hash table */
+
 	pairingheap *queue;			/* queue of unvisited items */
 	MemoryContext queueCxt;		/* context holding the queue */
 	bool		qual_ok;		/* false if qual can never be satisfied */
@@ -547,6 +573,11 @@ extern void gistSplitByKey(Relation r, Page page, IndexTuple *itup,
 extern IndexBuildResult *gistbuild(Relation heap, Relation index,
 								   struct IndexInfo *indexInfo);
 
+/* gistutil.c */
+extern IndexTuple *gistExtractEntries(GISTSTATE *giststate, Relation index,
+									  Datum *values, bool *isnull,
+									  int32 *nentries);
+
 /* gistbuildbuffers.c */
 extern GISTBuildBuffers *gistInitBuildBuffers(int pagesPerBuffer, int levelStep,
 											  int maxLevel);
-- 
2.50.1 (Apple Git-155)

