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
+103
View File
@@ -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