From 5f524b1722e7c15108454a6f70b3f01c820ea5b7 Mon Sep 17 00:00:00 2001
From: William Bernbaum <wbernbaum@dwdev.com>
Date: Tue, 25 Aug 2026 19:28:03 -0700
Subject: [PATCH 3/4] semijoin-v1-patch-c

Co-authored-by: Cursor <cursoragent@cursor.com>
---
 doc/src/sgml/config.sgml                      |   4 +-
 src/backend/optimizer/README                  |   7 +-
 src/backend/optimizer/plan/analyzejoins.c     |  72 +++-
 .../regress/expected/semijoin_conversion.out  | 405 ++++++++++++++++++
 src/test/regress/sql/semijoin_conversion.sql  | 202 +++++++++
 5 files changed, 684 insertions(+), 6 deletions(-)

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index bc707985a5f..0e01e92045c 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -6247,7 +6247,9 @@ ANY <replaceable class="parameter">num_sync</replaceable> ( <replaceable class="
         The relation must not contribute any values beyond the join
         conditions, and the query must not depend on how many times a row is
         duplicated.  <literal>SELECT DISTINCT</literal> qualifies, as does
-        <literal>GROUP BY</literal> without aggregates.  Matching rows
+        <literal>GROUP BY</literal> without aggregates, and so do aggregates
+        such as <function>min</function> and <function>max</function> whose
+        result does not depend on how often an input appears.  Matching rows
         then do not multiply the other inputs.  Only queries whose joins are
         all inner joins are considered, and one relation at a time, so a
         chain of such joins is left alone.  The default is
diff --git a/src/backend/optimizer/README b/src/backend/optimizer/README
index ae6e7966db3..4efc4414419 100644
--- a/src/backend/optimizer/README
+++ b/src/backend/optimizer/README
@@ -292,9 +292,10 @@ Two conditions must hold.  First, the query must discard duplicates.  Second,
 nothing outside the joins may reference the relation, which is the attr_needed
 test join_is_removable() uses.
 
-DISTINCT qualifies, and so does GROUP BY without aggregates.  An aggregate may
-count its input rows, so a query containing one is left alone.  DISTINCT ON
-keeps the duplicates, since the sort order picks the surviving row.  So do window
+DISTINCT qualifies, and so does GROUP BY without aggregates.  min(), max() and
+DISTINCT-qualified aggregates qualify as well, while count(), sum() and the
+ordered-set aggregates count their input rows.  DISTINCT ON keeps the
+duplicates, since the sort order picks the surviving row.  So do window
 functions, set-returning functions in the target list, row locking, grouping
 sets and volatile output expressions.  A set operation keeps them too, since
 each arm is planned as a separate query level and a UNION above stays invisible
diff --git a/src/backend/optimizer/plan/analyzejoins.c b/src/backend/optimizer/plan/analyzejoins.c
index 3b09db63af5..1517a2d68ff 100644
--- a/src/backend/optimizer/plan/analyzejoins.c
+++ b/src/backend/optimizer/plan/analyzejoins.c
@@ -22,6 +22,8 @@
  */
 #include "postgres.h"
 
+#include "access/htup_details.h"
+#include "catalog/pg_aggregate.h"
 #include "catalog/pg_class.h"
 #include "nodes/nodeFuncs.h"
 #include "optimizer/clauses.h"
@@ -35,6 +37,7 @@
 #include "parser/parse_agg.h"
 #include "rewrite/rewriteManip.h"
 #include "utils/lsyscache.h"
+#include "utils/syscache.h"
 
 /*
  * Utility structure.  A sorting procedure is needed to simplify the search
@@ -88,6 +91,7 @@ static bool replace_relid_callback(Node *node,
 static bool query_discards_duplicates(PlannerInfo *root);
 static bool rel_is_output_irrelevant(PlannerInfo *root, RelOptInfo *rel,
 									 Relids inputrelids);
+static bool query_aggs_ignore_duplicates(PlannerInfo *root);
 static Relids semijoin_rhs_component(PlannerInfo *root, Relids pool, int seed);
 static List *semijoin_join_clauses(PlannerInfo *root, Relids lhs, Relids rhs);
 static bool convert_one_join_to_semijoin(PlannerInfo *root, Relids lhs,
@@ -1437,9 +1441,9 @@ query_discards_duplicates(PlannerInfo *root)
 	if (contain_volatile_functions((Node *) root->processed_tlist))
 		return false;
 
-	/* An aggregate can count its input rows. */
+	/* Aggregation is a boundary only if no aggregate counts its input rows. */
 	if (parse->hasAggs)
-		return false;
+		return query_aggs_ignore_duplicates(root);
 
 	/* Under DISTINCT ON, arrival order picks the surviving row of a tie. */
 	if (parse->distinctClause != NIL)
@@ -1490,6 +1494,70 @@ rel_is_output_irrelevant(PlannerInfo *root, RelOptInfo *rel,
 	return true;
 }
 
+/*
+ * query_aggs_ignore_duplicates
+ *		Is every aggregate in this query insensitive to how many times a given
+ *		input row is supplied?
+ *
+ * A DISTINCT-qualified aggregate drops duplicate inputs itself, and an
+ * aggregate carrying an aggsortop is min()-like.  Everything else is rejected,
+ * including bit_and() and bit_or(), which do ignore duplicates but cannot be
+ * recognized as such from the catalog.
+ */
+static bool
+query_aggs_ignore_duplicates(PlannerInfo *root)
+{
+	List	   *nodes;
+	ListCell   *l;
+	bool		result = true;
+
+	nodes = pull_var_clause((Node *) root->processed_tlist,
+							PVC_INCLUDE_AGGREGATES |
+							PVC_INCLUDE_WINDOWFUNCS |
+							PVC_INCLUDE_PLACEHOLDERS);
+	nodes = list_concat(nodes,
+						pull_var_clause(root->parse->havingQual,
+										PVC_INCLUDE_AGGREGATES |
+										PVC_INCLUDE_WINDOWFUNCS |
+										PVC_INCLUDE_PLACEHOLDERS));
+
+	foreach(l, nodes)
+	{
+		Aggref	   *aggref = (Aggref *) lfirst(l);
+		HeapTuple	aggtuple;
+		Form_pg_aggregate aggform;
+		bool		insensitive;
+
+		if (!IsA(aggref, Aggref))
+			continue;
+
+		aggtuple = SearchSysCache1(AGGFNOID,
+								   ObjectIdGetDatum(aggref->aggfnoid));
+		if (!HeapTupleIsValid(aggtuple))
+			elog(ERROR, "cache lookup failed for aggregate %u",
+				 aggref->aggfnoid);
+		aggform = (Form_pg_aggregate) GETSTRUCT(aggtuple);
+
+		if (AGGKIND_IS_ORDERED_SET(aggform->aggkind))
+			insensitive = false;
+		else if (aggref->aggdistinct != NIL)
+			insensitive = true;
+		else
+			insensitive = OidIsValid(aggform->aggsortop);
+
+		ReleaseSysCache(aggtuple);
+
+		if (!insensitive)
+		{
+			result = false;
+			break;
+		}
+	}
+
+	list_free(nodes);
+	return result;
+}
+
 /*
  * rel_supports_distinctness
  *		Could the relation possibly be proven distinct on some set of columns?
diff --git a/src/test/regress/expected/semijoin_conversion.out b/src/test/regress/expected/semijoin_conversion.out
index fbde99261bc..29875be96de 100644
--- a/src/test/regress/expected/semijoin_conversion.out
+++ b/src/test/regress/expected/semijoin_conversion.out
@@ -68,6 +68,41 @@ SELECT d.grp
                ->  Seq Scan on sjc_driver d
 (8 rows)
 
+-- every aggregate present ignores them
+EXPLAIN (COSTS OFF)
+SELECT max(d.id), count(DISTINCT d.grp)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: d.grp
+         ->  Hash Right Semi Join
+               Hash Cond: (f.driver_id = d.id)
+               ->  Seq Scan on sjc_filter f
+                     Filter: flag
+               ->  Hash
+                     ->  Seq Scan on sjc_driver d
+(9 rows)
+
+-- bool_and() is min() over bool and carries the same marker, so duplicates are
+-- ignored there too
+EXPLAIN (COSTS OFF)
+SELECT bool_and(d.id > 0)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                 QUERY PLAN                 
+--------------------------------------------
+ Aggregate
+   ->  Hash Right Semi Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
 -- a chain of filtering joins forms one group of two relations, and a group of
 -- two is declined
 EXPLAIN (COSTS OFF)
@@ -162,6 +197,87 @@ SELECT d.id
          ->  Seq Scan on sjc_driver d
 (6 rows)
 
+-- count(*) counts them
+EXPLAIN (COSTS OFF)
+SELECT count(*)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                 QUERY PLAN                 
+--------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
+-- sum() adds them up
+EXPLAIN (COSTS OFF)
+SELECT sum(d.id)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                 QUERY PLAN                 
+--------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
+-- an ordered-set aggregate reads a position in the input distribution, and
+-- mode() reports the most frequent input outright
+EXPLAIN (COSTS OFF)
+SELECT mode() WITHIN GROUP (ORDER BY d.grp)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                 QUERY PLAN                 
+--------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
+-- bit_xor() cancels a value supplied twice, and so counts its inputs
+EXPLAIN (COSTS OFF)
+SELECT bit_xor(d.id)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                 QUERY PLAN                 
+--------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
+-- bit_and() ignores duplicates but carries no marker, and so is not admitted
+EXPLAIN (COSTS OFF)
+SELECT bit_and(d.id)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+                 QUERY PLAN                 
+--------------------------------------------
+ Aggregate
+   ->  Hash Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(7 rows)
+
 -- the inner relation is projected, and so does more than filter
 EXPLAIN (COSTS OFF)
 SELECT DISTINCT d.id, f.id
@@ -179,6 +295,26 @@ SELECT DISTINCT d.id, f.id
                ->  Seq Scan on sjc_driver d
 (8 rows)
 
+-- HAVING counts the inner relation
+EXPLAIN (COSTS OFF)
+SELECT d.grp
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag
+ GROUP BY d.grp
+HAVING count(f.id) > 4;
+                 QUERY PLAN                 
+--------------------------------------------
+ HashAggregate
+   Group Key: d.grp
+   Filter: (count(*) > 4)
+   ->  Hash Join
+         Hash Cond: (f.driver_id = d.id)
+         ->  Seq Scan on sjc_filter f
+               Filter: flag
+         ->  Hash
+               ->  Seq Scan on sjc_driver d
+(9 rows)
+
 -- a window function can see the partition's row count
 EXPLAIN (COSTS OFF)
 SELECT DISTINCT d.id, count(*) OVER () AS n
@@ -257,6 +393,197 @@ SELECT DISTINCT d.id, d.payload
                ->  Seq Scan on sjc_driver d
 (12 rows)
 
+--
+-- A key set filtering a chain of to-many joins
+--
+-- The chain below the counted relation only filters, and the many leaves per
+-- ancestor are duplicates.  It is declined all the same, since the righthand
+-- side would hold more than one relation.
+CREATE TABLE sjc_customer (id int PRIMARY KEY, name text, owner_group int);
+CREATE TABLE sjc_order (id int PRIMARY KEY, customer_id int, status text);
+CREATE TABLE sjc_item (id int PRIMARY KEY, order_id int, sku text);
+CREATE TABLE sjc_keyset (item_id int PRIMARY KEY);
+CREATE TABLE sjc_keyblob (keys bytea);
+CREATE TABLE sjc_group (id int PRIMARY KEY, default_access int);
+CREATE TABLE sjc_grant (owner_id int, grantee_id int, access int);
+INSERT INTO sjc_customer
+  SELECT g, 'cust' || g, (g % 3) + 1 FROM generate_series(1, 200) g;
+INSERT INTO sjc_order
+  SELECT g, ((g - 1) / 3) + 1, 'open' FROM generate_series(1, 600) g;
+INSERT INTO sjc_item
+  SELECT g, ((g - 1) / 4) + 1, 'sku' || g FROM generate_series(1, 2400) g;
+INSERT INTO sjc_keyset SELECT g FROM generate_series(1, 400) g;
+-- the same 400 keys packed as four-byte big-endian ints
+INSERT INTO sjc_keyblob
+  SELECT string_agg(decode(lpad(to_hex(g), 8, '0'), 'hex'), ''::bytea ORDER BY g)
+    FROM generate_series(1, 400) g;
+INSERT INTO sjc_group SELECT g, 1 FROM generate_series(1, 3) g;
+INSERT INTO sjc_grant SELECT 1, 7, 2;
+CREATE INDEX ON sjc_order (customer_id);
+CREATE INDEX ON sjc_item (order_id);
+ANALYZE sjc_customer;
+ANALYZE sjc_order;
+ANALYZE sjc_item;
+ANALYZE sjc_keyset;
+ANALYZE sjc_keyblob;
+ANALYZE sjc_group;
+ANALYZE sjc_grant;
+-- The key set join is strict in the item, so reduce_outer_joins() has made all
+-- three inner before the transformation runs.  They form one group of three.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id;
+                                     QUERY PLAN                                      
+-------------------------------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: c.id
+         ->  Hash Join
+               Hash Cond: (o.customer_id = c.id)
+               ->  Hash Join
+                     Hash Cond: (i.order_id = o.id)
+                     ->  Merge Join
+                           Merge Cond: (i.id = k.item_id)
+                           ->  Index Scan using sjc_item_pkey on sjc_item i
+                           ->  Index Only Scan using sjc_keyset_pkey on sjc_keyset k
+                     ->  Hash
+                           ->  Seq Scan on sjc_order o
+               ->  Hash
+                     ->  Seq Scan on sjc_customer c
+(15 rows)
+
+-- The same query with the key set unpacked from a blob, which adds the blob and
+-- the function scan walking it to the group.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN (SELECT (('x' || encode(substring(b.keys FROM g.i * 4 + 1 FOR 4),
+                                    'hex'))::bit(32)::int) AS item_id
+               FROM sjc_keyblob b
+                    CROSS JOIN generate_series(0, 399) AS g(i)) AS ks
+         ON ks.item_id = i.id;
+                                                                      QUERY PLAN                                                                      
+------------------------------------------------------------------------------------------------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: c.id
+         ->  Hash Join
+               Hash Cond: (o.customer_id = c.id)
+               ->  Hash Join
+                     Hash Cond: (i.order_id = o.id)
+                     ->  Hash Join
+                           Hash Cond: (((('x'::text || encode(SUBSTRING(b.keys FROM ((g.i * 4) + 1) FOR 4), 'hex'::text)))::bit(32))::integer = i.id)
+                           ->  Nested Loop
+                                 ->  Seq Scan on sjc_keyblob b
+                                 ->  Function Scan on generate_series g
+                           ->  Hash
+                                 ->  Seq Scan on sjc_item i
+                     ->  Hash
+                           ->  Seq Scan on sjc_order o
+               ->  Hash
+                     ->  Seq Scan on sjc_customer c
+(18 rows)
+
+-- Counting the leaf relation instead of an ancestor leaves nothing to lift.
+-- The key set is unique on the item, so no fanout remains to remove.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT i.id)
+  FROM sjc_item i JOIN sjc_keyset k ON k.item_id = i.id;
+                            QUERY PLAN                             
+-------------------------------------------------------------------
+ Aggregate
+   ->  Merge Join
+         Merge Cond: (i.id = k.item_id)
+         ->  Index Only Scan using sjc_item_pkey on sjc_item i
+         ->  Index Only Scan using sjc_keyset_pkey on sjc_keyset k
+(5 rows)
+
+-- The coalesce is not strict in the granting relation, so the left join
+-- survives reduce_outer_joins() and the whole query is declined.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       LEFT JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE c.owner_group = 7
+    OR coalesce(a.access, g.default_access, 1) >= 2;
+                                                         QUERY PLAN                                                         
+----------------------------------------------------------------------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: c.id
+         ->  Hash Join
+               Hash Cond: (i.order_id = o.id)
+               ->  Merge Join
+                     Merge Cond: (i.id = k.item_id)
+                     ->  Index Scan using sjc_item_pkey on sjc_item i
+                     ->  Index Only Scan using sjc_keyset_pkey on sjc_keyset k
+               ->  Hash
+                     ->  Hash Join
+                           Hash Cond: (o.customer_id = c.id)
+                           ->  Seq Scan on sjc_order o
+                           ->  Hash
+                                 ->  Hash Join
+                                       Hash Cond: (c.owner_group = g.id)
+                                       Join Filter: ((c.owner_group = 7) OR (COALESCE(a.access, g.default_access, 1) >= 2))
+                                       ->  Hash Left Join
+                                             Hash Cond: (c.owner_group = a.owner_id)
+                                             ->  Seq Scan on sjc_customer c
+                                             ->  Hash
+                                                   ->  Seq Scan on sjc_grant a
+                                                         Filter: (grantee_id = 7)
+                                       ->  Hash
+                                             ->  Seq Scan on sjc_group g
+(25 rows)
+
+-- Written as an inner join instead, the same access check reaches the group
+-- test.  Every relation below the customer still only filters.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE a.access >= 2;
+                                              QUERY PLAN                                              
+------------------------------------------------------------------------------------------------------
+ Aggregate
+   ->  Sort
+         Sort Key: c.id
+         ->  Hash Join
+               Hash Cond: (i.order_id = o.id)
+               ->  Merge Join
+                     Merge Cond: (i.id = k.item_id)
+                     ->  Index Scan using sjc_item_pkey on sjc_item i
+                     ->  Index Only Scan using sjc_keyset_pkey on sjc_keyset k
+               ->  Hash
+                     ->  Hash Join
+                           Hash Cond: (o.customer_id = c.id)
+                           ->  Seq Scan on sjc_order o
+                           ->  Hash
+                                 ->  Hash Join
+                                       Hash Cond: (c.owner_group = g.id)
+                                       ->  Seq Scan on sjc_customer c
+                                       ->  Hash
+                                             ->  Nested Loop
+                                                   Join Filter: (g.id = a.owner_id)
+                                                   ->  Seq Scan on sjc_grant a
+                                                         Filter: ((access >= 2) AND (grantee_id = 7))
+                                                   ->  Seq Scan on sjc_group g
+(23 rows)
+
 -- Beyond join_collapse_limit the jointree stays nested and is planned one
 -- sub-list at a time, and a semijoin must not span two sub-lists.  Only the
 -- result is checked, a nine-way join plan being too unstable to compare.
@@ -312,6 +639,44 @@ SELECT count(*), sum(id) FROM (
     30 | 630
 (1 row)
 
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id;
+ count 
+-------
+    34
+(1 row)
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN (SELECT (('x' || encode(substring(b.keys FROM g.i * 4 + 1 FOR 4),
+                                    'hex'))::bit(32)::int) AS item_id
+               FROM sjc_keyblob b
+                    CROSS JOIN generate_series(0, 399) AS g(i)) AS ks
+         ON ks.item_id = i.id;
+ count 
+-------
+    34
+(1 row)
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE a.access >= 2;
+ count 
+-------
+    11
+(1 row)
+
 SET enable_semijoin_conversion = off;
 SELECT count(*), sum(id), sum(length(payload)) FROM (
   SELECT DISTINCT d.id, d.payload
@@ -343,4 +708,44 @@ SELECT count(*), sum(id) FROM (
     30 | 630
 (1 row)
 
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id;
+ count 
+-------
+    34
+(1 row)
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN (SELECT (('x' || encode(substring(b.keys FROM g.i * 4 + 1 FOR 4),
+                                    'hex'))::bit(32)::int) AS item_id
+               FROM sjc_keyblob b
+                    CROSS JOIN generate_series(0, 399) AS g(i)) AS ks
+         ON ks.item_id = i.id;
+ count 
+-------
+    34
+(1 row)
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE a.access >= 2;
+ count 
+-------
+    11
+(1 row)
+
 DROP TABLE sjc_driver, sjc_filter, sjc_deep, sjc_unique, sjc_uniq2, sjc_bygrp;
+DROP TABLE sjc_customer, sjc_order, sjc_item, sjc_keyset, sjc_keyblob,
+           sjc_group, sjc_grant;
diff --git a/src/test/regress/sql/semijoin_conversion.sql b/src/test/regress/sql/semijoin_conversion.sql
index 27999d6a48c..37ce12e776a 100644
--- a/src/test/regress/sql/semijoin_conversion.sql
+++ b/src/test/regress/sql/semijoin_conversion.sql
@@ -52,6 +52,19 @@ SELECT d.grp
  WHERE f.flag
  GROUP BY d.grp;
 
+-- every aggregate present ignores them
+EXPLAIN (COSTS OFF)
+SELECT max(d.id), count(DISTINCT d.grp)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
+-- bool_and() is min() over bool and carries the same marker, so duplicates are
+-- ignored there too
+EXPLAIN (COSTS OFF)
+SELECT bool_and(d.id > 0)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
 -- a chain of filtering joins forms one group of two relations, and a group of
 -- two is declined
 EXPLAIN (COSTS OFF)
@@ -89,12 +102,51 @@ SELECT d.id
   FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
  WHERE f.flag;
 
+-- count(*) counts them
+EXPLAIN (COSTS OFF)
+SELECT count(*)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
+-- sum() adds them up
+EXPLAIN (COSTS OFF)
+SELECT sum(d.id)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
+-- an ordered-set aggregate reads a position in the input distribution, and
+-- mode() reports the most frequent input outright
+EXPLAIN (COSTS OFF)
+SELECT mode() WITHIN GROUP (ORDER BY d.grp)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
+-- bit_xor() cancels a value supplied twice, and so counts its inputs
+EXPLAIN (COSTS OFF)
+SELECT bit_xor(d.id)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
+-- bit_and() ignores duplicates but carries no marker, and so is not admitted
+EXPLAIN (COSTS OFF)
+SELECT bit_and(d.id)
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag;
+
 -- the inner relation is projected, and so does more than filter
 EXPLAIN (COSTS OFF)
 SELECT DISTINCT d.id, f.id
   FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
  WHERE f.flag;
 
+-- HAVING counts the inner relation
+EXPLAIN (COSTS OFF)
+SELECT d.grp
+  FROM sjc_driver d JOIN sjc_filter f ON f.driver_id = d.id
+ WHERE f.flag
+ GROUP BY d.grp
+HAVING count(f.id) > 4;
+
 -- a window function can see the partition's row count
 EXPLAIN (COSTS OFF)
 SELECT DISTINCT d.id, count(*) OVER () AS n
@@ -123,6 +175,102 @@ SELECT DISTINCT d.id, d.payload
        JOIN sjc_uniq2 u2 ON u2.unique_id = u.id
  WHERE u2.id < 30;
 
+--
+-- A key set filtering a chain of to-many joins
+--
+-- The chain below the counted relation only filters, and the many leaves per
+-- ancestor are duplicates.  It is declined all the same, since the righthand
+-- side would hold more than one relation.
+
+CREATE TABLE sjc_customer (id int PRIMARY KEY, name text, owner_group int);
+CREATE TABLE sjc_order (id int PRIMARY KEY, customer_id int, status text);
+CREATE TABLE sjc_item (id int PRIMARY KEY, order_id int, sku text);
+CREATE TABLE sjc_keyset (item_id int PRIMARY KEY);
+CREATE TABLE sjc_keyblob (keys bytea);
+CREATE TABLE sjc_group (id int PRIMARY KEY, default_access int);
+CREATE TABLE sjc_grant (owner_id int, grantee_id int, access int);
+
+INSERT INTO sjc_customer
+  SELECT g, 'cust' || g, (g % 3) + 1 FROM generate_series(1, 200) g;
+INSERT INTO sjc_order
+  SELECT g, ((g - 1) / 3) + 1, 'open' FROM generate_series(1, 600) g;
+INSERT INTO sjc_item
+  SELECT g, ((g - 1) / 4) + 1, 'sku' || g FROM generate_series(1, 2400) g;
+INSERT INTO sjc_keyset SELECT g FROM generate_series(1, 400) g;
+
+-- the same 400 keys packed as four-byte big-endian ints
+INSERT INTO sjc_keyblob
+  SELECT string_agg(decode(lpad(to_hex(g), 8, '0'), 'hex'), ''::bytea ORDER BY g)
+    FROM generate_series(1, 400) g;
+
+INSERT INTO sjc_group SELECT g, 1 FROM generate_series(1, 3) g;
+INSERT INTO sjc_grant SELECT 1, 7, 2;
+
+CREATE INDEX ON sjc_order (customer_id);
+CREATE INDEX ON sjc_item (order_id);
+ANALYZE sjc_customer;
+ANALYZE sjc_order;
+ANALYZE sjc_item;
+ANALYZE sjc_keyset;
+ANALYZE sjc_keyblob;
+ANALYZE sjc_group;
+ANALYZE sjc_grant;
+
+-- The key set join is strict in the item, so reduce_outer_joins() has made all
+-- three inner before the transformation runs.  They form one group of three.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id;
+
+-- The same query with the key set unpacked from a blob, which adds the blob and
+-- the function scan walking it to the group.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN (SELECT (('x' || encode(substring(b.keys FROM g.i * 4 + 1 FOR 4),
+                                    'hex'))::bit(32)::int) AS item_id
+               FROM sjc_keyblob b
+                    CROSS JOIN generate_series(0, 399) AS g(i)) AS ks
+         ON ks.item_id = i.id;
+
+-- Counting the leaf relation instead of an ancestor leaves nothing to lift.
+-- The key set is unique on the item, so no fanout remains to remove.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT i.id)
+  FROM sjc_item i JOIN sjc_keyset k ON k.item_id = i.id;
+
+-- The coalesce is not strict in the granting relation, so the left join
+-- survives reduce_outer_joins() and the whole query is declined.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       LEFT JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE c.owner_group = 7
+    OR coalesce(a.access, g.default_access, 1) >= 2;
+
+-- Written as an inner join instead, the same access check reaches the group
+-- test.  Every relation below the customer still only filters.
+EXPLAIN (COSTS OFF)
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE a.access >= 2;
+
 -- Beyond join_collapse_limit the jointree stays nested and is planned one
 -- sub-list at a time, and a semijoin must not span two sub-lists.  Only the
 -- result is checked, a nine-way join plan being too unstable to compare.
@@ -163,6 +311,32 @@ SELECT count(*), sum(id) FROM (
          JOIN sjc_deep e ON e.filter_id = f.id
    WHERE f.flag) s;
 
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id;
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN (SELECT (('x' || encode(substring(b.keys FROM g.i * 4 + 1 FOR 4),
+                                    'hex'))::bit(32)::int) AS item_id
+               FROM sjc_keyblob b
+                    CROSS JOIN generate_series(0, 399) AS g(i)) AS ks
+         ON ks.item_id = i.id;
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE a.access >= 2;
+
 SET enable_semijoin_conversion = off;
 
 SELECT count(*), sum(id), sum(length(payload)) FROM (
@@ -183,4 +357,32 @@ SELECT count(*), sum(id) FROM (
          JOIN sjc_deep e ON e.filter_id = f.id
    WHERE f.flag) s;
 
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id;
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       LEFT JOIN sjc_order o ON o.customer_id = c.id
+       LEFT JOIN sjc_item i ON i.order_id = o.id
+       JOIN (SELECT (('x' || encode(substring(b.keys FROM g.i * 4 + 1 FOR 4),
+                                    'hex'))::bit(32)::int) AS item_id
+               FROM sjc_keyblob b
+                    CROSS JOIN generate_series(0, 399) AS g(i)) AS ks
+         ON ks.item_id = i.id;
+
+SELECT count(DISTINCT c.id)
+  FROM sjc_customer c
+       JOIN sjc_order o ON o.customer_id = c.id
+       JOIN sjc_item i ON i.order_id = o.id
+       JOIN sjc_keyset k ON k.item_id = i.id
+       JOIN sjc_group g ON g.id = c.owner_group
+       JOIN sjc_grant a
+              ON a.owner_id = c.owner_group AND a.grantee_id = 7
+ WHERE a.access >= 2;
+
 DROP TABLE sjc_driver, sjc_filter, sjc_deep, sjc_unique, sjc_uniq2, sjc_bygrp;
+DROP TABLE sjc_customer, sjc_order, sjc_item, sjc_keyset, sjc_keyblob,
+           sjc_group, sjc_grant;
