Skip to content

Commit 2569d87

Browse files
kenchan0130claude
andauthored
feat: support PostgreSQL 18 temporal constraints (WITHOUT OVERLAPS / PERIOD) (#365)
Add support for PostgreSQL 18 temporal constraints: - PRIMARY KEY / UNIQUE with WITHOUT OVERLAPS - FOREIGN KEY with PERIOD Read pg_constraint.conperiod via to_jsonb() for backward compatibility with PostgreSQL 14-17 where the column does not exist. Replace unit tests with integration test fixtures in testdata/diff/. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e4464b8 commit 2569d87

22 files changed

Lines changed: 190 additions & 21 deletions

File tree

internal/diff/constraint.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,36 @@ func generateConstraintSQL(constraint *ir.Constraint, targetSchema string) strin
2121
switch constraint.Type {
2222
case ir.ConstraintTypePrimaryKey:
2323
// Always include CONSTRAINT name to be explicit and consistent
24-
return fmt.Sprintf("CONSTRAINT %s PRIMARY KEY (%s)", ir.QuoteIdentifier(constraint.Name), strings.Join(getColumnNames(constraint.Columns), ", "))
24+
cols := getColumnNames(constraint.Columns)
25+
if constraint.IsTemporal && len(cols) > 0 {
26+
cols[len(cols)-1] = cols[len(cols)-1] + " WITHOUT OVERLAPS"
27+
}
28+
return fmt.Sprintf("CONSTRAINT %s PRIMARY KEY (%s)", ir.QuoteIdentifier(constraint.Name), strings.Join(cols, ", "))
2529
case ir.ConstraintTypeUnique:
2630
// Always include CONSTRAINT name to be explicit and consistent
27-
return fmt.Sprintf("CONSTRAINT %s UNIQUE (%s)", ir.QuoteIdentifier(constraint.Name), strings.Join(getColumnNames(constraint.Columns), ", "))
31+
cols := getColumnNames(constraint.Columns)
32+
if constraint.IsTemporal && len(cols) > 0 {
33+
cols[len(cols)-1] = cols[len(cols)-1] + " WITHOUT OVERLAPS"
34+
}
35+
return fmt.Sprintf("CONSTRAINT %s UNIQUE (%s)", ir.QuoteIdentifier(constraint.Name), strings.Join(cols, ", "))
2836
case ir.ConstraintTypeForeignKey:
2937
// Always include CONSTRAINT name to preserve explicit FK names
3038
// Use QualifyEntityNameWithQuotes to add schema qualifier when referencing tables in other schemas
39+
cols := getColumnNames(constraint.Columns)
40+
refCols := getColumnNames(constraint.ReferencedColumns)
41+
if constraint.IsTemporal {
42+
if len(cols) > 0 {
43+
cols[len(cols)-1] = "PERIOD " + cols[len(cols)-1]
44+
}
45+
if len(refCols) > 0 {
46+
refCols[len(refCols)-1] = "PERIOD " + refCols[len(refCols)-1]
47+
}
48+
}
3149
qualifiedRefTable := ir.QualifyEntityNameWithQuotes(constraint.ReferencedSchema, constraint.ReferencedTable, targetSchema)
3250
stmt := fmt.Sprintf("CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)",
3351
ir.QuoteIdentifier(constraint.Name),
34-
strings.Join(getColumnNames(constraint.Columns), ", "),
35-
qualifiedRefTable, strings.Join(getColumnNames(constraint.ReferencedColumns), ", "))
52+
strings.Join(cols, ", "),
53+
qualifiedRefTable, strings.Join(refCols, ", "))
3654
// Only add ON UPDATE/DELETE if they are not the default "NO ACTION"
3755
if constraint.UpdateRule != "" && constraint.UpdateRule != "NO ACTION" {
3856
stmt += fmt.Sprintf(" ON UPDATE %s", constraint.UpdateRule)
@@ -149,6 +167,9 @@ func constraintsEqual(old, new *ir.Constraint) bool {
149167
if old.InitiallyDeferred != new.InitiallyDeferred {
150168
return false
151169
}
170+
if old.IsTemporal != new.IsTemporal {
171+
return false
172+
}
152173

153174
// Validation status - only compare for CHECK and FOREIGN KEY constraints
154175
// PRIMARY KEY and UNIQUE constraints are always valid (IsValid is not meaningful for them)

internal/diff/table.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,9 @@ func generateDeferredConstraintsSQL(deferred []*deferredConstraint, targetSchema
510510
for _, col := range columns {
511511
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
512512
}
513+
if constraint.IsTemporal && len(columnNames) > 0 {
514+
columnNames[len(columnNames)-1] = "PERIOD " + columnNames[len(columnNames)-1]
515+
}
513516

514517
tableName := getTableNameWithSchema(item.table.Schema, item.table.Name, targetSchema)
515518
sql := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s FOREIGN KEY (%s) %s;",
@@ -816,6 +819,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
816819
for _, col := range columns {
817820
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
818821
}
822+
if constraint.IsTemporal && len(columnNames) > 0 {
823+
columnNames[len(columnNames)-1] = columnNames[len(columnNames)-1] + " WITHOUT OVERLAPS"
824+
}
819825
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)
820826
sql := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s UNIQUE (%s);",
821827
tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", "))
@@ -852,6 +858,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
852858
for _, col := range columns {
853859
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
854860
}
861+
if constraint.IsTemporal && len(columnNames) > 0 {
862+
columnNames[len(columnNames)-1] = "PERIOD " + columnNames[len(columnNames)-1]
863+
}
855864

856865
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)
857866
canonicalSQL := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s FOREIGN KEY (%s) %s;",
@@ -875,6 +884,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
875884
for _, col := range columns {
876885
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
877886
}
887+
if constraint.IsTemporal && len(columnNames) > 0 {
888+
columnNames[len(columnNames)-1] = columnNames[len(columnNames)-1] + " WITHOUT OVERLAPS"
889+
}
878890
tableName := getTableNameWithSchema(td.Table.Schema, td.Table.Name, targetSchema)
879891
sql := fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s PRIMARY KEY (%s);",
880892
tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", "))
@@ -930,6 +942,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
930942
for _, col := range columns {
931943
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
932944
}
945+
if constraint.IsTemporal && len(columnNames) > 0 {
946+
columnNames[len(columnNames)-1] = columnNames[len(columnNames)-1] + " WITHOUT OVERLAPS"
947+
}
933948
addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s UNIQUE (%s);",
934949
tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", "))
935950

@@ -945,6 +960,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
945960
for _, col := range columns {
946961
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
947962
}
963+
if constraint.IsTemporal && len(columnNames) > 0 {
964+
columnNames[len(columnNames)-1] = "PERIOD " + columnNames[len(columnNames)-1]
965+
}
948966

949967
addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s FOREIGN KEY (%s) %s;",
950968
tableName, ir.QuoteIdentifier(constraint.Name),
@@ -958,6 +976,9 @@ func (td *tableDiff) generateAlterTableStatements(targetSchema string, collector
958976
for _, col := range columns {
959977
columnNames = append(columnNames, ir.QuoteIdentifier(col.Name))
960978
}
979+
if constraint.IsTemporal && len(columnNames) > 0 {
980+
columnNames[len(columnNames)-1] = columnNames[len(columnNames)-1] + " WITHOUT OVERLAPS"
981+
}
961982
addSQL = fmt.Sprintf("ALTER TABLE %s\nADD CONSTRAINT %s PRIMARY KEY (%s);",
962983
tableName, ir.QuoteIdentifier(constraint.Name), strings.Join(columnNames, ", "))
963984

@@ -1462,6 +1483,9 @@ func generateForeignKeyClause(constraint *ir.Constraint, targetSchema string, in
14621483
for _, col := range refColumns {
14631484
refColumnNames = append(refColumnNames, col.Name)
14641485
}
1486+
if constraint.IsTemporal && len(refColumnNames) > 0 {
1487+
refColumnNames[len(refColumnNames)-1] = "PERIOD " + refColumnNames[len(refColumnNames)-1]
1488+
}
14651489
clause += fmt.Sprintf(" (%s)", strings.Join(refColumnNames, ", "))
14661490
}
14671491
}

internal/plan/rewrite.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,17 @@ func generateForeignKeyRewrite(constraint *ir.Constraint) []RewriteStep {
236236
for _, col := range constraint.Columns {
237237
columnNames = append(columnNames, col.Name)
238238
}
239+
if constraint.IsTemporal && len(columnNames) > 0 {
240+
columnNames[len(columnNames)-1] = "PERIOD " + columnNames[len(columnNames)-1]
241+
}
239242

240243
var refColumnNames []string
241244
for _, col := range constraint.ReferencedColumns {
242245
refColumnNames = append(refColumnNames, col.Name)
243246
}
247+
if constraint.IsTemporal && len(refColumnNames) > 0 {
248+
refColumnNames[len(refColumnNames)-1] = "PERIOD " + refColumnNames[len(refColumnNames)-1]
249+
}
244250

245251
refTableName := getTableNameWithSchema(constraint.ReferencedSchema, constraint.ReferencedTable)
246252

ir/inspector.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,11 +482,12 @@ func (i *Inspector) buildConstraints(ctx context.Context, schema *IR, targetSche
482482
}
483483

484484
c = &Constraint{
485-
Schema: schemaName,
486-
Table: tableName,
487-
Name: constraintName,
488-
Type: cType,
489-
Columns: []*ConstraintColumn{},
485+
Schema: schemaName,
486+
Table: tableName,
487+
Name: constraintName,
488+
Type: cType,
489+
Columns: []*ConstraintColumn{},
490+
IsTemporal: constraint.IsPeriod, // PG18 temporal constraint (WITHOUT OVERLAPS / PERIOD)
490491
}
491492

492493
// Handle foreign key references

ir/ir.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ type Constraint struct {
222222
Deferrable bool `json:"deferrable,omitempty"`
223223
InitiallyDeferred bool `json:"initially_deferred,omitempty"`
224224
IsValid bool `json:"is_valid,omitempty"`
225+
IsTemporal bool `json:"is_temporal,omitempty"` // PG18: temporal constraint (WITHOUT OVERLAPS on PK/UNIQUE, PERIOD on FK)
225226
Comment string `json:"comment,omitempty"`
226227
}
227228

ir/queries/queries.sql

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,8 @@ SELECT
337337
END AS update_rule,
338338
c.condeferrable AS deferrable,
339339
c.condeferred AS initially_deferred,
340-
c.convalidated AS is_valid
340+
c.convalidated AS is_valid,
341+
COALESCE((to_jsonb(c) ->> 'conperiod')::boolean, false) AS is_period
341342
FROM pg_constraint c
342343
JOIN pg_class cl ON c.conrelid = cl.oid
343344
JOIN pg_namespace n ON cl.relnamespace = n.oid
@@ -913,7 +914,8 @@ SELECT
913914
END AS update_rule,
914915
c.condeferrable AS deferrable,
915916
c.condeferred AS initially_deferred,
916-
c.convalidated AS is_valid
917+
c.convalidated AS is_valid,
918+
COALESCE((to_jsonb(c) ->> 'conperiod')::boolean, false) AS is_period
917919
FROM pg_constraint c
918920
JOIN pg_class cl ON c.conrelid = cl.oid
919921
JOIN pg_namespace n ON cl.relnamespace = n.oid

ir/queries/queries.sql.go

Lines changed: 8 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

testdata/diff/create_table/add_fk/diff.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ ADD CONSTRAINT orders_manager_id_fkey FOREIGN KEY (manager_id) REFERENCES manage
1616
ALTER TABLE orders
1717
ADD CONSTRAINT orders_product_id_fkey FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE;
1818

19+
ALTER TABLE price_adjustments
20+
ADD CONSTRAINT price_adjustments_product_fkey FOREIGN KEY (product_id, PERIOD adjustment_period) REFERENCES price_history (product_id, PERIOD valid_period);
21+
1922
ALTER TABLE products
2023
ADD CONSTRAINT products_category_code_fkey FOREIGN KEY (category_code) REFERENCES categories (code) ON UPDATE CASCADE;
2124

testdata/diff/create_table/add_fk/new.sql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,20 @@ CREATE TABLE public.orders (
115115
CONSTRAINT orders_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE,
116116
CONSTRAINT orders_manager_id_fkey FOREIGN KEY (manager_id) REFERENCES public.managers(id) ON DELETE SET NULL
117117
);
118+
119+
-- Temporal FK case (PG18+)
120+
CREATE TABLE public.price_history (
121+
product_id integer NOT NULL,
122+
valid_period tsrange NOT NULL,
123+
price numeric(10,2) NOT NULL,
124+
CONSTRAINT price_history_pkey PRIMARY KEY (product_id, valid_period WITHOUT OVERLAPS)
125+
);
126+
127+
CREATE TABLE public.price_adjustments (
128+
id integer NOT NULL,
129+
product_id integer NOT NULL,
130+
adjustment_period tsrange NOT NULL,
131+
adjustment_pct numeric(5,2) NOT NULL,
132+
CONSTRAINT price_adjustments_pkey PRIMARY KEY (id),
133+
CONSTRAINT price_adjustments_product_fkey FOREIGN KEY (product_id, PERIOD adjustment_period) REFERENCES public.price_history (product_id, PERIOD valid_period)
134+
);

testdata/diff/create_table/add_fk/old.sql

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,19 @@ CREATE TABLE public.orders (
105105
manager_id integer,
106106
CONSTRAINT orders_pkey PRIMARY KEY (id)
107107
);
108+
109+
-- Temporal FK case (PG18+)
110+
CREATE TABLE public.price_history (
111+
product_id integer NOT NULL,
112+
valid_period tsrange NOT NULL,
113+
price numeric(10,2) NOT NULL,
114+
CONSTRAINT price_history_pkey PRIMARY KEY (product_id, valid_period WITHOUT OVERLAPS)
115+
);
116+
117+
CREATE TABLE public.price_adjustments (
118+
id integer NOT NULL,
119+
product_id integer NOT NULL,
120+
adjustment_period tsrange NOT NULL,
121+
adjustment_pct numeric(5,2) NOT NULL,
122+
CONSTRAINT price_adjustments_pkey PRIMARY KEY (id)
123+
);

0 commit comments

Comments
 (0)