From ca05414094514bd3a13c874d9363d708e47b218e Mon Sep 17 00:00:00 2001
From: zengxx <xiangxin_zeng@qq.com>
Date: Thu, 24 Sep 2026 08:51:28 +0800
Subject: [PATCH v3 2/2] Skip a redundant singleton GROUP BY node

Avoid a plain GROUP BY node when an immediate, unconditional unique index
proves that every input row is already its own group.  ORMs and generated
queries often retain grouping keys that are useful to the query contract
even when a primary key makes the grouping redundant.

The proof is intentionally narrow.  The input must be a single ordinary
base relation or partitioned table.  The query must be a plain GROUP BY
with no aggregates, HAVING, window functions, set operations, DISTINCT,
SRFs, or row locking.  The index must be immediate, non-partial, and
non-expression, and its key columns must be covered by simple grouping
Vars.  NULLS DISTINCT unique indexes also require every key column to be
NOT NULL; NULLS NOT DISTINCT removes that requirement.  The index and
grouping keys must agree on equality semantics, including opfamily and
collation.  Additional grouping expressions are safe because they can
only subdivide singleton groups.

Add direct complete and partial paths as alternatives to ordinary
grouping paths.  This preserves standard and partitionwise grouping plans
and extension hooks.  Partial paths let create_ordered_paths() consider
worker-local Sort followed by Gather Merge.  A parallel-safe final target
can be projected below Gather; parallel-restricted targets remain
leader-side.  A set-operation parent that needs sorted input still
rejects its child, while a UNION ALL child may use this local proof.

To preserve the observable evaluation behavior of volatile output columns
under partial retrieval, suppress the direct paths when a LIMIT is needed
or tuple_fraction indicates partial retrieval and the query target
contains volatile functions.

Regression coverage includes opfamily/collation mismatches, prepared-plan
invalidation, partitioned tables, partitionwise aggregate retention,
worker-local ordering, parallel-safe and parallel-restricted targets,
volatile output columns with LIMIT, UNION and UNION ALL children,
inheritance, nullable keys, and implicit/degenerate grouping boundaries.
The full core regression suite passes 240/240.
---
 doc/src/sgml/ref/select.sgml                  |   6 +
 src/backend/optimizer/README                  |   4 +-
 src/backend/optimizer/path/indxpath.c         |  37 +
 src/backend/optimizer/plan/planner.c          | 206 ++++-
 src/include/optimizer/paths.h                 |   3 +
 src/test/regress/expected/aggregates.out      |  74 +-
 .../regress/expected/collate.icu.utf8.out     |  20 +
 src/test/regress/expected/join.out            |  10 +-
 .../regress/expected/singleton_grouping.out   | 812 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   2 +
 src/test/regress/sql/collate.icu.utf8.sql     |  14 +
 src/test/regress/sql/singleton_grouping.sql   | 424 +++++++++
 12 files changed, 1555 insertions(+), 57 deletions(-)
 create mode 100644 src/test/regress/expected/singleton_grouping.out
 create mode 100644 src/test/regress/sql/singleton_grouping.sql

diff --git a/doc/src/sgml/ref/select.sgml b/doc/src/sgml/ref/select.sgml
index 18392f7cae..c89b875d69 100644
--- a/doc/src/sgml/ref/select.sgml
+++ b/doc/src/sgml/ref/select.sgml
@@ -2146,6 +2146,12 @@ SELECT 2+2;
     The SQL standard specifies additional conditions that should be
     recognized.
    </para>
+
+   <para>
+    The planner can also avoid a separate grouping step in restricted cases
+    where an immediate, non-partial unique index proves that every input row
+    is its own group.
+   </para>
   </refsect2>
 
   <refsect2>
diff --git a/src/backend/optimizer/README b/src/backend/optimizer/README
index 78a307cc52..39447815ed 100644
--- a/src/backend/optimizer/README
+++ b/src/backend/optimizer/README
@@ -1355,7 +1355,9 @@ additional RelOptInfos during upper-level planning:
 
 UPPERREL_SETOP		result of UNION/INTERSECT/EXCEPT, if any
 UPPERREL_PARTIAL_GROUP_AGG	result of partial grouping/aggregation, if any
-UPPERREL_GROUP_AGG	result of grouping/aggregation, if any
+UPPERREL_GROUP_AGG	result of grouping/aggregation, if any; for a plain
+					GROUP BY that a unique index proves redundant, this rel
+					can also contain direct complete and partial paths
 UPPERREL_WINDOW		result of window functions, if any
 UPPERREL_PARTIAL_DISTINCT	result of partial "SELECT DISTINCT", if any
 UPPERREL_DISTINCT	result of "SELECT DISTINCT", if any
diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index ddac5594a5..7763f1f730 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -4465,6 +4465,43 @@ relation_removable_groupby_columns(RelOptInfo *rel, List *groupClause,
 	return bms_difference(groupbyattnos, best_keycolumns);
 }
 
+/*
+ * relation_has_unique_index_covered_by_group_keys
+ *		Determine whether every input row is its own group under the given
+ *		plain GROUP BY keys.
+ *
+ * The caller has already restricted this to a single base relation and a plain
+ * GROUP BY list.  Index keys may be a subset of the grouping keys, just as an
+ * immediate PK makes every row its own group regardless of what else is listed
+ * in GROUP BY.
+ */
+bool
+relation_has_unique_index_covered_by_group_keys(RelOptInfo *rel,
+												List *groupClause,
+												List *targetList)
+{
+	List	   *groupbycols;
+	ListCell   *lc;
+
+	if (groupClause == NIL)
+		return false;
+
+	Assert(bms_membership(rel->relids) == BMS_SINGLETON);
+
+	groupbycols = build_groupby_col_infos(rel, groupClause, targetList, NULL);
+	if (groupbycols == NIL)
+		return false;
+
+	foreach(lc, rel->indexlist)
+	{
+		if (unique_index_keys_match_groupby_cols(lfirst_node(IndexOptInfo, lc),
+												 rel, groupbycols, NULL))
+			return true;
+	}
+
+	return false;
+}
+
 /*
  * indexcol_is_bool_constant_for_query
  *
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index 8d30131855..6fa53e6dad 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -177,11 +177,22 @@ static double get_number_of_groups(PlannerInfo *root,
 								   double path_rows,
 								   grouping_sets_data *gd,
 								   List *target_list);
+static bool grouping_input_is_singleton(PlannerInfo *root,
+										RelOptInfo *input_rel,
+										List *targetList,
+										bool setop_child);
+static void create_singleton_grouping_paths(PlannerInfo *root,
+											RelOptInfo *input_rel,
+											RelOptInfo *grouped_rel);
+static void create_singleton_partial_grouping_paths(PlannerInfo *root,
+										 RelOptInfo *input_rel,
+										 RelOptInfo *grouped_rel);
 static RelOptInfo *create_grouping_paths(PlannerInfo *root,
 										 RelOptInfo *input_rel,
 										 PathTarget *target,
 										 bool target_parallel_safe,
-										 grouping_sets_data *gd);
+										 grouping_sets_data *gd,
+										 SetOperationStmt *setops);
 static bool is_degenerate_grouping(PlannerInfo *root);
 static void create_degenerate_grouping_paths(PlannerInfo *root,
 											 RelOptInfo *input_rel,
@@ -196,6 +207,10 @@ static void create_ordinary_grouping_paths(PlannerInfo *root,
 										   grouping_sets_data *gd,
 										   GroupPathExtraData *extra,
 										   RelOptInfo **partially_grouped_rel_p);
+static void add_foreign_and_custom_grouping_paths(PlannerInfo *root,
+												  RelOptInfo *input_rel,
+												  RelOptInfo *grouped_rel,
+												  GroupPathExtraData *extra);
 static void consider_groupingsets_paths(PlannerInfo *root,
 										RelOptInfo *grouped_rel,
 										Path *path,
@@ -2090,7 +2105,8 @@ grouping_planner(PlannerInfo *root, double tuple_fraction,
 												current_rel,
 												grouping_target,
 												grouping_target_parallel_safe,
-												gset_data);
+												gset_data,
+												setops);
 			/* Fix things up if grouping_target contains SRFs */
 			if (parse->hasTargetSRFs)
 				adjust_paths_for_srfs(root, current_rel,
@@ -4048,6 +4064,152 @@ get_number_of_groups(PlannerInfo *root,
 	return dNumGroups;
 }
 
+/*
+ * grouping_input_is_singleton
+ *		Can this plain GROUP BY be implemented without a grouping node?
+ *
+ * This is intentionally narrower than the general uniqueness machinery.  The
+ * current proof accepts one ordinary base relation (or partitioned parent) and
+ * requires its immediate unique index keys to appear as simple Var grouping
+ * keys.  Additional grouping expressions are allowed and left to normal
+ * projection.  A set-operation child is rejected only when its parent asked
+ * for sorted input; a UNION ALL child may still use this local proof.
+ */
+static bool
+grouping_input_is_singleton(PlannerInfo *root, RelOptInfo *input_rel,
+							List *targetList, bool setop_child)
+{
+	Query	   *parse = root->parse;
+	Index		rti;
+	RangeTblEntry *rte;
+
+	if (setop_child || parse->groupClause == NIL ||
+		parse->groupingSets != NIL || parse->hasAggs ||
+		parse->hasWindowFuncs || parse->hasTargetSRFs ||
+		parse->distinctClause != NIL || parse->hasDistinctOn ||
+		parse->rowMarks != NIL || parse->setOperations != NULL ||
+		root->hasHavingQual || root->numOrderedAggs > 0)
+		return false;
+
+	/*
+	 * With partial retrieval, ordinary grouping can evaluate a volatile output
+	 * column only for returned groups.  Direct paths may move that projection
+	 * below LIMIT or into parallel workers, changing observable evaluation
+	 * counts.  Be conservative and keep the ordinary grouping paths in this
+	 * case.
+	 */
+	if ((limit_needed(parse) || root->tuple_fraction > 0.0) &&
+		contain_volatile_functions((Node *) targetList))
+		return false;
+
+	/* Deliberately avoid upper, join, and partitionwise-child relations. */
+	if (input_rel->reloptkind != RELOPT_BASEREL)
+		return false;
+
+	Assert(bms_membership(input_rel->relids) == BMS_SINGLETON);
+	rti = input_rel->relid;
+	rte = planner_rt_fetch(rti, root);
+	if (rte == NULL || rte->rtekind != RTE_RELATION || rte->lateral ||
+		!(rte->relkind == RELKIND_RELATION ||
+		  rte->relkind == RELKIND_PARTITIONED_TABLE))
+		return false;
+
+	/*
+	 * An old-style inheritance parent can contain the same key in more than
+	 * one child.  A partitioned table's unique constraint proves uniqueness
+	 * across all of its partitions.
+	 */
+	if (rte->inh && rte->relkind != RELKIND_PARTITIONED_TABLE)
+		return false;
+
+	/*
+	 * Use the original clause rather than processed_groupClause: equivalence
+	 * processing may remove redundant keys, but every original key remains a
+	 * valid grouping key.  The original superset can only make the unique-key
+	 * proof stronger.
+	 */
+	return relation_has_unique_index_covered_by_group_keys(input_rel,
+														   parse->groupClause,
+														   targetList);
+}
+
+/*
+ * create_singleton_grouping_paths
+ *		Put input paths directly under the grouping upper relation.
+ *
+ * The input relation was built with make_group_input_target(), so projecting
+ * each path onto grouped_rel's target produces exactly the same rows as a
+ * grouping node when every input row is its own group.  ProjectionPath keeps
+ * the input pathkeys, which lets ORDER BY continue to use an index order.
+ */
+static void
+create_singleton_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
+								RelOptInfo *grouped_rel)
+{
+	ListCell   *lc;
+
+	foreach(lc, input_rel->pathlist)
+	{
+		Path	   *input_path = (Path *) lfirst(lc);
+
+		/*
+		 * Avoid stacking a new projection on the scan/join projection.  The
+		 * final grouped target can be computed directly from the underlying
+		 * path in the narrow case accepted by grouping_input_is_singleton().
+		 */
+		if (IsA(input_path, ProjectionPath))
+			input_path = ((ProjectionPath *) input_path)->subpath;
+
+		add_path(grouped_rel, (Path *)
+				 create_projection_path(root, grouped_rel, input_path,
+										grouped_rel->reltarget));
+	}
+}
+
+/*
+ * create_singleton_partial_grouping_paths
+ *		Expose singleton output as an alternative partial path.
+ *
+ * Ordinary grouping can preserve parallel paths by partial aggregation.  When
+ * grouping itself is redundant, projecting the input partial paths provides
+ * the same opportunity.  In particular, create_ordered_paths() can then sort
+ * such a path below Gather Merge instead of sorting the gathered result above
+ * Gather.  Parallel-restricted targets are rejected by ProjectionPath's
+ * parallel_safe calculation.
+ */
+static void
+create_singleton_partial_grouping_paths(PlannerInfo *root,
+										RelOptInfo *input_rel,
+										RelOptInfo *grouped_rel)
+{
+	ListCell   *lc;
+
+	if (!grouped_rel->consider_parallel ||
+		input_rel->partial_pathlist == NIL)
+		return;
+
+	foreach(lc, input_rel->partial_pathlist)
+	{
+		Path	   *input_path = (Path *) lfirst(lc);
+		ProjectionPath *path;
+
+		/*
+		 * As in create_singleton_grouping_paths(), avoid a ProjectionPath
+		 * stacked directly on another projection.
+		 */
+		if (IsA(input_path, ProjectionPath))
+			input_path = ((ProjectionPath *) input_path)->subpath;
+
+		path = create_projection_path(root, grouped_rel, input_path,
+									  grouped_rel->reltarget);
+
+		if (!path->path.parallel_safe)
+			continue;
+
+		add_partial_path(grouped_rel, &path->path);
+	}
+}
+
 /*
  * create_grouping_paths
  *
@@ -4070,15 +4232,14 @@ create_grouping_paths(PlannerInfo *root,
 					  RelOptInfo *input_rel,
 					  PathTarget *target,
 					  bool target_parallel_safe,
-					  grouping_sets_data *gd)
+					  grouping_sets_data *gd,
+					  SetOperationStmt *setops)
 {
 	Query	   *parse = root->parse;
 	RelOptInfo *grouped_rel;
 	RelOptInfo *partially_grouped_rel;
 	AggClauseCosts agg_costs;
-
-	MemSet(&agg_costs, 0, sizeof(AggClauseCosts));
-	get_agg_clause_costs(root, AGGSPLIT_SIMPLE, &agg_costs);
+	bool		add_singleton_paths;
 
 	/*
 	 * Create grouping relation to hold fully aggregated grouping and/or
@@ -4087,6 +4248,24 @@ create_grouping_paths(PlannerInfo *root,
 	grouped_rel = make_grouping_rel(root, input_rel, target,
 									target_parallel_safe, parse->havingQual);
 
+	/*
+	 * A singleton proof adds alternatives; it does not remove the ordinary
+	 * grouped paths.  Adding these paths first also lets FDWs and upper-path
+	 * hooks see the complete set of core alternatives for this relation.
+	 */
+	add_singleton_paths = grouping_input_is_singleton(root, input_rel,
+													  parse->targetList,
+													  setops != NULL);
+	if (add_singleton_paths)
+	{
+		create_singleton_grouping_paths(root, input_rel, grouped_rel);
+		create_singleton_partial_grouping_paths(root, input_rel,
+												grouped_rel);
+	}
+
+	MemSet(&agg_costs, 0, sizeof(AggClauseCosts));
+	get_agg_clause_costs(root, AGGSPLIT_SIMPLE, &agg_costs);
+
 	/*
 	 * Create either paths for a degenerate grouping or paths for ordinary
 	 * grouping, as appropriate.
@@ -4426,6 +4605,21 @@ create_ordinary_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
 				 errmsg("could not implement GROUP BY"),
 				 errdetail("Some of the datatypes only support hashing, while others only support sorting.")));
 
+	add_foreign_and_custom_grouping_paths(root, input_rel, grouped_rel,
+										  extra);
+}
+
+/*
+ * add_foreign_and_custom_grouping_paths
+ *
+ * Give FDWs and extensions a chance to add or replace paths for the fully
+ * grouped relation.  Callers must set grouped_rel->pathlist before calling.
+ */
+static void
+add_foreign_and_custom_grouping_paths(PlannerInfo *root, RelOptInfo *input_rel,
+									  RelOptInfo *grouped_rel,
+									  GroupPathExtraData *extra)
+{
 	/*
 	 * If there is an FDW that's responsible for all baserels of the query,
 	 * let it consider adding ForeignPaths.
diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h
index d09f57eb0a..52a38551c6 100644
--- a/src/include/optimizer/paths.h
+++ b/src/include/optimizer/paths.h
@@ -86,6 +86,9 @@ extern bool relation_has_unique_index_for(PlannerInfo *root, RelOptInfo *rel,
 extern Bitmapset *relation_removable_groupby_columns(RelOptInfo *rel,
 											 List *groupClause,
 											 List *targetList);
+extern bool relation_has_unique_index_covered_by_group_keys(RelOptInfo *rel,
+															List *groupClause,
+															List *targetList);
 extern bool indexcol_is_bool_constant_for_query(PlannerInfo *root,
 												IndexOptInfo *index,
 												int indexcol);
diff --git a/src/test/regress/expected/aggregates.out b/src/test/regress/expected/aggregates.out
index 7d07619956..01231311b4 100644
--- a/src/test/regress/expected/aggregates.out
+++ b/src/test/regress/expected/aggregates.out
@@ -1548,12 +1548,10 @@ create temp table t2 (x int, y int, z int, primary key (x, y));
 create temp table t3 (a int, b int, c int, primary key(a, b) deferrable);
 -- Non-primary-key columns can be removed from GROUP BY
 explain (costs off) select * from t1 group by a,b,c,d;
-      QUERY PLAN      
-----------------------
- HashAggregate
-   Group Key: a, b
-   ->  Seq Scan on t1
-(3 rows)
+   QUERY PLAN   
+----------------
+ Seq Scan on t1
+(1 row)
 
 -- No removal can happen if the complete PK is not present in GROUP BY
 explain (costs off) select a,c from t1 group by a,c,d;
@@ -1617,12 +1615,10 @@ explain (costs off) select * from t1 group by a,b,c,d;
 
 -- Okay to remove columns if we're only querying the parent.
 explain (costs off) select * from only t1 group by a,b,c,d;
-      QUERY PLAN      
-----------------------
- HashAggregate
-   Group Key: a, b
-   ->  Seq Scan on t1
-(3 rows)
+   QUERY PLAN   
+----------------
+ Seq Scan on t1
+(1 row)
 
 create temp table p_t1 (
   a int,
@@ -1635,14 +1631,12 @@ create temp table p_t1_1 partition of p_t1 for values in(1);
 create temp table p_t1_2 partition of p_t1 for values in(2);
 -- Ensure we can remove non-PK columns for partitioned tables.
 explain (costs off) select * from p_t1 group by a,b,c,d;
-           QUERY PLAN           
---------------------------------
- HashAggregate
-   Group Key: p_t1.a, p_t1.b
-   ->  Append
-         ->  Seq Scan on p_t1_1
-         ->  Seq Scan on p_t1_2
-(5 rows)
+        QUERY PLAN        
+--------------------------
+ Append
+   ->  Seq Scan on p_t1_1
+   ->  Seq Scan on p_t1_2
+(3 rows)
 
 create unique index t2_z_uidx on t2(z);
 -- Ensure we don't remove any columns from the GROUP BY for a unique
@@ -1658,33 +1652,27 @@ explain (costs off) select y,z from t2 group by y,z;
 -- Make the column NOT NULL and ensure we remove the redundant column
 alter table t2 alter column z set not null;
 explain (costs off) select y,z from t2 group by y,z;
-      QUERY PLAN      
-----------------------
- HashAggregate
-   Group Key: z
-   ->  Seq Scan on t2
-(3 rows)
+   QUERY PLAN   
+----------------
+ Seq Scan on t2
+(1 row)
 
 -- When there are multiple supporting unique indexes and the GROUP BY contains
 -- columns to cover all of those, ensure we pick the index with the least
 -- number of columns so that we can remove more columns from the GROUP BY.
 explain (costs off) select x,y,z from t2 group by x,y,z;
-      QUERY PLAN      
-----------------------
- HashAggregate
-   Group Key: z
-   ->  Seq Scan on t2
-(3 rows)
+   QUERY PLAN   
+----------------
+ Seq Scan on t2
+(1 row)
 
 -- As above but try ordering the columns differently to ensure we get the
 -- same result.
 explain (costs off) select x,y,z from t2 group by z,x,y;
-      QUERY PLAN      
-----------------------
- HashAggregate
-   Group Key: z
-   ->  Seq Scan on t2
-(3 rows)
+   QUERY PLAN   
+----------------
+ Seq Scan on t2
+(1 row)
 
 -- Ensure we don't use a partial index as proof of functional dependency
 drop index t2_z_uidx;
@@ -1704,12 +1692,10 @@ drop index t2_z_uidx;
 alter table t2 alter column z drop not null;
 create unique index t2_z_uidx on t2(z) nulls not distinct;
 explain (costs off) select y,z from t2 group by y,z;
-      QUERY PLAN      
-----------------------
- HashAggregate
-   Group Key: z
-   ->  Seq Scan on t2
-(3 rows)
+   QUERY PLAN   
+----------------
+ Seq Scan on t2
+(1 row)
 
 drop table t1 cascade;
 NOTICE:  drop cascades to table t1c
diff --git a/src/test/regress/expected/collate.icu.utf8.out b/src/test/regress/expected/collate.icu.utf8.out
index cb5795f036..7facc3d3fb 100644
--- a/src/test/regress/expected/collate.icu.utf8.out
+++ b/src/test/regress/expected/collate.icu.utf8.out
@@ -3847,6 +3847,26 @@ LINE 1: ...ON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLAT...
                                                              ^
 DETAIL:  "C" versus "case_insensitive"
 DROP DOMAIN d1, d2;
+-- A unique index proves uniqueness only under its own collation.  The
+-- case-insensitive GROUP BY may merge rows that the deterministic "C" unique
+-- index treats as distinct, so singleton grouping elimination must reject the
+-- index as a proof.
+CREATE TABLE singleton_collation_mismatch (
+    val text COLLATE case_insensitive
+);
+CREATE UNIQUE INDEX singleton_collation_mismatch_val
+    ON singleton_collation_mismatch (val COLLATE "C");
+INSERT INTO singleton_collation_mismatch VALUES ('abc'), ('ABC');
+EXPLAIN (COSTS OFF)
+SELECT val FROM singleton_collation_mismatch GROUP BY val;
+                   QUERY PLAN                   
+------------------------------------------------
+ HashAggregate
+   Group Key: val
+   ->  Seq Scan on singleton_collation_mismatch
+(3 rows)
+
+DROP TABLE singleton_collation_mismatch;
 -- cleanup
 RESET search_path;
 SET client_min_messages TO warning;
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 75544fe6aa..6c1cc6f0d5 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -7553,17 +7553,15 @@ select d.* from d left join (select distinct * from b) s
 explain (costs off)
 select d.* from d left join (select * from b group by b.id, b.c_id) s
   on d.a = s.id;
-                QUERY PLAN                
-------------------------------------------
+             QUERY PLAN             
+------------------------------------
  Merge Right Join
    Merge Cond: (b.id = d.a)
-   ->  Group
-         Group Key: b.id
-         ->  Index Scan using b_pkey on b
+   ->  Index Scan using b_pkey on b
    ->  Sort
          Sort Key: d.a
          ->  Seq Scan on d
-(8 rows)
+(6 rows)
 
 -- join removal is not possible when the GROUP BY contains non-empty grouping
 -- sets or multiple empty grouping sets
diff --git a/src/test/regress/expected/singleton_grouping.out b/src/test/regress/expected/singleton_grouping.out
new file mode 100644
index 0000000000..793fd36b27
--- /dev/null
+++ b/src/test/regress/expected/singleton_grouping.out
@@ -0,0 +1,812 @@
+-- Test path-level elimination of grouping when each input row is its own group.
+CREATE TEMP TABLE singleton_base (
+    id int PRIMARY KEY,
+    status text NOT NULL,
+    payload text
+);
+INSERT INTO singleton_base
+SELECT g, 'status' || g, 'payload' || g FROM generate_series(1, 5) g;
+-- The unique key makes each row its own group.
+EXPLAIN (COSTS OFF)
+SELECT id, status, payload FROM singleton_base GROUP BY id, status, payload;
+         QUERY PLAN         
+----------------------------
+ Seq Scan on singleton_base
+(1 row)
+
+SELECT id, string_agg(status, ',' ORDER BY status)
+FROM singleton_base GROUP BY id, status, payload ORDER BY id;
+ id | string_agg 
+----+------------
+  1 | status1
+  2 | status2
+  3 | status3
+  4 | status4
+  5 | status5
+(5 rows)
+
+-- The input pathkeys survive projection.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id ORDER BY id;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using singleton_base_pkey on singleton_base
+(1 row)
+
+-- A constant-covered grouping key may be reduced by equivalence processing;
+-- the original key still proves singleton grouping.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base WHERE id = 1 GROUP BY id;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ Index Only Scan using singleton_base_pkey on singleton_base
+   Index Cond: (id = 1)
+(2 rows)
+
+-- Equivalence processing may remove only some original keys.  The untouched
+-- original clause still contains the unique key and remains a valid proof.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base WHERE id = 1 GROUP BY id, status;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Index Scan using singleton_base_pkey on singleton_base
+   Index Cond: (id = 1)
+(2 rows)
+
+-- A composite key is sufficient; remaining grouping keys may be redundant.
+CREATE TEMP TABLE singleton_composite (
+    a int, b int, c int, d text, PRIMARY KEY (a, b)
+);
+INSERT INTO singleton_composite
+VALUES (1, 1, 1, 'one'), (1, 2, 2, 'two'), (2, 1, 3, 'three');
+EXPLAIN (COSTS OFF)
+SELECT a, b, c, d FROM singleton_composite GROUP BY a, b, c, d;
+           QUERY PLAN            
+---------------------------------
+ Seq Scan on singleton_composite
+(1 row)
+
+-- Non-deferrable unique constraints and indexes, including NULLS NOT
+-- DISTINCT, also prove singleton grouping.
+CREATE TEMP TABLE singleton_unique (
+    id int NOT NULL, val text, UNIQUE (id)
+);
+INSERT INTO singleton_unique VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_unique GROUP BY id, val;
+          QUERY PLAN          
+------------------------------
+ Seq Scan on singleton_unique
+(1 row)
+
+CREATE TEMP TABLE singleton_plain_unique (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_plain_unique_id ON singleton_plain_unique (id);
+INSERT INTO singleton_plain_unique VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_plain_unique GROUP BY id, val;
+             QUERY PLAN             
+------------------------------------
+ Seq Scan on singleton_plain_unique
+(1 row)
+
+CREATE TEMP TABLE singleton_nulls_not_distinct (
+    id int, val text, UNIQUE NULLS NOT DISTINCT (id)
+);
+INSERT INTO singleton_nulls_not_distinct VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_nulls_not_distinct GROUP BY id, val;
+                QUERY PLAN                
+------------------------------------------
+ Seq Scan on singleton_nulls_not_distinct
+(1 row)
+
+-- A composite NULLS NOT DISTINCT index treats rows with NULL keys as
+-- duplicates, so nullable keys can still prove singleton grouping.
+CREATE TEMP TABLE singleton_composite_nnd (
+    a int, b int, val text,
+    UNIQUE NULLS NOT DISTINCT (a, b)
+);
+INSERT INTO singleton_composite_nnd
+VALUES (1, NULL, 'one'), (NULL, 1, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val FROM singleton_composite_nnd GROUP BY a, b, val;
+             QUERY PLAN              
+-------------------------------------
+ Seq Scan on singleton_composite_nnd
+(1 row)
+
+-- A partitioned table has a table-wide unique proof.
+CREATE TEMP TABLE singleton_partitioned (
+    id int PRIMARY KEY, val text
+) PARTITION BY RANGE (id);
+CREATE TEMP TABLE singleton_partitioned_1
+    PARTITION OF singleton_partitioned FOR VALUES FROM (0) TO (10);
+CREATE TEMP TABLE singleton_partitioned_2
+    PARTITION OF singleton_partitioned FOR VALUES FROM (10) TO (20);
+INSERT INTO singleton_partitioned VALUES (1, 'one'), (11, 'eleven');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_partitioned GROUP BY id, val;
+                QUERY PLAN                 
+-------------------------------------------
+ Append
+   ->  Seq Scan on singleton_partitioned_1
+   ->  Seq Scan on singleton_partitioned_2
+(3 rows)
+
+SET enable_partitionwise_aggregate = on;
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_partitioned GROUP BY id, val;
+                QUERY PLAN                 
+-------------------------------------------
+ Append
+   ->  Seq Scan on singleton_partitioned_1
+   ->  Seq Scan on singleton_partitioned_2
+(3 rows)
+
+RESET enable_partitionwise_aggregate;
+-- Singleton paths are alternatives, not a replacement for ordinary grouping.
+-- A larger permanent relation is required because temporary tables cannot be
+-- scanned in parallel.
+CREATE TABLE singleton_parallel_part (
+    bucket int NOT NULL,
+    id int NOT NULL,
+    rank_key int NOT NULL,
+    payload text NOT NULL,
+    PRIMARY KEY (bucket, id)
+) PARTITION BY RANGE (bucket);
+CREATE TABLE singleton_parallel_part_1
+    PARTITION OF singleton_parallel_part FOR VALUES FROM (0) TO (1);
+CREATE TABLE singleton_parallel_part_2
+    PARTITION OF singleton_parallel_part FOR VALUES FROM (1) TO (2);
+INSERT INTO singleton_parallel_part
+SELECT g % 2, g, (g * 37) % 1000, 'payload' || g
+FROM generate_series(1, 10000) g;
+ALTER TABLE singleton_parallel_part_1 SET (parallel_workers = 2);
+ALTER TABLE singleton_parallel_part_2 SET (parallel_workers = 2);
+CREATE FUNCTION singleton_safe_slow(p text) RETURNS text
+LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE COST 100 AS $$
+DECLARE r text := p;
+BEGIN
+    r := md5(p || r);
+    RETURN r;
+END $$;
+CREATE FUNCTION singleton_unsafe_slow(p text) RETURNS text
+LANGUAGE plpgsql VOLATILE STRICT PARALLEL UNSAFE COST 100 AS $$
+DECLARE r text := p;
+BEGIN
+    r := md5(p || r);
+    RETURN r;
+END $$;
+-- A direct partial path allows worker-local sorting and Gather Merge.
+BEGIN;
+SET LOCAL enable_partitionwise_aggregate = off;
+SET LOCAL enable_parallel_append = on;
+SET LOCAL max_parallel_workers_per_gather = 2;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, rank_key FROM singleton_parallel_part
+GROUP BY bucket, id, rank_key ORDER BY rank_key + 1;
+                               QUERY PLAN                               
+------------------------------------------------------------------------
+ Gather Merge
+   Workers Planned: 2
+   ->  Sort
+         Sort Key: ((singleton_parallel_part.rank_key + 1))
+         ->  Result
+               ->  Parallel Append
+                     ->  Parallel Seq Scan on singleton_parallel_part_1
+                     ->  Parallel Seq Scan on singleton_parallel_part_2
+(8 rows)
+
+ROLLBACK;
+-- With ordinary grouping methods disabled, the direct partial path remains.
+-- A parallel-safe output expression is projected below Gather.
+BEGIN;
+SET LOCAL enable_partitionwise_aggregate = off;
+SET LOCAL enable_parallel_append = on;
+SET LOCAL max_parallel_workers_per_gather = 2;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+SET LOCAL enable_hashagg = off;
+SET LOCAL enable_sort = off;
+SET LOCAL enable_indexscan = off;
+SET LOCAL enable_indexonlyscan = off;
+SET LOCAL enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, singleton_safe_slow(payload)
+FROM singleton_parallel_part GROUP BY bucket, id;
+                            QUERY PLAN                            
+------------------------------------------------------------------
+ Gather
+   Workers Planned: 2
+   ->  Result
+         ->  Parallel Append
+               ->  Parallel Seq Scan on singleton_parallel_part_1
+               ->  Parallel Seq Scan on singleton_parallel_part_2
+(6 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, singleton_unsafe_slow(payload)
+FROM singleton_parallel_part GROUP BY bucket, id;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Result
+   ->  Append
+         ->  Seq Scan on singleton_parallel_part_1
+         ->  Seq Scan on singleton_parallel_part_2
+(4 rows)
+
+ROLLBACK;
+-- Partitionwise aggregate remains available and can win when its child-wise
+-- estimates are favorable.  It must not be erased by the singleton alternative.
+BEGIN;
+SET LOCAL enable_partitionwise_aggregate = on;
+SET LOCAL enable_parallel_append = on;
+SET LOCAL max_parallel_workers_per_gather = 2;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, rank_key FROM singleton_parallel_part
+GROUP BY bucket, id, rank_key ORDER BY rank_key + 1;
+                                          QUERY PLAN                                           
+-----------------------------------------------------------------------------------------------
+ Gather Merge
+   Workers Planned: 2
+   ->  Sort
+         Sort Key: ((singleton_parallel_part.rank_key + 1))
+         ->  Parallel Append
+               ->  HashAggregate
+                     Group Key: singleton_parallel_part.bucket, singleton_parallel_part.id
+                     ->  Seq Scan on singleton_parallel_part_1 singleton_parallel_part
+               ->  HashAggregate
+                     Group Key: singleton_parallel_part_1.bucket, singleton_parallel_part_1.id
+                     ->  Seq Scan on singleton_parallel_part_2 singleton_parallel_part_1
+(11 rows)
+
+ROLLBACK;
+DROP FUNCTION singleton_safe_slow(text);
+DROP FUNCTION singleton_unsafe_slow(text);
+DROP TABLE singleton_parallel_part CASCADE;
+-- Aggregate semantics must never be removed.
+EXPLAIN (COSTS OFF)
+SELECT id, count(*) FROM singleton_base GROUP BY id;
+            QUERY PLAN            
+----------------------------------
+ HashAggregate
+   Group Key: id
+   ->  Seq Scan on singleton_base
+(3 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id HAVING true;
+            QUERY PLAN            
+----------------------------------
+ HashAggregate
+   Group Key: id
+   ->  Seq Scan on singleton_base
+(3 rows)
+
+-- Aggregate-less implicit/degenerate grouping phases must not use singleton
+-- elimination.  A pure DISTINCT is a separate upper relation and has no
+-- GROUP BY proof.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM singleton_base;
+            QUERY PLAN            
+----------------------------------
+ Aggregate
+   ->  Seq Scan on singleton_base
+(2 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT true FROM singleton_base HAVING true;
+      QUERY PLAN       
+-----------------------
+ Result
+   Replaces: Aggregate
+(2 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT id, status FROM singleton_base
+GROUP BY GROUPING SETS ((id), (status));
+            QUERY PLAN            
+----------------------------------
+ HashAggregate
+   Hash Key: id
+   Hash Key: status
+   ->  Seq Scan on singleton_base
+(4 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT id FROM singleton_base;
+            QUERY PLAN            
+----------------------------------
+ HashAggregate
+   Group Key: id
+   ->  Seq Scan on singleton_base
+(3 rows)
+
+-- A grouped view is planned as its own query.  Its explicit unique-key proof
+-- may be applied there even though the outer query sees only a subquery RTE.
+CREATE TEMP VIEW singleton_grouped_view AS
+    SELECT id FROM singleton_base GROUP BY id;
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_grouped_view;
+         QUERY PLAN         
+----------------------------
+ Seq Scan on singleton_base
+(1 row)
+
+-- Other row-changing or ordering-sensitive semantics remain excluded.
+EXPLAIN (COSTS OFF)
+SELECT id, row_number() OVER (ORDER BY id)
+FROM singleton_base GROUP BY id;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ WindowAgg
+   Window: w1 AS (ORDER BY id ROWS UNBOUNDED PRECEDING)
+   ->  Group
+         Group Key: id
+         ->  Index Only Scan using singleton_base_pkey on singleton_base
+(5 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT id, generate_series(1, 1) FROM singleton_base GROUP BY id;
+               QUERY PLAN               
+----------------------------------------
+ ProjectSet
+   ->  HashAggregate
+         Group Key: id
+         ->  Seq Scan on singleton_base
+(4 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT id FROM singleton_base GROUP BY id;
+               QUERY PLAN               
+----------------------------------------
+ HashAggregate
+   Group Key: id
+   ->  HashAggregate
+         Group Key: id
+         ->  Seq Scan on singleton_base
+(5 rows)
+
+-- A UNION parent needs sorted children, so they stay rejected.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id
+UNION
+SELECT id FROM singleton_base GROUP BY id;
+                          QUERY PLAN                           
+---------------------------------------------------------------
+ HashAggregate
+   Group Key: singleton_base.id
+   ->  Append
+         ->  HashAggregate
+               Group Key: singleton_base.id
+               ->  Seq Scan on singleton_base
+         ->  HashAggregate
+               Group Key: singleton_base_1.id
+               ->  Seq Scan on singleton_base singleton_base_1
+(9 rows)
+
+-- A UNION ALL parent does not require ordered children, so each leaf may use
+-- its own singleton proof.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id
+UNION ALL
+SELECT id FROM singleton_base GROUP BY id;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Append
+   ->  Seq Scan on singleton_base
+   ->  Seq Scan on singleton_base singleton_base_1
+(3 rows)
+
+-- A non-flattenable subquery input stays rejected.
+EXPLAIN (COSTS OFF)
+SELECT id FROM (SELECT id FROM singleton_base OFFSET 0) s GROUP BY id;
+            QUERY PLAN            
+----------------------------------
+ HashAggregate
+   Group Key: singleton_base.id
+   ->  Seq Scan on singleton_base
+(3 rows)
+
+-- Additional non-simple grouping keys do not invalidate a unique-key proof.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id, length(payload);
+         QUERY PLAN         
+----------------------------
+ Seq Scan on singleton_base
+(1 row)
+
+SELECT id, length(payload) FROM singleton_base GROUP BY id, length(payload)
+ORDER BY id, length(payload);
+ id | length 
+----+--------
+  1 |      8
+  2 |      8
+  3 |      8
+  4 |      8
+  5 |      8
+(5 rows)
+
+-- Only direct grouping Vars can cover unique keys; extra expressions may
+-- appear before, after, or alongside those Vars.
+EXPLAIN (COSTS OFF)
+SELECT a, b, c
+FROM singleton_composite
+GROUP BY c, a, length(d), b;
+           QUERY PLAN            
+---------------------------------
+ Seq Scan on singleton_composite
+(1 row)
+
+SELECT a, b, c, length(d)
+FROM singleton_composite
+GROUP BY c, a, length(d), b
+ORDER BY a, b, c;
+ a | b | c | length 
+---+---+---+--------
+ 1 | 1 | 1 |      3
+ 1 | 2 | 2 |      3
+ 2 | 1 | 3 |      5
+(3 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT a, b
+FROM singleton_composite
+GROUP BY a, b, a + 0, b + 0;
+           QUERY PLAN            
+---------------------------------
+ Seq Scan on singleton_composite
+(1 row)
+
+-- An expression cannot itself cover the unique key.
+EXPLAIN (COSTS OFF)
+SELECT length(payload) FROM singleton_base GROUP BY length(payload);
+            QUERY PLAN            
+----------------------------------
+ HashAggregate
+   Group Key: length(payload)
+   ->  Seq Scan on singleton_base
+(3 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT a, b + 0 FROM singleton_composite GROUP BY a, b + 0;
+              QUERY PLAN               
+---------------------------------------
+ HashAggregate
+   Group Key: a, (b + 0)
+   ->  Seq Scan on singleton_composite
+(3 rows)
+
+-- Expressions are also allowed with NULLS NOT DISTINCT proofs.
+EXPLAIN (COSTS OFF)
+SELECT a, b, val
+FROM singleton_composite_nnd
+GROUP BY a, b, val, length(val);
+             QUERY PLAN              
+-------------------------------------
+ Seq Scan on singleton_composite_nnd
+(1 row)
+
+-- Preserve evaluation of extra volatile grouping expressions.  The singleton
+-- plan must evaluate them once per input row.
+CREATE TEMP SEQUENCE singleton_expr_calls;
+CREATE FUNCTION pg_temp.singleton_expr_call() RETURNS int
+LANGUAGE plpgsql VOLATILE AS $$
+BEGIN
+    RETURN nextval('singleton_expr_calls');
+END
+$$;
+SELECT setval('singleton_expr_calls', 1, false);
+ setval 
+--------
+      1
+(1 row)
+
+SELECT count(*) FROM (
+    SELECT id FROM singleton_base
+    GROUP BY id, pg_temp.singleton_expr_call()
+) q;
+ count 
+-------
+     5
+(1 row)
+
+SELECT last_value FROM singleton_expr_calls;
+ last_value 
+------------
+          5
+(1 row)
+
+-- With partial retrieval, keep ordinary grouping when the target contains a
+-- volatile function.  LIMIT should observe only three evaluations.
+SELECT setval('singleton_expr_calls', 1, false);
+ setval 
+--------
+      1
+(1 row)
+
+EXPLAIN (COSTS OFF)
+SELECT id, nextval('singleton_expr_calls') AS calls
+FROM singleton_base
+GROUP BY id
+ORDER BY id
+LIMIT 3;
+                               QUERY PLAN                                
+-------------------------------------------------------------------------
+ Limit
+   ->  Group
+         Group Key: id
+         ->  Index Only Scan using singleton_base_pkey on singleton_base
+(4 rows)
+
+SELECT id, nextval('singleton_expr_calls') AS calls
+FROM singleton_base
+GROUP BY id
+ORDER BY id
+LIMIT 3;
+ id | calls 
+----+-------
+  1 |     1
+  2 |     2
+  3 |     3
+(3 rows)
+
+SELECT last_value, is_called, last_value = 3 AS calls_ok
+FROM singleton_expr_calls;
+ last_value | is_called | calls_ok 
+------------+-----------+----------
+          3 | t         | t
+(1 row)
+
+-- A unique side does not make a join output unique.
+CREATE TEMP TABLE singleton_many (sid int, val text);
+INSERT INTO singleton_many
+SELECT g % 5, 'many' || g FROM generate_series(1, 10) g;
+EXPLAIN (COSTS OFF)
+SELECT b.id
+FROM singleton_base b JOIN singleton_many m ON m.sid = b.id
+GROUP BY b.id;
+                   QUERY PLAN                   
+------------------------------------------------
+ HashAggregate
+   Group Key: b.id
+   ->  Hash Join
+         Hash Cond: (m.sid = b.id)
+         ->  Seq Scan on singleton_many m
+         ->  Hash
+               ->  Seq Scan on singleton_base b
+(7 rows)
+
+-- A unique index proves uniqueness only under its own equality semantics.
+-- record_image_ops is bytewise equality, while plain GROUP BY on a composite
+-- uses record_ops.  Rows that the index distinguishes can still be one group.
+CREATE TYPE pg_temp.singleton_avg_rec AS (x numeric);
+CREATE TEMP TABLE singleton_opf (a singleton_avg_rec NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_opf_a ON singleton_opf (a record_image_ops);
+INSERT INTO singleton_opf
+VALUES (row(1.0)::singleton_avg_rec, 'X'),
+       (row(1.00)::singleton_avg_rec, 'Y');
+EXPLAIN (COSTS OFF)
+SELECT a, val FROM singleton_opf GROUP BY a, val;
+           QUERY PLAN            
+---------------------------------
+ HashAggregate
+   Group Key: a, val
+   ->  Seq Scan on singleton_opf
+(3 rows)
+
+-- Unsafe uniqueness proofs remain rejected.
+CREATE TEMP TABLE singleton_nullable (id int, val text);
+CREATE UNIQUE INDEX singleton_nullable_id ON singleton_nullable (id);
+INSERT INTO singleton_nullable VALUES (NULL, 'one'), (NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_nullable GROUP BY id, val;
+              QUERY PLAN              
+--------------------------------------
+ HashAggregate
+   Group Key: id, val
+   ->  Seq Scan on singleton_nullable
+(3 rows)
+
+-- Every key of a NULLS DISTINCT unique index must be NOT NULL.
+CREATE TEMP TABLE singleton_composite_nullable (
+    a int NOT NULL, b int, val text,
+    CONSTRAINT singleton_composite_nullable_key UNIQUE (a, b)
+);
+INSERT INTO singleton_composite_nullable
+VALUES (1, NULL, 'one'), (1, NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val FROM singleton_composite_nullable GROUP BY a, b, val;
+                   QUERY PLAN                   
+------------------------------------------------
+ HashAggregate
+   Group Key: a, b, val
+   ->  Seq Scan on singleton_composite_nullable
+(3 rows)
+
+-- Extra expressions cannot repair the missing NOT NULL proof.
+EXPLAIN (COSTS OFF)
+SELECT a, b, val
+FROM singleton_composite_nullable
+GROUP BY a, b, val, length(val);
+                   QUERY PLAN                   
+------------------------------------------------
+ HashAggregate
+   Group Key: a, b, val, length(val)
+   ->  Seq Scan on singleton_composite_nullable
+(3 rows)
+
+CREATE TEMP TABLE singleton_deferrable (
+    id int, CONSTRAINT singleton_deferrable_id UNIQUE (id) DEFERRABLE
+);
+INSERT INTO singleton_deferrable VALUES (1), (2);
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_deferrable GROUP BY id;
+               QUERY PLAN               
+----------------------------------------
+ HashAggregate
+   Group Key: id
+   ->  Seq Scan on singleton_deferrable
+(3 rows)
+
+CREATE TEMP TABLE singleton_partial (id int NOT NULL, active boolean);
+CREATE UNIQUE INDEX singleton_partial_id
+    ON singleton_partial (id) WHERE active;
+INSERT INTO singleton_partial VALUES (1, true), (2, true);
+EXPLAIN (COSTS OFF)
+SELECT id, active FROM singleton_partial GROUP BY id, active;
+             QUERY PLAN              
+-------------------------------------
+ HashAggregate
+   Group Key: id, active
+   ->  Seq Scan on singleton_partial
+(3 rows)
+
+-- Even an apparently NULL-safe partial predicate is outside V1.
+CREATE TEMP TABLE singleton_partial_nullable (a int, b int, val text);
+CREATE UNIQUE INDEX singleton_partial_nullable_ab
+    ON singleton_partial_nullable (a, b)
+    WHERE a IS NOT NULL AND b IS NOT NULL;
+INSERT INTO singleton_partial_nullable
+VALUES (1, NULL, 'one'), (1, NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val FROM singleton_partial_nullable GROUP BY a, b, val;
+                  QUERY PLAN                  
+----------------------------------------------
+ HashAggregate
+   Group Key: a, b, val
+   ->  Seq Scan on singleton_partial_nullable
+(3 rows)
+
+CREATE TEMP TABLE singleton_expression (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_expression_upper
+    ON singleton_expression (upper(val));
+INSERT INTO singleton_expression VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_expression GROUP BY id, val;
+               QUERY PLAN               
+----------------------------------------
+ HashAggregate
+   Group Key: id, val
+   ->  Seq Scan on singleton_expression
+(3 rows)
+
+CREATE TEMP TABLE singleton_parent (id int PRIMARY KEY, val text);
+CREATE TEMP TABLE singleton_child () INHERITS (singleton_parent);
+INSERT INTO singleton_parent VALUES (1, 'parent');
+INSERT INTO singleton_child VALUES (1, 'child');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_parent GROUP BY id, val;
+                         QUERY PLAN                          
+-------------------------------------------------------------
+ HashAggregate
+   Group Key: singleton_parent.id, singleton_parent.val
+   ->  Append
+         ->  Seq Scan on singleton_parent singleton_parent_1
+         ->  Seq Scan on singleton_child singleton_parent_2
+(5 rows)
+
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM ONLY singleton_parent GROUP BY id, val;
+          QUERY PLAN          
+------------------------------
+ Seq Scan on singleton_parent
+(1 row)
+
+-- A partitioned composite unique index must still prove every key.
+CREATE TEMP TABLE singleton_partitioned_nullable (
+    a int, b int, val text
+) PARTITION BY RANGE (a);
+CREATE UNIQUE INDEX singleton_partitioned_nullable_ab
+    ON singleton_partitioned_nullable (a, b);
+CREATE TEMP TABLE singleton_partitioned_nullable_1
+    PARTITION OF singleton_partitioned_nullable FOR VALUES FROM (0) TO (10);
+INSERT INTO singleton_partitioned_nullable
+VALUES (1, NULL, 'one'), (1, NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val
+FROM singleton_partitioned_nullable GROUP BY a, b, val;
+                                                     QUERY PLAN                                                      
+---------------------------------------------------------------------------------------------------------------------
+ HashAggregate
+   Group Key: singleton_partitioned_nullable.a, singleton_partitioned_nullable.b, singleton_partitioned_nullable.val
+   ->  Seq Scan on singleton_partitioned_nullable_1 singleton_partitioned_nullable
+(3 rows)
+
+-- Dropping the proof must invalidate a cached plan.
+CREATE TEMP TABLE singleton_invalidate (id int PRIMARY KEY, val text);
+INSERT INTO singleton_invalidate VALUES (1, 'one'), (2, 'two');
+PREPARE singleton_prep AS
+    SELECT id, val FROM singleton_invalidate GROUP BY id, val;
+EXPLAIN (COSTS OFF) EXECUTE singleton_prep;
+            QUERY PLAN            
+----------------------------------
+ Seq Scan on singleton_invalidate
+(1 row)
+
+ALTER TABLE singleton_invalidate DROP CONSTRAINT singleton_invalidate_pkey;
+EXPLAIN (COSTS OFF) EXECUTE singleton_prep;
+               QUERY PLAN               
+----------------------------------------
+ HashAggregate
+   Group Key: id, val
+   ->  Seq Scan on singleton_invalidate
+(3 rows)
+
+DEALLOCATE singleton_prep;
+-- A plain unique index is also invalidation evidence for the proof.
+CREATE TEMP TABLE singleton_invalidate_index (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_invalidate_index_id
+    ON singleton_invalidate_index (id);
+INSERT INTO singleton_invalidate_index VALUES (1, 'one'), (2, 'two');
+PREPARE singleton_invalidate_index_prep AS
+    SELECT id, val FROM singleton_invalidate_index GROUP BY id, val;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_index_prep;
+               QUERY PLAN               
+----------------------------------------
+ Seq Scan on singleton_invalidate_index
+(1 row)
+
+DROP INDEX singleton_invalidate_index_id;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_index_prep;
+                  QUERY PLAN                  
+----------------------------------------------
+ HashAggregate
+   Group Key: id, val
+   ->  Seq Scan on singleton_invalidate_index
+(3 rows)
+
+DEALLOCATE singleton_invalidate_index_prep;
+-- Removing a key column's NOT NULL constraint must invalidate the proof.
+CREATE TEMP TABLE singleton_invalidate_notnull (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_invalidate_notnull_id
+    ON singleton_invalidate_notnull (id);
+INSERT INTO singleton_invalidate_notnull VALUES (1, 'one'), (2, 'two');
+PREPARE singleton_invalidate_notnull_prep AS
+    SELECT id, val FROM singleton_invalidate_notnull GROUP BY id, val;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_notnull_prep;
+                QUERY PLAN                
+------------------------------------------
+ Seq Scan on singleton_invalidate_notnull
+(1 row)
+
+ALTER TABLE singleton_invalidate_notnull ALTER COLUMN id DROP NOT NULL;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_notnull_prep;
+                   QUERY PLAN                   
+------------------------------------------------
+ HashAggregate
+   Group Key: id, val
+   ->  Seq Scan on singleton_invalidate_notnull
+(3 rows)
+
+DEALLOCATE singleton_invalidate_notnull_prep;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 75063f87a4..d006945159 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -61,6 +61,8 @@ test: sanity_check
 # aggregates depends on create_aggregate
 # join depends on create_misc
 # ----------
+test: singleton_grouping
+
 test: select_into select_distinct select_distinct_on select_implicit select_having subselect union case join aggregates transactions random portals arrays btree_index hash_index update delete namespace prepared_xacts
 
 # ----------
diff --git a/src/test/regress/sql/collate.icu.utf8.sql b/src/test/regress/sql/collate.icu.utf8.sql
index e96ad737aa..1ef5001675 100644
--- a/src/test/regress/sql/collate.icu.utf8.sql
+++ b/src/test/regress/sql/collate.icu.utf8.sql
@@ -1475,6 +1475,20 @@ SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A'::d2 ON EMPTY) = '
 SELECT JSON_VALUE('{"a": "A"}', '$.c' RETURNING d1 DEFAULT 'A' COLLATE "C" ON EMPTY) = 'a'; -- error
 DROP DOMAIN d1, d2;
 
+-- A unique index proves uniqueness only under its own collation.  The
+-- case-insensitive GROUP BY may merge rows that the deterministic "C" unique
+-- index treats as distinct, so singleton grouping elimination must reject the
+-- index as a proof.
+CREATE TABLE singleton_collation_mismatch (
+    val text COLLATE case_insensitive
+);
+CREATE UNIQUE INDEX singleton_collation_mismatch_val
+    ON singleton_collation_mismatch (val COLLATE "C");
+INSERT INTO singleton_collation_mismatch VALUES ('abc'), ('ABC');
+EXPLAIN (COSTS OFF)
+SELECT val FROM singleton_collation_mismatch GROUP BY val;
+DROP TABLE singleton_collation_mismatch;
+
 -- cleanup
 RESET search_path;
 SET client_min_messages TO warning;
diff --git a/src/test/regress/sql/singleton_grouping.sql b/src/test/regress/sql/singleton_grouping.sql
new file mode 100644
index 0000000000..31dacbe8d3
--- /dev/null
+++ b/src/test/regress/sql/singleton_grouping.sql
@@ -0,0 +1,424 @@
+-- Test path-level elimination of grouping when each input row is its own group.
+CREATE TEMP TABLE singleton_base (
+    id int PRIMARY KEY,
+    status text NOT NULL,
+    payload text
+);
+INSERT INTO singleton_base
+SELECT g, 'status' || g, 'payload' || g FROM generate_series(1, 5) g;
+
+-- The unique key makes each row its own group.
+EXPLAIN (COSTS OFF)
+SELECT id, status, payload FROM singleton_base GROUP BY id, status, payload;
+SELECT id, string_agg(status, ',' ORDER BY status)
+FROM singleton_base GROUP BY id, status, payload ORDER BY id;
+
+-- The input pathkeys survive projection.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id ORDER BY id;
+
+-- A constant-covered grouping key may be reduced by equivalence processing;
+-- the original key still proves singleton grouping.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base WHERE id = 1 GROUP BY id;
+
+-- Equivalence processing may remove only some original keys.  The untouched
+-- original clause still contains the unique key and remains a valid proof.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base WHERE id = 1 GROUP BY id, status;
+
+-- A composite key is sufficient; remaining grouping keys may be redundant.
+CREATE TEMP TABLE singleton_composite (
+    a int, b int, c int, d text, PRIMARY KEY (a, b)
+);
+INSERT INTO singleton_composite
+VALUES (1, 1, 1, 'one'), (1, 2, 2, 'two'), (2, 1, 3, 'three');
+EXPLAIN (COSTS OFF)
+SELECT a, b, c, d FROM singleton_composite GROUP BY a, b, c, d;
+
+-- Non-deferrable unique constraints and indexes, including NULLS NOT
+-- DISTINCT, also prove singleton grouping.
+CREATE TEMP TABLE singleton_unique (
+    id int NOT NULL, val text, UNIQUE (id)
+);
+INSERT INTO singleton_unique VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_unique GROUP BY id, val;
+
+CREATE TEMP TABLE singleton_plain_unique (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_plain_unique_id ON singleton_plain_unique (id);
+INSERT INTO singleton_plain_unique VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_plain_unique GROUP BY id, val;
+
+CREATE TEMP TABLE singleton_nulls_not_distinct (
+    id int, val text, UNIQUE NULLS NOT DISTINCT (id)
+);
+INSERT INTO singleton_nulls_not_distinct VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_nulls_not_distinct GROUP BY id, val;
+
+-- A composite NULLS NOT DISTINCT index treats rows with NULL keys as
+-- duplicates, so nullable keys can still prove singleton grouping.
+CREATE TEMP TABLE singleton_composite_nnd (
+    a int, b int, val text,
+    UNIQUE NULLS NOT DISTINCT (a, b)
+);
+INSERT INTO singleton_composite_nnd
+VALUES (1, NULL, 'one'), (NULL, 1, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val FROM singleton_composite_nnd GROUP BY a, b, val;
+
+-- A partitioned table has a table-wide unique proof.
+CREATE TEMP TABLE singleton_partitioned (
+    id int PRIMARY KEY, val text
+) PARTITION BY RANGE (id);
+CREATE TEMP TABLE singleton_partitioned_1
+    PARTITION OF singleton_partitioned FOR VALUES FROM (0) TO (10);
+CREATE TEMP TABLE singleton_partitioned_2
+    PARTITION OF singleton_partitioned FOR VALUES FROM (10) TO (20);
+INSERT INTO singleton_partitioned VALUES (1, 'one'), (11, 'eleven');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_partitioned GROUP BY id, val;
+SET enable_partitionwise_aggregate = on;
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_partitioned GROUP BY id, val;
+RESET enable_partitionwise_aggregate;
+
+-- Singleton paths are alternatives, not a replacement for ordinary grouping.
+-- A larger permanent relation is required because temporary tables cannot be
+-- scanned in parallel.
+CREATE TABLE singleton_parallel_part (
+    bucket int NOT NULL,
+    id int NOT NULL,
+    rank_key int NOT NULL,
+    payload text NOT NULL,
+    PRIMARY KEY (bucket, id)
+) PARTITION BY RANGE (bucket);
+CREATE TABLE singleton_parallel_part_1
+    PARTITION OF singleton_parallel_part FOR VALUES FROM (0) TO (1);
+CREATE TABLE singleton_parallel_part_2
+    PARTITION OF singleton_parallel_part FOR VALUES FROM (1) TO (2);
+INSERT INTO singleton_parallel_part
+SELECT g % 2, g, (g * 37) % 1000, 'payload' || g
+FROM generate_series(1, 10000) g;
+ALTER TABLE singleton_parallel_part_1 SET (parallel_workers = 2);
+ALTER TABLE singleton_parallel_part_2 SET (parallel_workers = 2);
+CREATE FUNCTION singleton_safe_slow(p text) RETURNS text
+LANGUAGE plpgsql IMMUTABLE STRICT PARALLEL SAFE COST 100 AS $$
+DECLARE r text := p;
+BEGIN
+    r := md5(p || r);
+    RETURN r;
+END $$;
+CREATE FUNCTION singleton_unsafe_slow(p text) RETURNS text
+LANGUAGE plpgsql VOLATILE STRICT PARALLEL UNSAFE COST 100 AS $$
+DECLARE r text := p;
+BEGIN
+    r := md5(p || r);
+    RETURN r;
+END $$;
+
+-- A direct partial path allows worker-local sorting and Gather Merge.
+BEGIN;
+SET LOCAL enable_partitionwise_aggregate = off;
+SET LOCAL enable_parallel_append = on;
+SET LOCAL max_parallel_workers_per_gather = 2;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, rank_key FROM singleton_parallel_part
+GROUP BY bucket, id, rank_key ORDER BY rank_key + 1;
+ROLLBACK;
+
+-- With ordinary grouping methods disabled, the direct partial path remains.
+-- A parallel-safe output expression is projected below Gather.
+BEGIN;
+SET LOCAL enable_partitionwise_aggregate = off;
+SET LOCAL enable_parallel_append = on;
+SET LOCAL max_parallel_workers_per_gather = 2;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+SET LOCAL enable_hashagg = off;
+SET LOCAL enable_sort = off;
+SET LOCAL enable_indexscan = off;
+SET LOCAL enable_indexonlyscan = off;
+SET LOCAL enable_bitmapscan = off;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, singleton_safe_slow(payload)
+FROM singleton_parallel_part GROUP BY bucket, id;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, singleton_unsafe_slow(payload)
+FROM singleton_parallel_part GROUP BY bucket, id;
+ROLLBACK;
+
+-- Partitionwise aggregate remains available and can win when its child-wise
+-- estimates are favorable.  It must not be erased by the singleton alternative.
+BEGIN;
+SET LOCAL enable_partitionwise_aggregate = on;
+SET LOCAL enable_parallel_append = on;
+SET LOCAL max_parallel_workers_per_gather = 2;
+SET LOCAL min_parallel_table_scan_size = 0;
+SET LOCAL min_parallel_index_scan_size = 0;
+SET LOCAL parallel_setup_cost = 0;
+SET LOCAL parallel_tuple_cost = 0;
+EXPLAIN (COSTS OFF)
+SELECT bucket, id, rank_key FROM singleton_parallel_part
+GROUP BY bucket, id, rank_key ORDER BY rank_key + 1;
+ROLLBACK;
+DROP FUNCTION singleton_safe_slow(text);
+DROP FUNCTION singleton_unsafe_slow(text);
+DROP TABLE singleton_parallel_part CASCADE;
+
+-- Aggregate semantics must never be removed.
+EXPLAIN (COSTS OFF)
+SELECT id, count(*) FROM singleton_base GROUP BY id;
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id HAVING true;
+
+-- Aggregate-less implicit/degenerate grouping phases must not use singleton
+-- elimination.  A pure DISTINCT is a separate upper relation and has no
+-- GROUP BY proof.
+EXPLAIN (COSTS OFF)
+SELECT count(*) FROM singleton_base;
+EXPLAIN (COSTS OFF)
+SELECT true FROM singleton_base HAVING true;
+EXPLAIN (COSTS OFF)
+SELECT id, status FROM singleton_base
+GROUP BY GROUPING SETS ((id), (status));
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT id FROM singleton_base;
+-- A grouped view is planned as its own query.  Its explicit unique-key proof
+-- may be applied there even though the outer query sees only a subquery RTE.
+CREATE TEMP VIEW singleton_grouped_view AS
+    SELECT id FROM singleton_base GROUP BY id;
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_grouped_view;
+
+-- Other row-changing or ordering-sensitive semantics remain excluded.
+EXPLAIN (COSTS OFF)
+SELECT id, row_number() OVER (ORDER BY id)
+FROM singleton_base GROUP BY id;
+EXPLAIN (COSTS OFF)
+SELECT id, generate_series(1, 1) FROM singleton_base GROUP BY id;
+EXPLAIN (COSTS OFF)
+SELECT DISTINCT id FROM singleton_base GROUP BY id;
+
+-- A UNION parent needs sorted children, so they stay rejected.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id
+UNION
+SELECT id FROM singleton_base GROUP BY id;
+
+-- A UNION ALL parent does not require ordered children, so each leaf may use
+-- its own singleton proof.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id
+UNION ALL
+SELECT id FROM singleton_base GROUP BY id;
+
+-- A non-flattenable subquery input stays rejected.
+EXPLAIN (COSTS OFF)
+SELECT id FROM (SELECT id FROM singleton_base OFFSET 0) s GROUP BY id;
+
+-- Additional non-simple grouping keys do not invalidate a unique-key proof.
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_base GROUP BY id, length(payload);
+SELECT id, length(payload) FROM singleton_base GROUP BY id, length(payload)
+ORDER BY id, length(payload);
+
+-- Only direct grouping Vars can cover unique keys; extra expressions may
+-- appear before, after, or alongside those Vars.
+EXPLAIN (COSTS OFF)
+SELECT a, b, c
+FROM singleton_composite
+GROUP BY c, a, length(d), b;
+SELECT a, b, c, length(d)
+FROM singleton_composite
+GROUP BY c, a, length(d), b
+ORDER BY a, b, c;
+EXPLAIN (COSTS OFF)
+SELECT a, b
+FROM singleton_composite
+GROUP BY a, b, a + 0, b + 0;
+
+-- An expression cannot itself cover the unique key.
+EXPLAIN (COSTS OFF)
+SELECT length(payload) FROM singleton_base GROUP BY length(payload);
+EXPLAIN (COSTS OFF)
+SELECT a, b + 0 FROM singleton_composite GROUP BY a, b + 0;
+
+-- Expressions are also allowed with NULLS NOT DISTINCT proofs.
+EXPLAIN (COSTS OFF)
+SELECT a, b, val
+FROM singleton_composite_nnd
+GROUP BY a, b, val, length(val);
+
+-- Preserve evaluation of extra volatile grouping expressions.  The singleton
+-- plan must evaluate them once per input row.
+CREATE TEMP SEQUENCE singleton_expr_calls;
+CREATE FUNCTION pg_temp.singleton_expr_call() RETURNS int
+LANGUAGE plpgsql VOLATILE AS $$
+BEGIN
+    RETURN nextval('singleton_expr_calls');
+END
+$$;
+SELECT setval('singleton_expr_calls', 1, false);
+SELECT count(*) FROM (
+    SELECT id FROM singleton_base
+    GROUP BY id, pg_temp.singleton_expr_call()
+) q;
+SELECT last_value FROM singleton_expr_calls;
+
+-- With partial retrieval, keep ordinary grouping when the target contains a
+-- volatile function.  LIMIT should observe only three evaluations.
+SELECT setval('singleton_expr_calls', 1, false);
+EXPLAIN (COSTS OFF)
+SELECT id, nextval('singleton_expr_calls') AS calls
+FROM singleton_base
+GROUP BY id
+ORDER BY id
+LIMIT 3;
+SELECT id, nextval('singleton_expr_calls') AS calls
+FROM singleton_base
+GROUP BY id
+ORDER BY id
+LIMIT 3;
+SELECT last_value, is_called, last_value = 3 AS calls_ok
+FROM singleton_expr_calls;
+
+-- A unique side does not make a join output unique.
+CREATE TEMP TABLE singleton_many (sid int, val text);
+INSERT INTO singleton_many
+SELECT g % 5, 'many' || g FROM generate_series(1, 10) g;
+EXPLAIN (COSTS OFF)
+SELECT b.id
+FROM singleton_base b JOIN singleton_many m ON m.sid = b.id
+GROUP BY b.id;
+
+-- A unique index proves uniqueness only under its own equality semantics.
+-- record_image_ops is bytewise equality, while plain GROUP BY on a composite
+-- uses record_ops.  Rows that the index distinguishes can still be one group.
+CREATE TYPE pg_temp.singleton_avg_rec AS (x numeric);
+CREATE TEMP TABLE singleton_opf (a singleton_avg_rec NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_opf_a ON singleton_opf (a record_image_ops);
+INSERT INTO singleton_opf
+VALUES (row(1.0)::singleton_avg_rec, 'X'),
+       (row(1.00)::singleton_avg_rec, 'Y');
+EXPLAIN (COSTS OFF)
+SELECT a, val FROM singleton_opf GROUP BY a, val;
+
+-- Unsafe uniqueness proofs remain rejected.
+CREATE TEMP TABLE singleton_nullable (id int, val text);
+CREATE UNIQUE INDEX singleton_nullable_id ON singleton_nullable (id);
+INSERT INTO singleton_nullable VALUES (NULL, 'one'), (NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_nullable GROUP BY id, val;
+
+-- Every key of a NULLS DISTINCT unique index must be NOT NULL.
+CREATE TEMP TABLE singleton_composite_nullable (
+    a int NOT NULL, b int, val text,
+    CONSTRAINT singleton_composite_nullable_key UNIQUE (a, b)
+);
+INSERT INTO singleton_composite_nullable
+VALUES (1, NULL, 'one'), (1, NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val FROM singleton_composite_nullable GROUP BY a, b, val;
+
+-- Extra expressions cannot repair the missing NOT NULL proof.
+EXPLAIN (COSTS OFF)
+SELECT a, b, val
+FROM singleton_composite_nullable
+GROUP BY a, b, val, length(val);
+
+CREATE TEMP TABLE singleton_deferrable (
+    id int, CONSTRAINT singleton_deferrable_id UNIQUE (id) DEFERRABLE
+);
+INSERT INTO singleton_deferrable VALUES (1), (2);
+EXPLAIN (COSTS OFF)
+SELECT id FROM singleton_deferrable GROUP BY id;
+
+CREATE TEMP TABLE singleton_partial (id int NOT NULL, active boolean);
+CREATE UNIQUE INDEX singleton_partial_id
+    ON singleton_partial (id) WHERE active;
+INSERT INTO singleton_partial VALUES (1, true), (2, true);
+EXPLAIN (COSTS OFF)
+SELECT id, active FROM singleton_partial GROUP BY id, active;
+
+-- Even an apparently NULL-safe partial predicate is outside V1.
+CREATE TEMP TABLE singleton_partial_nullable (a int, b int, val text);
+CREATE UNIQUE INDEX singleton_partial_nullable_ab
+    ON singleton_partial_nullable (a, b)
+    WHERE a IS NOT NULL AND b IS NOT NULL;
+INSERT INTO singleton_partial_nullable
+VALUES (1, NULL, 'one'), (1, NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val FROM singleton_partial_nullable GROUP BY a, b, val;
+
+CREATE TEMP TABLE singleton_expression (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_expression_upper
+    ON singleton_expression (upper(val));
+INSERT INTO singleton_expression VALUES (1, 'one'), (2, 'two');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_expression GROUP BY id, val;
+
+CREATE TEMP TABLE singleton_parent (id int PRIMARY KEY, val text);
+CREATE TEMP TABLE singleton_child () INHERITS (singleton_parent);
+INSERT INTO singleton_parent VALUES (1, 'parent');
+INSERT INTO singleton_child VALUES (1, 'child');
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM singleton_parent GROUP BY id, val;
+EXPLAIN (COSTS OFF)
+SELECT id, val FROM ONLY singleton_parent GROUP BY id, val;
+
+-- A partitioned composite unique index must still prove every key.
+CREATE TEMP TABLE singleton_partitioned_nullable (
+    a int, b int, val text
+) PARTITION BY RANGE (a);
+CREATE UNIQUE INDEX singleton_partitioned_nullable_ab
+    ON singleton_partitioned_nullable (a, b);
+CREATE TEMP TABLE singleton_partitioned_nullable_1
+    PARTITION OF singleton_partitioned_nullable FOR VALUES FROM (0) TO (10);
+INSERT INTO singleton_partitioned_nullable
+VALUES (1, NULL, 'one'), (1, NULL, 'two');
+EXPLAIN (COSTS OFF)
+SELECT a, b, val
+FROM singleton_partitioned_nullable GROUP BY a, b, val;
+
+-- Dropping the proof must invalidate a cached plan.
+CREATE TEMP TABLE singleton_invalidate (id int PRIMARY KEY, val text);
+INSERT INTO singleton_invalidate VALUES (1, 'one'), (2, 'two');
+PREPARE singleton_prep AS
+    SELECT id, val FROM singleton_invalidate GROUP BY id, val;
+EXPLAIN (COSTS OFF) EXECUTE singleton_prep;
+ALTER TABLE singleton_invalidate DROP CONSTRAINT singleton_invalidate_pkey;
+EXPLAIN (COSTS OFF) EXECUTE singleton_prep;
+DEALLOCATE singleton_prep;
+
+-- A plain unique index is also invalidation evidence for the proof.
+CREATE TEMP TABLE singleton_invalidate_index (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_invalidate_index_id
+    ON singleton_invalidate_index (id);
+INSERT INTO singleton_invalidate_index VALUES (1, 'one'), (2, 'two');
+PREPARE singleton_invalidate_index_prep AS
+    SELECT id, val FROM singleton_invalidate_index GROUP BY id, val;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_index_prep;
+DROP INDEX singleton_invalidate_index_id;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_index_prep;
+DEALLOCATE singleton_invalidate_index_prep;
+
+-- Removing a key column's NOT NULL constraint must invalidate the proof.
+CREATE TEMP TABLE singleton_invalidate_notnull (id int NOT NULL, val text);
+CREATE UNIQUE INDEX singleton_invalidate_notnull_id
+    ON singleton_invalidate_notnull (id);
+INSERT INTO singleton_invalidate_notnull VALUES (1, 'one'), (2, 'two');
+PREPARE singleton_invalidate_notnull_prep AS
+    SELECT id, val FROM singleton_invalidate_notnull GROUP BY id, val;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_notnull_prep;
+ALTER TABLE singleton_invalidate_notnull ALTER COLUMN id DROP NOT NULL;
+EXPLAIN (COSTS OFF) EXECUTE singleton_invalidate_notnull_prep;
+DEALLOCATE singleton_invalidate_notnull_prep;
-- 
2.43.0

