From de31ce2ff007b7a1cad1d8a0fa95b725a57346a3 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Sun, 26 Jul 2026 11:39:07 +0000
Subject: [PATCH v10 11/21] Consider foreign keys when estimating filter
 selectivity

When estimating the filter selectivity, consider foreign keys, similarly
to calc_joinrel_size_estimate.

This change is incomplete - it only works for filters built directly on
a base relation, not for joins. bloom_build_side_join_ratio fails to
consider the FK when calculating the join size. While it calls
get_foreign_key_join_selectivity, that only handles FK between the base
relation (where we try to push the filter) and the join the filter is
built on. We'd have to deduce how to split the build relids (in reverse
to how we build them), which seems unnecessary. Instead, we should
calculate the join size incrementally during the bottom-up phase, just
like during the regular join planning phase.
---
 src/backend/optimizer/path/allpaths.c | 74 ++++++++++++++++++++++++---
 src/backend/optimizer/path/costsize.c |  7 +--
 src/include/optimizer/cost.h          |  5 ++
 src/test/regress/expected/join.out    | 10 ++--
 4 files changed, 78 insertions(+), 18 deletions(-)

diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c
index 78cfc5013b6..ef96e27f56d 100644
--- a/src/backend/optimizer/path/allpaths.c
+++ b/src/backend/optimizer/path/allpaths.c
@@ -1068,6 +1068,7 @@ bloom_build_side_join_ratio(PlannerInfo *root, RelOptInfo *rel,
 	int		   *dsu;
 	double		nrows = 1.0;
 	Selectivity clausesel;
+	Selectivity fkselec;
 	SpecialJoinInfo sjinfo;
 	ListCell   *lc;
 	int			i;
@@ -1249,7 +1250,21 @@ bloom_build_side_join_ratio(PlannerInfo *root, RelOptInfo *rel,
 	 * use the same thing here?
 	 */
 	init_dummy_sjinfo(&sjinfo, rel->relids, build_relids);
-	clausesel = clauselist_selectivity(root, clauses, 0, JOIN_INNER, &sjinfo);
+
+	/*
+	 * Just like calc_joinrel_size_estimate (and also
+	 * find_interesting_bloom_filters for the per-relation estimates), match
+	 * the clauses to foreign keys and estimate those using FK semantics.
+	 * Without this, joins matching a multi-column foreign key get badly
+	 * underestimated, because each of the join clauses applies the
+	 * selectivity of the restrictions on the referenced relation.
+	 */
+	fkselec = get_foreign_key_join_selectivity(root, rel->relids, build_relids,
+											   &sjinfo, &clauses);
+
+	clausesel = fkselec * clauselist_selectivity(root, clauses, 0,
+												 JOIN_INNER, &sjinfo);
+	CLAMP_PROBABILITY(clausesel);
 
 	bms_free(setrelids);
 
@@ -1318,7 +1333,7 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 		if (rinfo->parent_ec != NULL)
 			continue;
 
-		candidates = lappend(candidates, rinfo->clause);
+		candidates = lappend(candidates, rinfo);
 	}
 
 	/*
@@ -1345,6 +1360,7 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 	foreach(lc, candidates)
 	{
 		Node	   *clause = (Node *) lfirst(lc);
+		RestrictInfo *rinfo = NULL;
 		Node	   *left;
 		Node	   *right;
 		Node	   *ownerexpr;
@@ -1353,9 +1369,16 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 		Relids		build_relids;
 		int			buildrel;
 
-		/* strip RestrictInfo (see comment above) */
+		/*
+		 * Strip RestrictInfo (see comment above), but remember it - we need
+		 * it to match the clauses to foreign keys when estimating the filter
+		 * selectivity below.
+		 */
 		if (IsA(clause, RestrictInfo))
-			clause = (Node *) ((RestrictInfo *) clause)->clause;
+		{
+			rinfo = (RestrictInfo *) clause;
+			clause = (Node *) rinfo->clause;
+		}
 
 		/*
 		 * Only care about (Expr op Expr) clauses. We know one side has to be
@@ -1404,13 +1427,26 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 
 		Assert(root->simple_rel_array[buildrel]->reloptkind == RELOPT_BASEREL);
 
-		clauses_by_rel[buildrel] = lappend(clauses_by_rel[buildrel], clause);
+		/* all the candidate clauses come from RestrictInfos */
+		Assert(rinfo != NULL);
+
+		clauses_by_rel[buildrel] = lappend(clauses_by_rel[buildrel], rinfo);
 		has_rel[buildrel] = true;
 	}
 
 	/*
 	 * Pre-compute the per-build-relation semijoin selectivity.
 	 *
+	 * A build relation may be joined by multiple clauses (e.g. when the join
+	 * matches a multi-column key). Simply multiplying the per-clause
+	 * selectivities assumes the columns are independent, which is badly wrong
+	 * for such joins - it applies the selectivity of restrictions on the
+	 * build relation once per join clause. So we first match the clauses to
+	 * foreign keys (the same way join size estimates do in
+	 * calc_joinrel_size_estimate), and estimate those using FK semantics.
+	 * Only the clauses not matched to any foreign key are then estimated the
+	 * regular way.
+	 *
 	 * XXX I think we could calculate the selectivity only for semijoin-like
 	 * joins (semijoin, inner join), and ignore the rest. We don't even need
 	 * to calculate interesting filters for those cases.
@@ -1420,6 +1456,10 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 	{
 		SpecialJoinInfo sjinfo;
 		Relids		build_relids;
+		List	   *worklist;
+		List	   *clauses = NIL;
+		Selectivity fkselec;
+		ListCell   *lc2;
 
 		/* ignore relations without join clauses */
 		if (!has_rel[i])
@@ -1429,8 +1469,28 @@ find_interesting_bloom_filters(PlannerInfo *root, RelOptInfo *rel)
 		init_dummy_sjinfo(&sjinfo, rel->relids, build_relids);
 		sjinfo.jointype = JOIN_SEMI;
 
-		sel_by_rel[i] = clauselist_selectivity(root, clauses_by_rel[i], 0,
-											   JOIN_SEMI, &sjinfo);
+		/*
+		 * Estimate clauses matching a foreign key using FK semantics. This
+		 * removes the matched clauses from the worklist (which is a copy, the
+		 * original list is left alone).
+		 */
+		worklist = clauses_by_rel[i];
+		fkselec = get_foreign_key_join_selectivity(root, rel->relids,
+												   build_relids, &sjinfo,
+												   &worklist);
+
+		/* strip RestrictInfo from the remaining clauses (see comment above) */
+		foreach(lc2, worklist)
+			clauses = lappend(clauses, ((RestrictInfo *) lfirst(lc2))->clause);
+
+		sel_by_rel[i] = fkselec * clauselist_selectivity(root, clauses, 0,
+														 JOIN_SEMI, &sjinfo);
+
+		CLAMP_PROBABILITY(sel_by_rel[i]);
+
+		list_free(clauses);
+		if (worklist != clauses_by_rel[i])
+			list_free(worklist);
 		bms_free(build_relids);
 	}
 
diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 48e6d582a73..0448d4f839c 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -227,11 +227,6 @@ static double calc_joinrel_size_estimate(PlannerInfo *root,
 										 double inner_rows,
 										 SpecialJoinInfo *sjinfo,
 										 List *restrictlist);
-static Selectivity get_foreign_key_join_selectivity(PlannerInfo *root,
-													Relids outer_relids,
-													Relids inner_relids,
-													SpecialJoinInfo *sjinfo,
-													List **restrictlist);
 static Cost append_nonpartial_cost(List *subpaths, int numpaths,
 								   int parallel_workers);
 static void set_rel_width(PlannerInfo *root, RelOptInfo *rel);
@@ -5858,7 +5853,7 @@ calc_joinrel_size_estimate(PlannerInfo *root,
  * able to get a better answer when the pg_statistic stats are missing or out
  * of date.
  */
-static Selectivity
+Selectivity
 get_foreign_key_join_selectivity(PlannerInfo *root,
 								 Relids outer_relids,
 								 Relids inner_relids,
diff --git a/src/include/optimizer/cost.h b/src/include/optimizer/cost.h
index 00f6ab5653b..d98eb2cb571 100644
--- a/src/include/optimizer/cost.h
+++ b/src/include/optimizer/cost.h
@@ -210,6 +210,11 @@ extern double get_parameterized_joinrel_size(PlannerInfo *root,
 											 Path *inner_path,
 											 SpecialJoinInfo *sjinfo,
 											 List *restrict_clauses);
+extern Selectivity get_foreign_key_join_selectivity(PlannerInfo *root,
+													Relids outer_relids,
+													Relids inner_relids,
+													SpecialJoinInfo *sjinfo,
+													List **restrictlist);
 extern void set_joinrel_size_estimates(PlannerInfo *root, RelOptInfo *rel,
 									   RelOptInfo *outer_rel,
 									   RelOptInfo *inner_rel,
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 42ab6de25e1..d467cba0bea 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -10267,16 +10267,16 @@ select * from fkest f1
                           QUERY PLAN                           
 ---------------------------------------------------------------
  Hash Join
-   Hash Cond: ((f2.x = f1.x) AND (f2.x10b = f1.x10))
+   Hash Cond: ((f1.x = f2.x) AND (f1.x10 = f2.x10b))
    ->  Nested Loop
-         ->  Seq Scan on fkest f2
+         ->  Seq Scan on fkest f1
                Filter: (x100 = 2)
-               Bloom Filter 1: keys=(x, x10b)
+               Bloom Filter 1: keys=(x, x10)
          ->  Index Scan using fkest_x_x10_x100_idx on fkest f3
-               Index Cond: (x = f2.x)
+               Index Cond: (x = f1.x)
    ->  Hash
          Bloom Filter 1
-         ->  Seq Scan on fkest f1
+         ->  Seq Scan on fkest f2
                Filter: (x100 = 2)
 (12 rows)
 
-- 
2.50.1 (Apple Git-155)

