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
+313
View File
@@ -1027,3 +1027,316 @@ func TestIndexesEqual_OperatorClassAndStorageParameters(t *testing.T) {
})
}
}
func uniqueConstraintTestModel() *models.Database {
model := models.InitDatabase("testdb")
schema := models.InitSchema("public")
table := models.InitTable("login", "public")
idCol := models.InitColumn("id", "login", "public")
idCol.Type = "integer"
idCol.NotNull = true
table.Columns["id"] = idCol
guidCol := models.InitColumn("guid", "login", "public")
guidCol.Type = "uuid"
table.Columns["guid"] = guidCol
uc := models.InitConstraint("ukey_login_guid", models.UniqueConstraint)
uc.Schema = "public"
uc.Table = "login"
uc.Columns = []string{"guid"}
table.Constraints[uc.Name] = uc
schema.Tables = append(schema.Tables, table)
model.Schemas = append(model.Schemas, schema)
return model
}
func TestWriteMigration_UniqueConstraint(t *testing.T) {
tests := []struct {
name string
current func() *models.Database
wantConstrnt bool
}{
{
name: "new table",
current: func() *models.Database {
c := models.InitDatabase("testdb")
c.Schemas = append(c.Schemas, models.InitSchema("public"))
return c
},
wantConstrnt: true,
},
{
name: "existing table without constraint",
current: func() *models.Database {
c := uniqueConstraintTestModel()
delete(c.Schemas[0].Tables[0].Constraints, "ukey_login_guid")
return c
},
wantConstrnt: true,
},
{
name: "existing table with constraint",
current: uniqueConstraintTestModel,
wantConstrnt: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(uniqueConstraintTestModel(), tt.current()); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
has := strings.Contains(output, "ADD CONSTRAINT") && strings.Contains(output, "ukey_login_guid") && strings.Contains(output, "UNIQUE")
if has != tt.wantConstrnt {
t.Fatalf("unique constraint present = %v, want %v; output:\n%s", has, tt.wantConstrnt, output)
}
if strings.Contains(output, "DROP CONSTRAINT") {
t.Fatalf("unexpected DROP CONSTRAINT; output:\n%s", output)
}
})
}
}
func TestWriteMigration_UniqueConstraintAdoptsSameNamedModelIndex(t *testing.T) {
newIndex := func(name string, unique bool, where string) *models.Index {
idx := models.InitIndex(name, "login", "public")
idx.Columns = []string{"guid"}
idx.Unique = unique
idx.Type = "btree"
idx.Where = where
return idx
}
tests := []struct {
name string
modelIdx *models.Index
liveIdx *models.Index // nil: fresh table
wantAdopt bool
wantDrop bool
}{
{
name: "fresh table: same-named unique index is promoted",
modelIdx: newIndex("ukey_login_guid", true, ""),
wantAdopt: true,
},
{
name: "existing live unique index is promoted",
modelIdx: newIndex("ukey_login_guid", true, ""),
liveIdx: newIndex("ukey_login_guid", true, ""),
wantAdopt: true,
},
{
name: "live unique index not in model is dropped and constraint built fresh",
liveIdx: newIndex("ukey_login_guid", true, ""),
wantDrop: true,
},
{
name: "partial model index is not promoted",
modelIdx: newIndex("ukey_login_guid", true, "guid IS NOT NULL"),
},
{
name: "non-unique same-named index is not promoted",
modelIdx: newIndex("ukey_login_guid", false, ""),
},
{
name: "differently named index is not promoted",
modelIdx: newIndex("idx_login_guid", true, ""),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
model := uniqueConstraintTestModel()
if tt.modelIdx != nil {
model.Schemas[0].Tables[0].Indexes[tt.modelIdx.Name] = tt.modelIdx
}
current := models.InitDatabase("testdb")
current.Schemas = append(current.Schemas, models.InitSchema("public"))
if tt.liveIdx != nil {
current = uniqueConstraintTestModel()
curTable := current.Schemas[0].Tables[0]
delete(curTable.Constraints, "ukey_login_guid")
curTable.Indexes[tt.liveIdx.Name] = tt.liveIdx
}
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
output := buf.String()
if got := strings.Contains(output, "UNIQUE USING INDEX"); got != tt.wantAdopt {
t.Errorf("USING INDEX present = %v, want %v; output:\n%s", got, tt.wantAdopt, output)
}
if got := strings.Contains(output, "UNIQUE (guid)") || strings.Contains(output, "UNIQUE (\"guid\")"); got == tt.wantAdopt {
t.Errorf("column-list ADD CONSTRAINT present = %v, want %v; output:\n%s", got, !tt.wantAdopt, output)
}
if got := strings.Contains(output, "DROP INDEX"); got != tt.wantDrop {
t.Errorf("DROP INDEX present = %v, want %v; output:\n%s", got, tt.wantDrop, output)
}
})
}
}
func TestWriteDatabase_UniqueConstraintAdoptsSameNamedIndex(t *testing.T) {
db := uniqueConstraintTestModel()
idx := models.InitIndex("ukey_login_guid", "login", "public")
idx.Columns = []string{"guid"}
idx.Unique = true
db.Schemas[0].Tables[0].Indexes[idx.Name] = idx
var buf bytes.Buffer
w := NewWriter(&writers.WriterOptions{})
w.writer = &buf
if err := w.WriteDatabase(db); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
output := buf.String()
if !strings.Contains(output, "UNIQUE USING INDEX") {
t.Fatalf("expected UNIQUE USING INDEX in output:\n%s", output)
}
if strings.Index(output, "CREATE UNIQUE INDEX") > strings.Index(output, "UNIQUE USING INDEX") {
t.Fatalf("index must be created before the constraint adopts it:\n%s", output)
}
}
func fkDeferralTestModel(def, init *bool) *models.Database {
db := models.InitDatabase("testdb")
schema := models.InitSchema("public")
users := models.InitTable("users", "public")
uid := models.InitColumn("id", "users", "public")
uid.Type = "integer"
uid.NotNull = true
users.Columns["id"] = uid
posts := models.InitTable("posts", "public")
pid := models.InitColumn("id", "posts", "public")
pid.Type = "integer"
pid.NotNull = true
posts.Columns["id"] = pid
puid := models.InitColumn("user_id", "posts", "public")
puid.Type = "integer"
posts.Columns["user_id"] = puid
fk := models.InitConstraint("fk_posts_user_id", models.ForeignKeyConstraint)
fk.Schema = "public"
fk.Table = "posts"
fk.Columns = []string{"user_id"}
fk.ReferencedSchema = "public"
fk.ReferencedTable = "users"
fk.ReferencedColumns = []string{"id"}
fk.Deferrable = def
fk.InitiallyDeferred = init
posts.Constraints[fk.Name] = fk
schema.Tables = append(schema.Tables, users, posts)
db.Schemas = append(db.Schemas, schema)
return db
}
func migrationOutput(t *testing.T, model, current *models.Database) string {
t.Helper()
var buf bytes.Buffer
writer, err := NewMigrationWriter(&writers.WriterOptions{})
if err != nil {
t.Fatalf("Failed to create writer: %v", err)
}
writer.writer = &buf
if err := writer.WriteMigration(model, current); err != nil {
t.Fatalf("WriteMigration failed: %v", err)
}
return buf.String()
}
func TestWriteMigration_ForeignKeyDeferral(t *testing.T) {
yes, no := true, false
empty := func() *models.Database {
c := models.InitDatabase("testdb")
c.Schemas = append(c.Schemas, models.InitSchema("public"))
return c
}
tests := []struct {
name string
def *bool
init *bool
wantFrag string
}{
{"unspecified keeps DEFERRABLE default", nil, nil, "ON UPDATE NO ACTION\n DEFERRABLE;"},
{"deferrable", &yes, nil, "DEFERRABLE;"},
{"initially deferred", &yes, &yes, "DEFERRABLE INITIALLY DEFERRED;"},
{"not deferrable", &no, nil, "NOT DEFERRABLE;"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := migrationOutput(t, fkDeferralTestModel(tt.def, tt.init), empty())
if !strings.Contains(out, tt.wantFrag) {
t.Fatalf("expected %q in output:\n%s", tt.wantFrag, out)
}
})
}
}
func TestWriteMigration_ForeignKeyDeferralDiff(t *testing.T) {
yes, no := true, false
live := func(def, init bool) *models.Database {
db := fkDeferralTestModel(&def, &init)
return db
}
tests := []struct {
name string
def, init *bool
liveDef bool
liveInit bool
wantRecreate bool
}{
{"unspecified vs deferrable live: untouched", nil, nil, true, false, false},
{"unspecified vs not deferrable live: untouched", nil, nil, false, false, false},
{"deferrable matches live", &yes, nil, true, true, false},
{"deferrable vs not deferrable live: recreated", &yes, nil, false, false, true},
{"not deferrable vs deferrable live: recreated", &no, nil, true, false, true},
{"initially deferred vs initially immediate live: recreated", &yes, &yes, true, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := migrationOutput(t, fkDeferralTestModel(tt.def, tt.init), live(tt.liveDef, tt.liveInit))
got := strings.Contains(out, "ADD CONSTRAINT") && strings.Contains(out, "FOREIGN KEY")
if got != tt.wantRecreate {
t.Fatalf("FK recreated = %v, want %v; output:\n%s", got, tt.wantRecreate, out)
}
})
}
}
func TestWriteDatabase_ForeignKeyDeferral(t *testing.T) {
yes := true
var buf bytes.Buffer
w := NewWriter(&writers.WriterOptions{})
w.writer = &buf
if err := w.WriteDatabase(fkDeferralTestModel(&yes, &yes)); err != nil {
t.Fatalf("WriteDatabase failed: %v", err)
}
if !strings.Contains(buf.String(), "DEFERRABLE INITIALLY DEFERRED;") {
t.Fatalf("expected DEFERRABLE INITIALLY DEFERRED in output:\n%s", buf.String())
}
}