Skip to content

Commit 2e9d313

Browse files
kenchan0130claude
andcommitted
feat: support PostgreSQL 18 temporal constraints (WITHOUT OVERLAPS / PERIOD)
Add support for PostgreSQL 18 temporal primary keys (WITHOUT OVERLAPS) and temporal foreign keys (PERIOD). Read pg_constraint.conperiod from the catalog to detect temporal constraints and preserve these clauses in both plan and dump output. Uses COALESCE(c.conperiod, false) for backward compatibility with PostgreSQL 14-17 where the conperiod column does not exist. Signed-off-by: kenchan0130 <1155067+kenchan0130@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e4464b8 commit 2e9d313

6 files changed

Lines changed: 159 additions & 13 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.WithoutOverlaps && 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.WithoutOverlaps && 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.WithoutOverlaps {
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.WithoutOverlaps != new.WithoutOverlaps {
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/identifier_quote_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,121 @@ func TestGenerateConstraintSQL_WithQuoting(t *testing.T) {
100100
}
101101
}
102102

103+
func TestGenerateConstraintSQL_TemporalConstraints(t *testing.T) {
104+
tests := []struct {
105+
name string
106+
constraint *ir.Constraint
107+
want string
108+
}{
109+
{
110+
name: "PRIMARY KEY WITHOUT OVERLAPS",
111+
constraint: &ir.Constraint{
112+
Name: "pk_temporal",
113+
Type: ir.ConstraintTypePrimaryKey,
114+
Columns: []*ir.ConstraintColumn{
115+
{Name: "id", Position: 1},
116+
{Name: "valid_period", Position: 2},
117+
},
118+
WithoutOverlaps: true,
119+
},
120+
want: `CONSTRAINT pk_temporal PRIMARY KEY (id, valid_period WITHOUT OVERLAPS)`,
121+
},
122+
{
123+
name: "UNIQUE WITHOUT OVERLAPS",
124+
constraint: &ir.Constraint{
125+
Name: "uq_temporal",
126+
Type: ir.ConstraintTypeUnique,
127+
Columns: []*ir.ConstraintColumn{
128+
{Name: "tenant_id", Position: 1},
129+
{Name: "valid_period", Position: 2},
130+
},
131+
WithoutOverlaps: true,
132+
},
133+
want: `CONSTRAINT uq_temporal UNIQUE (tenant_id, valid_period WITHOUT OVERLAPS)`,
134+
},
135+
{
136+
name: "FOREIGN KEY with PERIOD",
137+
constraint: &ir.Constraint{
138+
Name: "fk_temporal",
139+
Type: ir.ConstraintTypeForeignKey,
140+
Columns: []*ir.ConstraintColumn{
141+
{Name: "parent_id", Position: 1},
142+
{Name: "valid_period", Position: 2},
143+
},
144+
ReferencedSchema: "public",
145+
ReferencedTable: "parent",
146+
ReferencedColumns: []*ir.ConstraintColumn{
147+
{Name: "id", Position: 1},
148+
{Name: "valid_period", Position: 2},
149+
},
150+
WithoutOverlaps: true,
151+
IsValid: true,
152+
},
153+
want: `CONSTRAINT fk_temporal FOREIGN KEY (parent_id, PERIOD valid_period) REFERENCES parent (id, PERIOD valid_period)`,
154+
},
155+
{
156+
name: "PRIMARY KEY without WithoutOverlaps remains unchanged",
157+
constraint: &ir.Constraint{
158+
Name: "pk_normal",
159+
Type: ir.ConstraintTypePrimaryKey,
160+
Columns: []*ir.ConstraintColumn{
161+
{Name: "id", Position: 1},
162+
},
163+
WithoutOverlaps: false,
164+
},
165+
want: `CONSTRAINT pk_normal PRIMARY KEY (id)`,
166+
},
167+
}
168+
169+
for _, tt := range tests {
170+
t.Run(tt.name, func(t *testing.T) {
171+
got := generateConstraintSQL(tt.constraint, "public")
172+
if got != tt.want {
173+
t.Errorf("generateConstraintSQL() = %q, want %q", got, tt.want)
174+
}
175+
})
176+
}
177+
}
178+
179+
func TestConstraintsEqual_WithoutOverlaps(t *testing.T) {
180+
base := &ir.Constraint{
181+
Name: "pk_temporal",
182+
Type: ir.ConstraintTypePrimaryKey,
183+
Columns: []*ir.ConstraintColumn{
184+
{Name: "id", Position: 1},
185+
{Name: "valid_period", Position: 2},
186+
},
187+
WithoutOverlaps: true,
188+
}
189+
190+
same := &ir.Constraint{
191+
Name: "pk_temporal",
192+
Type: ir.ConstraintTypePrimaryKey,
193+
Columns: []*ir.ConstraintColumn{
194+
{Name: "id", Position: 1},
195+
{Name: "valid_period", Position: 2},
196+
},
197+
WithoutOverlaps: true,
198+
}
199+
200+
different := &ir.Constraint{
201+
Name: "pk_temporal",
202+
Type: ir.ConstraintTypePrimaryKey,
203+
Columns: []*ir.ConstraintColumn{
204+
{Name: "id", Position: 1},
205+
{Name: "valid_period", Position: 2},
206+
},
207+
WithoutOverlaps: false,
208+
}
209+
210+
if !constraintsEqual(base, same) {
211+
t.Error("constraintsEqual() should return true for identical temporal constraints")
212+
}
213+
if constraintsEqual(base, different) {
214+
t.Error("constraintsEqual() should return false when WithoutOverlaps differs")
215+
}
216+
}
217+
103218
func TestCheckConstraintQuoting(t *testing.T) {
104219
tests := []struct {
105220
name string

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+
WithoutOverlaps: constraint.IsPeriod, // PG17+ temporal constraint
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+
WithoutOverlaps bool `json:"without_overlaps,omitempty"` // PG17+: temporal constraint (WITHOUT OVERLAPS / PERIOD)
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(c.conperiod, 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(c.conperiod, 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.

0 commit comments

Comments
 (0)