From 3289b4ab2a3a8771b42e6fb3263c0f27911690a5 Mon Sep 17 00:00:00 2001 From: Haibo Yan Date: Tue, 8 Sep 2026 23:09:13 -0700 Subject: [PATCH v1] Fix loss of PERIOD semantics when validating temporal foreign keys The bulk validation paths for a temporal (PERIOD) foreign key did not apply PERIOD semantics to pre-existing rows, so violating rows could escape the check. ATTACH PARTITION could admit a populated partition holding rows not covered by the referenced key, and VALIDATE CONSTRAINT or ALTER CONSTRAINT ... ENFORCED could mark such a constraint valid and enforced. Ordinary DML was unaffected, since the RI triggers read pg_constraint.conperiod directly. The cause is NewConstraint.conwithperiod, which tells phase 3 whether the constraint being checked uses PERIOD. When it is wrongly false, validateForeignKeyConstraint() allows the set-based RI_Initial_Check() shortcut for a PERIOD foreign key. That shortcut cannot validate one: PERIOD semantics require the referencing period to be covered by the aggregate of the matching referenced periods, referencing_period <@ range_agg(matching referenced periods) whereas the bulk join, driven by the period column's overlaps operator, establishes only that some overlapping referenced row exists. The three relevant producers of a foreign-key NewConstraint did not propagate the PERIOD flag correctly in all paths. addFkRecurseReferencing() read it from fkconstraint->fk_with_period, which is only set by the parser and is left false in the Constraint that CloneFkReferencing() reconstructs from the catalog; the reconstruction is deliberately partial, so it is not a usable source. The other two, QueueFKConstraintValidation() and ATExecAlterFKConstrEnforceability(), never initialized it. Populate conwithperiod instead from the state describing the constraint actually being validated: the with_period argument in addFkRecurseReferencing(), and pg_constraint.conperiod in the other two. Add regression coverage in without_overlaps.sql for both ATTACH PARTITION paths, VALIDATE CONSTRAINT and ALTER CONSTRAINT ... ENFORCED. Backpatch to 18, where temporal foreign keys were introduced. Reported-by: jian he Discussion: https://postgr.es/m/CACJufxHnEu9UfoZsVN2v8FrKopDG+PKCfAGU6fpx7GhGcOa3xg@mail.gmail.com Backpatch-through: 18 --- src/backend/commands/tablecmds.c | 4 +- .../regress/expected/without_overlaps.out | 112 +++++++++++++++++ src/test/regress/sql/without_overlaps.sql | 113 ++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 8dc70bfa0f1..f538ebd7ff7 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -11182,7 +11182,7 @@ addFkRecurseReferencing(List **wqueue, Constraint *fkconstraint, Relation rel, newcon->refrelid = RelationGetRelid(pkrel); newcon->refindid = indexOid; newcon->conid = parentConstr; - newcon->conwithperiod = fkconstraint->fk_with_period; + newcon->conwithperiod = with_period; newcon->qual = (Node *) fkconstraint; tab->constraints = lappend(tab->constraints, newcon); @@ -12621,6 +12621,7 @@ ATExecAlterFKConstrEnforceability(List **wqueue, ATAlterConstraint *cmdcon, newcon->refrelid = currcon->confrelid; newcon->refindid = currcon->conindid; newcon->conid = currcon->oid; + newcon->conwithperiod = currcon->conperiod; newcon->qual = (Node *) fkconstraint; /* Find or create work queue entry for this table */ @@ -13509,6 +13510,7 @@ QueueFKConstraintValidation(List **wqueue, Relation conrel, Relation fkrel, newcon->refrelid = con->confrelid; newcon->refindid = con->conindid; newcon->conid = con->oid; + newcon->conwithperiod = con->conperiod; newcon->qual = (Node *) fkconstraint; /* Find or create work queue entry for this table */ diff --git a/src/test/regress/expected/without_overlaps.out b/src/test/regress/expected/without_overlaps.out index de2f8bc4786..c15fbca7d8e 100644 --- a/src/test/regress/expected/without_overlaps.out +++ b/src/test/regress/expected/without_overlaps.out @@ -1732,6 +1732,53 @@ ALTER TABLE temporal_fk_rng2rng FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES temporal_rng; -- +-- test NOT VALID and NOT ENFORCED with rows already +-- +-- Validating a temporal FK must use PERIOD semantics, so a row whose +-- range is not covered by the referenced row(s) must be rejected. +CREATE TABLE temporal_rng_nv ( + id int4range, + valid_at daterange, + CONSTRAINT temporal_rng_nv_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_rng_nv (id, valid_at) VALUES + ('[1,2)', daterange('2018-01-01', '2018-02-01')); +CREATE TABLE temporal_fk_nv ( + id int4range, + valid_at daterange, + parent_id int4range +); +INSERT INTO temporal_fk_nv (id, valid_at, parent_id) VALUES + ('[1,2)', daterange('2018-01-01', '2018-06-01'), '[1,2)'); +ALTER TABLE temporal_fk_nv + ADD CONSTRAINT temporal_fk_nv_fk + FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_nv + NOT VALID; +-- should fail: +ALTER TABLE temporal_fk_nv VALIDATE CONSTRAINT temporal_fk_nv_fk; +ERROR: insert or update on table "temporal_fk_nv" violates foreign key constraint "temporal_fk_nv_fk" +DETAIL: Key (parent_id, valid_at)=([1,2), [2018-01-01,2018-06-01)) is not present in table "temporal_rng_nv". +-- okay once the row is covered: +UPDATE temporal_fk_nv SET valid_at = daterange('2018-01-01', '2018-02-01'); +ALTER TABLE temporal_fk_nv VALIDATE CONSTRAINT temporal_fk_nv_fk; +ALTER TABLE temporal_fk_nv DROP CONSTRAINT temporal_fk_nv_fk; +UPDATE temporal_fk_nv SET valid_at = daterange('2018-01-01', '2018-06-01'); +ALTER TABLE temporal_fk_nv + ADD CONSTRAINT temporal_fk_nv_fk + FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_nv + NOT ENFORCED; +-- should fail: +ALTER TABLE temporal_fk_nv ALTER CONSTRAINT temporal_fk_nv_fk ENFORCED; +ERROR: insert or update on table "temporal_fk_nv" violates foreign key constraint "temporal_fk_nv_fk" +DETAIL: Key (parent_id, valid_at)=([1,2), [2018-01-01,2018-06-01)) is not present in table "temporal_rng_nv". +-- okay once the row is covered: +UPDATE temporal_fk_nv SET valid_at = daterange('2018-01-01', '2018-02-01'); +ALTER TABLE temporal_fk_nv ALTER CONSTRAINT temporal_fk_nv_fk ENFORCED; +DROP TABLE temporal_fk_nv; +DROP TABLE temporal_rng_nv; +-- -- test pg_get_constraintdef -- SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'temporal_fk_rng2rng_fk'; @@ -2577,6 +2624,71 @@ ERROR: unsupported ON UPDATE action for foreign key constraint using PERIOD DROP TABLE temporal_partitioned_fk_rng2rng; DROP TABLE temporal_partitioned_rng; -- +-- partitioned FK referencing: ATTACH PARTITION with rows already +-- +-- Attaching a populated table must validate its rows with PERIOD +-- semantics, i.e. each referencing range must be contained in the union +-- of the matching referenced ranges. +CREATE TABLE temporal_rng_att ( + id int4range, + valid_at daterange, + CONSTRAINT temporal_rng_att_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_rng_att (id, valid_at) VALUES + ('[1,2)', daterange('2000-01-01', '2000-02-01')), + ('[1,2)', daterange('2000-02-01', '2000-03-01')); +CREATE TABLE temporal_partitioned_fk_att ( + id int4range, + valid_at daterange, + parent_id int4range, + CONSTRAINT temporal_partitioned_fk_att_fk FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_att (id, PERIOD valid_at) +) PARTITION BY LIST (id); +-- okay: the row is covered by the two referenced rows combined +CREATE TABLE temporal_fk_att1 (LIKE temporal_partitioned_fk_att); +INSERT INTO temporal_fk_att1 (id, valid_at, parent_id) VALUES + ('[1,2)', daterange('2000-01-15', '2000-02-15'), '[1,2)'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att1 FOR VALUES IN ('[1,2)'); +-- should fail: the row's range is not covered by the referenced rows +CREATE TABLE temporal_fk_att2 (LIKE temporal_partitioned_fk_att); +INSERT INTO temporal_fk_att2 (id, valid_at, parent_id) VALUES + ('[2,3)', daterange('2000-01-01', '2010-01-01'), '[1,2)'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att2 FOR VALUES IN ('[2,3)'); +ERROR: insert or update on table "temporal_fk_att2" violates foreign key constraint "temporal_partitioned_fk_att_fk" +DETAIL: Key (parent_id, valid_at)=([1,2), [2000-01-01,2010-01-01)) is not present in table "temporal_rng_att". +-- okay once the row is covered: +UPDATE temporal_fk_att2 SET valid_at = daterange('2000-01-01', '2000-03-01'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att2 FOR VALUES IN ('[2,3)'); +-- and the constraint is still enforced with PERIOD semantics afterward: +INSERT INTO temporal_partitioned_fk_att (id, valid_at, parent_id) VALUES + ('[2,3)', daterange('2000-03-01', '2000-04-01'), '[1,2)'); +ERROR: insert or update on table "temporal_fk_att2" violates foreign key constraint "temporal_partitioned_fk_att_fk" +DETAIL: Key (parent_id, valid_at)=([1,2), [2000-03-01,2000-04-01)) is not present in table "temporal_rng_att". +-- Same again, but the candidate partition already has an equivalent +-- NOT VALID constraint, so it is reparented instead of cloned. +-- should fail: +CREATE TABLE temporal_fk_att3 (LIKE temporal_partitioned_fk_att); +INSERT INTO temporal_fk_att3 (id, valid_at, parent_id) VALUES + ('[3,4)', daterange('2000-01-01', '2010-01-01'), '[1,2)'); +ALTER TABLE temporal_fk_att3 + ADD CONSTRAINT temporal_partitioned_fk_att_fk + FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_att (id, PERIOD valid_at) + NOT VALID; +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att3 FOR VALUES IN ('[3,4)'); +ERROR: insert or update on table "temporal_fk_att3" violates foreign key constraint "temporal_partitioned_fk_att_fk" +DETAIL: Key (parent_id, valid_at)=([1,2), [2000-01-01,2010-01-01)) is not present in table "temporal_rng_att". +-- okay once the row is covered: +UPDATE temporal_fk_att3 SET valid_at = daterange('2000-01-01', '2000-03-01'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att3 FOR VALUES IN ('[3,4)'); +DROP TABLE temporal_partitioned_fk_att; +DROP TABLE temporal_rng_att; +-- -- FK between partitioned tables: multiranges -- CREATE TABLE temporal_partitioned_mltrng ( diff --git a/src/test/regress/sql/without_overlaps.sql b/src/test/regress/sql/without_overlaps.sql index 4833b8ac5f0..881b73a9837 100644 --- a/src/test/regress/sql/without_overlaps.sql +++ b/src/test/regress/sql/without_overlaps.sql @@ -1246,6 +1246,53 @@ ALTER TABLE temporal_fk_rng2rng FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES temporal_rng; +-- +-- test NOT VALID and NOT ENFORCED with rows already +-- +-- Validating a temporal FK must use PERIOD semantics, so a row whose +-- range is not covered by the referenced row(s) must be rejected. + +CREATE TABLE temporal_rng_nv ( + id int4range, + valid_at daterange, + CONSTRAINT temporal_rng_nv_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_rng_nv (id, valid_at) VALUES + ('[1,2)', daterange('2018-01-01', '2018-02-01')); +CREATE TABLE temporal_fk_nv ( + id int4range, + valid_at daterange, + parent_id int4range +); +INSERT INTO temporal_fk_nv (id, valid_at, parent_id) VALUES + ('[1,2)', daterange('2018-01-01', '2018-06-01'), '[1,2)'); + +ALTER TABLE temporal_fk_nv + ADD CONSTRAINT temporal_fk_nv_fk + FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_nv + NOT VALID; +-- should fail: +ALTER TABLE temporal_fk_nv VALIDATE CONSTRAINT temporal_fk_nv_fk; +-- okay once the row is covered: +UPDATE temporal_fk_nv SET valid_at = daterange('2018-01-01', '2018-02-01'); +ALTER TABLE temporal_fk_nv VALIDATE CONSTRAINT temporal_fk_nv_fk; +ALTER TABLE temporal_fk_nv DROP CONSTRAINT temporal_fk_nv_fk; + +UPDATE temporal_fk_nv SET valid_at = daterange('2018-01-01', '2018-06-01'); +ALTER TABLE temporal_fk_nv + ADD CONSTRAINT temporal_fk_nv_fk + FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_nv + NOT ENFORCED; +-- should fail: +ALTER TABLE temporal_fk_nv ALTER CONSTRAINT temporal_fk_nv_fk ENFORCED; +-- okay once the row is covered: +UPDATE temporal_fk_nv SET valid_at = daterange('2018-01-01', '2018-02-01'); +ALTER TABLE temporal_fk_nv ALTER CONSTRAINT temporal_fk_nv_fk ENFORCED; +DROP TABLE temporal_fk_nv; +DROP TABLE temporal_rng_nv; + -- -- test pg_get_constraintdef -- @@ -2016,6 +2063,72 @@ ALTER TABLE temporal_partitioned_fk_rng2rng DROP TABLE temporal_partitioned_fk_rng2rng; DROP TABLE temporal_partitioned_rng; +-- +-- partitioned FK referencing: ATTACH PARTITION with rows already +-- +-- Attaching a populated table must validate its rows with PERIOD +-- semantics, i.e. each referencing range must be contained in the union +-- of the matching referenced ranges. + +CREATE TABLE temporal_rng_att ( + id int4range, + valid_at daterange, + CONSTRAINT temporal_rng_att_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS) +); +INSERT INTO temporal_rng_att (id, valid_at) VALUES + ('[1,2)', daterange('2000-01-01', '2000-02-01')), + ('[1,2)', daterange('2000-02-01', '2000-03-01')); + +CREATE TABLE temporal_partitioned_fk_att ( + id int4range, + valid_at daterange, + parent_id int4range, + CONSTRAINT temporal_partitioned_fk_att_fk FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_att (id, PERIOD valid_at) +) PARTITION BY LIST (id); + +-- okay: the row is covered by the two referenced rows combined +CREATE TABLE temporal_fk_att1 (LIKE temporal_partitioned_fk_att); +INSERT INTO temporal_fk_att1 (id, valid_at, parent_id) VALUES + ('[1,2)', daterange('2000-01-15', '2000-02-15'), '[1,2)'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att1 FOR VALUES IN ('[1,2)'); + +-- should fail: the row's range is not covered by the referenced rows +CREATE TABLE temporal_fk_att2 (LIKE temporal_partitioned_fk_att); +INSERT INTO temporal_fk_att2 (id, valid_at, parent_id) VALUES + ('[2,3)', daterange('2000-01-01', '2010-01-01'), '[1,2)'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att2 FOR VALUES IN ('[2,3)'); +-- okay once the row is covered: +UPDATE temporal_fk_att2 SET valid_at = daterange('2000-01-01', '2000-03-01'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att2 FOR VALUES IN ('[2,3)'); +-- and the constraint is still enforced with PERIOD semantics afterward: +INSERT INTO temporal_partitioned_fk_att (id, valid_at, parent_id) VALUES + ('[2,3)', daterange('2000-03-01', '2000-04-01'), '[1,2)'); + +-- Same again, but the candidate partition already has an equivalent +-- NOT VALID constraint, so it is reparented instead of cloned. +-- should fail: +CREATE TABLE temporal_fk_att3 (LIKE temporal_partitioned_fk_att); +INSERT INTO temporal_fk_att3 (id, valid_at, parent_id) VALUES + ('[3,4)', daterange('2000-01-01', '2010-01-01'), '[1,2)'); +ALTER TABLE temporal_fk_att3 + ADD CONSTRAINT temporal_partitioned_fk_att_fk + FOREIGN KEY (parent_id, PERIOD valid_at) + REFERENCES temporal_rng_att (id, PERIOD valid_at) + NOT VALID; +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att3 FOR VALUES IN ('[3,4)'); +-- okay once the row is covered: +UPDATE temporal_fk_att3 SET valid_at = daterange('2000-01-01', '2000-03-01'); +ALTER TABLE temporal_partitioned_fk_att + ATTACH PARTITION temporal_fk_att3 FOR VALUES IN ('[3,4)'); + +DROP TABLE temporal_partitioned_fk_att; +DROP TABLE temporal_rng_att; + -- -- FK between partitioned tables: multiranges -- -- 2.54.0