From 76e3f1ea2d1d859ec90b97679eff5da9065b95d4 Mon Sep 17 00:00:00 2001 From: "Paul A. Jungwirth" Date: Wed, 23 Sep 2026 00:14:04 -0700 Subject: [PATCH v1] Fix bad functional grouping inference with a temporal primary key We can't use a WITHOUT OVERLAPS primary key to prove functional dependency for GROUP BY, because grouping uses the default btree equality operator, which may not match the GiST opclass's equality used by the primary key. For instance with citext, the default btree equality does case-insensitive comparison, but in a GiST index 'a' and 'A' still differ (using gist_text_ops through binary coercion, to be precise). Therefore we update check_functional_grouping to accept only btree indexes. Eventually we could be more sophisticated and compare each key column's equality against the grouping equality, but that seems too ambitious for a backpatch. Reported-by: Andres Freund Author: Paul A. Jungwirth Discussion: https://postgr.es/m/e5nb5jbus2oa3pffmlo7pdvdckmchd54tqld4k3n6huyg5xxqn@7rounyvufjx6 Backpatch-through: 18 --- src/backend/catalog/pg_constraint.c | 10 +++++++ src/test/regress/expected/functional_deps.out | 26 +++++++++++++++++++ src/test/regress/sql/functional_deps.sql | 25 ++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/backend/catalog/pg_constraint.c b/src/backend/catalog/pg_constraint.c index 3a8dbbee2fd..390a066d945 100644 --- a/src/backend/catalog/pg_constraint.c +++ b/src/backend/catalog/pg_constraint.c @@ -24,6 +24,7 @@ #include "catalog/heap.h" #include "catalog/indexing.h" #include "catalog/objectaccess.h" +#include "catalog/pg_am.h" #include "catalog/pg_constraint.h" #include "catalog/pg_operator.h" #include "catalog/pg_type.h" @@ -1772,6 +1773,15 @@ check_functional_grouping(Oid relid, if (bms_is_subset(pkattnos, groupbyattnos)) { + /* + * Since GROUP BY uses the btree default opclass's equality operator, + * require that the index be btree. XXX: If a GiST WITHOUT OVERLAPS + * index uses the same proc for its equality operator, lets it prove + * functionality grouping too. + */ + if (get_rel_relam(get_constraint_index(constraintOid)) != BTREE_AM_OID) + return false; + /* The PK is a subset of grouping_columns, so we win */ *constraintDeps = lappend_oid(*constraintDeps, constraintOid); return true; diff --git a/src/test/regress/expected/functional_deps.out b/src/test/regress/expected/functional_deps.out index 32381b8ae7b..fb706ac634f 100644 --- a/src/test/regress/expected/functional_deps.out +++ b/src/test/regress/expected/functional_deps.out @@ -230,3 +230,29 @@ EXECUTE foo; ALTER TABLE articles DROP CONSTRAINT articles_pkey RESTRICT; EXECUTE foo; -- fail ERROR: column "articles.keywords" must appear in the GROUP BY clause or be used in an aggregate function +-- Temporal (WITHOUT OVERLAPS) primary keys do not prove functional dependency: +-- the key enforces uniqueness with its operator class's equality and overlap +-- semantics, which need not match the equality that GROUP BY uses for the same +-- columns. +CREATE TEMP TABLE temporal_articles ( + id int4range, + valid_at int4range, + title text NOT NULL, + PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) +); +-- group by a temporal primary key (fail) +SELECT id, valid_at, title +FROM temporal_articles +GROUP BY id, valid_at; +ERROR: column "temporal_articles.title" must appear in the GROUP BY clause or be used in an aggregate function +LINE 1: SELECT id, valid_at, title + ^ +-- but grouping by all the selected columns is fine +SELECT id, valid_at, title +FROM temporal_articles +GROUP BY id, valid_at, title; + id | valid_at | title +----+----------+------- +(0 rows) + +DROP TABLE temporal_articles; diff --git a/src/test/regress/sql/functional_deps.sql b/src/test/regress/sql/functional_deps.sql index 406490b995c..bbff78238ce 100644 --- a/src/test/regress/sql/functional_deps.sql +++ b/src/test/regress/sql/functional_deps.sql @@ -208,3 +208,28 @@ EXECUTE foo; ALTER TABLE articles DROP CONSTRAINT articles_pkey RESTRICT; EXECUTE foo; -- fail + + +-- Temporal (WITHOUT OVERLAPS) primary keys do not prove functional dependency: +-- the key enforces uniqueness with its operator class's equality and overlap +-- semantics, which need not match the equality that GROUP BY uses for the same +-- columns. + +CREATE TEMP TABLE temporal_articles ( + id int4range, + valid_at int4range, + title text NOT NULL, + PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) +); + +-- group by a temporal primary key (fail) +SELECT id, valid_at, title +FROM temporal_articles +GROUP BY id, valid_at; + +-- but grouping by all the selected columns is fine +SELECT id, valid_at, title +FROM temporal_articles +GROUP BY id, valid_at, title; + +DROP TABLE temporal_articles; -- 2.45.0