Compare commits

..
2 Commits
Author SHA1 Message Date
warkanum 9f0269f591 chore(release): update package version to 1.0.88
Release / test (push) Failing after 1m36s
Release / release (push) Skipped
Release / pkg-aur (push) Skipped
Release / pkg-deb (push) Skipped
Release / pkg-rpm (push) Skipped
2026-10-08 00:08:26 +02:00
warkanum b4f18f088b 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
2026-10-08 00:07:49 +02:00
23 changed files with 759 additions and 39 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.87 1.0.88
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=relspec pkgname=relspec
pkgver=1.0.87 pkgver=1.0.88
pkgrel=1 pkgrel=1
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs." pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: relspec Name: relspec
Version: 1.0.87 Version: 1.0.88
Release: 1%{?dist} Release: 1%{?dist}
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs. Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
+3
View File
@@ -504,6 +504,9 @@ func compareConstraintDetails(source, target *models.Constraint) map[string]any
if normalizeConstraintAction(source.OnUpdate) != normalizeConstraintAction(target.OnUpdate) { if normalizeConstraintAction(source.OnUpdate) != normalizeConstraintAction(target.OnUpdate) {
changes["on_update"] = map[string]string{"source": source.OnUpdate, "target": target.OnUpdate} changes["on_update"] = map[string]string{"source": source.OnUpdate, "target": target.OnUpdate}
} }
if !models.DeferralMatches(source, target) {
changes["deferral"] = map[string]string{"source": source.DeferralClause(), "target": target.DeferralClause()}
}
return changes return changes
} }
+10 -2
View File
@@ -605,8 +605,8 @@ func cloneConstraint(constraint *models.Constraint) *models.Constraint {
OnDelete: constraint.OnDelete, OnDelete: constraint.OnDelete,
Expression: constraint.Expression, Expression: constraint.Expression,
Name: constraint.Name, Name: constraint.Name,
Deferrable: constraint.Deferrable, Deferrable: copyBoolPtr(constraint.Deferrable),
InitiallyDeferred: constraint.InitiallyDeferred, InitiallyDeferred: copyBoolPtr(constraint.InitiallyDeferred),
Sequence: constraint.Sequence, Sequence: constraint.Sequence,
} }
copy(newConstraint.Columns, constraint.Columns) copy(newConstraint.Columns, constraint.Columns)
@@ -823,3 +823,11 @@ func min(a, b int) int {
} }
return b return b
} }
func copyBoolPtr(b *bool) *bool {
if b == nil {
return nil
}
v := *b
return &v
}
+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"` 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. 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"` OnUpdate string `json:"on_update" yaml:"on_update" xml:"on_update"`
Deferrable bool `json:"deferrable,omitempty" yaml:"deferrable,omitempty" xml:"deferrable,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"` 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"` Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
GUID string `json:"guid" yaml:"guid" xml:"guid"` GUID string `json:"guid" yaml:"guid" xml:"guid"`
} }
+25 -1
View File
@@ -86,16 +86,40 @@ Ref: posts.user_id > users.id [delete: cascade]
- Table definitions with columns - Table definitions with columns
- Primary keys (`pk`) - Primary keys (`pk`)
- Not null constraints (`not null`) - Not null constraints (`not null`)
- Unique constraints (`unique`) - Unique constraints (`unique`) → constraint `ukey_<table>_<col>`
- Default values (`default`) - Default values (`default`)
- Inline references (`ref`) - Inline references (`ref`)
- Standalone `Ref` blocks - Standalone `Ref` blocks
- FK deferral: `deferrable`, `initially deferred` (see below)
- Commented cross-file refs (`// Ref:` — see below) - Commented cross-file refs (`// Ref:` — see below)
- Indexes and composite indexes - Indexes and composite indexes
- Table notes and column notes - Table notes and column notes
- Enums - Enums
- Dialect directives (`@postgres:` / `@sqlite:` — see below) - Dialect directives (`@postgres:` / `@sqlite:` — see below)
## Naming
| Source | Generated name |
| ------ | -------------- |
| Column `[unique]` | constraint `ukey_<table>_<col>` |
| Unnamed unique index | index `ukey_<table>_<cols>` (same name as the constraint on the same columns) |
| Unnamed index | `idx_<table>_<cols>` |
Explicit `name:` is never changed. Leading `_` in the table name is stripped.
## FK deferral
```
col int [ref: > t.id, deferrable, initially deferred]
Ref: a.x > b.id [delete: cascade, deferrable]
```
| Setting | SQL |
| ------- | --- |
| `deferrable` | `DEFERRABLE` |
| `initially deferred` | `DEFERRABLE INITIALLY DEFERRED` |
| neither | unspecified (existing FK left as is) |
## Commented cross-file refs ## Commented cross-file refs
`// Ref:` / `// ref:` lines (ignored by dbdiagram) become FKs + relationships once both ends are loaded. `// Ref:` / `// ref:` lines (ignored by dbdiagram) become FKs + relationships once both ends are loaded.
+26 -3
View File
@@ -848,6 +848,13 @@ func (r *Reader) parseColumn(line, tableName, schemaName string) (*models.Column
} }
} }
// "deferrable" / "initially deferred" column settings apply to the inline foreign key
if constraint != nil && constraint.Type == models.ForeignKeyConstraint && attrs != "" {
for _, attr := range splitColumnAttrs(attrs) {
applyDeferralSetting(constraint, attr)
}
}
// Parse inline comment // Parse inline comment
if inlineComment != "" { if inlineComment != "" {
column.Comment = inlineComment column.Comment = inlineComment
@@ -1081,11 +1088,13 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
// Generate name if not provided // Generate name if not provided
if index.Name == "" { if index.Name == "" {
prefix := "idx" // Unique indexes share the ukey_ naming of unique constraints, so an index and a
// constraint on the same columns get the same name and can be merged on write.
prefix, name := "idx", tableName
if index.Unique { if index.Unique {
prefix = "uidx" prefix, name = "ukey", strings.TrimLeft(tableName, "_")
} }
index.Name = fmt.Sprintf("%s_%s_%s", prefix, tableName, strings.Join(columns, "_")) index.Name = fmt.Sprintf("%s_%s_%s", prefix, name, strings.Join(columns, "_"))
} }
return index return index
@@ -1182,6 +1191,8 @@ func (r *Reader) parseRef(refStr string) *models.Constraint {
constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "ondelete:")) constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "ondelete:"))
} else if strings.HasPrefix(action, "onupdate:") { } else if strings.HasPrefix(action, "onupdate:") {
constraint.OnUpdate = strings.TrimSpace(strings.TrimPrefix(action, "onupdate:")) constraint.OnUpdate = strings.TrimSpace(strings.TrimPrefix(action, "onupdate:"))
} else {
applyDeferralSetting(constraint, action)
} }
} }
} }
@@ -1190,6 +1201,18 @@ func (r *Reader) parseRef(refStr string) *models.Constraint {
return constraint return constraint
} }
// applyDeferralSetting applies a "deferrable" or "initially deferred" Ref setting to a
// foreign key constraint. Other settings are ignored.
func applyDeferralSetting(constraint *models.Constraint, setting string) {
yes := true
switch strings.Join(strings.Fields(strings.ToLower(setting)), " ") {
case "deferrable":
constraint.Deferrable = &yes
case "initially deferred", "initially_deferred":
constraint.InitiallyDeferred = &yes
}
}
// parseTableRef parses a table reference like "schema.table.(column1, column2)" or "schema"."table"."column" // parseTableRef parses a table reference like "schema.table.(column1, column2)" or "schema"."table"."column"
func (r *Reader) parseTableRef(ref string) (schema, table string, columns []string) { func (r *Reader) parseTableRef(ref string) (schema, table string, columns []string) {
// Extract columns if present in parentheses format // Extract columns if present in parentheses format
+70 -3
View File
@@ -3,6 +3,7 @@ package dbml
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/models"
@@ -842,9 +843,9 @@ func TestConstraintNaming(t *testing.T) {
t.Logf("Available constraints: %v", getKeys(postsTable.Constraints)) t.Logf("Available constraints: %v", getKeys(postsTable.Constraints))
} }
// Test unique index naming: uidx_table_columns // Test unique index naming: ukey_table_columns, matching the unique constraint name
if _, exists := postsTable.Indexes["uidx_posts_slug"]; !exists { if _, exists := postsTable.Indexes["ukey_posts_slug"]; !exists {
t.Error("Expected unique index 'uidx_posts_slug' not found") t.Error("Expected unique index 'ukey_posts_slug' not found")
t.Logf("Available indexes: %v", getKeys(postsTable.Indexes)) t.Logf("Available indexes: %v", getKeys(postsTable.Indexes))
} }
@@ -1121,3 +1122,69 @@ func TestReader_DuplicateIndexNameInTableKept(t *testing.T) {
} }
} }
} }
func TestReader_ForeignKeyDeferral(t *testing.T) {
dbml := `Table users {
id integer [pk]
}
Table posts {
id integer [pk]
user_id integer [ref: > users.id, deferrable, initially deferred]
editor_id integer
plain_id integer [ref: > users.id]
}
Table comments {
id integer [pk]
post_id integer
author_id integer
}
Ref: comments.post_id > posts.id [delete: cascade, deferrable]
Ref: comments.author_id > users.id [deferrable, initially deferred]
`
path := filepath.Join(t.TempDir(), "deferral.dbml")
if err := os.WriteFile(path, []byte(dbml), 0o600); err != nil {
t.Fatal(err)
}
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
if err != nil {
t.Fatalf("ReadDatabase() error = %v", err)
}
find := func(table, column string) *models.Constraint {
for _, s := range db.Schemas {
for _, tb := range s.Tables {
if tb.Name != table {
continue
}
for _, c := range tb.Constraints {
if c.Type == models.ForeignKeyConstraint && len(c.Columns) == 1 && c.Columns[0] == column {
return c
}
}
}
}
t.Fatalf("FK %s.%s not found", table, column)
return nil
}
tests := []struct {
table, column string
want string
}{
{"posts", "user_id", "DEFERRABLE INITIALLY DEFERRED"},
{"posts", "plain_id", ""},
{"comments", "post_id", "DEFERRABLE"},
{"comments", "author_id", "DEFERRABLE INITIALLY DEFERRED"},
}
for _, tt := range tests {
if got := find(tt.table, tt.column).DeferralClause(); got != tt.want {
t.Errorf("%s.%s deferral = %q, want %q", tt.table, tt.column, got, tt.want)
}
}
if got := find("comments", "post_id").OnDelete; !strings.EqualFold(got, "cascade") {
t.Errorf("delete action lost, got %q", got)
}
}
+12 -2
View File
@@ -439,7 +439,9 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
fcl.relname AS referenced_table_name, fcl.relname AS referenced_table_name,
fatt.attname AS referenced_column, fatt.attname AS referenced_column,
` + actionCase("con.confupdtype") + ` AS update_rule, ` + actionCase("con.confupdtype") + ` AS update_rule,
` + actionCase("con.confdeltype") + ` AS delete_rule ` + actionCase("con.confdeltype") + ` AS delete_rule,
con.condeferrable AS deferrable,
con.condeferred AS initially_deferred
FROM pg_catalog.pg_constraint con FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid
JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace JOIN pg_catalog.pg_namespace ns ON ns.oid = cl.relnamespace
@@ -470,6 +472,8 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
referencedColumns []string referencedColumns []string
updateRule string updateRule string
deleteRule string deleteRule string
deferrable bool
initiallyDeferred bool
} }
fkMap := make(map[string]*fkData) fkMap := make(map[string]*fkData)
@@ -479,8 +483,9 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
var foreignSchema, foreignTable, foreignColumn string var foreignSchema, foreignTable, foreignColumn string
var referencedSchema, referencedTable, referencedColumn string var referencedSchema, referencedTable, referencedColumn string
var updateRule, deleteRule string var updateRule, deleteRule string
var deferrable, initiallyDeferred bool
if err := rows.Scan(&schema, &tableName, &constraintName, &foreignSchema, &foreignTable, &foreignColumn, &referencedSchema, &referencedTable, &referencedColumn, &updateRule, &deleteRule); err != nil { if err := rows.Scan(&schema, &tableName, &constraintName, &foreignSchema, &foreignTable, &foreignColumn, &referencedSchema, &referencedTable, &referencedColumn, &updateRule, &deleteRule, &deferrable, &initiallyDeferred); err != nil {
return nil, err return nil, err
} }
@@ -497,6 +502,8 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
referencedColumns: []string{}, referencedColumns: []string{},
updateRule: updateRule, updateRule: updateRule,
deleteRule: deleteRule, deleteRule: deleteRule,
deferrable: deferrable,
initiallyDeferred: initiallyDeferred,
} }
} }
@@ -517,6 +524,9 @@ func (r *Reader) queryForeignKeys(schemaName string) (map[string][]*models.Const
constraint.ReferencedColumns = fk.referencedColumns constraint.ReferencedColumns = fk.referencedColumns
constraint.OnUpdate = fk.updateRule constraint.OnUpdate = fk.updateRule
constraint.OnDelete = fk.deleteRule constraint.OnDelete = fk.deleteRule
deferrable, initiallyDeferred := fk.deferrable, fk.initiallyDeferred
constraint.Deferrable = &deferrable
constraint.InitiallyDeferred = &initiallyDeferred
tableKey := fk.schema + "." + fk.tableName tableKey := fk.schema + "." + fk.tableName
foreignKeys[tableKey] = append(foreignKeys[tableKey], constraint) foreignKeys[tableKey] = append(foreignKeys[tableKey], constraint)
+7
View File
@@ -273,6 +273,13 @@ func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string
actions = append(actions, fmt.Sprintf("update: %s", c.OnUpdate)) 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) refLine := fmt.Sprintf("Ref: %s %s %s", fromRef, relationship, toRef)
if len(actions) > 0 { if len(actions) > 0 {
refLine += fmt.Sprintf(" [%s]", strings.Join(actions, ", ")) refLine += fmt.Sprintf(" [%s]", strings.Join(actions, ", "))
+28
View File
@@ -171,3 +171,31 @@ func TestWriter_WriteDatabase_OneToOneRelationship(t *testing.T) {
assert.Contains(t, output, "Ref: public.profiles.user_id - public.users.id") 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]")
}
+13 -13
View File
@@ -8,8 +8,8 @@ Standardized naming rules for all database objects in RelSpec PostgreSQL output.
| ----------------- | ----------- | ---------------------------------- | ------------------------ | | ----------------- | ----------- | ---------------------------------- | ------------------------ |
| Primary Key | `pk_` | `pk_<schema>_<table>` | `pk_public_users` | | Primary Key | `pk_` | `pk_<schema>_<table>` | `pk_public_users` |
| Foreign Key | `fk_` | `fk_<table>_<referenced_table>` | `fk_posts_users` | | Foreign Key | `fk_` | `fk_<table>_<referenced_table>` | `fk_posts_users` |
| Unique Constraint | `uk_` | `uk_<table>_<column>` | `uk_users_email` | | Unique Constraint | `ukey_` | `ukey_<table>_<column>` | `ukey_users_email` |
| Unique Index | `uidx_` | `uidx_<table>_<column>` | `uidx_users_email` | | Unique Index | `ukey_` | `ukey_<table>_<column>` | `ukey_users_email` |
| Regular Index | `idx_` | `idx_<table>_<column>` | `idx_posts_user_id` | | Regular Index | `idx_` | `idx_<table>_<column>` | `idx_posts_user_id` |
| Check Constraint | `chk_` | `chk_<table>_<constraint_purpose>` | `chk_users_age_positive` | | Check Constraint | `chk_` | `chk_<table>_<constraint_purpose>` | `chk_users_age_positive` |
| Sequence | `identity_` | `identity_<table>_<column>` | `identity_users_id` | | 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 ### 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 - Include column name for clarity
- Examples: - Examples:
- `uk_users_email` - `ukey_users_email`
- `uk_users_username` - `ukey_users_username`
- `uk_products_sku` - `ukey_products_sku`
### Unique Indexes ### Unique Indexes
**Pattern:** `uidx_<table>_<column>` **Pattern:** `ukey_<table>_<column>`
- Use `uidx_` prefix strictly for index type objects - Unnamed unique indexes use the same name as a unique constraint on the same columns
- Distinguished from constraints for clarity and implementation flexibility - Writers then build the constraint on the index (`ADD CONSTRAINT ... UNIQUE USING INDEX`), so no duplicate index is created
- Examples: - Examples:
- `uidx_users_email` - `ukey_users_email`
- `uidx_sessions_token` - `ukey_sessions_token`
- `uidx_api_keys_key` - `ukey_api_keys_key`
### Regular Indexes ### Regular Indexes
+23
View File
@@ -154,6 +154,29 @@ When `migration_mode` is enabled, generates migration scripts with:
- Transactional DDL - Transactional DDL
- Rollback support - 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 ### Audit Triggers
When `include_audit` is enabled, adds: When `include_audit` is enabled, adds:
+103
View File
@@ -222,6 +222,13 @@ func (w *MigrationWriter) generateSchemaScripts(model, current *models.Schema) (
} }
scripts = append(scripts, indexScripts...) 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) // Phase 5: Create foreign keys (Priority 195)
fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs) fkScripts, err := w.generateForeignKeyScripts(model, current, droppedFKs)
if err != nil { if err != nil {
@@ -751,6 +758,92 @@ func buildIndexColumnExpressionsFiltered(table *models.Table, index *models.Inde
return columnExprs 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. // generateForeignKeyScripts generates ADD CONSTRAINT FOREIGN KEY scripts using templates.
// forceRecreate is a set of FK constraint keys (schema.table.name) that must be recreated // 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. // 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) 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{ sql, err := w.executor.ExecuteCreateForeignKey(CreateForeignKeyData{
SchemaName: model.Name, SchemaName: model.Name,
TableName: modelTable.Name, TableName: modelTable.Name,
@@ -810,6 +909,7 @@ func (w *MigrationWriter) generateForeignKeyScripts(model, current *models.Schem
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "), TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
OnDelete: onDelete, OnDelete: onDelete,
OnUpdate: onUpdate, OnUpdate: onUpdate,
Deferral: deferral,
}) })
if err != nil { if err != nil {
return nil, err 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) { if !fkActionsEqual(c1.OnDelete, c2.OnDelete) || !fkActionsEqual(c1.OnUpdate, c2.OnUpdate) {
return false return false
} }
if !models.DeferralMatches(c1, c2) {
return false
}
} }
return true return true
+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())
}
}
+3 -1
View File
@@ -164,6 +164,7 @@ type CreateForeignKeyData struct {
TargetColumns string TargetColumns string
OnDelete string OnDelete string
OnUpdate string OnUpdate string
Deferral string // DEFERRABLE / DEFERRABLE INITIALLY DEFERRED / NOT DEFERRABLE, or empty
} }
// DropConstraintData contains data for drop constraint template // DropConstraintData contains data for drop constraint template
@@ -238,6 +239,7 @@ type CreateUniqueConstraintData struct {
TableName string TableName string
ConstraintName string ConstraintName string
Columns 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 // CreateCheckConstraintData contains data for create check constraint template
@@ -259,7 +261,7 @@ type CreateForeignKeyWithCheckData struct {
TargetColumns string TargetColumns string
OnDelete string OnDelete string
OnUpdate string OnUpdate string
Deferrable bool Deferral string // DEFERRABLE / DEFERRABLE INITIALLY DEFERRED / NOT DEFERRABLE, or empty
} }
// SetSequenceValueData contains data for set sequence value template // SetSequenceValueData contains data for set sequence value template
@@ -7,4 +7,4 @@ ALTER TABLE {{qual_table .SchemaName .TableName}}
REFERENCES {{qual_table .TargetSchema .TargetTable}} ({{.TargetColumns}}) REFERENCES {{qual_table .TargetSchema .TargetTable}} ({{.TargetColumns}})
ON DELETE {{.OnDelete}} ON DELETE {{.OnDelete}}
ON UPDATE {{.OnUpdate}} ON UPDATE {{.OnUpdate}}
DEFERRABLE; {{.Deferral}};
@@ -11,8 +11,8 @@ BEGIN
FOREIGN KEY ({{.SourceColumns}}) FOREIGN KEY ({{.SourceColumns}})
REFERENCES {{qual_table .TargetSchema .TargetTable}} ({{.TargetColumns}}) REFERENCES {{qual_table .TargetSchema .TargetTable}} ({{.TargetColumns}})
ON DELETE {{.OnDelete}} ON DELETE {{.OnDelete}}
ON UPDATE {{.OnUpdate}}{{if .Deferrable}} ON UPDATE {{.OnUpdate}}{{if .Deferral}}
DEFERRABLE{{end}}; {{.Deferral}}{{end}};
END IF; END IF;
END; END;
$$; $$;
@@ -6,7 +6,7 @@ BEGIN
AND table_name = '{{.TableName}}' AND table_name = '{{.TableName}}'
AND constraint_name = '{{.ConstraintName}}' AND constraint_name = '{{.ConstraintName}}'
) THEN ) 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 IF;
END; END;
$$; $$;
+6 -4
View File
@@ -345,6 +345,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
TableName: table.Name, TableName: table.Name,
ConstraintName: constraint.Name, ConstraintName: constraint.Name,
Columns: strings.Join(constraint.Columns, ", "), Columns: strings.Join(constraint.Columns, ", "),
UsingIndex: adoptableUniqueIndex(table, constraint),
} }
stmt, err := w.executor.ExecuteCreateUniqueConstraint(data) stmt, err := w.executor.ExecuteCreateUniqueConstraint(data)
@@ -411,7 +412,7 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
TargetColumns: strings.Join(constraint.ReferencedColumns, ", "), TargetColumns: strings.Join(constraint.ReferencedColumns, ", "),
OnDelete: onDelete, OnDelete: onDelete,
OnUpdate: onUpdate, OnUpdate: onUpdate,
Deferrable: false, Deferral: constraint.DeferralClause(),
} }
stmt, err := w.executor.ExecuteCreateForeignKeyWithCheck(data) stmt, err := w.executor.ExecuteCreateForeignKeyWithCheck(data)
@@ -1150,7 +1151,7 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
if indexName == "" { if indexName == "" {
indexType := "idx" indexType := "idx"
if index.Unique { if index.Unique {
indexType = "uidx" indexType = "ukey"
} }
columnSuffix := strings.Join(index.Columns, "_") columnSuffix := strings.Join(index.Columns, "_")
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.SQLName(), strings.ToLower(columnSuffix)) 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, TableName: table.Name,
ConstraintName: constraint.Name, ConstraintName: constraint.Name,
Columns: strings.Join(columnExprs, ", "), Columns: strings.Join(columnExprs, ", "),
UsingIndex: adoptableUniqueIndex(table, constraint),
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to generate unique constraint: %w", err) 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, ", "), TargetColumns: strings.Join(targetColumns, ", "),
OnDelete: onDelete, OnDelete: onDelete,
OnUpdate: onUpdate, OnUpdate: onUpdate,
Deferrable: true, Deferral: "DEFERRABLE",
} }
sql, err := w.executor.ExecuteCreateForeignKeyWithCheck(data) sql, err := w.executor.ExecuteCreateForeignKeyWithCheck(data)
if err != nil { if err != nil {
@@ -1459,7 +1461,7 @@ func (w *Writer) writeForeignKeys(schema *models.Schema) error {
TargetColumns: strings.Join(targetColumns, ", "), TargetColumns: strings.Join(targetColumns, ", "),
OnDelete: onDelete, OnDelete: onDelete,
OnUpdate: onUpdate, OnUpdate: onUpdate,
Deferrable: false, Deferral: constraint.DeferralClause(),
} }
sql, err := w.executor.ExecuteCreateForeignKeyWithCheck(data) sql, err := w.executor.ExecuteCreateForeignKeyWithCheck(data)
if err != nil { if err != nil {