From 694c64bc50cd11f1e41eb110b3bcf0997edb50be Mon Sep 17 00:00:00 2001
From: Evdokimov Ilia <ilya.evdokimov@tantorlabs.com>
Date: Mon, 28 Sep 2026 15:49:01 +0500
Subject: [PATCH v4 1/2] Account for NULLs in FK-based join selectivity

get_foreign_key_join_selectivity() assumes that each referencing row
matches exactly one row in the referenced table.  That's not true for
rows having a NULL in any of the referencing columns, which match
nothing, so the join size could be overestimated considerably: by 2x
when half of the referencing values are NULL.  The regular clause-based
estimate in eqjoinsel() does take the null fraction into account.

Derate the FK-based selectivity by the fraction of referencing rows with
a NULL in some FK column.  The XXX comment there gave two reasons for
not doing so, which are handled as follows.  Columns constrained by a
strict restriction clause of the referencing rel are skipped, since
their NULLs are already excluded from its row count.  For multi-column
FKs, take the largest per-column null fraction: that's exact when the
columns are NULL together, as is typical, and a lower bound otherwise.
---
 src/backend/optimizer/path/costsize.c | 70 +++++++++++++++++++++++----
 1 file changed, 61 insertions(+), 9 deletions(-)

diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c
index 7bbddb8bee4..cd494d29cbb 100644
--- a/src/backend/optimizer/path/costsize.c
+++ b/src/backend/optimizer/path/costsize.c
@@ -88,12 +88,14 @@
 #include "access/amapi.h"
 #include "access/htup_details.h"
 #include "access/tsmapi.h"
+#include "catalog/pg_statistic.h"
 #include "executor/executor.h"
 #include "executor/nodeAgg.h"
 #include "executor/nodeHash.h"
 #include "executor/nodeMemoize.h"
 #include "miscadmin.h"
 #include "nodes/makefuncs.h"
+#include "nodes/multibitmapset.h"
 #include "nodes/nodeFuncs.h"
 #include "nodes/tidbitmap.h"
 #include "optimizer/clauses.h"
@@ -108,6 +110,7 @@
 #include "utils/lsyscache.h"
 #include "utils/selfuncs.h"
 #include "utils/spccache.h"
+#include "utils/syscache.h"
 #include "utils/tuplesort.h"
 
 
@@ -198,6 +201,8 @@ static Selectivity get_foreign_key_join_selectivity(PlannerInfo *root,
 													Relids inner_relids,
 													SpecialJoinInfo *sjinfo,
 													List **restrictlist);
+static Selectivity fkey_referencing_nullfrac(PlannerInfo *root,
+											 ForeignKeyOptInfo *fkinfo);
 static Cost append_nonpartial_cost(List *subpaths, int numpaths,
 								   int parallel_workers);
 static void set_rel_width(PlannerInfo *root, RelOptInfo *rel);
@@ -6051,15 +6056,9 @@ get_foreign_key_join_selectivity(PlannerInfo *root,
 		/*
 		 * Finally we get to the payoff: estimate selectivity using the
 		 * knowledge that each referencing row will match exactly one row in
-		 * the referenced table.
-		 *
-		 * XXX that's not true in the presence of nulls in the referencing
-		 * column(s), so in principle we should derate the estimate for those.
-		 * However (1) if there are any strict restriction clauses for the
-		 * referencing column(s) elsewhere in the query, derating here would
-		 * be double-counting the null fraction, and (2) it's not very clear
-		 * how to combine null fractions for multiple referencing columns. So
-		 * we do nothing for now about correcting for nulls.
+		 * the referenced table.  That's not true for referencing rows with
+		 * nulls in the FK columns, which match nothing; we derate the
+		 * estimate for those below.
 		 *
 		 * XXX another point here is that if either side of an FK constraint
 		 * is an inheritance parent, we estimate as though the constraint
@@ -6102,6 +6101,9 @@ get_foreign_key_join_selectivity(PlannerInfo *root,
 			fkselec *= 1.0 / ref_tuples;
 		}
 
+		/* Referencing rows with nulls in the FK columns have no match */
+		fkselec *= 1.0 - fkey_referencing_nullfrac(root, fkinfo);
+
 		/*
 		 * If any of the FK columns participated in ec_has_const ECs, then
 		 * equivclass.c will have generated "var = const" restrictions for
@@ -6147,6 +6149,56 @@ get_foreign_key_join_selectivity(PlannerInfo *root,
 	return fkselec;
 }
 
+/*
+ * fkey_referencing_nullfrac
+ *		Estimate the fraction of the referencing rel's rows that have a null
+ *		in at least one of the FK columns.
+ *
+ * Columns constrained by a strict restriction clause of the referencing rel
+ * are skipped: their nulls are already excluded from the rel's row count, so
+ * counting them again here would underestimate the join size.  For the
+ * remaining columns we take the largest null fraction.  That's exact when
+ * the columns are null together, as is typical for multi-column FKs, and
+ * otherwise it's a lower bound.
+ */
+static Selectivity
+fkey_referencing_nullfrac(PlannerInfo *root, ForeignKeyOptInfo *fkinfo)
+{
+	RelOptInfo *con_rel = find_base_rel(root, fkinfo->con_relid);
+	RangeTblEntry *rte = planner_rt_fetch(fkinfo->con_relid, root);
+	List	   *nonnullable_vars = NIL;
+	Selectivity nullfrac = 0.0;
+
+	foreach_node(RestrictInfo, rinfo, con_rel->baserestrictinfo)
+		nonnullable_vars =
+			mbms_add_members(nonnullable_vars,
+							 find_nonnullable_vars((Node *) rinfo->clause));
+
+	for (int i = 0; i < fkinfo->nkeys; i++)
+	{
+		AttrNumber	attno = fkinfo->conkey[i];
+		HeapTuple	tup;
+
+		if (mbms_is_member(fkinfo->con_relid,
+						   attno - FirstLowInvalidHeapAttributeNumber,
+						   nonnullable_vars))
+			continue;
+
+		tup = SearchSysCache3(STATRELATTINH,
+							  ObjectIdGetDatum(rte->relid),
+							  Int16GetDatum(attno),
+							  BoolGetDatum(rte->inh));
+		if (HeapTupleIsValid(tup))
+		{
+			nullfrac = Max(nullfrac,
+						   ((Form_pg_statistic) GETSTRUCT(tup))->stanullfrac);
+			ReleaseSysCache(tup);
+		}
+	}
+
+	return nullfrac;
+}
+
 /*
  * set_subquery_size_estimates
  *		Set the size estimates for a base relation that is a subquery.
-- 
2.43.0

