From 0fa6941af7e189d33ef9dcdf286f1046cf17eb3c Mon Sep 17 00:00:00 2001
From: William Bernbaum <wbernbaum@dwdev.com>
Date: Wed, 26 Aug 2026 15:40:31 -0700
Subject: [PATCH v1 12/16] Derive grouping keys from aggregates carrying
 DISTINCT

agg(DISTINCT x) discards duplicate x before aggregating, so its arguments serve as grouping keys for a query with no GROUP BY or DISTINCT. Eager aggregation can then deduplicate on those keys beneath a to-many join.

An aggregate without DISTINCT counts duplicates and rules this out, as does a FILTER clause.
---
 src/backend/optimizer/plan/initsplan.c        | 280 ++++++++--
 src/backend/optimizer/util/relnode.c          |  13 +-
 src/test/regress/expected/eager_aggregate.out | 500 ++++++++++++++++++
 src/test/regress/sql/eager_aggregate.sql      | 232 ++++++++
 4 files changed, 984 insertions(+), 41 deletions(-)

diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index d7ff206c1d3..afec4939654 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -98,6 +98,14 @@ typedef struct GroupByColInfo
 static bool is_partial_agg_memory_risky(PlannerInfo *root);
 static void create_agg_clause_infos(PlannerInfo *root);
 static bool grouping_key_usable(Expr *expr);
+static Index max_sortgroupref(List *tlist);
+static List *pull_agg_level_exprs(PlannerInfo *root);
+static bool aggref_is_plain(Aggref *aggref);
+static bool collect_distinct_agg_keys(Aggref *aggref, Index *nextref,
+									  List **exprs, List **clauses);
+static void add_grouping_expr_infos(PlannerInfo *root, List *exprs,
+									List *clauses);
+static bool create_distinct_agg_grouping(PlannerInfo *root);
 static void create_grouping_expr_infos(PlannerInfo *root);
 static EquivalenceClass *get_eclass_for_sortgroupclause(PlannerInfo *root,
 														SortGroupClause *sgc,
@@ -711,9 +719,6 @@ setup_eager_aggregation(PlannerInfo *root)
 	else if (root->parse->distinctClause && !root->parse->hasDistinctOn)
 		root->eager_group_clause = root->processed_distinctClause;
 
-	if (root->eager_group_clause == NIL)
-		return;
-
 	/*
 	 * For now we don't try to support grouping sets.
 	 */
@@ -721,31 +726,46 @@ setup_eager_aggregation(PlannerInfo *root)
 		return;
 
 	/*
-	 * For now we don't try to support DISTINCT or ORDER BY aggregates.
+	 * We don't try to apply eager aggregation if there are set-returning
+	 * functions in targetlist.
 	 */
-	if (root->numOrderedAggs > 0)
+	if (root->parse->hasTargetSRFs)
 		return;
 
 	/*
-	 * If there are any aggregates that do not support partial mode, or any
-	 * partial aggregates that are non-serializable, do not apply eager
-	 * aggregation.
+	 * Eager aggregation only makes sense if there are multiple base rels in
+	 * the query.
 	 */
-	if (root->hasNonPartialAggs || root->hasNonSerialAggs)
+	if (bms_membership(root->all_baserels) != BMS_MULTIPLE)
 		return;
 
 	/*
-	 * We don't try to apply eager aggregation if there are set-returning
-	 * functions in targetlist.
+	 * agg(DISTINCT x) supplies its arguments as grouping keys if the query
+	 * has no GROUP BY or DISTINCT.  The pushdown is then a deduplication, and
+	 * the checks below govern aggregates, so return ahead of them.
 	 */
-	if (root->parse->hasTargetSRFs)
+	if (root->eager_group_clause == NIL)
+	{
+		if (!create_distinct_agg_grouping(root))
+			return;
+
+		root->eager_dedup_only = true;
+		root->filter_only_rels = find_filter_only_rels(root);
 		return;
+	}
 
 	/*
-	 * Eager aggregation only makes sense if there are multiple base rels in
-	 * the query.
+	 * For now we don't try to support DISTINCT or ORDER BY aggregates.
 	 */
-	if (bms_membership(root->all_baserels) != BMS_MULTIPLE)
+	if (root->numOrderedAggs > 0)
+		return;
+
+	/*
+	 * If there are any aggregates that do not support partial mode, or any
+	 * partial aggregates that are non-serializable, do not apply eager
+	 * aggregation.
+	 */
+	if (root->hasNonPartialAggs || root->hasNonSerialAggs)
 		return;
 
 	/*
@@ -1004,6 +1024,207 @@ grouping_key_usable(Expr *expr)
 											 ObjectIdGetDatum(tce->btree_opintype)));
 }
 
+/*
+ * max_sortgroupref
+ *	  The largest sortgroupref the given targetlist uses.
+ */
+static Index
+max_sortgroupref(List *tlist)
+{
+	Index		maxref = 0;
+	ListCell   *lc;
+
+	foreach(lc, tlist)
+	{
+		TargetEntry *tle = lfirst_node(TargetEntry, lc);
+
+		if (tle->ressortgroupref > maxref)
+			maxref = tle->ressortgroupref;
+	}
+
+	return maxref;
+}
+
+/*
+ * pull_agg_level_exprs
+ *	  The aggregates and plain Vars the query evaluates above the joins.
+ */
+static List *
+pull_agg_level_exprs(PlannerInfo *root)
+{
+	List	   *exprs;
+
+	exprs = pull_var_clause((Node *) root->processed_tlist,
+							PVC_INCLUDE_AGGREGATES |
+							PVC_RECURSE_WINDOWFUNCS |
+							PVC_RECURSE_PLACEHOLDERS);
+
+	if (root->parse->havingQual != NULL)
+		exprs = list_concat(exprs,
+							pull_var_clause(root->parse->havingQual,
+											PVC_INCLUDE_AGGREGATES |
+											PVC_RECURSE_PLACEHOLDERS));
+
+	return exprs;
+}
+
+/*
+ * aggref_is_plain
+ *	  Is the aggregate free of the decorations we cannot see through?
+ *
+ * FILTER picks which rows reach the aggregate, while ORDER BY and the direct
+ * arguments of an ordered-set aggregate make the result depend on the order
+ * they arrive in.  A VARIADIC aggregate takes its arguments as an array, which
+ * we do not try to match against a grouping key.
+ */
+static bool
+aggref_is_plain(Aggref *aggref)
+{
+	return (aggref->aggfilter == NULL &&
+			aggref->aggorder == NIL &&
+			aggref->aggdirectargs == NIL &&
+			!aggref->aggvariadic);
+}
+
+/*
+ * collect_distinct_agg_keys
+ *	  Make a grouping key of each expression the aggregate takes DISTINCT.
+ *
+ * Appends to *exprs and *clauses, numbering the new clauses from *nextref.
+ * Returns false if an expression cannot serve as a grouping key.
+ */
+static bool
+collect_distinct_agg_keys(Aggref *aggref, Index *nextref,
+						  List **exprs, List **clauses)
+{
+	ListCell   *lc;
+
+	foreach(lc, aggref->aggdistinct)
+	{
+		SortGroupClause *sgc = lfirst_node(SortGroupClause, lc);
+		TargetEntry *tle;
+		SortGroupClause *key;
+
+		tle = get_sortgroupclause_tle(sgc, aggref->args);
+
+		if (!grouping_key_usable(tle->expr))
+			return false;
+
+		/*
+		 * A key already collected from another aggregate needs no second
+		 * clause; grouping on it once is enough.
+		 */
+		if (list_member(*exprs, tle->expr))
+			continue;
+
+		key = makeNode(SortGroupClause);
+		key->tleSortGroupRef = ++(*nextref);
+		key->eqop = sgc->eqop;
+		key->sortop = sgc->sortop;
+		key->nulls_first = sgc->nulls_first;
+		key->hashable = sgc->hashable;
+
+		*exprs = lappend(*exprs, tle->expr);
+		*clauses = lappend(*clauses, key);
+	}
+
+	return true;
+}
+
+/*
+ * add_grouping_expr_infos
+ *	  Record a GroupingExprInfo for each of the given keys.
+ */
+static void
+add_grouping_expr_infos(PlannerInfo *root, List *exprs, List *clauses)
+{
+	ListCell   *lc;
+	ListCell   *lc2;
+
+	forboth(lc, exprs, lc2, clauses)
+	{
+		Expr	   *expr = (Expr *) lfirst(lc);
+		SortGroupClause *sgc = lfirst_node(SortGroupClause, lc2);
+		GroupingExprInfo *ge_info;
+
+		ge_info = makeNode(GroupingExprInfo);
+		ge_info->expr = (Expr *) copyObject(expr);
+		ge_info->sortgroupref = sgc->tleSortGroupRef;
+		ge_info->ec = get_eclass_for_sortgroupclause(root, sgc, expr);
+
+		root->group_expr_list = lappend(root->group_expr_list, ge_info);
+	}
+}
+
+/*
+ * create_distinct_agg_grouping
+ *	  Derive grouping keys from aggregates that ignore duplicate input rows.
+ *
+ * agg(DISTINCT x) discards duplicate x before aggregating, so its arguments
+ * serve as grouping keys for a query with no GROUP BY or DISTINCT.  Eager
+ * aggregation can then deduplicate on those keys beneath a to-many join, and
+ * since the keys are the arguments, the deduplicated rows still carry what
+ * the aggregates above read.
+ *
+ * An aggregate without DISTINCT counts duplicates and rules this out, as does
+ * a FILTER clause, whose result depends on which rows arrive.
+ *
+ * Returns true if such keys were found, having set root->eager_group_clause
+ * and root->group_expr_list.
+ */
+static bool
+create_distinct_agg_grouping(PlannerInfo *root)
+{
+	List	   *exprs = NIL;
+	List	   *clauses = NIL;
+	List	   *agg_level_exprs;
+	Index		nextref;
+	ListCell   *lc;
+
+	if (!root->parse->hasAggs)
+		return false;
+
+	/* The synthesized clauses take the refs the query has left over */
+	nextref = max_sortgroupref(root->processed_tlist);
+
+	agg_level_exprs = pull_agg_level_exprs(root);
+
+	foreach(lc, agg_level_exprs)
+	{
+		Expr	   *expr = (Expr *) lfirst(lc);
+		Aggref	   *aggref;
+
+		/*
+		 * A plain Var outside an aggregate would have to be a grouping key,
+		 * and there is no GROUP BY here, so this cannot happen.
+		 */
+		if (!IsA(expr, Aggref))
+			return false;
+
+		aggref = (Aggref *) expr;
+
+		if (aggref->aggdistinct == NIL || !aggref_is_plain(aggref))
+			return false;
+
+		if (!collect_distinct_agg_keys(aggref, &nextref, &exprs, &clauses))
+			return false;
+	}
+
+	list_free(agg_level_exprs);
+
+	if (exprs == NIL)
+		return false;
+
+	/*
+	 * These keys have no TargetEntry to be found from, so
+	 * create_grouping_expr_infos() cannot build their GroupingExprInfos.
+	 */
+	add_grouping_expr_infos(root, exprs, clauses);
+
+	root->eager_group_clause = clauses;
+	return true;
+}
+
 /*
  * create_grouping_expr_infos
  *	  Create a GroupingExprInfo for each expression usable as grouping key.
@@ -1015,12 +1236,8 @@ static void
 create_grouping_expr_infos(PlannerInfo *root)
 {
 	List	   *exprs = NIL;
-	List	   *sortgrouprefs = NIL;
-	List	   *ecs = NIL;
-	ListCell   *lc,
-			   *lc1,
-			   *lc2,
-			   *lc3;
+	List	   *clauses = NIL;
+	ListCell   *lc;
 
 	Assert(root->group_expr_list == NIL);
 
@@ -1035,27 +1252,10 @@ create_grouping_expr_infos(PlannerInfo *root)
 			return;
 
 		exprs = lappend(exprs, tle->expr);
-		sortgrouprefs = lappend_int(sortgrouprefs, tle->ressortgroupref);
-		ecs = lappend(ecs, get_eclass_for_sortgroupclause(root, sgc, tle->expr));
+		clauses = lappend(clauses, sgc);
 	}
 
-	/*
-	 * Construct a GroupingExprInfo for each expression.
-	 */
-	forthree(lc1, exprs, lc2, sortgrouprefs, lc3, ecs)
-	{
-		Expr	   *expr = (Expr *) lfirst(lc1);
-		int			sortgroupref = lfirst_int(lc2);
-		EquivalenceClass *ec = (EquivalenceClass *) lfirst(lc3);
-		GroupingExprInfo *ge_info;
-
-		ge_info = makeNode(GroupingExprInfo);
-		ge_info->expr = (Expr *) copyObject(expr);
-		ge_info->sortgroupref = sortgroupref;
-		ge_info->ec = ec;
-
-		root->group_expr_list = lappend(root->group_expr_list, ge_info);
-	}
+	add_grouping_expr_infos(root, exprs, clauses);
 }
 
 /*
diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index 44de2cebb73..886999a6000 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -2985,7 +2985,11 @@ init_grouping_targets(PlannerInfo *root, RelOptInfo *rel,
 	List	   *possibly_dependent = NIL;
 	Index		maxSortGroupRef;
 
-	/* Identify the max sortgroupref */
+	/*
+	 * Identify the max sortgroupref.  Grouping clauses synthesized from
+	 * DISTINCT aggregates carry refs of their own, past the targetlist's, so
+	 * they have to be counted too.
+	 */
 	maxSortGroupRef = 0;
 	foreach(lc, root->processed_tlist)
 	{
@@ -2994,6 +2998,13 @@ init_grouping_targets(PlannerInfo *root, RelOptInfo *rel,
 		if (ref > maxSortGroupRef)
 			maxSortGroupRef = ref;
 	}
+	foreach(lc, root->eager_group_clause)
+	{
+		Index		ref = lfirst_node(SortGroupClause, lc)->tleSortGroupRef;
+
+		if (ref > maxSortGroupRef)
+			maxSortGroupRef = ref;
+	}
 
 	/*
 	 * At this point, all Vars from this relation that are needed by upper
diff --git a/src/test/regress/expected/eager_aggregate.out b/src/test/regress/expected/eager_aggregate.out
index a77234e353b..12302ee14cb 100644
--- a/src/test/regress/expected/eager_aggregate.out
+++ b/src/test/regress/expected/eager_aggregate.out
@@ -2553,3 +2553,503 @@ RESET enable_eager_aggregate;
 DROP TABLE eager_semi_d;
 DROP TABLE eager_semi_f1;
 DROP TABLE eager_semi_f2;
+--
+-- Test that a DISTINCT aggregate supplies the grouping keys, so that a query
+-- with no GROUP BY of its own can still deduplicate below the join
+--
+CREATE TABLE eager_distinct_a1 (id int PRIMARY KEY, title text, k numeric);
+CREATE TABLE eager_distinct_a2 (id int PRIMARY KEY, a1_id int, flag bool);
+CREATE TABLE eager_distinct_a3 (id int PRIMARY KEY, a2_id int, flag bool);
+INSERT INTO eager_distinct_a1
+  SELECT i, 'p' || i, i::numeric FROM generate_series(1, 100) i;
+INSERT INTO eager_distinct_a2
+  SELECT i, ((i - 1) / 10) + 1, i % 2 = 0 FROM generate_series(1, 1000) i;
+INSERT INTO eager_distinct_a3
+  SELECT i, ((i - 1) / 10) + 1, i % 2 = 0 FROM generate_series(1, 10000) i;
+CREATE INDEX ON eager_distinct_a2 (a1_id);
+CREATE INDEX ON eager_distinct_a3 (a2_id);
+ANALYZE eager_distinct_a1;
+ANALYZE eager_distinct_a2;
+ANALYZE eager_distinct_a3;
+-- count(DISTINCT) discards the duplicate rows the join produces, so they need
+-- not be produced at all
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  HashAggregate
+               Group Key: a1.id
+               ->  Hash Join
+                     Hash Cond: (a2.a1_id = a1.id)
+                     ->  Partial HashAggregate
+                           Group Key: a2.a1_id
+                           ->  Seq Scan on eager_distinct_a2 a2
+                                 Filter: flag
+                     ->  Hash
+                           ->  Seq Scan on eager_distinct_a1 a1
+(13 rows)
+
+-- sum(DISTINCT) is insensitive to duplicates in the same way
+EXPLAIN (COSTS OFF)
+SELECT sum(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  HashAggregate
+               Group Key: a1.id
+               ->  Hash Join
+                     Hash Cond: (a2.a1_id = a1.id)
+                     ->  Partial HashAggregate
+                           Group Key: a2.a1_id
+                           ->  Seq Scan on eager_distinct_a2 a2
+                                 Filter: flag
+                     ->  Hash
+                           ->  Seq Scan on eager_distinct_a1 a1
+(13 rows)
+
+-- Each DISTINCT aggregate contributes a grouping key
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id), count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  HashAggregate
+               Group Key: a1.id, a1.title
+               ->  Hash Join
+                     Hash Cond: (a2.a1_id = a1.id)
+                     ->  Partial HashAggregate
+                           Group Key: a2.a1_id
+                           ->  Seq Scan on eager_distinct_a2 a2
+                                 Filter: flag
+                     ->  Hash
+                           ->  Seq Scan on eager_distinct_a1 a1
+(13 rows)
+
+-- On a chain the deduplication reaches every hop, as it does for DISTINCT
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+  JOIN eager_distinct_a3 a3 ON a3.a2_id = a2.id
+ WHERE a3.flag;
+                                             QUERY PLAN                                             
+----------------------------------------------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  HashAggregate
+               Group Key: a1.id
+               ->  Nested Loop Semi Join
+                     ->  Seq Scan on eager_distinct_a1 a1
+                     ->  Nested Loop
+                           ->  Index Scan using eager_distinct_a2_a1_id_idx on eager_distinct_a2 a2
+                                 Index Cond: (a1_id = a1.id)
+                           ->  Index Scan using eager_distinct_a3_a2_id_idx on eager_distinct_a3 a3
+                                 Index Cond: (a2_id = a2.id)
+                                 Filter: flag
+(13 rows)
+
+SELECT count(DISTINCT a1.id), sum(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+ count | sum  
+-------+------
+   100 | 5050
+(1 row)
+
+SELECT count(DISTINCT a1.id), count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+ count | count 
+-------+-------
+   100 |   100
+(1 row)
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+  JOIN eager_distinct_a3 a3 ON a3.a2_id = a2.id
+ WHERE a3.flag;
+ count 
+-------
+   100
+(1 row)
+
+SET enable_eager_aggregate TO off;
+SELECT count(DISTINCT a1.id), sum(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+ count | sum  
+-------+------
+   100 | 5050
+(1 row)
+
+SELECT count(DISTINCT a1.id), count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+ count | count 
+-------+-------
+   100 |   100
+(1 row)
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+  JOIN eager_distinct_a3 a3 ON a3.a2_id = a2.id
+ WHERE a3.flag;
+ count 
+-------
+   100
+(1 row)
+
+RESET enable_eager_aggregate;
+-- An aggregate that counts the duplicates needs every one of them
+EXPLAIN (COSTS OFF)
+SELECT count(*)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (a2.a1_id = a1.id)
+         ->  Seq Scan on eager_distinct_a2 a2
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on eager_distinct_a1 a1
+(7 rows)
+
+-- ... and so does one that sums over the driver without discarding them
+EXPLAIN (COSTS OFF)
+SELECT sum(a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (a2.a1_id = a1.id)
+         ->  Seq Scan on eager_distinct_a2 a2
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on eager_distinct_a1 a1
+(7 rows)
+
+-- One such aggregate is enough to require them, even beside a DISTINCT one
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id), count(*)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+-- A DISTINCT aggregate over the to-many side makes its rows observable
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a2.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a2.id
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+-- An aggregate carrying FILTER or its own ORDER BY is left alone
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id) FILTER (WHERE a1.id > 50)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id ORDER BY a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+-- Equality does not imply image equality for numeric, so such an argument
+-- cannot become a grouping key
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.k)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.k
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+-- Nor can an argument that is not a plain column
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id + 1)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: ((a1.id + 1))
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+-- A HAVING clause naming the same aggregate is no obstacle
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag
+HAVING count(DISTINCT a1.id) > 5;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Aggregate
+   Filter: (count(DISTINCT a1.id) > 5)
+   ->  Sort
+         Sort Key: a1.id
+         ->  HashAggregate
+               Group Key: a1.id
+               ->  Hash Join
+                     Hash Cond: (a2.a1_id = a1.id)
+                     ->  Partial HashAggregate
+                           Group Key: a2.a1_id
+                           ->  Seq Scan on eager_distinct_a2 a2
+                                 Filter: flag
+                     ->  Hash
+                           ->  Seq Scan on eager_distinct_a1 a1
+(14 rows)
+
+-- ... but one that counts the duplicates is
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag
+HAVING count(*) > 5;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   Filter: (count(*) > 5)
+   ->  Sort
+         Sort Key: a1.id
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(10 rows)
+
+-- A DISTINCT aggregate beside a grouping clause of the query's own is no
+-- obstacle, though at this size the plain join still costs less
+EXPLAIN (COSTS OFF)
+SELECT a1.id, count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag
+ GROUP BY a1.id;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ GroupAggregate
+   Group Key: a1.id
+   ->  Sort
+         Sort Key: a1.id, a1.title
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(10 rows)
+
+-- The deduplication threshold governs the derived keys too
+SET min_eager_distinct_group_size TO 8;
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  Hash Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+ count 
+-------
+   100
+(1 row)
+
+RESET min_eager_distinct_group_size;
+-- Here the side worth deduplicating is the nullable one, which nothing may
+-- be pushed to, so the count is taken over the join as it stands
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  LEFT JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id AND a2.flag;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  Hash Right Join
+               Hash Cond: (a2.a1_id = a1.id)
+               ->  Seq Scan on eager_distinct_a2 a2
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on eager_distinct_a1 a1
+(9 rows)
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  LEFT JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id AND a2.flag;
+ count 
+-------
+   100
+(1 row)
+
+SET enable_eager_aggregate TO off;
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  LEFT JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id AND a2.flag;
+ count 
+-------
+   100
+(1 row)
+
+RESET enable_eager_aggregate;
+-- The derived keys reach parallel plans as well
+SET parallel_setup_cost=0;
+SET parallel_tuple_cost=0;
+SET min_parallel_table_scan_size=0;
+SET max_parallel_workers_per_gather=4;
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+                                  QUERY PLAN                                   
+-------------------------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: a1.id
+         ->  HashAggregate
+               Group Key: a1.id
+               ->  Hash Join
+                     Hash Cond: (a2.a1_id = a1.id)
+                     ->  Partial HashAggregate
+                           Group Key: a2.a1_id
+                           ->  Gather
+                                 Workers Planned: 2
+                                 ->  Parallel Seq Scan on eager_distinct_a2 a2
+                                       Filter: flag
+                     ->  Hash
+                           ->  Gather
+                                 Workers Planned: 1
+                                 ->  Parallel Seq Scan on eager_distinct_a1 a1
+(17 rows)
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+ count 
+-------
+   100
+(1 row)
+
+RESET parallel_setup_cost;
+RESET parallel_tuple_cost;
+RESET min_parallel_table_scan_size;
+RESET max_parallel_workers_per_gather;
+DROP TABLE eager_distinct_a1;
+DROP TABLE eager_distinct_a2;
+DROP TABLE eager_distinct_a3;
diff --git a/src/test/regress/sql/eager_aggregate.sql b/src/test/regress/sql/eager_aggregate.sql
index 32650fa945c..224d2b98123 100644
--- a/src/test/regress/sql/eager_aggregate.sql
+++ b/src/test/regress/sql/eager_aggregate.sql
@@ -745,3 +745,235 @@ RESET enable_eager_aggregate;
 DROP TABLE eager_semi_d;
 DROP TABLE eager_semi_f1;
 DROP TABLE eager_semi_f2;
+
+
+--
+-- Test that a DISTINCT aggregate supplies the grouping keys, so that a query
+-- with no GROUP BY of its own can still deduplicate below the join
+--
+
+CREATE TABLE eager_distinct_a1 (id int PRIMARY KEY, title text, k numeric);
+CREATE TABLE eager_distinct_a2 (id int PRIMARY KEY, a1_id int, flag bool);
+CREATE TABLE eager_distinct_a3 (id int PRIMARY KEY, a2_id int, flag bool);
+
+INSERT INTO eager_distinct_a1
+  SELECT i, 'p' || i, i::numeric FROM generate_series(1, 100) i;
+INSERT INTO eager_distinct_a2
+  SELECT i, ((i - 1) / 10) + 1, i % 2 = 0 FROM generate_series(1, 1000) i;
+INSERT INTO eager_distinct_a3
+  SELECT i, ((i - 1) / 10) + 1, i % 2 = 0 FROM generate_series(1, 10000) i;
+
+CREATE INDEX ON eager_distinct_a2 (a1_id);
+CREATE INDEX ON eager_distinct_a3 (a2_id);
+
+ANALYZE eager_distinct_a1;
+ANALYZE eager_distinct_a2;
+ANALYZE eager_distinct_a3;
+
+-- count(DISTINCT) discards the duplicate rows the join produces, so they need
+-- not be produced at all
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- sum(DISTINCT) is insensitive to duplicates in the same way
+EXPLAIN (COSTS OFF)
+SELECT sum(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- Each DISTINCT aggregate contributes a grouping key
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id), count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- On a chain the deduplication reaches every hop, as it does for DISTINCT
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+  JOIN eager_distinct_a3 a3 ON a3.a2_id = a2.id
+ WHERE a3.flag;
+
+SELECT count(DISTINCT a1.id), sum(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+SELECT count(DISTINCT a1.id), count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+  JOIN eager_distinct_a3 a3 ON a3.a2_id = a2.id
+ WHERE a3.flag;
+
+SET enable_eager_aggregate TO off;
+
+SELECT count(DISTINCT a1.id), sum(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+SELECT count(DISTINCT a1.id), count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+  JOIN eager_distinct_a3 a3 ON a3.a2_id = a2.id
+ WHERE a3.flag;
+
+RESET enable_eager_aggregate;
+
+-- An aggregate that counts the duplicates needs every one of them
+EXPLAIN (COSTS OFF)
+SELECT count(*)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- ... and so does one that sums over the driver without discarding them
+EXPLAIN (COSTS OFF)
+SELECT sum(a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- One such aggregate is enough to require them, even beside a DISTINCT one
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id), count(*)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- A DISTINCT aggregate over the to-many side makes its rows observable
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a2.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- An aggregate carrying FILTER or its own ORDER BY is left alone
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id) FILTER (WHERE a1.id > 50)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id ORDER BY a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- Equality does not imply image equality for numeric, so such an argument
+-- cannot become a grouping key
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.k)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- Nor can an argument that is not a plain column
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id + 1)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+-- A HAVING clause naming the same aggregate is no obstacle
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag
+HAVING count(DISTINCT a1.id) > 5;
+
+-- ... but one that counts the duplicates is
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag
+HAVING count(*) > 5;
+
+-- A DISTINCT aggregate beside a grouping clause of the query's own is no
+-- obstacle, though at this size the plain join still costs less
+EXPLAIN (COSTS OFF)
+SELECT a1.id, count(DISTINCT a1.title)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag
+ GROUP BY a1.id;
+
+-- The deduplication threshold governs the derived keys too
+SET min_eager_distinct_group_size TO 8;
+
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+RESET min_eager_distinct_group_size;
+
+-- Here the side worth deduplicating is the nullable one, which nothing may
+-- be pushed to, so the count is taken over the join as it stands
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  LEFT JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id AND a2.flag;
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  LEFT JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id AND a2.flag;
+
+SET enable_eager_aggregate TO off;
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  LEFT JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id AND a2.flag;
+
+RESET enable_eager_aggregate;
+
+-- The derived keys reach parallel plans as well
+SET parallel_setup_cost=0;
+SET parallel_tuple_cost=0;
+SET min_parallel_table_scan_size=0;
+SET max_parallel_workers_per_gather=4;
+
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+SELECT count(DISTINCT a1.id)
+  FROM eager_distinct_a1 a1
+  JOIN eager_distinct_a2 a2 ON a2.a1_id = a1.id
+ WHERE a2.flag;
+
+RESET parallel_setup_cost;
+RESET parallel_tuple_cost;
+RESET min_parallel_table_scan_size;
+RESET max_parallel_workers_per_gather;
+
+DROP TABLE eager_distinct_a1;
+DROP TABLE eager_distinct_a2;
+DROP TABLE eager_distinct_a3;
