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) } } }