From ccd88bc8dbb674476b5ce68a8b114b4e57fbc067 Mon Sep 17 00:00:00 2001
From: William Bernbaum <wbernbaum@dwdev.com>
Date: Wed, 26 Aug 2026 12:51:09 -0700
Subject: [PATCH v1 09/16] Deduplicate via semijoin

Where a join input only decides which rows of the other survive, add paths that build the deduplicated relation with a semijoin, so the join stops at the first match and emits one row per lefthand row instead of one row per match.
---
 src/backend/optimizer/path/joinrels.c         | 86 ++++++++++++++++++-
 src/test/regress/expected/eager_aggregate.out | 26 ++----
 2 files changed, 93 insertions(+), 19 deletions(-)

diff --git a/src/backend/optimizer/path/joinrels.c b/src/backend/optimizer/path/joinrels.c
index d5557d88686..b0df37ca4f3 100644
--- a/src/backend/optimizer/path/joinrels.c
+++ b/src/backend/optimizer/path/joinrels.c
@@ -41,12 +41,16 @@ static bool restriction_is_constant_false(List *restrictlist,
 static void make_grouped_join_rel(PlannerInfo *root, RelOptInfo *rel1,
 								  RelOptInfo *rel2, RelOptInfo *joinrel,
 								  SpecialJoinInfo *sjinfo, List *restrictlist);
+static bool dedup_rhs_unobservable(PlannerInfo *root, RelOptInfo *grouped_rel,
+								   RelOptInfo *rhs);
+static void make_dedup_semijoin_paths(PlannerInfo *root, RelOptInfo *lhs,
+									  RelOptInfo *rhs, RelOptInfo *joinrel,
+									  RelOptInfo *grouped_rel,
+									  List *restrictlist);
 static void make_deduplicated_join_rel(PlannerInfo *root, RelOptInfo *rel1,
 									   RelOptInfo *rel2, RelOptInfo *joinrel,
 									   SpecialJoinInfo *sjinfo,
 									   List *restrictlist);
-static bool dedup_rhs_unobservable(PlannerInfo *root, RelOptInfo *grouped_rel,
-								   RelOptInfo *rhs);
 static void populate_joinrel_with_paths(PlannerInfo *root, RelOptInfo *rel1,
 										RelOptInfo *rel2, RelOptInfo *joinrel,
 										SpecialJoinInfo *sjinfo, List *restrictlist);
@@ -1124,6 +1128,17 @@ make_deduplicated_join_rel(PlannerInfo *root, RelOptInfo *rel1,
 	if (IS_DUMMY_REL(grouped_rel))
 		return;
 
+	/* A filtering input needs only one match */
+	if (sjinfo->jointype == JOIN_INNER)
+	{
+		if (dedup_rhs_unobservable(root, grouped_rel, rel2))
+			make_dedup_semijoin_paths(root, rel1, rel2, joinrel, grouped_rel,
+									  restrictlist);
+		if (dedup_rhs_unobservable(root, grouped_rel, rel1))
+			make_dedup_semijoin_paths(root, rel2, rel1, joinrel, grouped_rel,
+									  restrictlist);
+	}
+
 	/* generate_grouped_paths() covers the case of two plain inputs */
 	if (rel1_empty && rel2_empty)
 		return;
@@ -1182,6 +1197,73 @@ dedup_rhs_unobservable(PlannerInfo *root, RelOptInfo *grouped_rel,
 	return true;
 }
 
+/*
+ * make_dedup_semijoin_paths
+ *	  Add paths that reach the deduplicated relation through a semijoin.
+ *
+ * The SpecialJoinInfo exists to cost these paths.  Join order enumeration
+ * works from root->join_info_list, which still describes the query's own
+ * joins.  The paths land in the deduplicated relation beside the ones already
+ * there, so cost chooses between probing for a match and deduplicating below
+ * the join.
+ */
+static void
+make_dedup_semijoin_paths(PlannerInfo *root, RelOptInfo *lhs, RelOptInfo *rhs,
+						  RelOptInfo *joinrel, RelOptInfo *grouped_rel,
+						  List *restrictlist)
+{
+	SpecialJoinInfo *sjinfo;
+	RelOptInfo *semi;
+	SavedSelectivities *saved;
+
+	/* Reducing matches to their existence is only safe if none are counted */
+	Assert(root->agg_clause_list == NIL);
+
+	sjinfo = makeNode(SpecialJoinInfo);
+	sjinfo->min_lefthand = lhs->relids;
+	sjinfo->min_righthand = rhs->relids;
+	sjinfo->syn_lefthand = lhs->relids;
+	sjinfo->syn_righthand = rhs->relids;
+	sjinfo->jointype = JOIN_SEMI;
+	sjinfo->ojrelid = 0;
+	sjinfo->commute_above_l = NULL;
+	sjinfo->commute_above_r = NULL;
+	sjinfo->commute_below_l = NULL;
+	sjinfo->commute_below_r = NULL;
+	sjinfo->lhs_strict = false;
+
+	/*
+	 * A semijoin can be planned by deduplicating the righthand side on the
+	 * join keys and joining that as an inner join.  That sorts or hashes
+	 * every righthand row before the join starts.  The point here is to stop
+	 * at the first match, touching only as much of the righthand side as that
+	 * takes.
+	 */
+	sjinfo->semi_can_btree = false;
+	sjinfo->semi_can_hash = false;
+	sjinfo->semi_operators = NIL;
+	sjinfo->semi_rhs_exprs = NIL;
+
+	semi = build_grouped_rel(root, joinrel);
+	semi->reltarget = grouped_rel->agg_info->agg_input;
+
+	/* These clauses belong to the inner join we are shadowing */
+	saved = hide_clause_selectivities(restrictlist);
+
+	set_joinrel_size_estimates(root, semi, lhs, rhs, sjinfo, restrictlist);
+
+	add_paths_to_joinrel(root, semi, lhs, rhs, JOIN_SEMI, sjinfo,
+						 restrictlist);
+
+	restore_clause_selectivities(saved);
+
+	if (semi->pathlist == NIL)
+		return;
+
+	set_cheapest(semi);
+	generate_grouped_paths(root, grouped_rel, semi);
+}
+
 /*
  * populate_joinrel_with_paths
  *	  Add paths to the given joinrel for given pair of joining relations. The
diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out
index aa76e5cd92e..96b28cb9b61 100644
--- a/src/test/regress/expected/eager_aggregate.out
+++ b/src/test/regress/expected/eager_aggregate.out
@@ -1948,8 +1948,8 @@ SELECT DISTINCT t1.id
   FROM eager_distinct_t1 t1
   JOIN eager_distinct_t2 t2 ON t2.t1_id = t1.id
 ORDER BY t1.id;
-                                 QUERY PLAN                                  
------------------------------------------------------------------------------
+                           QUERY PLAN                            
+-----------------------------------------------------------------
  Sort
    Output: t1.id
    Sort Key: t1.id
@@ -1959,22 +1959,14 @@ ORDER BY t1.id;
          ->  Sort
                Output: t1.id
                Sort Key: t1.id
-               ->  Hash Join
+               ->  Nested Loop Semi Join
                      Output: t1.id
-                     Hash Cond: (t2.t1_id = t1.id)
-                     ->  Partial GroupAggregate
-                           Output: t2.t1_id
-                           Group Key: t2.t1_id
-                           ->  Sort
-                                 Output: t2.t1_id
-                                 Sort Key: t2.t1_id
-                                 ->  Seq Scan on public.eager_distinct_t2 t2
-                                       Output: t2.t1_id
-                     ->  Hash
-                           Output: t1.id
-                           ->  Seq Scan on public.eager_distinct_t1 t1
-                                 Output: t1.id
-(24 rows)
+                     Join Filter: (t1.id = t2.t1_id)
+                     ->  Seq Scan on public.eager_distinct_t1 t1
+                           Output: t1.id, t1.val
+                     ->  Seq Scan on public.eager_distinct_t2 t2
+                           Output: t2.id, t2.t1_id, t2.flag
+(16 rows)
 
 SELECT DISTINCT t1.id
   FROM eager_distinct_t1 t1
