From 1401fa0b2090fa32f762041a80a1f2f345fcbba1 Mon Sep 17 00:00:00 2001 From: kenxx Date: Wed, 16 Sep 2026 22:22:19 +0800 Subject: [PATCH v1 2/2] Skip grouping when every input row is its own group --- doc/src/sgml/ref/select.sgml | 5 + src/backend/optimizer/path/indxpath.c | 63 +- src/backend/optimizer/plan/planner.c | 156 ++++- src/include/nodes/pathnodes.h | 2 +- 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 | 628 ++++++++++++++++++ src/test/regress/parallel_schedule | 2 + src/test/regress/sql/collate.icu.utf8.sql | 14 + src/test/regress/sql/singleton_grouping.sql | 311 +++++++++ 12 files changed, 1230 insertions(+), 58 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..82a1f8aae1 100644 --- a/doc/src/sgml/ref/select.sgml +++ b/doc/src/sgml/ref/select.sgml @@ -2146,6 +2146,11 @@ SELECT 2+2; The SQL standard specifies additional conditions that should be recognized. + + + The planner can also avoid a separate grouping step in restricted cases + where it can prove that every input row is its own group. + diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c index a4cd25d15f..82b78b057c 100644 --- a/src/backend/optimizer/path/indxpath.c +++ b/src/backend/optimizer/path/indxpath.c @@ -74,7 +74,6 @@ typedef struct int indexcol; /* index column we want to match to */ } ec_member_matches_arg; - static void consider_index_join_clauses(PlannerInfo *root, RelOptInfo *rel, IndexOptInfo *index, IndexClauseSet *rclauseset, @@ -4350,6 +4349,68 @@ unique_index_keys_match_groupby_cols(IndexOptInfo *index, RelOptInfo *rel, return true; } +/* + * 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. Only simple Vars from that relation can cover index keys; + * other grouping items are additional keys and cannot invalidate the proof. + * 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 *group_keys = NIL; + ListCell *lc; + + if (groupClause == NIL) + return false; + + Assert(bms_membership(rel->relids) == BMS_SINGLETON); + + foreach(lc, groupClause) + { + SortGroupClause *sgc = lfirst_node(SortGroupClause, lc); + TargetEntry *tle = get_sortgroupclause_tle(sgc, targetList); + Var *var; + GroupByColInfo *key; + + if (tle == NULL) + return false; + + /* Extra grouping expressions do not affect the singleton proof. */ + if (!IsA(tle->expr, Var)) + continue; + + var = (Var *) tle->expr; + if (var->varlevelsup != 0 || var->varattno <= 0 || + var->varno != rel->relid) + continue; + + key = palloc_object(GroupByColInfo); + key->attno = var->varattno; + key->eq_opfamilies = get_mergejoin_opfamilies(sgc->eqop); + key->coll = var->varcollid; + group_keys = lappend(group_keys, key); + } + + if (group_keys == NIL) + return false; + + foreach_node(IndexOptInfo, index, rel->indexlist) + { + if (unique_index_keys_match_groupby_cols(index, rel, group_keys, 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..8cbd295e02 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -177,11 +177,19 @@ 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 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 +204,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 +2102,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 +4061,97 @@ 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. In particular, set operation children are rejected because + * their output target conventions are planned separately. + */ +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; + + /* 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_grouping_paths * @@ -4070,16 +4174,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); - /* * Create grouping relation to hold fully aggregated grouping and/or * aggregation paths. @@ -4087,6 +4189,33 @@ create_grouping_paths(PlannerInfo *root, grouped_rel = make_grouping_rel(root, input_rel, target, target_parallel_safe, parse->havingQual); + if (grouping_input_is_singleton(root, input_rel, parse->targetList, + setops != NULL)) + { + GroupPathExtraData extra; + + create_singleton_grouping_paths(root, input_rel, grouped_rel); + + /* + * Singleton paths replace the standard grouping implementations, so + * advertise no standard grouping methods. Keep the FDW and extension + * hooks in the same position as for ordinary grouping. + */ + MemSet(&extra, 0, sizeof(extra)); + extra.target_parallel_safe = target_parallel_safe; + extra.havingQual = parse->havingQual; + extra.targetList = parse->targetList; + extra.patype = PARTITIONWISE_AGGREGATE_NONE; + add_foreign_and_custom_grouping_paths(root, input_rel, grouped_rel, + &extra); + + set_cheapest(grouped_rel); + return 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 +4555,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/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index 1c6d1fe3d0..0904847f93 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -1079,7 +1079,7 @@ typedef struct RelOptInfo Relids *attr_needed pg_node_attr(read_write_ignore); /* array indexed [min_attr .. max_attr] */ int32 *attr_widths pg_node_attr(read_write_ignore); - /* zero-based set containing attnums of NOT NULL columns */ + /* set of heap attnums for columns with valid NOT NULL constraints */ Bitmapset *notnullattnums; /* relids of outer joins that can null this baserel */ Relids nulling_relids; diff --git a/src/include/optimizer/paths.h b/src/include/optimizer/paths.h index 3285bd77af..3954d01a95 100644 --- a/src/include/optimizer/paths.h +++ b/src/include/optimizer/paths.h @@ -98,6 +98,9 @@ extern bool unique_index_keys_match_groupby_cols(IndexOptInfo *index, RelOptInfo *rel, List *groupbycols, Bitmapset **index_attnos); +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..a1c202388a --- /dev/null +++ b/src/test/regress/expected/singleton_grouping.out @@ -0,0 +1,628 @@ +-- 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; +-- 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) + +-- Set-operation children use a separate target convention and 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 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) + +-- 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..0059d1c1df --- /dev/null +++ b/src/test/regress/sql/singleton_grouping.sql @@ -0,0 +1,311 @@ +-- 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; + +-- 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; + +-- Set-operation children use a separate target convention and stay rejected. +EXPLAIN (COSTS OFF) +SELECT id FROM singleton_base GROUP BY id +UNION +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; + +-- 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