Commented refs are collected per file and resolved against the combined model after all inputs are loaded (directory, --from-list, merge, jobs). Matched refs become FKs and relationships; duplicates of existing FKs are skipped; missing targets are skipped with a warning; column type mismatches warn. Also keep reused index names within a DBML table instead of overwriting, give a second FK to the same table a distinct relationship name, and make the pgsql writer match relationships to FKs by name first.
261 lines
7.7 KiB
Go
261 lines
7.7 KiB
Go
package dbml
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
|
)
|
|
|
|
func readDBMLString(t *testing.T, content string) *models.Database {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "in.dbml")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
|
|
if err != nil {
|
|
t.Fatalf("ReadDatabase() error = %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func findTable(db *models.Database, schema, table string) *models.Table {
|
|
for _, s := range db.Schemas {
|
|
if s.Name != schema {
|
|
continue
|
|
}
|
|
for _, t := range s.Tables {
|
|
if t.Name == table {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func fkOn(table *models.Table, col string) *models.Constraint {
|
|
for _, c := range table.Constraints {
|
|
if c.Type == models.ForeignKeyConstraint && len(c.Columns) == 1 && c.Columns[0] == col {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func relFor(table *models.Table, fkName string) *models.Relationship {
|
|
for _, r := range table.Relationships {
|
|
if r.ForeignKey == fkName {
|
|
return r
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestCommentedRef(t *testing.T) {
|
|
tests := []struct {
|
|
line string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{`// Ref: a.b.c > d.e.f`, `a.b.c > d.e.f`, true},
|
|
{`// ref: a.b.c - d.e.f`, `a.b.c - d.e.f`, true},
|
|
{`//Ref:a.b.c > d.e.f`, `a.b.c > d.e.f`, true},
|
|
{`// Reference notes`, "", false},
|
|
{`// see Ref: a.b.c > d.e.f`, "", false},
|
|
{`// Ref:`, "", false},
|
|
}
|
|
for _, tt := range tests {
|
|
got, ok := commentedRef(tt.line)
|
|
if ok != tt.ok || got != tt.want {
|
|
t.Errorf("commentedRef(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A commented ref whose tables are in the same file resolves on read.
|
|
func TestReader_CommentedRefSameFile(t *testing.T) {
|
|
db := readDBMLString(t, `Table "org"."department" {
|
|
"id_department" bigserial [pk]
|
|
}
|
|
Table "entity"."employee" {
|
|
"id_employee" bigserial [pk]
|
|
"rid_department" bigint
|
|
}
|
|
// Ref: "entity"."employee"."rid_department" > "org"."department"."id_department" [delete: restrict, update: restrict]
|
|
`)
|
|
emp := findTable(db, "entity", "employee")
|
|
fk := fkOn(emp, "rid_department")
|
|
if fk == nil {
|
|
t.Fatal("expected FK on rid_department")
|
|
}
|
|
if fk.ReferencedSchema != "org" || fk.ReferencedTable != "department" || fk.ReferencedColumns[0] != "id_department" {
|
|
t.Errorf("FK target = %s.%s.%v", fk.ReferencedSchema, fk.ReferencedTable, fk.ReferencedColumns)
|
|
}
|
|
if fk.OnDelete != "restrict" || fk.OnUpdate != "restrict" {
|
|
t.Errorf("FK actions = %q/%q, want restrict/restrict", fk.OnDelete, fk.OnUpdate)
|
|
}
|
|
if relFor(emp, fk.Name) == nil {
|
|
t.Error("expected relationship for FK")
|
|
}
|
|
if refs := PendingCommentedRefs(db); len(refs) != 0 {
|
|
t.Errorf("pending = %v, want none", refs)
|
|
}
|
|
}
|
|
|
|
// A cross-file commented ref stays pending after a single-file read.
|
|
func TestReader_CommentedRefCrossFilePending(t *testing.T) {
|
|
db := readDBMLString(t, `Table "entity"."employee" {
|
|
"id_employee" bigserial [pk]
|
|
"rid_department" bigint
|
|
}
|
|
// Ref: "entity"."employee"."rid_department" > "org"."department"."id_department"
|
|
`)
|
|
if fk := fkOn(findTable(db, "entity", "employee"), "rid_department"); fk != nil {
|
|
t.Fatal("FK must not resolve without the target table")
|
|
}
|
|
if refs := PendingCommentedRefs(db); len(refs) != 1 {
|
|
t.Fatalf("pending = %v, want 1", refs)
|
|
}
|
|
}
|
|
|
|
// Directory reads resolve commented refs after all files are merged.
|
|
func TestReader_CommentedRefDirectory(t *testing.T) {
|
|
db, err := NewReader(&readers.ReaderOptions{
|
|
FilePath: filepath.Join("..", "..", "..", "tests", "assets", "dbml", "multifile"),
|
|
}).ReadDatabase()
|
|
if err != nil {
|
|
t.Fatalf("ReadDatabase() error = %v", err)
|
|
}
|
|
fk := fkOn(findTable(db, "public", "posts"), "user_id")
|
|
if fk == nil {
|
|
t.Fatal("expected FK posts.user_id from 9_refs.dbml commented ref")
|
|
}
|
|
if fk.ReferencedTable != "users" || fk.OnDelete != "CASCADE" {
|
|
t.Errorf("FK = %s ondelete %s, want users ondelete CASCADE", fk.ReferencedTable, fk.OnDelete)
|
|
}
|
|
}
|
|
|
|
func crossFileDB(t *testing.T, refs ...string) *models.Database {
|
|
t.Helper()
|
|
db := readDBMLString(t, `Table "org"."department" {
|
|
"id_department" bigserial [pk]
|
|
}
|
|
Table "entity"."employee" {
|
|
"id_employee" bigserial [pk]
|
|
"rid_department" bigint
|
|
"rid_manager" integer
|
|
"rid_team" bigint
|
|
}
|
|
`)
|
|
setPendingCommentedRefs(db, refs)
|
|
return db
|
|
}
|
|
|
|
func TestResolveCommentedRefs(t *testing.T) {
|
|
t.Run("lowercase ref and one-to-one", func(t *testing.T) {
|
|
db := crossFileDB(t,
|
|
`"entity"."employee"."rid_department" > "org"."department"."id_department"`,
|
|
`entity.employee.rid_team - org.department.id_department`,
|
|
)
|
|
if w := ResolveCommentedRefs(db, true); len(w) != 0 {
|
|
t.Errorf("warnings = %v, want none", w)
|
|
}
|
|
emp := findTable(db, "entity", "employee")
|
|
for _, col := range []string{"rid_department", "rid_team"} {
|
|
fk := fkOn(emp, col)
|
|
if fk == nil {
|
|
t.Fatalf("expected FK on %s", col)
|
|
}
|
|
if relFor(emp, fk.Name) == nil {
|
|
t.Errorf("expected relationship for %s", fk.Name)
|
|
}
|
|
}
|
|
if len(emp.Relationships) != 2 {
|
|
t.Errorf("relationships = %d, want 2 (same target must not overwrite)", len(emp.Relationships))
|
|
}
|
|
})
|
|
|
|
t.Run("type mismatch warns but resolves", func(t *testing.T) {
|
|
db := crossFileDB(t, `entity.employee.rid_manager > org.department.id_department`)
|
|
w := ResolveCommentedRefs(db, true)
|
|
if len(w) != 1 || !strings.Contains(w[0], "type mismatch") {
|
|
t.Errorf("warnings = %v, want one type mismatch", w)
|
|
}
|
|
if fkOn(findTable(db, "entity", "employee"), "rid_manager") == nil {
|
|
t.Error("expected FK on rid_manager")
|
|
}
|
|
})
|
|
|
|
t.Run("missing target stays pending until final", func(t *testing.T) {
|
|
db := crossFileDB(t, `entity.employee.rid_team > hr.team.id_team`)
|
|
if w := ResolveCommentedRefs(db, false); len(w) != 0 {
|
|
t.Errorf("non-final warnings = %v, want none", w)
|
|
}
|
|
if len(PendingCommentedRefs(db)) != 1 {
|
|
t.Fatal("ref should stay pending")
|
|
}
|
|
w := ResolveCommentedRefs(db, true)
|
|
if len(w) != 1 || !strings.Contains(w[0], "table hr.team not found") {
|
|
t.Errorf("final warnings = %v, want missing table", w)
|
|
}
|
|
if len(PendingCommentedRefs(db)) != 0 {
|
|
t.Error("final pass must clear pending refs")
|
|
}
|
|
})
|
|
|
|
t.Run("missing column", func(t *testing.T) {
|
|
db := crossFileDB(t, `entity.employee.rid_nope > org.department.id_department`)
|
|
w := ResolveCommentedRefs(db, true)
|
|
if len(w) != 1 || !strings.Contains(w[0], "column entity.employee.rid_nope not found") {
|
|
t.Errorf("warnings = %v, want missing column", w)
|
|
}
|
|
})
|
|
|
|
t.Run("deduplicates against uncommented ref", func(t *testing.T) {
|
|
db := readDBMLString(t, `Table "org"."department" {
|
|
"id_department" bigserial [pk]
|
|
}
|
|
Table "entity"."employee" {
|
|
"id_employee" bigserial [pk]
|
|
"rid_department" bigint
|
|
}
|
|
Ref: "entity"."employee"."rid_department" > "org"."department"."id_department"
|
|
// Ref: "entity"."employee"."rid_department" > "org"."department"."id_department"
|
|
`)
|
|
emp := findTable(db, "entity", "employee")
|
|
count := 0
|
|
for _, c := range emp.Constraints {
|
|
if c.Type == models.ForeignKeyConstraint {
|
|
count++
|
|
}
|
|
}
|
|
if count != 1 || len(emp.Relationships) != 1 {
|
|
t.Errorf("FKs = %d, relationships = %d, want 1 and 1", count, len(emp.Relationships))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCompatibleFKTypes(t *testing.T) {
|
|
tests := []struct {
|
|
a, b string
|
|
want bool
|
|
}{
|
|
{"bigint", "bigserial", true},
|
|
{"integer", "serial", true},
|
|
{"INT4", "integer", true},
|
|
{"varchar(10)", "varchar(20)", true},
|
|
{"bigint", "serial", false},
|
|
{"uuid", "bigint", false},
|
|
{"", "bigint", true},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := compatibleFKTypes(tt.a, tt.b); got != tt.want {
|
|
t.Errorf("compatibleFKTypes(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
|
}
|
|
}
|
|
}
|