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:
@@ -86,16 +86,40 @@ Ref: posts.user_id > users.id [delete: cascade]
|
||||
- Table definitions with columns
|
||||
- Primary keys (`pk`)
|
||||
- Not null constraints (`not null`)
|
||||
- Unique constraints (`unique`)
|
||||
- Unique constraints (`unique`) → constraint `ukey_<table>_<col>`
|
||||
- Default values (`default`)
|
||||
- Inline references (`ref`)
|
||||
- Standalone `Ref` blocks
|
||||
- FK deferral: `deferrable`, `initially deferred` (see below)
|
||||
- Commented cross-file refs (`// Ref:` — see below)
|
||||
- Indexes and composite indexes
|
||||
- Table notes and column notes
|
||||
- Enums
|
||||
- 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
|
||||
|
||||
`// Ref:` / `// ref:` lines (ignored by dbdiagram) become FKs + relationships once both ends are loaded.
|
||||
|
||||
@@ -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
|
||||
if inlineComment != "" {
|
||||
column.Comment = inlineComment
|
||||
@@ -1081,11 +1088,13 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index {
|
||||
|
||||
// Generate name if not provided
|
||||
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 {
|
||||
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
|
||||
@@ -1182,6 +1191,8 @@ func (r *Reader) parseRef(refStr string) *models.Constraint {
|
||||
constraint.OnDelete = strings.TrimSpace(strings.TrimPrefix(action, "ondelete:"))
|
||||
} else if strings.HasPrefix(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
|
||||
}
|
||||
|
||||
// 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"
|
||||
func (r *Reader) parseTableRef(ref string) (schema, table string, columns []string) {
|
||||
// Extract columns if present in parentheses format
|
||||
|
||||
@@ -3,6 +3,7 @@ package dbml
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -842,9 +843,9 @@ func TestConstraintNaming(t *testing.T) {
|
||||
t.Logf("Available constraints: %v", getKeys(postsTable.Constraints))
|
||||
}
|
||||
|
||||
// Test unique index naming: uidx_table_columns
|
||||
if _, exists := postsTable.Indexes["uidx_posts_slug"]; !exists {
|
||||
t.Error("Expected unique index 'uidx_posts_slug' not found")
|
||||
// Test unique index naming: ukey_table_columns, matching the unique constraint name
|
||||
if _, exists := postsTable.Indexes["ukey_posts_slug"]; !exists {
|
||||
t.Error("Expected unique index 'ukey_posts_slug' not found")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user