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:
@@ -273,6 +273,13 @@ func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string
|
||||
actions = append(actions, fmt.Sprintf("update: %s", c.OnUpdate))
|
||||
}
|
||||
|
||||
if c.Deferrable != nil && *c.Deferrable {
|
||||
actions = append(actions, "deferrable")
|
||||
}
|
||||
if c.InitiallyDeferred != nil && *c.InitiallyDeferred {
|
||||
actions = append(actions, "initially deferred")
|
||||
}
|
||||
|
||||
refLine := fmt.Sprintf("Ref: %s %s %s", fromRef, relationship, toRef)
|
||||
if len(actions) > 0 {
|
||||
refLine += fmt.Sprintf(" [%s]", strings.Join(actions, ", "))
|
||||
|
||||
@@ -171,3 +171,31 @@ func TestWriter_WriteDatabase_OneToOneRelationship(t *testing.T) {
|
||||
|
||||
assert.Contains(t, output, "Ref: public.profiles.user_id - public.users.id")
|
||||
}
|
||||
|
||||
func TestWriter_WriteDatabase_ForeignKeyDeferral(t *testing.T) {
|
||||
yes := true
|
||||
db := models.InitDatabase("test_db")
|
||||
schema := models.InitSchema("public")
|
||||
users := models.InitTable("users", "public")
|
||||
users.Columns["id"] = models.InitColumn("id", "users", "public")
|
||||
users.Columns["id"].Type = "bigint"
|
||||
posts := models.InitTable("posts", "public")
|
||||
posts.Columns["user_id"] = models.InitColumn("user_id", "posts", "public")
|
||||
posts.Columns["user_id"].Type = "bigint"
|
||||
fk := models.InitConstraint("fk_posts_user", models.ForeignKeyConstraint)
|
||||
fk.Table, fk.Schema = "posts", "public"
|
||||
fk.Columns = []string{"user_id"}
|
||||
fk.ReferencedTable, fk.ReferencedSchema = "users", "public"
|
||||
fk.ReferencedColumns = []string{"id"}
|
||||
fk.Deferrable = &yes
|
||||
fk.InitiallyDeferred = &yes
|
||||
posts.Constraints[fk.Name] = fk
|
||||
schema.Tables = append(schema.Tables, users, posts)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
|
||||
outputPath := filepath.Join(t.TempDir(), "test.dbml")
|
||||
assert.NoError(t, NewWriter(&writers.WriterOptions{OutputPath: outputPath}).WriteDatabase(db))
|
||||
content, err := os.ReadFile(outputPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(content), "Ref: public.posts.user_id > public.users.id [deferrable, initially deferred]")
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ Standardized naming rules for all database objects in RelSpec PostgreSQL output.
|
||||
| ----------------- | ----------- | ---------------------------------- | ------------------------ |
|
||||
| Primary Key | `pk_` | `pk_<schema>_<table>` | `pk_public_users` |
|
||||
| Foreign Key | `fk_` | `fk_<table>_<referenced_table>` | `fk_posts_users` |
|
||||
| Unique Constraint | `uk_` | `uk_<table>_<column>` | `uk_users_email` |
|
||||
| Unique Index | `uidx_` | `uidx_<table>_<column>` | `uidx_users_email` |
|
||||
| Unique Constraint | `ukey_` | `ukey_<table>_<column>` | `ukey_users_email` |
|
||||
| Unique Index | `ukey_` | `ukey_<table>_<column>` | `ukey_users_email` |
|
||||
| Regular Index | `idx_` | `idx_<table>_<column>` | `idx_posts_user_id` |
|
||||
| Check Constraint | `chk_` | `chk_<table>_<constraint_purpose>` | `chk_users_age_positive` |
|
||||
| Sequence | `identity_` | `identity_<table>_<column>` | `identity_users_id` |
|
||||
@@ -43,25 +43,25 @@ Standardized naming rules for all database objects in RelSpec PostgreSQL output.
|
||||
|
||||
### Unique Constraints
|
||||
|
||||
**Pattern:** `uk_<table>_<column>`
|
||||
**Pattern:** `ukey_<table>_<column>`
|
||||
|
||||
- Use `uk_` prefix strictly for database constraints (CONSTRAINT type)
|
||||
- Generated by the DBML reader for column `[unique]`
|
||||
- Include column name for clarity
|
||||
- Examples:
|
||||
- `uk_users_email`
|
||||
- `uk_users_username`
|
||||
- `uk_products_sku`
|
||||
- `ukey_users_email`
|
||||
- `ukey_users_username`
|
||||
- `ukey_products_sku`
|
||||
|
||||
### Unique Indexes
|
||||
|
||||
**Pattern:** `uidx_<table>_<column>`
|
||||
**Pattern:** `ukey_<table>_<column>`
|
||||
|
||||
- Use `uidx_` prefix strictly for index type objects
|
||||
- Distinguished from constraints for clarity and implementation flexibility
|
||||
- Unnamed unique indexes use the same name as a unique constraint on the same columns
|
||||
- Writers then build the constraint on the index (`ADD CONSTRAINT ... UNIQUE USING INDEX`), so no duplicate index is created
|
||||
- Examples:
|
||||
- `uidx_users_email`
|
||||
- `uidx_sessions_token`
|
||||
- `uidx_api_keys_key`
|
||||
- `ukey_users_email`
|
||||
- `ukey_sessions_token`
|
||||
- `ukey_api_keys_key`
|
||||
|
||||
### Regular Indexes
|
||||
|
||||
|
||||
@@ -154,6 +154,29 @@ When `migration_mode` is enabled, generates migration scripts with:
|
||||
- Transactional DDL
|
||||
- Rollback support
|
||||
|
||||
### Unique constraints and indexes
|
||||
|
||||
- Unique constraint → `ADD CONSTRAINT <name> UNIQUE (cols)`, idempotent (checked by name)
|
||||
- Migration mode and direct apply both emit them (not only `convert`)
|
||||
- Same-named model unique index → `UNIQUE USING INDEX` (no duplicate index). Requires: same name, same columns, btree, non-partial, no `INCLUDE`
|
||||
- Otherwise a plain `ADD CONSTRAINT` (second index is built)
|
||||
- Naming: unique constraint and unique index both `ukey_<table>_<cols>`; see `NAMING_CONVENTIONS.md`
|
||||
- Renaming `uidx_…` → `ukey_…` drops the old index and re-adds the constraint on the next diff
|
||||
|
||||
### FK deferral
|
||||
|
||||
| Model (`Deferrable` / `InitiallyDeferred`) | SQL |
|
||||
| ------------------------------------------ | --- |
|
||||
| unset | create: `DEFERRABLE` (migration/apply), none (full DDL constraints); existing FK never diffed |
|
||||
| `true` / unset or `false` | `DEFERRABLE` |
|
||||
| `true` / `true`, or unset / `true` | `DEFERRABLE INITIALLY DEFERRED` |
|
||||
| `false` | `NOT DEFERRABLE` |
|
||||
|
||||
- Both fields are `*bool`; `nil` = unspecified
|
||||
- Specified value differing from the live DB → FK dropped and re-added
|
||||
- Live reader reads `condeferrable` / `condeferred`
|
||||
- Initial mode compared only when both sides state it
|
||||
|
||||
### Audit Triggers
|
||||
|
||||
When `include_audit` is enabled, adds:
|
||||
|
||||
@@ -222,6 +222,13 @@ func (w *MigrationWriter) generateSchemaScripts(model, current *models.Schema) (
|
||||
}
|
||||
scripts = append(scripts, indexScripts...)
|
||||
|
||||
// Phase 4.5: Create unique constraints (Priority 185)
|
||||
uniqueScripts, err := w.generateUniqueConstraintScripts(model, current)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unique constraint scripts: %w", err)
|
||||
}
|
||||
scripts = append(scripts, uniqueScripts...)
|
||||
|
||||
// Phase 5: Create foreign keys (Priority 195)
|
||||
fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs)
|
||||
if err != nil {
|
||||
@@ -751,6 +758,92 @@ func buildIndexColumnExpressionsFiltered(table *models.Table, index *models.Inde
|
||||
return columnExprs
|
||||
}
|
||||
|
||||
// generateUniqueConstraintScripts generates ADD CONSTRAINT UNIQUE scripts for model unique
|
||||
// constraints that are missing from, or differ in, the live schema.
|
||||
func (w *MigrationWriter) generateUniqueConstraintScripts(model, current *models.Schema) ([]MigrationScript, error) {
|
||||
scripts := make([]MigrationScript, 0)
|
||||
|
||||
currentTables := make(map[string]*models.Table)
|
||||
if current != nil {
|
||||
for _, table := range current.Tables {
|
||||
currentTables[strings.ToLower(table.Name)] = table
|
||||
}
|
||||
}
|
||||
|
||||
for _, modelTable := range model.Tables {
|
||||
currentTable := currentTables[strings.ToLower(modelTable.Name)]
|
||||
|
||||
for _, constraint := range sortConstraints(modelTable.Constraints) {
|
||||
if constraint.Type != models.UniqueConstraint {
|
||||
continue
|
||||
}
|
||||
|
||||
if currentTable != nil {
|
||||
if currentConstraint, exists := lookupConstraint(currentTable.Constraints, constraint.Name); exists && constraintsEqual(constraint, currentConstraint) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
columnExprs := make([]string, 0, len(constraint.Columns))
|
||||
for _, colName := range constraint.Columns {
|
||||
if col, ok := modelTable.Columns[colName]; ok {
|
||||
columnExprs = append(columnExprs, col.SQLName())
|
||||
}
|
||||
}
|
||||
if len(columnExprs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sql, err := w.executor.ExecuteCreateUniqueConstraint(CreateUniqueConstraintData{
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
ConstraintName: constraint.Name,
|
||||
Columns: strings.Join(columnExprs, ", "),
|
||||
UsingIndex: adoptableUniqueIndex(modelTable, constraint),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scripts = append(scripts, MigrationScript{
|
||||
ObjectName: fmt.Sprintf("%s.%s.%s", model.Name, modelTable.Name, constraint.Name),
|
||||
ObjectType: "create unique constraint",
|
||||
Schema: model.Name,
|
||||
Priority: 185,
|
||||
Sequence: len(scripts),
|
||||
Body: sql,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// adoptableUniqueIndex returns the name of the model's unique index that the given unique
|
||||
// constraint can be built on with ADD CONSTRAINT ... UNIQUE USING INDEX, so the constraint does
|
||||
// not create a second index (or collide with the index's name). The index must carry the
|
||||
// constraint's name, be a plain non-partial btree index and cover exactly the constraint's
|
||||
// columns. Index creation runs before constraint creation, so this holds whether or not the
|
||||
// live database already has the index. Returns "" when none qualifies.
|
||||
func adoptableUniqueIndex(table *models.Table, constraint *models.Constraint) string {
|
||||
index, ok := table.Indexes[constraint.Name]
|
||||
if !ok || !index.Unique || index.Where != "" || len(index.Include) > 0 {
|
||||
return ""
|
||||
}
|
||||
if index.Type != "" && !strings.EqualFold(index.Type, "btree") {
|
||||
return ""
|
||||
}
|
||||
if len(index.Columns) != len(constraint.Columns) {
|
||||
return ""
|
||||
}
|
||||
for i, col := range index.Columns {
|
||||
if !strings.EqualFold(col, constraint.Columns[i]) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return index.Name
|
||||
}
|
||||
|
||||
// generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates.
|
||||
// forceRecreate is a set of FK constraint keys (schema.table.name) that must be recreated
|
||||
// even if unchanged, because their referenced PK was dropped and recreated.
|
||||
@@ -800,6 +893,12 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
|
||||
onUpdate = strings.ToUpper(constraint.OnUpdate)
|
||||
}
|
||||
|
||||
// An unspecified deferral keeps the historical default of DEFERRABLE.
|
||||
deferral := constraint.DeferralClause()
|
||||
if deferral == "" {
|
||||
deferral = "DEFERRABLE"
|
||||
}
|
||||
|
||||
sql, err := w.executor.ExecuteCreateForeignKey(CreateForeignKeyData{
|
||||
SchemaName: model.Name,
|
||||
TableName: modelTable.Name,
|
||||
@@ -810,6 +909,7 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
|
||||
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
|
||||
OnDelete: onDelete,
|
||||
OnUpdate: onUpdate,
|
||||
Deferral: deferral,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1149,6 +1249,9 @@ func constraintsEqual(c1, c2 *models.Constraint) bool {
|
||||
if !fkActionsEqual(c1.OnDelete, c2.OnDelete) || !fkActionsEqual(c1.OnUpdate, c2.OnUpdate) {
|
||||
return false
|
||||
}
|
||||
if !models.DeferralMatches(c1, c2) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ type CreateForeignKeyData struct {
|
||||
TargetColumns string
|
||||
OnDelete string
|
||||
OnUpdate string
|
||||
Deferral string // DEFERRABLE / DEFERRABLE INITIALLY DEFERRED / NOT DEFERRABLE, or empty
|
||||
}
|
||||
|
||||
// DropConstraintData contains data for drop constraint template
|
||||
@@ -238,6 +239,7 @@ type CreateUniqueConstraintData struct {
|
||||
TableName string
|
||||
ConstraintName string
|
||||
Columns string
|
||||
UsingIndex string // when set, promotes this existing unique index instead of building a new one
|
||||
}
|
||||
|
||||
// CreateCheckConstraintData contains data for create check constraint template
|
||||
@@ -259,7 +261,7 @@ type CreateForeignKeyWithCheckData struct {
|
||||
TargetColumns string
|
||||
OnDelete string
|
||||
OnUpdate string
|
||||
Deferrable bool
|
||||
Deferral string // DEFERRABLE / DEFERRABLE INITIALLY DEFERRED / NOT DEFERRABLE, or empty
|
||||
}
|
||||
|
||||
// SetSequenceValueData contains data for set sequence value template
|
||||
|
||||
@@ -7,4 +7,4 @@ ALTER TABLE {{qual_table .SchemaName .TableName}}
|
||||
REFERENCES {{qual_table .TargetSchema .TargetTable}} ({{.TargetColumns}})
|
||||
ON DELETE {{.OnDelete}}
|
||||
ON UPDATE {{.OnUpdate}}
|
||||
DEFERRABLE;
|
||||
{{.Deferral}};
|
||||
@@ -11,8 +11,8 @@ BEGIN
|
||||
FOREIGN KEY ({{.SourceColumns}})
|
||||
REFERENCES {{qual_table .TargetSchema .TargetTable}} ({{.TargetColumns}})
|
||||
ON DELETE {{.OnDelete}}
|
||||
ON UPDATE {{.OnUpdate}}{{if .Deferrable}}
|
||||
DEFERRABLE{{end}};
|
||||
ON UPDATE {{.OnUpdate}}{{if .Deferral}}
|
||||
{{.Deferral}}{{end}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -6,7 +6,7 @@ BEGIN
|
||||
AND table_name = '{{.TableName}}'
|
||||
AND constraint_name = '{{.ConstraintName}}'
|
||||
) THEN
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} UNIQUE ({{.Columns}});
|
||||
ALTER TABLE {{qual_table .SchemaName .TableName}} ADD CONSTRAINT {{quote_ident .ConstraintName}} UNIQUE {{if .UsingIndex}}USING INDEX {{quote_ident .UsingIndex}}{{else}}({{.Columns}}){{end}};
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
$$;
|
||||
|
||||
@@ -345,6 +345,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
TableName: table.Name,
|
||||
ConstraintName: constraint.Name,
|
||||
Columns: strings.Join(constraint.Columns, ", "),
|
||||
UsingIndex: adoptableUniqueIndex(table, constraint),
|
||||
}
|
||||
|
||||
stmt, err := w.executor.ExecuteCreateUniqueConstraint(data)
|
||||
@@ -411,7 +412,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
|
||||
OnDelete: onDelete,
|
||||
OnUpdate: onUpdate,
|
||||
Deferrable: false,
|
||||
Deferral: constraint.DeferralClause(),
|
||||
}
|
||||
|
||||
stmt, err := w.executor.ExecuteCreateForeignKeyWithCheck(data)
|
||||
@@ -1150,7 +1151,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
if indexName == "" {
|
||||
indexType := "idx"
|
||||
if index.Unique {
|
||||
indexType = "uidx"
|
||||
indexType = "ukey"
|
||||
}
|
||||
columnSuffix := strings.Join(index.Columns, "_")
|
||||
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.SQLName(), strings.ToLower(columnSuffix))
|
||||
@@ -1236,6 +1237,7 @@ func (w *Writer) writeUniqueConstraints(schema *models.Schema) error {
|
||||
TableName: table.Name,
|
||||
ConstraintName: constraint.Name,
|
||||
Columns: strings.Join(columnExprs, ", "),
|
||||
UsingIndex: adoptableUniqueIndex(table, constraint),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate unique constraint: %w", err)
|
||||
@@ -1380,7 +1382,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
TargetColumns: strings.Join(targetColumns, ", "),
|
||||
OnDelete: onDelete,
|
||||
OnUpdate: onUpdate,
|
||||
Deferrable: true,
|
||||
Deferral: "DEFERRABLE",
|
||||
}
|
||||
sql, err := w.executor.ExecuteCreateForeignKeyWithCheck(data)
|
||||
if err != nil {
|
||||
@@ -1459,7 +1461,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
|
||||
TargetColumns: strings.Join(targetColumns, ", "),
|
||||
OnDelete: onDelete,
|
||||
OnUpdate: onUpdate,
|
||||
Deferrable: false,
|
||||
Deferral: constraint.DeferralClause(),
|
||||
}
|
||||
sql, err := w.executor.ExecuteCreateForeignKeyWithCheck(data)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user