From aac253927a8b65d1d08e27a1a2bce4c9320dc57e Mon Sep 17 00:00:00 2001
From: Gleb Kashkin <g.kashkin@postgrespro.ru>
Date: Tue, 22 Sep 2026 17:29:43 +0300
Subject: [PATCH 1/2] Allow FDWs to reparameterize foreign scan paths

When building a parameterized Append over a partitioned table, every
child must have a path with exactly the requested parameterization.
For local scans, get_cheapest_parameterized_child_path() can obtain one
by calling reparameterize_path() on an existing path, but foreign paths
were not handled there, because core has no way to cost a foreign scan.
So if add_path() had discarded a foreign partition's parameterized path
(e.g. because the partition is empty and the path was dominated by the
unparameterized one), no parameterized Append could be built at all and
the planner was forced into a hash or merge join even when a
parameterized nestloop would have been far cheaper.

Add an optional ReparameterizeForeignPath callback to FdwRoutine, which
reparameterize_path() invokes for foreign paths of simple relations, and
implement it in postgres_fdw: the callback looks up the ParamPathInfo
for the requested outer rels, costs its clauses the same way
postgresGetForeignPaths() does, and returns a new unsorted parameterized
ForeignPath.
---
 .../postgres_fdw/expected/postgres_fdw.out    | 103 ++++++++++++++++++
 contrib/postgres_fdw/postgres_fdw.c           |  72 ++++++++++++
 contrib/postgres_fdw/sql/postgres_fdw.sql     |  49 +++++++++
 doc/src/sgml/fdwhandler.sgml                  |  35 +++++-
 src/backend/optimizer/util/pathnode.c         |  11 ++
 src/include/foreign/fdwapi.h                  |   4 +
 6 files changed, 273 insertions(+), 1 deletion(-)

diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index e90289e4ab1..e6b17800fe5 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -4581,6 +4581,109 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 -- ===================================================================
+-- parameterized Append paths over foreign partitions
+-- ===================================================================
+-- With use_remote_estimate, each foreign partition gets a path parameterized
+-- by the join clause.  If add_path() discards that path for some partition
+-- (here: because the partition is empty, so the parameterized path is
+-- dominated by the unparameterized one), the planner must still be able to
+-- build a parameterized Append by reparameterizing the surviving path.
+CREATE TABLE pa_loc1 (a int);
+CREATE TABLE pa_loc2 (a int);
+CREATE INDEX ON pa_loc1 (a);
+CREATE INDEX ON pa_loc2 (a);
+CREATE TABLE pa_parent (a int) PARTITION BY HASH (a);
+CREATE FOREIGN TABLE pa_f1 PARTITION OF pa_parent
+  FOR VALUES WITH (MODULUS 2, REMAINDER 0)
+  SERVER loopback OPTIONS (table_name 'pa_loc1', use_remote_estimate 'true');
+CREATE FOREIGN TABLE pa_f2 PARTITION OF pa_parent
+  FOR VALUES WITH (MODULUS 2, REMAINDER 1)
+  SERVER loopback OPTIONS (table_name 'pa_loc2', use_remote_estimate 'true');
+INSERT INTO pa_loc1 SELECT g FROM generate_series(1, 100000) g
+  WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 0, g);
+INSERT INTO pa_loc2 SELECT g FROM generate_series(1, 100000) g
+  WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 1, g);
+CREATE TABLE pa_outer (a int);
+INSERT INTO pa_outer VALUES (1);
+ANALYZE pa_loc1;
+ANALYZE pa_loc2;
+ANALYZE pa_f1;
+ANALYZE pa_f2;
+ANALYZE pa_outer;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Nested Loop
+   Output: o.a, p.a
+   ->  Seq Scan on public.pa_outer o
+         Output: o.a
+   ->  Append
+         ->  Foreign Scan on public.pa_f1 p_1
+               Output: p_1.a
+               Remote SQL: SELECT a FROM public.pa_loc1 WHERE ((a = $1::integer))
+         ->  Foreign Scan on public.pa_f2 p_2
+               Output: p_2.a
+               Remote SQL: SELECT a FROM public.pa_loc2 WHERE ((a = $1::integer))
+(11 rows)
+
+-- Empty one partition; its parameterized path is now dominated and dropped.
+DELETE FROM pa_f2;
+ANALYZE pa_loc2;
+ANALYZE pa_f2;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Nested Loop
+   Output: o.a, p.a
+   ->  Seq Scan on public.pa_outer o
+         Output: o.a
+   ->  Append
+         ->  Foreign Scan on public.pa_f1 p_1
+               Output: p_1.a
+               Remote SQL: SELECT a FROM public.pa_loc1 WHERE ((a = $1::integer))
+         ->  Foreign Scan on public.pa_f2 p_2
+               Output: p_2.a
+               Remote SQL: SELECT a FROM public.pa_loc2 WHERE ((a = $1::integer))
+(11 rows)
+
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+ a | a 
+---+---
+ 1 | 1
+(1 row)
+
+-- Once the remote table is known to be empty, the sorted and unsorted plain
+-- scans are fuzzily equal in cost, so only the sorted one survives; we must
+-- be able to reparameterize that one, too.
+VACUUM ANALYZE pa_loc2;
+ANALYZE pa_f2;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+                                    QUERY PLAN                                    
+----------------------------------------------------------------------------------
+ Nested Loop
+   Output: o.a, p.a
+   ->  Seq Scan on public.pa_outer o
+         Output: o.a
+   ->  Append
+         ->  Foreign Scan on public.pa_f1 p_1
+               Output: p_1.a
+               Remote SQL: SELECT a FROM public.pa_loc1 WHERE ((a = $1::integer))
+         ->  Foreign Scan on public.pa_f2 p_2
+               Output: p_2.a
+               Remote SQL: SELECT a FROM public.pa_loc2 WHERE ((a = $1::integer))
+(11 rows)
+
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+ a | a 
+---+---
+ 1 | 1
+(1 row)
+
+DROP TABLE pa_parent, pa_outer, pa_loc1, pa_loc2;
+-- ===================================================================
 -- conversion error
 -- ===================================================================
 ALTER FOREIGN TABLE ft1 ALTER COLUMN c8 TYPE int;
diff --git a/contrib/postgres_fdw/postgres_fdw.c b/contrib/postgres_fdw/postgres_fdw.c
index 0a589f8db74..929dd129480 100644
--- a/contrib/postgres_fdw/postgres_fdw.c
+++ b/contrib/postgres_fdw/postgres_fdw.c
@@ -593,6 +593,9 @@ static void postgresGetForeignJoinPaths(PlannerInfo *root,
 										RelOptInfo *innerrel,
 										JoinType jointype,
 										JoinPathExtraData *extra);
+static Path *postgresReparameterizeForeignPath(PlannerInfo *root,
+											   ForeignPath *path,
+											   Relids required_outer);
 static bool postgresRecheckForeignScan(ForeignScanState *node,
 									   TupleTableSlot *slot);
 static void postgresGetForeignUpperPaths(PlannerInfo *root,
@@ -821,6 +824,9 @@ postgres_fdw_handler(PG_FUNCTION_ARGS)
 	/* Support functions for upper relation push-down */
 	routine->GetForeignUpperPaths = postgresGetForeignUpperPaths;
 
+	/* Support functions for path reparameterization */
+	routine->ReparameterizeForeignPath = postgresReparameterizeForeignPath;
+
 	/* Support functions for asynchronous execution */
 	routine->IsForeignPathAsyncCapable = postgresIsForeignPathAsyncCapable;
 	routine->ForeignAsyncRequest = postgresForeignAsyncRequest;
@@ -1438,6 +1444,72 @@ postgresGetForeignPaths(PlannerInfo *root,
 	}
 }
 
+/*
+ * postgresReparameterizeForeignPath
+ *		Build a version of a base-relation foreign scan path that is
+ *		parameterized by required_outer, i.e. that additionally enforces the
+ *		join clauses available from those relations.
+ */
+static Path *
+postgresReparameterizeForeignPath(PlannerInfo *root, ForeignPath *path,
+								  Relids required_outer)
+{
+	RelOptInfo *baserel = path->path.parent;
+	PgFdwRelationInfo *fpinfo = (PgFdwRelationInfo *) baserel->fdw_private;
+	ParamPathInfo *param_info;
+	double		rows;
+	int			width;
+	int			disabled_nodes;
+	Cost		startup_cost;
+	Cost		total_cost;
+
+	Assert(IS_SIMPLE_REL(baserel));
+
+	/*
+	 * No way to get a good estimate on pushed down clauses, don't build
+	 * parameterized paths.
+	 */
+	if (!fpinfo->use_remote_estimate)
+		return NULL;
+
+	/*
+	 * We don't know how to carry an EPQ subplan along, but base-relation
+	 * paths never have one anyway.
+	 */
+	if (path->fdw_outerpath != NULL)
+		return NULL;
+
+	/*
+	 * Note that we ignore the given path's pathkeys and always produce an
+	 * unsorted path. A parameterized path is only ever used on the inside
+	 * of a NestLoop, where its ordering is of no interest.
+	 */
+
+	param_info = get_baserel_parampathinfo(root, baserel, required_outer);
+	if (param_info == NULL)
+		return NULL;			/* shouldn't happen */
+
+	/* Get a cost estimate from the remote */
+	estimate_path_cost_size(root, baserel,
+							param_info->ppi_clauses, NIL, NULL,
+							&rows, &width, &disabled_nodes,
+							&startup_cost, &total_cost);
+
+	param_info->ppi_rows = rows;
+
+	return (Path *) create_foreignscan_path(root, baserel,
+											NULL,	/* default pathtarget */
+											rows,
+											disabled_nodes,
+											startup_cost,
+											total_cost,
+											NIL,	/* no pathkeys */
+											param_info->ppi_req_outer,
+											NULL,
+											NIL,	/* no fdw_restrictinfo list */
+											NIL);	/* no fdw_private list */
+}
+
 /*
  * postgresGetForeignPlan
  *		Create ForeignScan plan node which implements selected best path
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index dfc58beb0d2..2ccdb30e2a2 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -1315,6 +1315,55 @@ REINDEX TABLE reind_fdw_parent; -- ok
 REINDEX TABLE CONCURRENTLY reind_fdw_parent; -- ok
 DROP TABLE reind_fdw_parent;
 
+-- ===================================================================
+-- parameterized Append paths over foreign partitions
+-- ===================================================================
+-- With use_remote_estimate, each foreign partition gets a path parameterized
+-- by the join clause.  If add_path() discards that path for some partition
+-- (here: because the partition is empty, so the parameterized path is
+-- dominated by the unparameterized one), the planner must still be able to
+-- build a parameterized Append by reparameterizing the surviving path.
+CREATE TABLE pa_loc1 (a int);
+CREATE TABLE pa_loc2 (a int);
+CREATE INDEX ON pa_loc1 (a);
+CREATE INDEX ON pa_loc2 (a);
+CREATE TABLE pa_parent (a int) PARTITION BY HASH (a);
+CREATE FOREIGN TABLE pa_f1 PARTITION OF pa_parent
+  FOR VALUES WITH (MODULUS 2, REMAINDER 0)
+  SERVER loopback OPTIONS (table_name 'pa_loc1', use_remote_estimate 'true');
+CREATE FOREIGN TABLE pa_f2 PARTITION OF pa_parent
+  FOR VALUES WITH (MODULUS 2, REMAINDER 1)
+  SERVER loopback OPTIONS (table_name 'pa_loc2', use_remote_estimate 'true');
+INSERT INTO pa_loc1 SELECT g FROM generate_series(1, 100000) g
+  WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 0, g);
+INSERT INTO pa_loc2 SELECT g FROM generate_series(1, 100000) g
+  WHERE satisfies_hash_partition('pa_parent'::regclass, 2, 1, g);
+CREATE TABLE pa_outer (a int);
+INSERT INTO pa_outer VALUES (1);
+ANALYZE pa_loc1;
+ANALYZE pa_loc2;
+ANALYZE pa_f1;
+ANALYZE pa_f2;
+ANALYZE pa_outer;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+-- Empty one partition; its parameterized path is now dominated and dropped.
+DELETE FROM pa_f2;
+ANALYZE pa_loc2;
+ANALYZE pa_f2;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+-- Once the remote table is known to be empty, the sorted and unsorted plain
+-- scans are fuzzily equal in cost, so only the sorted one survives; we must
+-- be able to reparameterize that one, too.
+VACUUM ANALYZE pa_loc2;
+ANALYZE pa_f2;
+EXPLAIN (VERBOSE, COSTS OFF)
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+SELECT * FROM pa_outer o JOIN pa_parent p ON p.a = o.a;
+DROP TABLE pa_parent, pa_outer, pa_loc1, pa_loc2;
+
 -- ===================================================================
 -- conversion error
 -- ===================================================================
diff --git a/doc/src/sgml/fdwhandler.sgml b/doc/src/sgml/fdwhandler.sgml
index 8685a078c52..a04a113ef2c 100644
--- a/doc/src/sgml/fdwhandler.sgml
+++ b/doc/src/sgml/fdwhandler.sgml
@@ -1721,6 +1721,36 @@ ReparameterizeForeignPathByChild(PlannerInfo *root, List *fdw_private,
     <literal>adjust_appendrel_attrs</literal> or
     <literal>adjust_appendrel_attrs_multilevel</literal> as required.
     </para>
+
+    <para>
+<programlisting>
+Path *
+ReparameterizeForeignPath(PlannerInfo *root, ForeignPath *path,
+                          Relids required_outer);
+</programlisting>
+    This function is called when the planner needs a version of an existing
+    base-relation <structname>ForeignPath</structname> that is parameterized
+    by the relations in <literal>required_outer</literal>, which is a
+    superset of the parameterization of <literal>path</literal>.  This
+    happens, for example, while building a parameterized
+    <literal>Append</literal> path over a partitioned table with foreign
+    partitions, since all children of such a path must have exactly the same
+    parameterization.  The function must return a new
+    <structname>ForeignPath</structname> whose <literal>param_info</literal>
+    is the value obtained from <function>get_baserel_parampathinfo</function>
+    for <literal>required_outer</literal>, and whose row count and cost
+    estimates account for the additional join clauses found in that value's
+    <structfield>ppi_clauses</structfield>; all of those clauses must be
+    enforced by the returned path, either remotely or locally.  It must
+    return <literal>NULL</literal> if no such path can be built, and it must
+    not pass the returned path to <function>add_path</function>.
+    </para>
+
+    <para>
+    This function is optional.  If it is not provided, the planner cannot
+    build a parameterized <literal>Append</literal> path unless every foreign
+    child relation already has a path with the required parameterization.
+    </para>
    </sect2>
 
    </sect1>
@@ -2014,7 +2044,10 @@ GetForeignServerByName(const char *name, bool missing_ok);
      to compute that value.  In <function>GetForeignPlan</function>, the
      <replaceable>local_variable</replaceable> portion of the join clause would be added
      to <structfield>fdw_exprs</structfield>, and then at run time the case works the
-     same as for an ordinary restriction clause.
+     same as for an ordinary restriction clause.  If the FDW provides
+     <function>ReparameterizeForeignPath</function>, the planner can also ask
+     it after the fact for a version of an existing path that uses additional
+     join clauses; see <xref linkend="fdw-callbacks-reparameterize-paths"/>.
     </para>
 
     <para>
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 73518c8f870..51f4c38a8a6 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -4053,6 +4053,17 @@ reparameterize_path(PlannerInfo *root, Path *path,
 													mpath->binary_mode,
 													mpath->est_calls);
 			}
+		case T_ForeignScan:
+			{
+				ReparameterizeForeignPath_function rfp_func;
+
+				if (!IS_SIMPLE_REL(rel) || rel->fdwroutine == NULL)
+					break;
+				rfp_func = rel->fdwroutine->ReparameterizeForeignPath;
+				if (rfp_func == NULL)
+					break;
+				return rfp_func(root, (ForeignPath *) path, required_outer);
+			}
 		default:
 			break;
 	}
diff --git a/src/include/foreign/fdwapi.h b/src/include/foreign/fdwapi.h
index abf59a0d8ad..7a88165919a 100644
--- a/src/include/foreign/fdwapi.h
+++ b/src/include/foreign/fdwapi.h
@@ -186,6 +186,9 @@ typedef bool (*IsForeignScanParallelSafe_function) (PlannerInfo *root,
 typedef List *(*ReparameterizeForeignPathByChild_function) (PlannerInfo *root,
 															List *fdw_private,
 															RelOptInfo *child_rel);
+typedef Path *(*ReparameterizeForeignPath_function) (PlannerInfo *root,
+													 ForeignPath *path,
+													 Relids required_outer);
 
 typedef bool (*IsForeignPathAsyncCapable_function) (ForeignPath *path);
 
@@ -277,6 +280,7 @@ typedef struct FdwRoutine
 
 	/* Support functions for path reparameterization. */
 	ReparameterizeForeignPathByChild_function ReparameterizeForeignPathByChild;
+	ReparameterizeForeignPath_function ReparameterizeForeignPath;
 
 	/* Support functions for asynchronous execution */
 	IsForeignPathAsyncCapable_function IsForeignPathAsyncCapable;
-- 
2.55.0

