From 910af216db121eef47a8a86b6c585b4df6f9b007 Mon Sep 17 00:00:00 2001 From: "Paul A. Jungwirth" Date: Tue, 22 Sep 2026 22:27:46 -0700 Subject: [PATCH v1] Fix UNIQUE NULLS NOT DISTINCT with WITHOUT OVERLAPS Such a constraint failed to treat NULLs as equal, so it wrongly permitted duplicates. Actually it worked correctly for non-lossy GiST indexes (rangetypes) but not lossy ones (multiranges), because the bug was in index_recheck_constraint. This commit teaches that function to behave like check_exclusion_or_unique_constraint. Ordinary EXCLUDE constraints were not affected, since they don't have NULLS NOT DISTINCT and treat all operators as strict (regardless of their declaration). Reported-by: Andres Freund Author: Paul A. Jungwirth Discussion: https://postgr.es/m/kcyaok346iwocfdourf2oojgtz7ggxmh2ugis7qqhggs4qfzc2@cu6pkox4rfwp Backpatch-through: 18 --- src/backend/executor/execIndexing.c | 22 +++++-- .../regress/expected/without_overlaps.out | 65 +++++++++++++++++++ src/test/regress/sql/without_overlaps.sql | 43 ++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c index d0a714a42f2..8bca00ad794 100644 --- a/src/backend/executor/execIndexing.c +++ b/src/backend/executor/execIndexing.c @@ -139,7 +139,8 @@ static bool check_exclusion_or_unique_constraint(Relation heap, Relation index, static bool index_recheck_constraint(Relation index, const Oid *constr_procs, const Datum *existing_values, const bool *existing_isnull, - const Datum *new_values); + const Datum *new_values, const bool *new_isnull, + bool nulls_not_distinct); static bool index_unchanged_by_update(ResultRelInfo *resultRelInfo, EState *estate, IndexInfo *indexInfo, Relation indexRelation); @@ -865,7 +866,9 @@ retry: constr_procs, existing_values, existing_isnull, - values)) + values, + isnull, + indexInfo->ii_NullsNotDistinct)) continue; /* tuple doesn't actually match, so no * conflict */ } @@ -1007,16 +1010,25 @@ check_exclusion_constraint(Relation heap, Relation index, static bool index_recheck_constraint(Relation index, const Oid *constr_procs, const Datum *existing_values, const bool *existing_isnull, - const Datum *new_values) + const Datum *new_values, const bool *new_isnull, + bool nulls_not_distinct) { int indnkeyatts = IndexRelationGetNumberOfKeyAttributes(index); int i; for (i = 0; i < indnkeyatts; i++) { - /* Assume the exclusion operators are strict */ - if (existing_isnull[i]) + /* + * EXCLUDE constraints assume their operators are strict, but a + * WITHOUT OVERLAPS unique constraint may have NULLS NOT DISTINCT. In + * that case, treat two NULLs as equal. + */ + if (existing_isnull[i] || new_isnull[i]) + { + if (nulls_not_distinct && existing_isnull[i] && new_isnull[i]) + continue; return false; + } if (!DatumGetBool(OidFunctionCall2Coll(constr_procs[i], index->rd_indcollation[i], diff --git a/src/test/regress/expected/without_overlaps.out b/src/test/regress/expected/without_overlaps.out index 8e3f6eee0b0..23ff2c6a3cf 100644 --- a/src/test/regress/expected/without_overlaps.out +++ b/src/test/regress/expected/without_overlaps.out @@ -668,6 +668,71 @@ SELECT * FROM temporal_rng3 ORDER BY id, valid_at; DROP TABLE temporal_rng3; -- +-- range UQ: NULLS NOT DISTINCT +-- +CREATE TABLE temporal_rng3 ( + id int4range, + valid_at int4range, + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_rng3 VALUES (NULL, '[1,10)'); +INSERT INTO temporal_rng3 VALUES (NULL, '[2,3)'); -- fails +ERROR: conflicting key value violates exclusion constraint "temporal_rng3_id_valid_at_key" +DETAIL: Key (id, valid_at)=(null, [2,3)) conflicts with existing key (id, valid_at)=(null, [1,10)). +INSERT INTO temporal_rng3 VALUES (NULL, '[1,10)') + ON CONFLICT ON CONSTRAINT temporal_rng3_id_valid_at_key DO NOTHING RETURNING *; -- nothing + id | valid_at +----+---------- +(0 rows) + +SELECT * FROM temporal_rng3; + id | valid_at +----+---------- + | [1,10) +(1 row) + +REINDEX INDEX temporal_rng3_id_valid_at_key; +ALTER TABLE temporal_rng3 DROP CONSTRAINT temporal_rng3_id_valid_at_key; +ALTER TABLE temporal_rng3 ADD CONSTRAINT temporal_rng3_id_valid_at_key + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS); +DROP TABLE temporal_rng3; +-- +-- multirange UQ: NULLS NOT DISTINCT +-- +CREATE TABLE temporal_mltrng3 ( + id int4multirange, + valid_at int4multirange, + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_mltrng3 VALUES (NULL, '{[1,10)}'); +INSERT INTO temporal_mltrng3 VALUES (NULL, '{[2,3)}'); -- fails +ERROR: conflicting key value violates exclusion constraint "temporal_mltrng3_id_valid_at_key" +DETAIL: Key (id, valid_at)=(null, {[2,3)}) conflicts with existing key (id, valid_at)=(null, {[1,10)}). +INSERT INTO temporal_mltrng3 VALUES (NULL, '{[1,10)}') + ON CONFLICT ON CONSTRAINT temporal_mltrng3_id_valid_at_key DO NOTHING RETURNING *; -- nothing + id | valid_at +----+---------- +(0 rows) + +INSERT INTO temporal_mltrng3 VALUES ('{[1,2)}', '{[2,3)}'); -- okay: NULL id doesn't match non-NULL +INSERT INTO temporal_mltrng3 VALUES ('{[1,2)}', NULL); +INSERT INTO temporal_mltrng3 VALUES ('{[1,2)}', NULL); -- fails +ERROR: conflicting key value violates exclusion constraint "temporal_mltrng3_id_valid_at_key" +DETAIL: Key (id, valid_at)=({[1,2)}, null) conflicts with existing key (id, valid_at)=({[1,2)}, null). +SELECT * FROM temporal_mltrng3 ORDER BY id, valid_at; + id | valid_at +---------+---------- + {[1,2)} | {[2,3)} + {[1,2)} | + | {[1,10)} +(3 rows) + +REINDEX INDEX temporal_mltrng3_id_valid_at_key; +ALTER TABLE temporal_mltrng3 DROP CONSTRAINT temporal_mltrng3_id_valid_at_key; +ALTER TABLE temporal_mltrng3 ADD CONSTRAINT temporal_mltrng3_id_valid_at_key + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS); +DROP TABLE temporal_mltrng3; +-- -- multirange PK: test with existing rows -- ALTER TABLE temporal_mltrng DROP CONSTRAINT temporal_mltrng_pk; diff --git a/src/test/regress/sql/without_overlaps.sql b/src/test/regress/sql/without_overlaps.sql index 46bdd7856a4..de46dda7698 100644 --- a/src/test/regress/sql/without_overlaps.sql +++ b/src/test/regress/sql/without_overlaps.sql @@ -461,6 +461,49 @@ WHERE id = '[1,2)' AND valid_at IS NULL; SELECT * FROM temporal_rng3 ORDER BY id, valid_at; DROP TABLE temporal_rng3; +-- +-- range UQ: NULLS NOT DISTINCT +-- + +CREATE TABLE temporal_rng3 ( + id int4range, + valid_at int4range, + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_rng3 VALUES (NULL, '[1,10)'); +INSERT INTO temporal_rng3 VALUES (NULL, '[2,3)'); -- fails +INSERT INTO temporal_rng3 VALUES (NULL, '[1,10)') + ON CONFLICT ON CONSTRAINT temporal_rng3_id_valid_at_key DO NOTHING RETURNING *; -- nothing +SELECT * FROM temporal_rng3; +REINDEX INDEX temporal_rng3_id_valid_at_key; +ALTER TABLE temporal_rng3 DROP CONSTRAINT temporal_rng3_id_valid_at_key; +ALTER TABLE temporal_rng3 ADD CONSTRAINT temporal_rng3_id_valid_at_key + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS); +DROP TABLE temporal_rng3; + +-- +-- multirange UQ: NULLS NOT DISTINCT +-- + +CREATE TABLE temporal_mltrng3 ( + id int4multirange, + valid_at int4multirange, + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_mltrng3 VALUES (NULL, '{[1,10)}'); +INSERT INTO temporal_mltrng3 VALUES (NULL, '{[2,3)}'); -- fails +INSERT INTO temporal_mltrng3 VALUES (NULL, '{[1,10)}') + ON CONFLICT ON CONSTRAINT temporal_mltrng3_id_valid_at_key DO NOTHING RETURNING *; -- nothing +INSERT INTO temporal_mltrng3 VALUES ('{[1,2)}', '{[2,3)}'); -- okay: NULL id doesn't match non-NULL +INSERT INTO temporal_mltrng3 VALUES ('{[1,2)}', NULL); +INSERT INTO temporal_mltrng3 VALUES ('{[1,2)}', NULL); -- fails +SELECT * FROM temporal_mltrng3 ORDER BY id, valid_at; +REINDEX INDEX temporal_mltrng3_id_valid_at_key; +ALTER TABLE temporal_mltrng3 DROP CONSTRAINT temporal_mltrng3_id_valid_at_key; +ALTER TABLE temporal_mltrng3 ADD CONSTRAINT temporal_mltrng3_id_valid_at_key + UNIQUE NULLS NOT DISTINCT (id, valid_at WITHOUT OVERLAPS); +DROP TABLE temporal_mltrng3; + -- -- multirange PK: test with existing rows -- -- 2.45.0