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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user