feat(pgsql): unique constraints on apply, matching unique index names, FK deferral

- migration writer now emits unique constraints (direct apply and live diff
  previously dropped them); idempotent via the existing DO block
- unique constraint adopts a same-named model unique index with
  ADD CONSTRAINT ... UNIQUE USING INDEX instead of building a second index
- dbml reader names unnamed unique indexes ukey_<table>_<cols> to match the
  unique constraint naming
- foreign key deferral: Constraint.Deferrable/InitiallyDeferred are *bool
  (nil = unspecified, never diffed); writers emit DEFERRABLE, DEFERRABLE
  INITIALLY DEFERRED and NOT DEFERRABLE; live reader reads condeferrable and
  condeferred; dbml reader/writer support deferrable and initially deferred
- tests and README/naming docs updated
This commit is contained in:
2026-10-08 00:07:49 +02:00
parent 20f8dd82b6
commit b4f18f088b
20 changed files with 756 additions and 36 deletions
+48
View File
@@ -0,0 +1,48 @@
package models
// DeferralClause returns the SQL deferral clause for a foreign key constraint
// ("DEFERRABLE", "DEFERRABLE INITIALLY DEFERRED" or "NOT DEFERRABLE"), or ""
// when the model does not specify one. InitiallyDeferred without Deferrable
// implies DEFERRABLE; InitiallyDeferred=false alone specifies nothing.
func (d *Constraint) DeferralClause() string {
deferrable, initially, set := d.effectiveDeferral()
switch {
case !set:
return ""
case !deferrable:
return "NOT DEFERRABLE"
case initially != nil && *initially:
return "DEFERRABLE INITIALLY DEFERRED"
default:
return "DEFERRABLE"
}
}
// effectiveDeferral resolves the optional flags. set is false when the model says nothing
// about deferral; initially is nil when the initial mode is unspecified.
func (d *Constraint) effectiveDeferral() (deferrable bool, initially *bool, set bool) {
switch {
case d.Deferrable != nil:
return *d.Deferrable, d.InitiallyDeferred, true
case d.InitiallyDeferred != nil && *d.InitiallyDeferred:
return true, d.InitiallyDeferred, true
}
return false, nil, false
}
// DeferralMatches reports whether two constraints agree on deferral. An unspecified side
// matches anything, and the initial mode is only compared when both sides specify it.
func DeferralMatches(a, b *Constraint) bool {
da, ia, aSet := a.effectiveDeferral()
db, ib, bSet := b.effectiveDeferral()
if !aSet || !bSet {
return true
}
if da != db {
return false
}
if !da || ia == nil || ib == nil {
return true
}
return *ia == *ib
}
+59
View File
@@ -0,0 +1,59 @@
package models
import "testing"
func boolPtr(b bool) *bool { return &b }
func TestConstraint_DeferralClause(t *testing.T) {
tests := []struct {
name string
c Constraint
want string
}{
{"unspecified", Constraint{}, ""},
{"deferrable", Constraint{Deferrable: boolPtr(true)}, "DEFERRABLE"},
{"deferrable initially deferred", Constraint{Deferrable: boolPtr(true), InitiallyDeferred: boolPtr(true)}, "DEFERRABLE INITIALLY DEFERRED"},
{"deferrable initially immediate", Constraint{Deferrable: boolPtr(true), InitiallyDeferred: boolPtr(false)}, "DEFERRABLE"},
{"initially deferred implies deferrable", Constraint{InitiallyDeferred: boolPtr(true)}, "DEFERRABLE INITIALLY DEFERRED"},
{"initially immediate alone is unspecified", Constraint{InitiallyDeferred: boolPtr(false)}, ""},
{"explicitly not deferrable", Constraint{Deferrable: boolPtr(false)}, "NOT DEFERRABLE"},
{"not deferrable ignores initially deferred", Constraint{Deferrable: boolPtr(false), InitiallyDeferred: boolPtr(true)}, "NOT DEFERRABLE"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.c.DeferralClause(); got != tt.want {
t.Errorf("DeferralClause() = %q, want %q", got, tt.want)
}
})
}
}
func TestDeferralMatches(t *testing.T) {
live := func(def, init bool) *Constraint {
return &Constraint{Deferrable: boolPtr(def), InitiallyDeferred: boolPtr(init)}
}
tests := []struct {
name string
model, other *Constraint
want bool
}{
{"model unspecified vs deferrable live", &Constraint{}, live(true, false), true},
{"model unspecified vs not deferrable live", &Constraint{}, live(false, false), true},
{"deferrable vs deferrable live", &Constraint{Deferrable: boolPtr(true)}, live(true, true), true},
{"deferrable vs not deferrable live", &Constraint{Deferrable: boolPtr(true)}, live(false, false), false},
{"not deferrable vs deferrable live", &Constraint{Deferrable: boolPtr(false)}, live(true, false), false},
{"initially deferred vs initially immediate live", &Constraint{InitiallyDeferred: boolPtr(true)}, live(true, false), false},
{"initially deferred vs initially deferred live", &Constraint{InitiallyDeferred: boolPtr(true)}, live(true, true), true},
{"explicit initially immediate vs initially deferred live", &Constraint{Deferrable: boolPtr(true), InitiallyDeferred: boolPtr(false)}, live(true, true), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DeferralMatches(tt.model, tt.other); got != tt.want {
t.Errorf("DeferralMatches() = %v, want %v", got, tt.want)
}
if got := DeferralMatches(tt.other, tt.model); got != tt.want {
t.Errorf("DeferralMatches() reversed = %v, want %v", got, tt.want)
}
})
}
}
+2 -2
View File
@@ -329,8 +329,8 @@ type Constraint struct {
ReferencedColumns []string `json:"referenced_columns" yaml:"referenced_columns" xml:"referenced_columns"`
OnDelete string `json:"on_delete" yaml:"on_delete" xml:"on_delete"` // CASCADE, SET NULL, RESTRICT, etc.
OnUpdate string `json:"on_update" yaml:"on_update" xml:"on_update"`
Deferrable bool `json:"deferrable,omitempty" yaml:"deferrable,omitempty" xml:"deferrable,omitempty"`
InitiallyDeferred bool `json:"initially_deferred,omitempty" yaml:"initially_deferred,omitempty" xml:"initially_deferred,omitempty"`
Deferrable *bool `json:"deferrable,omitempty" yaml:"deferrable,omitempty" xml:"deferrable,omitempty"` // nil: unspecified, left as is
InitiallyDeferred *bool `json:"initially_deferred,omitempty" yaml:"initially_deferred,omitempty" xml:"initially_deferred,omitempty"` // nil: unspecified, left as is
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
GUID string `json:"guid" yaml:"guid" xml:"guid"`
}