From a246d148c12df56eb21a7f24134a91b9a3709f30 Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Wed, 11 Mar 2026 16:26:12 +0900
Subject: [PATCH v1] Remove inner joins based on foreign keys

This patch allows the planner to safely remove an inner join to the
referenced relation of a foreign key when that relation acts solely as
a structural anchor.  To be removable, the inner join must be
mathematically guaranteed to produce exactly one matching row for each
row in the referencing relation.  The foreign key constraint
guarantees existence (at least one match), while the referenced
relation's unique constraint guarantees non-duplication (at most one
match).

To preserve the inner join's elimination semantics, the referencing
relation's foreign key columns must be guaranteed to be not null.  If
they are not defined as not null in the schema, this patch injects an
IS NOT NULL filter to prevent null-key rows from incorrectly surviving
the removal.

The referenced relation's non-key columns cannot be used anywhere in
the query.  Because the referenced relation will be removed, we would
have no way to read their values.  Even if used strictly within the
join conditions, they would act as local filters, violating the
exact-match guarantee.

Theoretically, the referenced relation's key columns represent the
exact same logical values as the referencing relation's columns due to
the foreign key equality.  However, they are not strictly
interchangeable in the query tree.  Substituting them requires
preserving exact data types, collations, and operator family
semantics.  Because the planner currently lacks a mechanism to safely
perform this variable substitution across differing schemas, their
usage is strictly limited.  They cannot be used in contexts that
require rewriting Var references, such as targetlist, restriction
clauses, lateral_vars, or righthand-side expressions of semijoins.

They are, however, permitted to bridge multi-hop joins via
EquivalenceClasses.  When the relation is removed, its members are
dropped from the EquivalenceClasses, allowing the planner's native
machinery to deduce the remaining transitive equalities.

In passing, this patch also updates the already-rotted comments for
remove_rel_from_query().
---
 src/backend/optimizer/path/equivclass.c   |  12 +-
 src/backend/optimizer/plan/analyzejoins.c | 612 +++++++++++++++++++---
 src/backend/optimizer/plan/initsplan.c    |   5 +-
 src/backend/optimizer/plan/planmain.c     |   8 +
 src/backend/optimizer/util/plancat.c      |   1 +
 src/backend/utils/cache/relcache.c        |   1 +
 src/include/nodes/pathnodes.h             |   2 +
 src/include/optimizer/paths.h             |   3 +-
 src/include/optimizer/planmain.h          |   1 +
 src/include/utils/rel.h                   |   3 +
 src/test/regress/expected/join.out        | 372 +++++++++++++
 src/test/regress/sql/join.sql             | 190 +++++++
 12 files changed, 1140 insertions(+), 70 deletions(-)

diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index e3697df51a2..34d3db9d9a4 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -2701,14 +2701,14 @@ exprs_known_equal(PlannerInfo *root, Node *item1, Node *item2, Oid opfamily)
  * we ignore that fine point here.)  This is much like exprs_known_equal,
  * except for the format of the input.
  *
- * On success, we also set fkinfo->eclass[colno] to the matching eclass,
- * and set fkinfo->fk_eclass_member[colno] to the eclass member for the
- * referencing Var.
+ * If fk_eclass_member is not NULL, *fk_eclass_member is set to the eclass
+ * member for the referencing Var.
  */
 EquivalenceClass *
 match_eclasses_to_foreign_key_col(PlannerInfo *root,
 								  ForeignKeyOptInfo *fkinfo,
-								  int colno)
+								  int colno,
+								  EquivalenceMember **fk_eclass_member)
 {
 	Index		var1varno = fkinfo->con_relid;
 	AttrNumber	var1attno = fkinfo->conkey[colno];
@@ -2778,8 +2778,8 @@ match_eclasses_to_foreign_key_col(PlannerInfo *root,
 					opfamilies = get_mergejoin_opfamilies(eqop);
 				if (equal(opfamilies, ec->ec_opfamilies))
 				{
-					fkinfo->eclass[colno] = ec;
-					fkinfo->fk_eclass_member[colno] = item2_em;
+					if (fk_eclass_member)
+						*fk_eclass_member = item2_em;
 					return ec;
 				}
 				/* Otherwise, done with this EC, move on to the next */
diff --git a/src/backend/optimizer/plan/analyzejoins.c b/src/backend/optimizer/plan/analyzejoins.c
index 12e9ed0d0c7..583a092fd47 100644
--- a/src/backend/optimizer/plan/analyzejoins.c
+++ b/src/backend/optimizer/plan/analyzejoins.c
@@ -23,12 +23,14 @@
 #include "postgres.h"
 
 #include "catalog/pg_class.h"
+#include "nodes/makefuncs.h"
 #include "nodes/nodeFuncs.h"
 #include "optimizer/joininfo.h"
 #include "optimizer/optimizer.h"
 #include "optimizer/pathnode.h"
 #include "optimizer/paths.h"
 #include "optimizer/placeholder.h"
+#include "optimizer/plancat.h"
 #include "optimizer/planmain.h"
 #include "optimizer/restrictinfo.h"
 #include "parser/parse_agg.h"
@@ -77,6 +79,10 @@ static bool is_innerrel_unique_for(PlannerInfo *root,
 static int	self_join_candidates_cmp(const void *a, const void *b);
 static bool replace_relid_callback(Node *node,
 								   ChangeVarNodes_context *context);
+static bool inner_join_is_removable(PlannerInfo *root, ForeignKeyOptInfo *fkinfo);
+static void inject_fk_not_null_quals(PlannerInfo *root, ForeignKeyOptInfo *fkinfo);
+static void remove_referenced_rel_from_query(PlannerInfo *root,
+											 ForeignKeyOptInfo *fkinfo);
 
 
 /*
@@ -312,24 +318,37 @@ join_is_removable(PlannerInfo *root, SpecialJoinInfo *sjinfo)
 	return false;
 }
 
-
 /*
- * Remove the target rel->relid and references to the target join from the
+ * Remove the target relid and references to the target join from the
  * planner's data structures, having determined that there is no need
- * to include them in the query. Optionally replace them with subst if subst
- * is non-negative.
+ * to include them in the query.  Optionally replace references to the
+ * removed relid with subst if this is a self-join removal.
  *
- * This function updates only parts needed for both left-join removal and
- * self-join removal.
+ * This function serves as the common infrastructure for three distinct
+ * optimization passes: left-join removal, inner-join removal, and self-join
+ * elimination.  It is intentionally scoped to update only the shared planner
+ * data structures that are universally affected by relation removal.  Each
+ * specific caller remains responsible for updating any remaining data
+ * structures required by its unique removal logic.
+ *
+ * The specific type of removal being performed is dictated by the combination
+ * of the sjinfo and subst parameters.  A non-NULL sjinfo indicates left-join
+ * removal.  When sjinfo is NULL, a positive subst value indicates self-join
+ * elimination (where references are replaced with subst), while a negative
+ * subst value indicates inner-join removal.
  */
 static void
-remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
+remove_rel_from_query(PlannerInfo *root, int relid,
 					  int subst, SpecialJoinInfo *sjinfo,
 					  Relids joinrelids)
 {
-	int			relid = rel->relid;
 	Index		rti;
 	ListCell   *l;
+	bool		is_outer_join = (sjinfo != NULL);
+	bool		is_self_join = (!is_outer_join && subst > 0);
+	bool		is_inner_join = (!is_outer_join && subst < 0);
+
+	Assert(is_outer_join || is_self_join || is_inner_join);
 
 	/*
 	 * Update all_baserels and related relid sets.
@@ -337,7 +356,7 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 	root->all_baserels = adjust_relid_set(root->all_baserels, relid, subst);
 	root->all_query_rels = adjust_relid_set(root->all_query_rels, relid, subst);
 
-	if (sjinfo != NULL)
+	if (is_outer_join)
 	{
 		root->outer_join_rels = bms_del_member(root->outer_join_rels,
 											   sjinfo->ojrelid);
@@ -348,10 +367,11 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 	/*
 	 * Likewise remove references from SpecialJoinInfo data structures.
 	 *
-	 * This is relevant in case the outer join we're deleting is nested inside
-	 * other outer joins: the upper joins' relid sets have to be adjusted. The
-	 * RHS of the target outer join will be made empty here, but that's OK
-	 * since caller will delete that SpecialJoinInfo entirely.
+	 * This is relevant in case the relation we're deleting is part of the
+	 * relid sets of other special joins: those sets have to be adjusted. If
+	 * we are removing an outer join, the RHS of the target outer join will be
+	 * made empty here, but that's OK since the caller will delete that
+	 * SpecialJoinInfo entirely.
 	 */
 	foreach(l, root->join_info_list)
 	{
@@ -373,10 +393,8 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 		sjinf->syn_lefthand = adjust_relid_set(sjinf->syn_lefthand, relid, subst);
 		sjinf->syn_righthand = adjust_relid_set(sjinf->syn_righthand, relid, subst);
 
-		if (sjinfo != NULL)
+		if (is_outer_join)
 		{
-			Assert(subst <= 0);
-
 			/* Remove sjinfo->ojrelid bits from the sets: */
 			sjinf->min_lefthand = bms_del_member(sjinf->min_lefthand,
 												 sjinfo->ojrelid);
@@ -396,23 +414,29 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 			sjinf->commute_below_r = bms_del_member(sjinf->commute_below_r,
 													sjinfo->ojrelid);
 		}
-		else
+		else if (is_self_join)
 		{
-			Assert(subst > 0);
-
 			ChangeVarNodesExtended((Node *) sjinf->semi_rhs_exprs, relid, subst,
 								   0, replace_relid_callback);
 		}
+		else if (is_inner_join)
+		{
+			/*
+			 * For inner-join removal, no additional modifications are needed.
+			 * inner_join_is_removable() has already guaranteed that the
+			 * target relation's columns do not leak into semi_rhs_exprs.
+			 */
+		}
 	}
 
 	/*
 	 * Likewise remove references from PlaceHolderVar data structures,
-	 * removing any no-longer-needed placeholders entirely.  We remove PHV
-	 * only for left-join removal.  With self-join elimination, PHVs already
-	 * get moved to the remaining relation, where they might still be needed.
-	 * It might also happen that we skip the removal of some PHVs that could
-	 * be removed.  However, the overhead of extra PHVs is small compared to
-	 * the complexity of analysis needed to remove them.
+	 * removing any no-longer-needed placeholders entirely.  We remove PHV for
+	 * left-join and inner-join removal.  With self-join elimination, PHVs
+	 * already get moved to the remaining relation, where they might still be
+	 * needed.  It might also happen that we skip the removal of some PHVs
+	 * that could be removed.  However, the overhead of extra PHVs is small
+	 * compared to the complexity of analysis needed to remove them.
 	 *
 	 * Removal is a bit trickier than it might seem: we can remove PHVs that
 	 * are used at the target rel and/or in the join qual, but not those that
@@ -421,18 +445,20 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 	 * since they will both have ph_needed sets that are subsets of
 	 * joinrelids.  However, a PHV used at a partner rel could not have the
 	 * target rel in ph_eval_at, so we check that while deciding whether to
-	 * remove or just update the PHV.  There is no corresponding test in
-	 * join_is_removable because it doesn't need to distinguish those cases.
+	 * remove or just update the PHV.  There are no corresponding tests in the
+	 * callers (like join_is_removable or inner_join_is_removable) because
+	 * they don't need to distinguish those cases.
 	 */
 	foreach(l, root->placeholder_list)
 	{
 		PlaceHolderInfo *phinfo = (PlaceHolderInfo *) lfirst(l);
 
-		Assert(sjinfo == NULL || !bms_is_member(relid, phinfo->ph_lateral));
-		if (sjinfo != NULL &&
+		Assert(is_self_join || !bms_is_member(relid, phinfo->ph_lateral));
+
+		if (!is_self_join &&
 			bms_is_subset(phinfo->ph_needed, joinrelids) &&
 			bms_is_member(relid, phinfo->ph_eval_at) &&
-			!bms_is_member(sjinfo->ojrelid, phinfo->ph_eval_at))
+			(is_inner_join || !bms_is_member(sjinfo->ojrelid, phinfo->ph_eval_at)))
 		{
 			/*
 			 * This code shouldn't be executed if one relation is substituted
@@ -448,10 +474,11 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 			PlaceHolderVar *phv = phinfo->ph_var;
 
 			phinfo->ph_eval_at = adjust_relid_set(phinfo->ph_eval_at, relid, subst);
-			if (sjinfo != NULL)
+			if (is_outer_join)
 				phinfo->ph_eval_at = adjust_relid_set(phinfo->ph_eval_at,
 													  sjinfo->ojrelid, subst);
 			Assert(!bms_is_empty(phinfo->ph_eval_at));	/* checked previously */
+
 			/* Reduce ph_needed to contain only "relation 0"; see below */
 			if (bms_is_member(0, phinfo->ph_needed))
 				phinfo->ph_needed = bms_make_singleton(0);
@@ -468,13 +495,14 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 			/* ph_lateral might or might not be empty */
 
 			phv->phrels = adjust_relid_set(phv->phrels, relid, subst);
-			if (sjinfo != NULL)
+			if (is_outer_join)
 				phv->phrels = adjust_relid_set(phv->phrels,
 											   sjinfo->ojrelid, subst);
 			Assert(!bms_is_empty(phv->phrels));
 
-			ChangeVarNodesExtended((Node *) phv->phexpr, relid, subst, 0,
-								   replace_relid_callback);
+			if (is_self_join)
+				ChangeVarNodesExtended((Node *) phv->phexpr, relid, subst, 0,
+									   replace_relid_callback);
 
 			Assert(phv->phnullingrels == NULL); /* no need to adjust */
 		}
@@ -487,24 +515,35 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 	{
 		EquivalenceClass *ec = (EquivalenceClass *) lfirst(l);
 
-		if (bms_is_member(relid, ec->ec_relids) ||
-			(sjinfo == NULL || bms_is_member(sjinfo->ojrelid, ec->ec_relids)))
+		/*
+		 * Self-join elimination visits all ECs.  Left-join and inner-join
+		 * removal only visit ECs containing the removed relation or outer
+		 * join relid.
+		 */
+		if (is_self_join ||
+			bms_is_member(relid, ec->ec_relids) ||
+			(is_outer_join && bms_is_member(sjinfo->ojrelid, ec->ec_relids)))
 			remove_rel_from_eclass(ec, sjinfo, relid, subst);
 	}
 
 	/*
-	 * Finally, we must recompute per-Var attr_needed and per-PlaceHolderVar
-	 * ph_needed relid sets.  These have to be known accurately, else we may
-	 * fail to remove other now-removable outer joins.  And our removal of the
-	 * join clause(s) for this outer join may mean that Vars that were
-	 * formerly needed no longer are.  So we have to do this honestly by
-	 * repeating the construction of those relid sets.  We can cheat to one
-	 * small extent: we can avoid re-examining the targetlist and HAVING qual
-	 * by preserving "relation 0" bits from the existing relid sets.  This is
-	 * safe because we'd never remove such references.
+	 * Finally, we must prepare for the caller to recompute per-Var
+	 * attr_needed and per-PlaceHolderVar ph_needed relid sets.  These have to
+	 * be known accurately, else we may fail to remove other now-removable
+	 * joins. Because the caller removes the join clause(s) associated with
+	 * the removed join, Vars that were formerly needed may no longer be.
 	 *
-	 * So, start by removing all other bits from attr_needed sets and
-	 * lateral_vars lists.  (We already did this above for ph_needed.)
+	 * The actual reconstruction of these relid sets is performed by the
+	 * specific caller.  Here, we simply clear out the existing attr_needed
+	 * sets (we already did this above for ph_needed) to ensure they are
+	 * rebuilt from scratch.  We can cheat to one small extent: we can avoid
+	 * re-examining the targetlist and HAVING qual by preserving "relation 0"
+	 * bits from the existing relid sets.  This is safe because we'd never
+	 * remove such references.
+	 *
+	 * Additionally, if we are performing self-join elimination, we must
+	 * replace references to the removed relid with subst within the
+	 * lateral_vars lists.
 	 */
 	for (rti = 1; rti < root->simple_rel_array_size; rti++)
 	{
@@ -527,7 +566,7 @@ remove_rel_from_query(PlannerInfo *root, RelOptInfo *rel,
 				otherrel->attr_needed[attroff] = NULL;
 		}
 
-		if (subst > 0)
+		if (is_self_join)
 			ChangeVarNodesExtended((Node *) otherrel->lateral_vars, relid,
 								   subst, 0, replace_relid_callback);
 	}
@@ -557,7 +596,7 @@ remove_leftjoinrel_from_query(PlannerInfo *root, int relid,
 	Assert(ojrelid != 0);
 	joinrelids = bms_add_member(joinrelids, ojrelid);
 
-	remove_rel_from_query(root, rel, -1, sjinfo, joinrelids);
+	remove_rel_from_query(root, relid, -1, sjinfo, joinrelids);
 
 	/*
 	 * Remove any joinquals referencing the rel from the joininfo lists.
@@ -648,7 +687,8 @@ remove_leftjoinrel_from_query(PlannerInfo *root, int relid,
 }
 
 /*
- * Remove any references to relid or ojrelid from the RestrictInfo.
+ * Remove any references to relid or ojrelid from the RestrictInfo.  If
+ * ojrelid is <= 0, it is ignored (used for inner-join removal).
  *
  * We only bother to clean out bits in clause_relids and required_relids,
  * not nullingrel bits in contained Vars and PHVs.  (This might have to be
@@ -667,11 +707,13 @@ remove_rel_from_restrictinfo(RestrictInfo *rinfo, int relid, int ojrelid)
 	 */
 	rinfo->clause_relids = bms_copy(rinfo->clause_relids);
 	rinfo->clause_relids = bms_del_member(rinfo->clause_relids, relid);
-	rinfo->clause_relids = bms_del_member(rinfo->clause_relids, ojrelid);
+	if (ojrelid > 0)
+		rinfo->clause_relids = bms_del_member(rinfo->clause_relids, ojrelid);
 	/* Likewise for required_relids */
 	rinfo->required_relids = bms_copy(rinfo->required_relids);
 	rinfo->required_relids = bms_del_member(rinfo->required_relids, relid);
-	rinfo->required_relids = bms_del_member(rinfo->required_relids, ojrelid);
+	if (ojrelid > 0)
+		rinfo->required_relids = bms_del_member(rinfo->required_relids, ojrelid);
 
 	/* If it's an OR, recurse to clean up sub-clauses */
 	if (restriction_is_or_clause(rinfo))
@@ -707,8 +749,9 @@ remove_rel_from_restrictinfo(RestrictInfo *rinfo, int relid, int ojrelid)
 }
 
 /*
- * Remove any references to relid or sjinfo->ojrelid (if sjinfo != NULL)
- * from the EquivalenceClass.
+ * Remove any references to relid or sjinfo->ojrelid (if this is a left-join
+ * removal) from the EquivalenceClass.  Optionally replace references to the
+ * removed relid with subst if this is a self-join removal.
  *
  * Like remove_rel_from_restrictinfo, we don't worry about cleaning out
  * any nullingrel bits in contained Vars and PHVs.  (This might have to be
@@ -721,10 +764,16 @@ remove_rel_from_eclass(EquivalenceClass *ec, SpecialJoinInfo *sjinfo,
 					   int relid, int subst)
 {
 	ListCell   *lc;
+	bool		is_outer_join = (sjinfo != NULL);
+	bool		is_self_join = (!is_outer_join && subst > 0);
+	bool		is_inner_join PG_USED_FOR_ASSERTS_ONLY =
+		(!is_outer_join && subst < 0);
+
+	Assert(is_outer_join || is_self_join || is_inner_join);
 
 	/* Fix up the EC's overall relids */
 	ec->ec_relids = adjust_relid_set(ec->ec_relids, relid, subst);
-	if (sjinfo != NULL)
+	if (is_outer_join)
 		ec->ec_relids = adjust_relid_set(ec->ec_relids,
 										 sjinfo->ojrelid, subst);
 
@@ -745,12 +794,11 @@ remove_rel_from_eclass(EquivalenceClass *ec, SpecialJoinInfo *sjinfo,
 		EquivalenceMember *cur_em = (EquivalenceMember *) lfirst(lc);
 
 		if (bms_is_member(relid, cur_em->em_relids) ||
-			(sjinfo != NULL && bms_is_member(sjinfo->ojrelid,
-											 cur_em->em_relids)))
+			(is_outer_join && bms_is_member(sjinfo->ojrelid, cur_em->em_relids)))
 		{
 			Assert(!cur_em->em_is_const);
 			cur_em->em_relids = adjust_relid_set(cur_em->em_relids, relid, subst);
-			if (sjinfo != NULL)
+			if (is_outer_join)
 				cur_em->em_relids = adjust_relid_set(cur_em->em_relids,
 													 sjinfo->ojrelid, subst);
 			if (bms_is_empty(cur_em->em_relids))
@@ -763,11 +811,22 @@ remove_rel_from_eclass(EquivalenceClass *ec, SpecialJoinInfo *sjinfo,
 	{
 		RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
 
-		if (sjinfo == NULL)
+		if (is_self_join)
+		{
+			/* Self-join elimination relies on Var substitution */
 			ChangeVarNodesExtended((Node *) rinfo, relid, subst, 0,
 								   replace_relid_callback);
+		}
 		else
-			remove_rel_from_restrictinfo(rinfo, relid, sjinfo->ojrelid);
+		{
+			/*
+			 * For left-join and inner-join removal, strip the removed
+			 * relation (and the outer join, if any) from the restrictinfo's
+			 * relid sets.
+			 */
+			remove_rel_from_restrictinfo(rinfo, relid,
+										 is_outer_join ? sjinfo->ojrelid : -1);
+		}
 	}
 
 	/*
@@ -1958,7 +2017,7 @@ remove_self_join_rel(PlannerInfo *root, PlanRowMark *kmark, PlanRowMark *rmark,
 						   0, replace_relid_callback);
 
 	/* Replace links in the planner info */
-	remove_rel_from_query(root, toRemove, toKeep->relid, NULL, NULL);
+	remove_rel_from_query(root, toRemove->relid, toKeep->relid, NULL, NULL);
 
 	/* At last, replace varno in root targetlist and HAVING clause */
 	ChangeVarNodesExtended((Node *) root->processed_tlist, toRemove->relid,
@@ -1986,7 +2045,6 @@ remove_self_join_rel(PlannerInfo *root, PlanRowMark *kmark, PlanRowMark *rmark,
 	/* And nuke the RelOptInfo, just in case there's another access path. */
 	pfree(toRemove);
 
-
 	/*
 	 * Now repeat construction of attr_needed bits coming from all other
 	 * sources.
@@ -2515,3 +2573,433 @@ remove_useless_self_joins(PlannerInfo *root, List *joinlist)
 
 	return joinlist;
 }
+
+/*
+ * remove_useless_inner_joins
+ *	  Scans all foreign keys in the query to find and remove referenced
+ *	  relations that act only to duplicate referential integrity guarantees.
+ *
+ * We are passed the current joinlist and return the updated list.  Other
+ * data structures that have to be updated are accessible via "root".
+ */
+List *
+remove_useless_inner_joins(PlannerInfo *root, List *joinlist)
+{
+	Relids		removed_relids = NULL;
+
+	foreach_node(ForeignKeyOptInfo, fkinfo, root->fkey_list)
+	{
+		int			nremoved;
+
+		/* Skip if the referenced relation has already been removed */
+		if (bms_is_member(fkinfo->ref_relid, removed_relids))
+			continue;
+
+		/* Skip if not removable */
+		if (!inner_join_is_removable(root, fkinfo))
+			continue;
+
+		/* Inject IS NOT NULL clauses for nullable foreign key columns */
+		inject_fk_not_null_quals(root, fkinfo);
+
+		/* Remove the referenced relation */
+		remove_referenced_rel_from_query(root, fkinfo);
+
+		/* We verify that exactly one reference gets removed from joinlist */
+		nremoved = 0;
+		joinlist = remove_rel_from_joinlist(joinlist, fkinfo->ref_relid, &nremoved);
+		if (nremoved != 1)
+			elog(ERROR, "failed to find relation %d in joinlist", fkinfo->ref_relid);
+
+		removed_relids = bms_add_member(removed_relids, fkinfo->ref_relid);
+	}
+
+	bms_free(removed_relids);
+
+	return joinlist;
+}
+
+/*
+ * inner_join_is_removable
+ *	  Check whether an inner join to the referenced relation of a foreign key
+ *	  can be safely removed from the query tree.
+ *
+ * To be removable, the referenced relation must act only as a structural
+ * anchor.  The inner join must be mathematically guaranteed to produce exactly
+ * one matching row for each referencing relation's row.  The foreign key
+ * constraint guarantees existence (at least one match), and the referenced
+ * relation's unique constraint guarantees non-duplication (at most one match).
+ *
+ * The referenced relation's non-key columns cannot be used anywhere in the
+ * query.  Because the referenced relation will be removed, we would have no
+ * way to read their values.  Even if used strictly within the join condition,
+ * they would act as local filters, violating the exact-match guarantee.
+ *
+ * Theoretically, the referenced relation's key columns represent the exact
+ * same logical values as the referencing relation's columns due to the foreign
+ * key equality.  However, they are not strictly interchangeable in the query
+ * tree.  Substituting them requires preserving exact data types, collations,
+ * and operator family semantics.  Because the planner currently lacks a
+ * mechanism to safely perform this variable substitution across differing
+ * schemas, their usage is strictly limited.  They cannot be used in contexts
+ * that require rewriting Var references, such as targetlist, restriction
+ * clauses, lateral_vars, or righthand-side expressions of semijoins.  They
+ * are, however, permitted to bridge multi-hop joins via EquivalenceClasses.
+ * Removing the relation safely drops its members from the ECs, and the planner
+ * natively deduces the remaining transitive equalities.
+ *
+ * NOTE: This function assumes the caller will inject IS NOT NULL filters for
+ * the referencing relation's FK columns if they are not strictly enforced by
+ * the schema to prevent partial-match ghost rows.
+ */
+static bool
+inner_join_is_removable(PlannerInfo *root, ForeignKeyOptInfo *fkinfo)
+{
+	RelOptInfo *con_rel;
+	RelOptInfo *ref_rel;
+	int			attroff;
+	Relids		inputrelids;
+	Bitmapset  *fk_attnums = NULL;
+	int			colno;
+
+	/*
+	 * If the constraint is deferrable, the physical tables may temporarily
+	 * violate the FK during an active transaction.  The inner join must be
+	 * executed to filter out uncommitted orphaned rows.
+	 */
+	if (fkinfo->con_deferrable)
+		return false;
+
+	/*
+	 * Either relid might identify a rel that is in the query's rtable but
+	 * isn't referenced by the jointree, or has been removed by join removal,
+	 * so that it won't have a RelOptInfo.  Hence we use find_base_rel_noerr()
+	 * here.  We can ignore such FKs.
+	 */
+	con_rel = find_base_rel_noerr(root, fkinfo->con_relid);
+	if (con_rel == NULL)
+		return false;
+	ref_rel = find_base_rel_noerr(root, fkinfo->ref_relid);
+	if (ref_rel == NULL)
+		return false;
+
+	/*
+	 * Ignore FK unless both rels are baserels.  This gets rid of FKs that
+	 * link to inheritance child rels (otherrels).
+	 */
+	if (con_rel->reloptkind != RELOPT_BASEREL ||
+		ref_rel->reloptkind != RELOPT_BASEREL)
+		return false;
+
+	/*
+	 * If the referenced relation has any restriction clauses or non-equality
+	 * join clauses, they act as explicit filters.  Since we cannot perform
+	 * variable substitution to rewrite these clauses, we must abort.
+	 */
+	if (ref_rel->baserestrictinfo || ref_rel->joininfo)
+		return false;
+
+	/*
+	 * Build a fast lookup bitmap for the referenced relation's foreign key
+	 * attributes to optimize the subsequent attribute usage checks.
+	 */
+	for (colno = 0; colno < fkinfo->nkeys; colno++)
+	{
+		fk_attnums = bms_add_member(fk_attnums,
+									fkinfo->confkey[colno] - ref_rel->min_attr);
+	}
+
+	/*
+	 * Verify attribute usage against the substitution constraints.
+	 *
+	 * As a micro-optimization, it seems better to start with max_attr and
+	 * count down rather than starting with min_attr and counting up, on the
+	 * theory that the system attributes are somewhat less likely to be wanted
+	 * and should be tested last.
+	 */
+	for (attroff = ref_rel->max_attr - ref_rel->min_attr;
+		 attroff >= 0;
+		 attroff--)
+	{
+		if (bms_is_member(attroff, fk_attnums))
+		{
+			/*
+			 * The specific columns involved in the foreign key are allowed to
+			 * bridge multi-hop joins via EquivalenceClasses.  However, they
+			 * must not be needed in the final targetlist, which would require
+			 * variable substitution.
+			 */
+			if (bms_is_member(0, ref_rel->attr_needed[attroff]))
+				return false;
+
+			continue;
+		}
+
+		/*
+		 * The non-key columns must not be used anywhere.  See comments above.
+		 */
+		if (!bms_is_empty(ref_rel->attr_needed[attroff]))
+			return false;
+	}
+
+	/* Compute the relid set for the join we are considering */
+	inputrelids = bms_make_singleton(fkinfo->con_relid);
+	inputrelids = bms_add_member(inputrelids, fkinfo->ref_relid);
+
+	/*
+	 * Similarly check that the referenced rel isn't needed by any
+	 * PlaceHolderVars that will be used above the join.  The PHV case is a
+	 * little bit more complicated, because PHVs may have been assigned a
+	 * ph_eval_at location that includes the ref_rel, yet their contained
+	 * expression might not actually reference the ref_rel (it could be just a
+	 * constant, for instance).  If such a PHV is due to be evaluated above
+	 * the join then it needn't prevent inner join removal.
+	 */
+	foreach_node(PlaceHolderInfo, phinfo, root->placeholder_list)
+	{
+		if (bms_overlap(phinfo->ph_lateral, ref_rel->relids))
+			return false;		/* it references ref_rel laterally */
+		if (!bms_overlap(phinfo->ph_eval_at, ref_rel->relids))
+			continue;			/* it definitely doesn't reference ref_rel */
+		if (bms_is_subset(phinfo->ph_needed, inputrelids))
+			continue;			/* PHV is not used above the join */
+
+		/*
+		 * We need to be sure there will still be a place to evaluate the PHV
+		 * if we remove the join, ie that ph_eval_at wouldn't become empty.
+		 */
+		if (!bms_overlap(con_rel->relids, phinfo->ph_eval_at))
+			return false;		/* there isn't any other place to eval PHV */
+		/* Check contained expression last, since this is a bit expensive */
+		if (bms_overlap(pull_varnos(root, (Node *) phinfo->ph_var->phexpr),
+						ref_rel->relids))
+			return false;		/* contained expression references ref_rel */
+	}
+
+	/*
+	 * If the referencing and referenced relations are separated by an outer
+	 * join boundary, removing the inner join alters the null-padding
+	 * semantics of the query.
+	 *
+	 * Additionally, if the referenced relation's columns are referenced in
+	 * the RHS expressions of any semi-join, we must punt.  We do not have a
+	 * way to rewrite those references.
+	 */
+	foreach_node(SpecialJoinInfo, sjinfo, root->join_info_list)
+	{
+		if ((bms_is_member(fkinfo->con_relid, sjinfo->syn_lefthand) ^
+			 bms_is_member(fkinfo->ref_relid, sjinfo->syn_lefthand)) ||
+			(bms_is_member(fkinfo->con_relid, sjinfo->syn_righthand) ^
+			 bms_is_member(fkinfo->ref_relid, sjinfo->syn_righthand)))
+			return false;
+
+		if (sjinfo->semi_rhs_exprs != NIL)
+		{
+			if (bms_is_member(fkinfo->ref_relid,
+							  pull_varnos(root, (Node *) sjinfo->semi_rhs_exprs)))
+				return false;
+		}
+	}
+
+	/*
+	 * If the referenced relation is referenced laterally by any other
+	 * relation, punt.  We do not have a way to rewrite those references.
+	 */
+	if (root->hasLateralRTEs)
+	{
+		Index		rti;
+
+		for (rti = 1; rti < root->simple_rel_array_size; rti++)
+		{
+			RelOptInfo *otherrel = root->simple_rel_array[rti];
+
+			if (otherrel == NULL || otherrel->relid == fkinfo->ref_relid)
+				continue;
+
+			if (otherrel->lateral_vars != NIL)
+			{
+				if (bms_is_member(fkinfo->ref_relid,
+								  pull_varnos(root, (Node *) otherrel->lateral_vars)))
+					return false;
+			}
+		}
+	}
+
+	/*
+	 * We must mathematically prove that every column in the referenced
+	 * relation's foreign key is bound to the referencing relation's foreign
+	 * key via a valid equality operator within an equivalence class.
+	 */
+	for (colno = 0; colno < fkinfo->nkeys; colno++)
+	{
+		EquivalenceClass *ec;
+
+		ec = match_eclasses_to_foreign_key_col(root, fkinfo, colno, NULL);
+
+		if (ec == NULL)
+			return false;
+	}
+
+	/*
+	 * We must also prove the inverse: the referenced relation must not
+	 * participate in any EquivalenceClasses other than the ones that bridge
+	 * it to the referencing relation's foreign key.
+	 *
+	 * We can prove this efficiently using a cardinality check.  We have
+	 * already verified that the relation participates in 'nkeys' valid ECs
+	 * bridging to the referencing relation.  If the total number of ECs it
+	 * participates in exceeds 'nkeys', it means a referenced key column is
+	 * involved in an equality that the planner refused to merge with the
+	 * foreign key's EC.
+	 *
+	 * This split occurs during collation mismatches, incompatible operator
+	 * families, or if the key is wrapped in an expression more complex than a
+	 * simple Var (e.g., ref_rel.pk + 1 = c.val).  In those scenarios,
+	 * removing the referenced relation would silently destroy the join
+	 * condition of that separate EC, because the referencing relation's
+	 * column is not present in it to take over the transitive equality.
+	 */
+	if (bms_num_members(ref_rel->eclass_indexes) != fkinfo->nkeys)
+		return false;
+
+	/* All checks passed */
+	return true;
+}
+
+/*
+ * inject_fk_not_null_quals
+ *	  Injects IS NOT NULL clauses into the referencing relation's restriction
+ *	  list for any foreign key columns that are not enforced as NOT NULL by the
+ *	  schema.
+ *
+ * When we remove a referenced relation (the inner join target) based on a
+ * foreign key, we lose the implicit filtering of NULLs that a standard inner
+ * join performs.  To preserve mathematical equivalence, we must ensure the
+ * referencing relation does not emit rows with NULL foreign keys.
+ */
+static void
+inject_fk_not_null_quals(PlannerInfo *root, ForeignKeyOptInfo *fkinfo)
+{
+	RelOptInfo *con_rel;
+	RangeTblEntry *con_rte;
+	Bitmapset  *notnullattnums;
+	int			i;
+
+	con_rel = find_base_rel(root, fkinfo->con_relid);
+	con_rte = root->simple_rte_array[fkinfo->con_relid];
+	Assert(con_rte->rtekind == RTE_RELATION);
+	Assert(!con_rte->inh);
+
+	/*
+	 * Get the column not-null constraint information for the referencing
+	 * relation.
+	 */
+	notnullattnums = find_relation_notnullatts(root, con_rte->relid);
+
+	for (i = 0; i < fkinfo->nkeys; i++)
+	{
+		AttrNumber	con_attno = fkinfo->conkey[i];
+		Var		   *var;
+		NullTest   *ntest;
+		RestrictInfo *rinfo;
+		Oid			vartype;
+		int32		vartypmod;
+		Oid			varcollid;
+
+		/* System columns are implicitly NOT NULL */
+		if (con_attno < 0)
+			continue;
+
+		/* Schema already guarantees the column is NOT NULL */
+		if (bms_is_member(con_attno, notnullattnums))
+			continue;
+
+		get_atttypetypmodcoll(con_rte->relid,
+							  con_attno,
+							  &vartype,
+							  &vartypmod,
+							  &varcollid);
+
+		var = makeVar(fkinfo->con_relid,
+					  con_attno,
+					  vartype,
+					  vartypmod,
+					  varcollid,
+					  0);
+
+		ntest = makeNode(NullTest);
+		ntest->arg = (Expr *) var;
+		ntest->nulltesttype = IS_NOT_NULL;
+		ntest->argisrow = false;
+		ntest->location = -1;
+
+		rinfo = make_restrictinfo(root,
+								  (Expr *) ntest,
+								  true,
+								  false,
+								  false,
+								  false,
+								  root->qual_security_level,
+								  bms_make_singleton(fkinfo->con_relid),
+								  NULL,
+								  NULL);
+
+		con_rel->baserestrictinfo = lappend(con_rel->baserestrictinfo, rinfo);
+	}
+}
+
+/*
+ * remove_referenced_rel_from_query
+ *	  Remove the referenced rel of a foreign key and references to it from
+ *	  the planner's data structures, having determined that there is no
+ *	  need to include it in the query.
+ *
+ * We are not terribly thorough here.  We only bother to update parts of
+ * the planner's data structures that will actually be consulted later.
+ */
+static void
+remove_referenced_rel_from_query(PlannerInfo *root, ForeignKeyOptInfo *fkinfo)
+{
+	int			relid = fkinfo->ref_relid;
+	RelOptInfo *ref_rel = find_base_rel(root, relid);
+	Relids		joinrelids;
+
+	/* Compute the relid set for the join we are considering */
+	joinrelids = bms_make_singleton(fkinfo->con_relid);
+	joinrelids = bms_add_member(joinrelids, relid);
+
+	remove_rel_from_query(root, relid, -1, NULL, joinrelids);
+
+	bms_free(joinrelids);
+
+	/*
+	 * We can assert that the referenced rel has no non-equality join clauses.
+	 * inner_join_is_removable rejected the removal if ref_rel->joininfo was
+	 * not NIL.
+	 */
+	Assert(ref_rel->joininfo == NIL);
+
+	/*
+	 * There may be references to the rel in root->fkey_list, but if so,
+	 * match_foreign_keys_to_quals() will get rid of them.
+	 */
+
+	/*
+	 * Now remove the rel from the baserel array to prevent it from being
+	 * referenced again.
+	 */
+	root->simple_rel_array[relid] = NULL;
+	root->simple_rte_array[relid] = NULL;
+
+	/* And nuke the RelOptInfo, just in case there's another access path */
+	pfree(ref_rel);
+
+	/*
+	 * Now repeat construction of attr_needed bits coming from all other
+	 * sources.
+	 */
+	rebuild_placeholder_attr_needed(root);
+	rebuild_joinclause_attr_needed(root);
+	rebuild_eclass_attr_needed(root);
+	rebuild_lateral_attr_needed(root);
+}
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index c20e7e49780..2ded2e93a0d 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -4007,15 +4007,18 @@ match_foreign_keys_to_quals(PlannerInfo *root)
 		for (colno = 0; colno < fkinfo->nkeys; colno++)
 		{
 			EquivalenceClass *ec;
+			EquivalenceMember *em = NULL;
 			AttrNumber	con_attno,
 						ref_attno;
 			Oid			fpeqop;
 			ListCell   *lc2;
 
-			ec = match_eclasses_to_foreign_key_col(root, fkinfo, colno);
+			ec = match_eclasses_to_foreign_key_col(root, fkinfo, colno, &em);
 			/* Don't bother looking for loose quals if we got an EC match */
 			if (ec != NULL)
 			{
+				fkinfo->eclass[colno] = ec;
+				fkinfo->fk_eclass_member[colno] = em;
 				fkinfo->nmatched_ec++;
 				if (ec->ec_has_const)
 					fkinfo->nconst_ec++;
diff --git a/src/backend/optimizer/plan/planmain.c b/src/backend/optimizer/plan/planmain.c
index 02495e22e24..194a84266e3 100644
--- a/src/backend/optimizer/plan/planmain.c
+++ b/src/backend/optimizer/plan/planmain.c
@@ -241,6 +241,14 @@ query_planner(PlannerInfo *root,
 	 */
 	joinlist = remove_useless_self_joins(root, joinlist);
 
+	/*
+	 * Remove any useless inner joins based on foreign key constraints.  This
+	 * must be done after equivalence classes are finalized and foreign key
+	 * lists are initialized, as the optimization relies on these structures
+	 * to safely identify and eliminate redundant relations.
+	 */
+	joinlist = remove_useless_inner_joins(root, joinlist);
+
 	/*
 	 * Now distribute "placeholders" to base rels as needed.  This has to be
 	 * done after join removal because removal could change whether a
diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c
index b2fbd6a082b..77318d1ccab 100644
--- a/src/backend/optimizer/util/plancat.c
+++ b/src/backend/optimizer/util/plancat.c
@@ -662,6 +662,7 @@ get_relation_foreign_keys(PlannerInfo *root, RelOptInfo *rel,
 			info->con_relid = rel->relid;
 			info->ref_relid = rti;
 			info->nkeys = cachedfk->nkeys;
+			info->con_deferrable = cachedfk->condeferrable;
 			memcpy(info->conkey, cachedfk->conkey, sizeof(info->conkey));
 			memcpy(info->confkey, cachedfk->confkey, sizeof(info->confkey));
 			memcpy(info->conpfeqop, cachedfk->conpfeqop, sizeof(info->conpfeqop));
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 3a4f19e8d58..2722173a11e 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -4763,6 +4763,7 @@ RelationGetFKeyList(Relation relation)
 		info->conoid = constraint->oid;
 		info->conrelid = constraint->conrelid;
 		info->confrelid = constraint->confrelid;
+		info->condeferrable = constraint->condeferrable;
 		info->conenforced = constraint->conenforced;
 
 		DeconstructFkConstraintRow(htup, &info->nkeys,
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index 27758ec16fe..fe0ad9d63b6 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -1461,6 +1461,8 @@ typedef struct ForeignKeyOptInfo
 	Index		ref_relid;
 	/* number of columns in the foreign key */
 	int			nkeys;
+	/* Is the constraint deferrable? */
+	bool		con_deferrable;
 	/* cols in referencing table */
 	AttrNumber	conkey[INDEX_MAX_KEYS] pg_node_attr(array_size(nkeys));
 	/* cols in referenced table */
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index 8751ad7381c..d4945cb9d4d 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -173,7 +173,8 @@ extern bool exprs_known_equal(PlannerInfo *root, Node *item1, Node *item2,
 							  Oid opfamily);
 extern EquivalenceClass *match_eclasses_to_foreign_key_col(PlannerInfo *root,
 														   ForeignKeyOptInfo *fkinfo,
-														   int colno);
+														   int colno,
+														   EquivalenceMember **fk_eclass_member);
 extern RestrictInfo *find_derived_clause_for_ec_member(PlannerInfo *root,
 													   EquivalenceClass *ec,
 													   EquivalenceMember *em);
diff --git a/src/include/optimizer/planmain.h b/src/include/optimizer/planmain.h
index d0dc3761b13..0b5c1c8aa98 100644
--- a/src/include/optimizer/planmain.h
+++ b/src/include/optimizer/planmain.h
@@ -120,6 +120,7 @@ extern bool innerrel_is_unique_ext(PlannerInfo *root, Relids joinrelids,
 								   JoinType jointype, List *restrictlist,
 								   bool force_cache, List **extra_clauses);
 extern List *remove_useless_self_joins(PlannerInfo *root, List *joinlist);
+extern List *remove_useless_inner_joins(PlannerInfo *root, List *joinlist);
 
 /*
  * prototypes for plan/setrefs.c
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 236830f6b93..86328e20d93 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -284,6 +284,9 @@ typedef struct ForeignKeyCacheInfo
 	/* number of columns in the foreign key */
 	int			nkeys;
 
+	/* Is deferrable ? */
+	bool		condeferrable;
+
 	/* Is enforced ? */
 	bool		conenforced;
 
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 84872c6f04e..e934cd75ed9 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -10104,3 +10104,375 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
  19000
 (1 row)
 
+--
+-- Test useless inner join removal for foreign key referenced relations
+--
+CREATE TABLE fk_parent1 (id int PRIMARY KEY, val text);
+CREATE TABLE fk_parent2 (id int PRIMARY KEY, val text);
+CREATE TABLE fk_parent_text (id text PRIMARY KEY);
+CREATE TABLE fk_multi_parent (id1 int, id2 int, val text, PRIMARY KEY (id1, id2));
+CREATE TABLE fk_child (
+    id int PRIMARY KEY,
+    p1_id int NOT NULL REFERENCES fk_parent1(id),
+    p2_id int REFERENCES fk_parent2(id),
+    p_text text REFERENCES fk_parent_text(id),
+    p_m1 int,
+    p_m2 int,
+    val text,
+    FOREIGN KEY (p_m1, p_m2) REFERENCES fk_multi_parent(id1, id2)
+);
+INSERT INTO fk_parent1 VALUES (1, 'p1_1'), (2, 'p1_2');
+INSERT INTO fk_parent2 VALUES (1, 'p2_1'), (2, 'p2_2');
+INSERT INTO fk_parent_text VALUES ('t1'), ('t2');
+INSERT INTO fk_multi_parent VALUES (1, 1, 'm1'), (2, 2, 'm2');
+INSERT INTO fk_child VALUES
+    (1, 1, 1, 't1', 1, 1, 'c1'),
+    (2, 2, NULL, 't2', 2, 2, 'c2'),
+    (3, 1, 1, 't1', 1, 1, 'c3');
+ANALYZE fk_parent1;
+ANALYZE fk_parent2;
+ANALYZE fk_parent_text;
+ANALYZE fk_multi_parent;
+ANALYZE fk_child;
+-- Ensure that fk_parent1 is removed
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+ORDER BY c.id;
+          QUERY PLAN          
+------------------------------
+ Sort
+   Sort Key: c.id
+   ->  Seq Scan on fk_child c
+(3 rows)
+
+-- Ensure that it returns all 3 rows
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+ORDER BY c.id;
+ id | val 
+----+-----
+  1 | c1
+  2 | c2
+  3 | c3
+(3 rows)
+
+-- Ensure that fk_parent2 is removed, and an IS NOT NULL qual is injected
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent2 p ON c.p2_id = p.id
+ORDER BY c.id;
+             QUERY PLAN              
+-------------------------------------
+ Sort
+   Sort Key: c.id
+   ->  Seq Scan on fk_child c
+         Filter: (p2_id IS NOT NULL)
+(4 rows)
+
+-- Ensure that it returns row 1 and row 3
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent2 p ON c.p2_id = p.id
+ORDER BY c.id;
+ id | val 
+----+-----
+  1 | c1
+  3 | c3
+(2 rows)
+
+-- Ensure that both fk_parent1 and fk_parent2 are removed
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_parent1 p1 ON c.p1_id = p1.id
+  JOIN fk_parent2 p2 ON c.p2_id = p2.id
+ORDER BY c.id;
+             QUERY PLAN              
+-------------------------------------
+ Sort
+   Sort Key: c.id
+   ->  Seq Scan on fk_child c
+         Filter: (p2_id IS NOT NULL)
+(4 rows)
+
+-- Ensure that it returns rows 1 and 3
+SELECT c.id
+FROM fk_child c
+  JOIN fk_parent1 p1 ON c.p1_id = p1.id
+  JOIN fk_parent2 p2 ON c.p2_id = p2.id
+ORDER BY c.id;
+ id 
+----
+  1
+  3
+(2 rows)
+
+-- Ensure that fk_parent1 is removed, leaving c1 joined to c2
+EXPLAIN (COSTS OFF)
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent1 p ON c1.p1_id = p.id
+  JOIN fk_child c2 ON p.id = c2.p1_id
+ORDER BY c1.id, c2.id;
+                QUERY PLAN                 
+-------------------------------------------
+ Sort
+   Sort Key: c1.id, c2.id
+   ->  Hash Join
+         Hash Cond: (c1.p1_id = c2.p1_id)
+         ->  Seq Scan on fk_child c1
+         ->  Hash
+               ->  Seq Scan on fk_child c2
+(7 rows)
+
+-- Ensure that we get 1x1, 1x3, 3x1, 3x3, 2x2
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent1 p ON c1.p1_id = p.id
+  JOIN fk_child c2 ON p.id = c2.p1_id
+ORDER BY c1.id, c2.id;
+ id | id 
+----+----
+  1 |  1
+  1 |  3
+  2 |  2
+  3 |  1
+  3 |  3
+(5 rows)
+
+-- Multi-column FK, ensure that fk_multi_parent is removed
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_multi_parent p ON c.p_m1 = p.id1 AND c.p_m2 = p.id2
+ORDER BY c.id;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Sort
+   Sort Key: c.id
+   ->  Seq Scan on fk_child c
+         Filter: ((p_m1 IS NOT NULL) AND (p_m2 IS NOT NULL))
+(4 rows)
+
+-- Ensure that we get all 3 rows
+SELECT c.id
+FROM fk_child c
+  JOIN fk_multi_parent p ON c.p_m1 = p.id1 AND c.p_m2 = p.id2
+ORDER BY c.id;
+ id 
+----
+  1
+  2
+  3
+(3 rows)
+
+-- fk_parent1 cannot be removed because p.val is selected
+EXPLAIN (COSTS OFF)
+SELECT c.id, p.val
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id;
+              QUERY PLAN              
+--------------------------------------
+ Nested Loop
+   Join Filter: (p.id = c.p1_id)
+   ->  Seq Scan on fk_child c
+   ->  Materialize
+         ->  Seq Scan on fk_parent1 p
+(5 rows)
+
+-- fk_parent1 cannot be removed because p.val is filtered
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+WHERE p.val = 'p1_1';
+              QUERY PLAN              
+--------------------------------------
+ Nested Loop
+   Join Filter: (p.id = c.p1_id)
+   ->  Seq Scan on fk_parent1 p
+         Filter: (val = 'p1_1'::text)
+   ->  Seq Scan on fk_child c
+(5 rows)
+
+-- fk_parent1 cannot be removed because p.id is in the targetlist
+EXPLAIN (COSTS OFF)
+SELECT c.id, p.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id;
+              QUERY PLAN              
+--------------------------------------
+ Nested Loop
+   Join Filter: (p.id = c.p1_id)
+   ->  Seq Scan on fk_child c
+   ->  Materialize
+         ->  Seq Scan on fk_parent1 p
+(5 rows)
+
+-- fk_parent1 cannot be removed because p.id is in the filter
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+WHERE p.id > 1;
+           QUERY PLAN            
+---------------------------------
+ Nested Loop
+   Join Filter: (p.id = c.p1_id)
+   ->  Seq Scan on fk_parent1 p
+         Filter: (id > 1)
+   ->  Seq Scan on fk_child c
+(5 rows)
+
+-- fk_parent1 cannot be removed because p.id is in the join clause
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+WHERE c.id > p.id;
+              QUERY PLAN              
+--------------------------------------
+ Hash Join
+   Hash Cond: (c.p1_id = p.id)
+   Join Filter: (c.id > p.id)
+   ->  Seq Scan on fk_child c
+   ->  Hash
+         ->  Seq Scan on fk_parent1 p
+(6 rows)
+
+-- fk_multi_parent cannot be removed because not all foreign key columns are
+-- matched
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_multi_parent p ON c.p_m1 = p.id1;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Join
+   Hash Cond: (c.p_m1 = p.id1)
+   ->  Seq Scan on fk_child c
+   ->  Hash
+         ->  Seq Scan on fk_multi_parent p
+(5 rows)
+
+-- fk_parent1 cannot be removed because p.id is referenced in lateral_vars
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_parent1 p ON c.p1_id = p.id
+  CROSS JOIN LATERAL (SELECT p.id OFFSET 0);
+                 QUERY PLAN                 
+--------------------------------------------
+ Hash Join
+   Hash Cond: (c.p1_id = p.id)
+   ->  Seq Scan on fk_child c
+   ->  Hash
+         ->  Nested Loop
+               ->  Seq Scan on fk_parent1 p
+               ->  Result
+(7 rows)
+
+-- fk_parent2 cannot be removed because p2.id is referenced in the semi-join
+-- RHS expressions
+EXPLAIN (COSTS OFF)
+SELECT * FROM fk_parent1 p1
+WHERE p1.id IN
+  (SELECT p2.id FROM fk_child c JOIN fk_parent2 p2 ON c.p2_id = p2.id);
+                    QUERY PLAN                     
+---------------------------------------------------
+ Nested Loop Semi Join
+   Join Filter: (p1.id = c.p2_id)
+   ->  Seq Scan on fk_parent1 p1
+   ->  Materialize
+         ->  Hash Join
+               Hash Cond: (c.p2_id = p2.id)
+               ->  Seq Scan on fk_child c
+               ->  Hash
+                     ->  Seq Scan on fk_parent2 p2
+(9 rows)
+
+-- fk_parent_text cannot be removed due to the COLLATE "C" mismatch splitting
+-- the EC
+EXPLAIN (COSTS OFF)
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent_text p ON c1.p_text = p.id
+  JOIN fk_child c2 ON p.id COLLATE "C" = c2.p_text COLLATE "C";
+                   QUERY PLAN                    
+-------------------------------------------------
+ Hash Join
+   Hash Cond: ((p.id)::text = (c2.p_text)::text)
+   ->  Nested Loop
+         Join Filter: (p.id = c1.p_text)
+         ->  Seq Scan on fk_child c1
+         ->  Materialize
+               ->  Seq Scan on fk_parent_text p
+   ->  Hash
+         ->  Seq Scan on fk_child c2
+(9 rows)
+
+-- fk_parent1 cannot be removed because p.id + 0 splits the EC
+EXPLAIN (COSTS OFF)
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent1 p ON c1.p1_id = p.id
+  JOIN fk_child c2 ON (p.id + 0) = c2.p1_id;
+                 QUERY PLAN                 
+--------------------------------------------
+ Hash Join
+   Hash Cond: ((p.id + 0) = c2.p1_id)
+   ->  Nested Loop
+         Join Filter: (p.id = c1.p1_id)
+         ->  Seq Scan on fk_child c1
+         ->  Materialize
+               ->  Seq Scan on fk_parent1 p
+   ->  Hash
+         ->  Seq Scan on fk_child c2
+(9 rows)
+
+-- Ensure that the right join is not removed
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c RIGHT JOIN fk_parent1 p ON c.p1_id = p.id;
+              QUERY PLAN              
+--------------------------------------
+ Hash Right Join
+   Hash Cond: (c.p1_id = p.id)
+   ->  Seq Scan on fk_child c
+   ->  Hash
+         ->  Seq Scan on fk_parent1 p
+(5 rows)
+
+-- Ensure that the full join is not removed
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c FULL JOIN fk_parent1 p ON c.p1_id = p.id;
+              QUERY PLAN              
+--------------------------------------
+ Hash Full Join
+   Hash Cond: (c.p1_id = p.id)
+   ->  Seq Scan on fk_child c
+   ->  Hash
+         ->  Seq Scan on fk_parent1 p
+(5 rows)
+
+-- fk_parent1 cannot be removed because it is separated with fk_child by the
+-- outer join boundary
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN (fk_parent1 p1
+          LEFT JOIN fk_parent2 p2 ON TRUE)
+  ON c.p1_id = p1.id;
+                 QUERY PLAN                  
+---------------------------------------------
+ Nested Loop Left Join
+   ->  Nested Loop
+         Join Filter: (p1.id = c.p1_id)
+         ->  Seq Scan on fk_child c
+         ->  Materialize
+               ->  Seq Scan on fk_parent1 p1
+   ->  Materialize
+         ->  Seq Scan on fk_parent2 p2
+(8 rows)
+
+DROP TABLE fk_child;
+DROP TABLE fk_multi_parent;
+DROP TABLE fk_parent_text;
+DROP TABLE fk_parent2;
+DROP TABLE fk_parent1;
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 30b479dda7c..f2f867d2f29 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -3868,3 +3868,193 @@ SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
     ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand);
 SELECT COUNT(*) FROM onek t1 LEFT JOIN tenk1 t2
     ON (t2.thousand = t1.tenthous OR t2.thousand = t1.thousand);
+
+--
+-- Test useless inner join removal for foreign key referenced relations
+--
+
+CREATE TABLE fk_parent1 (id int PRIMARY KEY, val text);
+CREATE TABLE fk_parent2 (id int PRIMARY KEY, val text);
+CREATE TABLE fk_parent_text (id text PRIMARY KEY);
+CREATE TABLE fk_multi_parent (id1 int, id2 int, val text, PRIMARY KEY (id1, id2));
+
+CREATE TABLE fk_child (
+    id int PRIMARY KEY,
+    p1_id int NOT NULL REFERENCES fk_parent1(id),
+    p2_id int REFERENCES fk_parent2(id),
+    p_text text REFERENCES fk_parent_text(id),
+    p_m1 int,
+    p_m2 int,
+    val text,
+    FOREIGN KEY (p_m1, p_m2) REFERENCES fk_multi_parent(id1, id2)
+);
+
+INSERT INTO fk_parent1 VALUES (1, 'p1_1'), (2, 'p1_2');
+INSERT INTO fk_parent2 VALUES (1, 'p2_1'), (2, 'p2_2');
+INSERT INTO fk_parent_text VALUES ('t1'), ('t2');
+INSERT INTO fk_multi_parent VALUES (1, 1, 'm1'), (2, 2, 'm2');
+
+INSERT INTO fk_child VALUES
+    (1, 1, 1, 't1', 1, 1, 'c1'),
+    (2, 2, NULL, 't2', 2, 2, 'c2'),
+    (3, 1, 1, 't1', 1, 1, 'c3');
+
+ANALYZE fk_parent1;
+ANALYZE fk_parent2;
+ANALYZE fk_parent_text;
+ANALYZE fk_multi_parent;
+ANALYZE fk_child;
+
+-- Ensure that fk_parent1 is removed
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+ORDER BY c.id;
+
+-- Ensure that it returns all 3 rows
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+ORDER BY c.id;
+
+-- Ensure that fk_parent2 is removed, and an IS NOT NULL qual is injected
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent2 p ON c.p2_id = p.id
+ORDER BY c.id;
+
+-- Ensure that it returns row 1 and row 3
+SELECT c.id, c.val
+FROM fk_child c JOIN fk_parent2 p ON c.p2_id = p.id
+ORDER BY c.id;
+
+-- Ensure that both fk_parent1 and fk_parent2 are removed
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_parent1 p1 ON c.p1_id = p1.id
+  JOIN fk_parent2 p2 ON c.p2_id = p2.id
+ORDER BY c.id;
+
+-- Ensure that it returns rows 1 and 3
+SELECT c.id
+FROM fk_child c
+  JOIN fk_parent1 p1 ON c.p1_id = p1.id
+  JOIN fk_parent2 p2 ON c.p2_id = p2.id
+ORDER BY c.id;
+
+-- Ensure that fk_parent1 is removed, leaving c1 joined to c2
+EXPLAIN (COSTS OFF)
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent1 p ON c1.p1_id = p.id
+  JOIN fk_child c2 ON p.id = c2.p1_id
+ORDER BY c1.id, c2.id;
+
+-- Ensure that we get 1x1, 1x3, 3x1, 3x3, 2x2
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent1 p ON c1.p1_id = p.id
+  JOIN fk_child c2 ON p.id = c2.p1_id
+ORDER BY c1.id, c2.id;
+
+-- Multi-column FK, ensure that fk_multi_parent is removed
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_multi_parent p ON c.p_m1 = p.id1 AND c.p_m2 = p.id2
+ORDER BY c.id;
+
+-- Ensure that we get all 3 rows
+SELECT c.id
+FROM fk_child c
+  JOIN fk_multi_parent p ON c.p_m1 = p.id1 AND c.p_m2 = p.id2
+ORDER BY c.id;
+
+-- fk_parent1 cannot be removed because p.val is selected
+EXPLAIN (COSTS OFF)
+SELECT c.id, p.val
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id;
+
+-- fk_parent1 cannot be removed because p.val is filtered
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+WHERE p.val = 'p1_1';
+
+-- fk_parent1 cannot be removed because p.id is in the targetlist
+EXPLAIN (COSTS OFF)
+SELECT c.id, p.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id;
+
+-- fk_parent1 cannot be removed because p.id is in the filter
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+WHERE p.id > 1;
+
+-- fk_parent1 cannot be removed because p.id is in the join clause
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c JOIN fk_parent1 p ON c.p1_id = p.id
+WHERE c.id > p.id;
+
+-- fk_multi_parent cannot be removed because not all foreign key columns are
+-- matched
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_multi_parent p ON c.p_m1 = p.id1;
+
+-- fk_parent1 cannot be removed because p.id is referenced in lateral_vars
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN fk_parent1 p ON c.p1_id = p.id
+  CROSS JOIN LATERAL (SELECT p.id OFFSET 0);
+
+-- fk_parent2 cannot be removed because p2.id is referenced in the semi-join
+-- RHS expressions
+EXPLAIN (COSTS OFF)
+SELECT * FROM fk_parent1 p1
+WHERE p1.id IN
+  (SELECT p2.id FROM fk_child c JOIN fk_parent2 p2 ON c.p2_id = p2.id);
+
+-- fk_parent_text cannot be removed due to the COLLATE "C" mismatch splitting
+-- the EC
+EXPLAIN (COSTS OFF)
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent_text p ON c1.p_text = p.id
+  JOIN fk_child c2 ON p.id COLLATE "C" = c2.p_text COLLATE "C";
+
+-- fk_parent1 cannot be removed because p.id + 0 splits the EC
+EXPLAIN (COSTS OFF)
+SELECT c1.id, c2.id
+FROM fk_child c1
+  JOIN fk_parent1 p ON c1.p1_id = p.id
+  JOIN fk_child c2 ON (p.id + 0) = c2.p1_id;
+
+-- Ensure that the right join is not removed
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c RIGHT JOIN fk_parent1 p ON c.p1_id = p.id;
+
+-- Ensure that the full join is not removed
+EXPLAIN (COSTS OFF)
+SELECT c.id, c.val
+FROM fk_child c FULL JOIN fk_parent1 p ON c.p1_id = p.id;
+
+-- fk_parent1 cannot be removed because it is separated with fk_child by the
+-- outer join boundary
+EXPLAIN (COSTS OFF)
+SELECT c.id
+FROM fk_child c
+  JOIN (fk_parent1 p1
+          LEFT JOIN fk_parent2 p2 ON TRUE)
+  ON c.p1_id = p1.id;
+
+DROP TABLE fk_child;
+DROP TABLE fk_multi_parent;
+DROP TABLE fk_parent_text;
+DROP TABLE fk_parent2;
+DROP TABLE fk_parent1;
-- 
2.39.5 (Apple Git-154)

