test: expand coverage across readers, writers, cmd, ui, diff and merge

Implements tests/_plans and previously deferred packages; updates plan
README with new coverage numbers.
This commit is contained in:
2026-10-03 21:33:59 +02:00
parent a32647ee16
commit 495a21b67b
50 changed files with 8461 additions and 8 deletions
+105
View File
@@ -0,0 +1,105 @@
package bun
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
)
func newTestReader() *Reader { return NewReader(&readers.ReaderOptions{}) }
func mustExpr(t *testing.T, src string) ast.Expr {
t.Helper()
e, err := parser.ParseExpr(src)
if err != nil {
t.Fatal(err)
}
return e
}
func TestGoTypeToSQL(t *testing.T) {
r := newTestReader()
tests := []struct{ src, want string }{
{"int", "integer"}, {"int32", "integer"}, {"int64", "bigint"},
{"string", "text"}, {"bool", "boolean"}, {"float32", "real"},
{"float64", "double precision"}, {"uint8", "text"},
{"time.Time", "timestamp"}, {"time.Duration", "text"},
{"sql_types.SqlString", "text"}, {"sql_types.SqlInt", "integer"},
{"sql_types.SqlInt64", "bigint"}, {"sql_types.SqlFloat", "double precision"},
{"sql_types.SqlBool", "boolean"}, {"sql_types.SqlTime", "timestamp"},
{"sql_types.Other", "text"}, {"other.Thing", "text"},
{"*int64", "bigint"}, {"*time.Time", "timestamp"}, {"[]byte", "text"},
}
for _, tt := range tests {
t.Run(tt.src, func(t *testing.T) {
if got := r.goTypeToSQL(mustExpr(t, tt.src)); got != tt.want {
t.Errorf("got %q want %q", got, tt.want)
}
})
}
}
func TestDeriveTableName(t *testing.T) {
r := newTestReader()
for in, want := range map[string]string{
"ModelUser": "user",
"ModelUserRole": "user_role",
"Account": "account",
"OrderItem": "order_item",
} {
if got := r.deriveTableName(in); got != want {
t.Errorf("%q: got %q want %q", in, got, want)
}
}
}
func TestGetReceiverType(t *testing.T) {
r := newTestReader()
for src, want := range map[string]string{"User": "User", "*User": "User", "*pkg.User": "", "[]User": ""} {
if got := r.getReceiverType(mustExpr(t, src)); got != want {
t.Errorf("%s: got %q want %q", src, got, want)
}
}
}
func TestGetRelationType(t *testing.T) {
r := newTestReader()
for tag, want := range map[string]string{
`bun:"rel:has-many,join:id=user_id"`: "has-many",
`bun:"rel:belongs-to,join:user_id=id"`: "belongs-to",
`bun:"rel:has-one,join:id=user_id"`: "has-one",
`bun:"rel:many-to-many,join_table:x"`: "many-to-many",
`bun:"rel:unknown"`: "",
`bun:"id,pk"`: "",
} {
if got := r.getRelationType(tag); got != want {
t.Errorf("%s: got %q want %q", tag, got, want)
}
}
}
func TestParseTableNameMethod(t *testing.T) {
r := newTestReader()
parse := func(src string) *ast.FuncDecl {
f, err := parser.ParseFile(token.NewFileSet(), "x.go", "package p\n"+src, 0)
if err != nil {
t.Fatal(err)
}
return f.Decls[0].(*ast.FuncDecl)
}
if tbl, sch := r.parseTableNameMethod(parse(`func (User) TableName() string { return "public.users" }`)); tbl != "users" || sch != "public" {
t.Errorf("qualified: %q %q", tbl, sch)
}
if tbl, sch := r.parseTableNameMethod(parse(`func (User) TableName() string { return "users" }`)); tbl != "users" || sch != "public" {
t.Errorf("plain: %q %q", tbl, sch)
}
if tbl, _ := r.parseTableNameMethod(parse(`func (User) TableName() string`)); tbl != "" {
t.Errorf("no body: %q", tbl)
}
if tbl, _ := r.parseTableNameMethod(parse(`func (User) TableName() string { x := 1; _ = x; return foo() }`)); tbl != "" {
t.Errorf("non-literal: %q", tbl)
}
}
+114
View File
@@ -0,0 +1,114 @@
package drizzle
import (
"os"
"path/filepath"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
)
const fixture = "../../../tests/assets/drizzle/schema.ts"
func readFile(t *testing.T, path string) *models.Database {
t.Helper()
db, err := NewReader(&readers.ReaderOptions{FilePath: path}).ReadDatabase()
if err != nil {
t.Fatal(err)
}
return db
}
func findTable(db *models.Database, name string) *models.Table {
for _, s := range db.Schemas {
for _, tb := range s.Tables {
if tb.Name == name {
return tb
}
}
}
return nil
}
func TestReadFixture(t *testing.T) {
db := readFile(t, fixture)
if len(db.Schemas) == 0 || len(db.Schemas[0].Tables) == 0 {
t.Fatal("expected tables")
}
if len(db.Schemas[0].Enums) != 1 || db.Schemas[0].Enums[0].Name != "Role" {
t.Fatalf("enums = %+v", db.Schemas[0].Enums)
}
var found bool
for _, tb := range db.Schemas[0].Tables {
if c, ok := tb.Columns["role"]; ok {
found = true
if c.Type != "Role" {
t.Errorf("role type = %q", c.Type)
}
}
for n := range tb.Columns {
if n == "profile" {
t.Errorf("relation field leaked as column in %s", tb.Name)
}
}
}
if !found {
t.Error("no role column")
}
}
func TestEnumColumnSyntax(t *testing.T) {
tests := []struct {
name string
src string
}{
{"enum constant", "export const role = pgEnum('Role', ['A','B']);\nexport const users = pgTable('users', {\n role: role('role').notNull(),\n});\n"},
{"legacy", "export const role = pgEnum('Role', ['A','B']);\nexport const users = pgTable('users', {\n role: pgEnum('Role')('role').notNull(),\n});\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := filepath.Join(t.TempDir(), "s.ts")
if err := os.WriteFile(p, []byte(tt.src), 0o644); err != nil {
t.Fatal(err)
}
tb := findTable(readFile(t, p), "users")
if tb == nil {
t.Fatal("users missing")
}
c := tb.Columns["role"]
if c == nil || c.Type != "Role" || !c.NotNull {
t.Errorf("column = %+v", c)
}
})
}
}
func TestReadDirectorySeparateEnums(t *testing.T) {
dir := t.TempDir()
files := map[string]string{
"enums.ts": "export const status = pgEnum('Status', ['on','off']);\n",
"tables.ts": "export const items = pgTable('items', {\n status: status('status'),\n});\n",
}
for n, c := range files {
if err := os.WriteFile(filepath.Join(dir, n), []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
tb := findTable(readFile(t, dir), "items")
if tb == nil {
t.Fatal("items missing")
}
if c := tb.Columns["status"]; c == nil || c.Type != "Status" {
t.Errorf("column = %+v", c)
}
}
func TestReaderErrors(t *testing.T) {
if _, err := NewReader(&readers.ReaderOptions{}).ReadDatabase(); err == nil {
t.Error("expected error for empty path")
}
if _, err := NewReader(&readers.ReaderOptions{FilePath: "/nonexistent.ts"}).ReadDatabase(); err == nil {
t.Error("expected error for missing file")
}
}
+152
View File
@@ -0,0 +1,152 @@
package gorm
import (
"go/ast"
"go/parser"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
)
func newTestReader() *Reader { return NewReader(&readers.ReaderOptions{}) }
func mustExpr(t *testing.T, src string) ast.Expr {
t.Helper()
e, err := parser.ParseExpr(src)
if err != nil {
t.Fatal(err)
}
return e
}
func TestGoTypeToSQL(t *testing.T) {
r := newTestReader()
tests := []struct{ src, want string }{
{"int", "integer"}, {"int32", "integer"}, {"int64", "bigint"},
{"string", "text"}, {"bool", "boolean"}, {"float32", "real"},
{"float64", "double precision"}, {"uint8", "text"},
{"time.Time", "timestamp"}, {"time.Duration", "text"},
{"sql_types.SqlString", "text"}, {"sql_types.SqlInt", "integer"},
{"sql_types.SqlInt64", "bigint"}, {"sql_types.SqlFloat", "double precision"},
{"sql_types.SqlBool", "boolean"}, {"sql_types.SqlTime", "timestamp"},
{"sql_types.Other", "text"}, {"other.Thing", "text"},
{"*int64", "bigint"}, {"*time.Time", "timestamp"}, {"[]byte", "text"},
}
for _, tt := range tests {
t.Run(tt.src, func(t *testing.T) {
if got := r.goTypeToSQL(mustExpr(t, tt.src)); got != tt.want {
t.Errorf("got %q want %q", got, tt.want)
}
})
}
}
func TestFieldNameToColumnName(t *testing.T) {
r := newTestReader()
for in, want := range map[string]string{"ID": "i_d", "UserName": "user_name", "name": "name", "": ""} {
if got := r.fieldNameToColumnName(in); got != want {
t.Errorf("%q: got %q want %q", in, got, want)
}
}
}
func TestGetReceiverType(t *testing.T) {
r := newTestReader()
tests := []struct{ src, want string }{
{"User", "User"}, {"*User", "User"}, {"*pkg.User", ""}, {"[]User", ""},
}
for _, tt := range tests {
if got := r.getReceiverType(mustExpr(t, tt.src)); got != tt.want {
t.Errorf("%s: got %q want %q", tt.src, got, tt.want)
}
}
}
func TestIsGORMModel(t *testing.T) {
r := newTestReader()
tests := []struct {
name string
field *ast.Field
want bool
}{
{"embedded gorm.Model", &ast.Field{Type: mustExpr(t, "gorm.Model")}, true},
{"named field", &ast.Field{Names: []*ast.Ident{ast.NewIdent("M")}, Type: mustExpr(t, "gorm.Model")}, false},
{"plain ident", &ast.Field{Type: mustExpr(t, "Model")}, false},
{"other package", &ast.Field{Type: mustExpr(t, "other.Model")}, false},
{"gorm other", &ast.Field{Type: mustExpr(t, "gorm.DB")}, false},
{"non-ident selector base", &ast.Field{Type: mustExpr(t, "a.b.Model")}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := r.isGORMModel(tt.field); got != tt.want {
t.Errorf("got %v want %v", got, tt.want)
}
})
}
}
func TestParseTypeWithReferences(t *testing.T) {
r := newTestReader()
tests := []struct {
in string
base string
length int
refInfo string
}{
{"bigint", "bigint", 0, ""},
{"varchar(50)", "varchar", 50, ""},
{"bigint references mainaccount(id) ON DELETE CASCADE", "bigint", 0, "mainaccount(id) ON DELETE CASCADE"},
{"varchar(20) REFERENCES t(c)", "varchar", 20, "t(c)"},
}
for _, tt := range tests {
base, length, ref := r.parseTypeWithReferences(tt.in)
if base != tt.base || length != tt.length || ref != tt.refInfo {
t.Errorf("%q: got (%q,%d,%q)", tt.in, base, length, ref)
}
}
}
func TestCreateInlineReferenceConstraint(t *testing.T) {
tests := []struct {
name string
ref string
wantNone bool
schema string
table string
col string
onDelete string
onUpdate string
}{
{"simple", "accounts(id)", false, "public", "accounts", "id", "NO ACTION", "NO ACTION"},
{"schema qualified", "billing.accounts(id)", false, "billing", "accounts", "id", "NO ACTION", "NO ACTION"},
{"cascade restrict", "accounts(id) ON DELETE CASCADE ON UPDATE RESTRICT", false, "public", "accounts", "id", "CASCADE", "RESTRICT"},
{"set null no action", "accounts(id) on delete set null on update no action", false, "public", "accounts", "id", "SET NULL", "NO ACTION"},
{"restrict delete cascade update", "accounts(id) ON DELETE RESTRICT ON UPDATE CASCADE", false, "public", "accounts", "id", "RESTRICT", "CASCADE"},
{"update set null", "accounts(id) ON DELETE NO ACTION ON UPDATE SET NULL", false, "public", "accounts", "id", "NO ACTION", "SET NULL"},
{"no parens", "accounts", true, "", "", "", "", ""},
{"reversed parens", "accounts)id(", true, "", "", "", "", ""},
}
r := newTestReader()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
table := models.InitTable("orders", "public")
col := models.InitColumn("account_id", "orders", "public")
r.createInlineReferenceConstraint(table, col, tt.ref)
if tt.wantNone {
if len(table.Constraints) != 0 {
t.Fatalf("unexpected constraints: %v", table.Constraints)
}
return
}
c := table.Constraints["fk_orders_account_id"]
if c == nil {
t.Fatal("constraint missing")
}
if c.ReferencedSchema != tt.schema || c.ReferencedTable != tt.table ||
c.ReferencedColumns[0] != tt.col || c.OnDelete != tt.onDelete || c.OnUpdate != tt.onUpdate {
t.Errorf("constraint = %+v", c)
}
})
}
}
+113
View File
@@ -0,0 +1,113 @@
package pgsql
import (
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
func TestNormalizePostgresDefault(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"function", "now()", "now()"},
{"nextval passthrough", "nextval('seq'::regclass)", "nextval('seq'::regclass)"},
{"number", "42", "42"},
{"null cast", "NULL::text", "NULL::text"},
{"quoted literal", "'abc'", "abc"},
{"quoted with cast", "'abc'::character varying", "abc"},
{"escaped quote", "'it''s'::text", "it's"},
{"empty literal", "''::text", ""},
{"only escaped quotes", "''''", "'"},
{"unterminated", "'abc", "abc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizePostgresDefault(tt.in); got != tt.want {
t.Errorf("normalizePostgresDefault(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestCountHelpers(t *testing.T) {
cols := map[string]map[string]*models.Column{
"a": {"x": {}, "y": {}},
"b": {"z": {}},
"c": {},
}
if got := countColumns(cols); got != 3 {
t.Errorf("countColumns = %d, want 3", got)
}
if got := countColumns(nil); got != 0 {
t.Errorf("countColumns(nil) = %d, want 0", got)
}
cons := map[string][]*models.Constraint{"a": {{}, {}}, "b": {{}}}
if got := countConstraints(cons); got != 3 {
t.Errorf("countConstraints = %d, want 3", got)
}
if got := countConstraints(nil); got != 0 {
t.Errorf("countConstraints(nil) = %d, want 0", got)
}
idx := map[string][]*models.Index{"a": {{}}, "b": {{}, {}, {}}}
if got := countIndexes(idx); got != 4 {
t.Errorf("countIndexes = %d, want 4", got)
}
if got := countIndexes(nil); got != 0 {
t.Errorf("countIndexes(nil) = %d, want 0", got)
}
}
func TestExtractIndexOperatorClass(t *testing.T) {
tests := []struct {
name string
in []string
want string
}{
{"none", nil, ""},
{"sort modifiers only", []string{"DESC", "NULLS", "LAST"}, ""},
{"opclass", []string{"", " Vector_Cosine_Ops "}, "vector_cosine_ops"},
{"opclass after ordering", []string{"desc", "gin_trgm_ops"}, "gin_trgm_ops"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractIndexOperatorClass(tt.in); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestBuildIndexHint(t *testing.T) {
tests := []struct {
opClass, params, want string
}{
{"", "", ""},
{"vector_cosine_ops", "", "opclass=vector_cosine_ops"},
{"", "m=16", "with (m=16)"},
{"vector_cosine_ops", "m=16", "opclass=vector_cosine_ops; with (m=16)"},
}
for _, tt := range tests {
if got := buildIndexHint(tt.opClass, tt.params); got != tt.want {
t.Errorf("buildIndexHint(%q,%q) = %q, want %q", tt.opClass, tt.params, got, tt.want)
}
}
}
func TestNormalizeIndexStorageParams(t *testing.T) {
tests := []struct{ in, want string }{
{"", ""},
{"m='16', ef_construction='64'", "m=16, ef_construction=64"},
{"key_field='id'", "key_field='id'"},
}
for _, tt := range tests {
if got := normalizeIndexStorageParams(tt.in); got != tt.want {
t.Errorf("normalizeIndexStorageParams(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
+348
View File
@@ -0,0 +1,348 @@
package prisma
import (
"os"
"path/filepath"
"strings"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
)
const examplePrisma = "../../../tests/assets/prisma/example.prisma"
func readFixture(t *testing.T) *models.Schema {
t.Helper()
db, err := NewReader(&readers.ReaderOptions{FilePath: examplePrisma}).ReadDatabase()
if err != nil {
t.Fatal(err)
}
return db.Schemas[0]
}
func readSource(t *testing.T, src string) *models.Database {
t.Helper()
p := filepath.Join(t.TempDir(), "schema.prisma")
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
db, err := NewReader(&readers.ReaderOptions{FilePath: p}).ReadDatabase()
if err != nil {
t.Fatal(err)
}
return db
}
func table(s *models.Schema, name string) *models.Table {
for _, tb := range s.Tables {
if tb.Name == name {
return tb
}
}
return nil
}
func TestFixture_NoRelationFieldColumns(t *testing.T) {
s := readFixture(t)
// Relation fields (user, author, posts, profile, categories) are not columns.
for tbl, fields := range map[string][]string{
"User": {"posts", "profile"}, "Profile": {"user"}, "Post": {"author", "categories"}, "Category": {"posts"},
} {
for _, f := range fields {
if _, ok := table(s, tbl).Columns[f]; ok {
t.Errorf("%s.%s is a relation field and must not be a column", tbl, f)
}
}
}
// Enum-typed fields stay columns.
if c := table(s, "User").Columns["role"]; c == nil || c.Type != "Role" || c.Default != "USER" {
t.Errorf("User.role: %+v", c)
}
}
func TestFixture_Structure(t *testing.T) {
s := readFixture(t)
if len(s.Enums) != 1 || s.Enums[0].Name != "Role" || len(s.Enums[0].Values) != 2 {
t.Errorf("enums: %+v", s.Enums)
}
for _, n := range []string{"User", "Profile", "Post", "Category", "_CategoryToPost"} {
if table(s, n) == nil {
t.Errorf("table %s missing", n)
}
}
user := table(s, "User")
if id := user.Columns["id"]; id == nil || !id.IsPrimaryKey || !id.AutoIncrement || id.Type != "integer" {
t.Errorf("User.id: %+v", id)
}
if c := user.Columns["name"]; c == nil || c.NotNull {
t.Errorf("optional name: %+v", c)
}
if uq := user.Constraints["uq_email"]; uq == nil || uq.Columns[0] != "email" {
t.Errorf("unique: %+v", user.Constraints)
}
post := table(s, "Post")
if c := post.Columns["createdAt"]; c == nil || c.Type != "timestamp" || c.Default != "now()" {
t.Errorf("createdAt: %+v", c)
}
if c := post.Columns["updatedAt"]; c == nil || !strings.Contains(c.Comment, "@updatedAt") {
t.Errorf("updatedAt: %+v", c)
}
if c := post.Columns["published"]; c == nil || c.Default != false {
t.Errorf("published default: %+v", c)
}
}
func TestFixture_Relations(t *testing.T) {
s := readFixture(t)
fk := table(s, "Post").Constraints["fk_Post_authorId"]
if fk == nil || fk.Type != models.ForeignKeyConstraint || fk.Columns[0] != "authorId" || fk.ReferencedTable != "User" || fk.ReferencedColumns[0] != "id" {
t.Errorf("Post.author fk: %+v", fk)
}
jt := table(s, "_CategoryToPost")
if len(jt.Columns) != 2 {
t.Fatalf("join columns: %v", jt.Columns)
}
var pk, fks int
for _, c := range jt.Constraints {
switch c.Type {
case models.PrimaryKeyConstraint:
pk++
case models.ForeignKeyConstraint:
fks++
if c.OnDelete != "Cascade" {
t.Errorf("join fk on delete: %q", c.OnDelete)
}
}
}
if pk != 1 || fks != 2 {
t.Errorf("join constraints: pk=%d fks=%d", pk, fks)
}
}
func TestBlockAttributesAndDefaults(t *testing.T) {
db := readSource(t, `datasource db {
provider = "mysql"
}
model Membership {
userId Int
groupId Int
role String @default("member")
alias String @default('x')
score Float @default(1.5)
tag String @default(cuid())
token String @default(uuid())
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, groupId])
@@unique([userId, role])
@@index([groupId])
@@map("memberships")
}
model User {
id Int @id
memberships Membership[]
slug String @unique @default(dbgenerated("abc(1)"))
}
`)
if db.DatabaseType != "mysql" {
t.Errorf("db type: %q", db.DatabaseType)
}
m := table(db.Schemas[0], "Membership")
pk := m.Constraints["pk_Membership"]
if pk == nil || len(pk.Columns) != 2 || !m.Columns["userId"].IsPrimaryKey || !m.Columns["groupId"].NotNull {
t.Errorf("composite pk: %+v", pk)
}
if uq := m.Constraints["uq_Membership_userId_role"]; uq == nil || len(uq.Columns) != 2 {
t.Errorf("composite unique: %+v", m.Constraints)
}
if ix := m.Indexes["idx_Membership_groupId"]; ix == nil || ix.Columns[0] != "groupId" {
t.Errorf("index: %+v", m.Indexes)
}
checks := map[string]any{"role": "member", "alias": "x", "score": "1.5"}
for col, want := range checks {
if got := m.Columns[col].Default; got != want {
t.Errorf("%s default = %#v, want %#v", col, got, want)
}
}
if m.Columns["tag"].Comment != "default(cuid())" {
t.Errorf("cuid comment: %q", m.Columns["tag"].Comment)
}
if m.Columns["token"].Default != "gen_random_uuid()" {
t.Errorf("uuid default: %v", m.Columns["token"].Default)
}
if m.Columns["score"].Type != "double precision" {
t.Errorf("score type: %s", m.Columns["score"].Type)
}
fk := m.Constraints["fk_Membership_userId"]
if fk == nil || fk.OnDelete != "Cascade" || fk.OnUpdate != "Restrict" {
t.Errorf("fk actions: %+v", fk)
}
// Default with nested parentheses is extracted whole.
if got := table(db.Schemas[0], "User").Columns["slug"].Default; got != `dbgenerated("abc(1)")` {
t.Errorf("nested default: %#v", got)
}
}
func TestEnumDeclaredAfterModel(t *testing.T) {
db := readSource(t, `model Account {
id Int @id
status Status @default(ACTIVE)
owner Owner?
}
model Owner {
id Int @id
}
enum Status {
ACTIVE
CLOSED
}
`)
a := table(db.Schemas[0], "Account")
if c := a.Columns["status"]; c == nil || c.Type != "Status" {
t.Errorf("enum column declared before enum: %+v", c)
}
if _, ok := a.Columns["owner"]; ok {
t.Error("model-typed field must not be a column")
}
}
func TestParseDatasourceProviders(t *testing.T) {
r := &Reader{}
tests := []struct {
provider string
want models.DatabaseType
}{
{`"postgresql"`, models.PostgresqlDatabaseType}, {`"postgres"`, models.PostgresqlDatabaseType},
{`"mysql"`, "mysql"}, {`"sqlite"`, models.SqlLiteDatabaseType},
{`"sqlserver"`, models.MSSQLDatabaseType}, {`"cockroachdb"`, models.PostgresqlDatabaseType},
}
for _, tt := range tests {
db := models.InitDatabase("d")
r.parseDatasource([]string{" provider = " + tt.provider}, db)
if db.DatabaseType != tt.want {
t.Errorf("%s -> %q, want %q", tt.provider, db.DatabaseType, tt.want)
}
}
}
func TestParseGenerator(t *testing.T) {
tests := []struct {
name string
lines []string
opts *readers.ReaderOptions
want string
}{
{"js client", []string{`provider = "prisma-client-js"`}, &readers.ReaderOptions{}, "prisma"},
{"new client", []string{`provider = "prisma-client"`}, &readers.ReaderOptions{}, "prisma7"},
{"no provider, flag", []string{`output = "x"`}, &readers.ReaderOptions{Prisma7: true}, "prisma7"},
{"no provider, nil options", nil, nil, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := models.InitDatabase("d")
db.SourceFormat = ""
(&Reader{options: tt.opts}).parseGenerator(tt.lines, db)
if db.SourceFormat != tt.want {
t.Errorf("got %q, want %q", db.SourceFormat, tt.want)
}
})
}
}
func TestPrisma7FlagWithoutGeneratorBlock(t *testing.T) {
p := filepath.Join(t.TempDir(), "s.prisma")
if err := os.WriteFile(p, []byte("model A {\n id Int @id\n}\n"), 0o644); err != nil {
t.Fatal(err)
}
db, err := NewReader(&readers.ReaderOptions{FilePath: p, Prisma7: true}).ReadDatabase()
if err != nil || db.SourceFormat != "prisma7" {
t.Errorf("%v %q", err, db.SourceFormat)
}
}
func TestMetadataNameAndComments(t *testing.T) {
p := filepath.Join(t.TempDir(), "s.prisma")
src := "// leading comment\nmodel A {\n // inner comment\n id Int @id\n}\n"
if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
t.Fatal(err)
}
db, err := NewReader(&readers.ReaderOptions{FilePath: p, Metadata: map[string]any{"name": "shop"}}).ReadDatabase()
if err != nil || db.Name != "shop" || len(db.Schemas[0].Tables[0].Columns) != 1 {
t.Errorf("%v %+v", err, db)
}
}
func TestReadSchemaAndTable(t *testing.T) {
r := NewReader(&readers.ReaderOptions{FilePath: examplePrisma})
s, err := r.ReadSchema()
if err != nil || s.Name != "public" {
t.Fatalf("schema: %v", err)
}
tbl, err := r.ReadTable()
if err != nil || tbl.Name != "User" {
t.Fatalf("table: %v %+v", err, tbl)
}
}
func TestReader_Errors(t *testing.T) {
if _, err := NewReader(&readers.ReaderOptions{}).ReadDatabase(); err == nil || !strings.Contains(err.Error(), "file path is required") {
t.Errorf("empty path: %v", err)
}
if _, err := NewReader(&readers.ReaderOptions{FilePath: filepath.Join(t.TempDir(), "x")}).ReadDatabase(); err == nil || !strings.Contains(err.Error(), "failed to read file") {
t.Errorf("missing file: %v", err)
}
if _, err := NewReader(&readers.ReaderOptions{}).ReadSchema(); err == nil {
t.Error("ReadSchema without path")
}
if _, err := NewReader(&readers.ReaderOptions{}).ReadTable(); err == nil {
t.Error("ReadTable without path")
}
empty := filepath.Join(t.TempDir(), "e.prisma")
if err := os.WriteFile(empty, []byte("// nothing\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := NewReader(&readers.ReaderOptions{FilePath: empty}).ReadTable(); err == nil || !strings.Contains(err.Error(), "no tables found") {
t.Errorf("ReadTable on empty: %v", err)
}
}
func TestExtractDefaultValue(t *testing.T) {
r := &Reader{}
tests := []struct{ in, want string }{
{"@id @default(autoincrement())", "autoincrement()"},
{`@default("a(b)")`, `"a(b)"`},
{"@unique", ""},
{"@default(unclosed(", ""},
}
for _, tt := range tests {
if got := r.extractDefaultValue(tt.in); got != tt.want {
t.Errorf("%q = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestPrismaTypeToSQL(t *testing.T) {
r := &Reader{}
tests := map[string]string{
"String": "text", "Boolean": "boolean", "Int": "integer", "BigInt": "bigint",
"Float": "double precision", "Decimal": "decimal", "DateTime": "timestamp",
"Json": "jsonb", "Bytes": "bytea", "Custom": "Custom",
}
for in, want := range tests {
if got := r.prismaTypeToSQL(in); got != want {
t.Errorf("%s = %s, want %s", in, got, want)
}
}
}
+375
View File
@@ -0,0 +1,375 @@
package typeorm
import (
"os"
"path/filepath"
"strings"
"testing"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/readers"
)
const exampleTS = "../../../tests/assets/typeorm/example.ts"
func readFixture(t *testing.T) *models.Schema {
t.Helper()
db, err := NewReader(&readers.ReaderOptions{FilePath: exampleTS}).ReadDatabase()
if err != nil {
t.Fatal(err)
}
if len(db.Schemas) != 1 {
t.Fatalf("schemas: %d", len(db.Schemas))
}
return db.Schemas[0]
}
func tableByName(s *models.Schema, name string) *models.Table {
for _, t := range s.Tables {
if t.Name == name {
return t
}
}
return nil
}
func parseSource(t *testing.T, src string) *models.Schema {
t.Helper()
db, err := NewReader(&readers.ReaderOptions{}).parseTypeORM(src)
if err != nil {
t.Fatal(err)
}
return db.Schemas[0]
}
func TestReadFixture_Tables(t *testing.T) {
s := readFixture(t)
for _, name := range []string{"User", "Project", "Task", "Comment", "Tag", "user_project", "tag_task"} {
if tableByName(s, name) == nil {
t.Errorf("table %q missing", name)
}
}
if len(s.Tables) != 7 {
t.Errorf("tables: %d, want 7 (5 entities + 2 join tables)", len(s.Tables))
}
}
func TestReadFixture_ColumnsAndKeys(t *testing.T) {
s := readFixture(t)
user := tableByName(s, "User")
id := user.Columns["id"]
if id == nil || id.Type != "uuid" || !id.IsPrimaryKey || id.Default != "gen_random_uuid()" {
t.Errorf("User.id: %+v", id)
}
if c := user.Columns["createdAt"]; c == nil || c.Type != "timestamp" || c.Default != "now()" {
t.Errorf("User.createdAt: %+v", c)
}
if c := user.Columns["updatedAt"]; c == nil || c.Type != "timestamp" || !strings.Contains(c.Comment, "auto-update") {
t.Errorf("User.updatedAt: %+v", c)
}
if uq := user.Constraints["uq_email"]; uq == nil || uq.Type != models.UniqueConstraint || uq.Columns[0] != "email" {
t.Errorf("unique email: %+v", user.Constraints)
}
if _, ok := user.Columns["ownedProjects"]; ok {
t.Error("relation fields must not become columns")
}
project := tableByName(s, "Project")
if c := project.Columns["description"]; c == nil || c.NotNull {
t.Errorf("nullable description: %+v", c)
}
if c := project.Columns["status"]; c == nil || c.Default != "active" {
t.Errorf("status default: %+v", c)
}
if c := tableByName(s, "Task").Columns["description"]; c == nil || c.Type != "text" || c.NotNull {
t.Errorf("Task.description: %+v", c)
}
if c := tableByName(s, "Comment").Columns["content"]; c == nil || c.Type != "text" {
t.Errorf("shorthand type: %+v", c)
}
}
func TestReadFixture_Relationships(t *testing.T) {
s := readFixture(t)
fk := tableByName(s, "Project").Constraints["fk_Project_owner"]
if fk == nil || fk.Type != models.ForeignKeyConstraint || fk.Columns[0] != "ownerId" || fk.ReferencedTable != "User" {
t.Errorf("Project.owner fk: %+v", fk)
}
if c := tableByName(s, "Project").Columns["ownerId"]; c == nil || c.Type != "uuid" || !c.NotNull {
t.Errorf("ownerId column: %+v", c)
}
// ManyToOne with { nullable: true } produces a nullable FK column.
if c := tableByName(s, "Task").Columns["assigneeId"]; c == nil || c.NotNull {
t.Errorf("assigneeId must be nullable: %+v", c)
}
for _, jt := range []string{"user_project", "tag_task"} {
tbl := tableByName(s, jt)
if len(tbl.Columns) != 2 {
t.Errorf("%s columns: %d", jt, len(tbl.Columns))
}
pk := 0
fks := 0
for _, c := range tbl.Constraints {
switch c.Type {
case models.PrimaryKeyConstraint:
pk++
if len(c.Columns) != 2 {
t.Errorf("%s composite pk: %v", jt, c.Columns)
}
case models.ForeignKeyConstraint:
fks++
}
}
if pk != 1 || fks != 2 {
t.Errorf("%s: pk=%d fks=%d", jt, pk, fks)
}
}
}
func TestReadSchemaAndTable(t *testing.T) {
r := NewReader(&readers.ReaderOptions{FilePath: exampleTS})
s, err := r.ReadSchema()
if err != nil || s.Name != "public" {
t.Fatalf("schema: %v %+v", err, s)
}
tbl, err := r.ReadTable()
if err != nil || tbl.Name != "User" {
t.Fatalf("table: %v %+v", err, tbl)
}
}
func TestReader_Errors(t *testing.T) {
if _, err := NewReader(&readers.ReaderOptions{}).ReadDatabase(); err == nil || !strings.Contains(err.Error(), "file path is required") {
t.Errorf("empty path: %v", err)
}
if _, err := NewReader(&readers.ReaderOptions{FilePath: filepath.Join(t.TempDir(), "x.ts")}).ReadDatabase(); err == nil || !strings.Contains(err.Error(), "failed to read file") {
t.Errorf("missing file: %v", err)
}
if _, err := NewReader(&readers.ReaderOptions{}).ReadSchema(); err == nil {
t.Error("ReadSchema without path must fail")
}
if _, err := NewReader(&readers.ReaderOptions{}).ReadTable(); err == nil {
t.Error("ReadTable without path must fail")
}
empty := filepath.Join(t.TempDir(), "empty.ts")
if err := os.WriteFile(empty, []byte("// nothing here\n"), 0o644); err != nil {
t.Fatal(err)
}
r := NewReader(&readers.ReaderOptions{FilePath: empty})
if db, err := r.ReadDatabase(); err != nil || len(db.Schemas[0].Tables) != 0 {
t.Errorf("empty file: %v %+v", err, db)
}
if _, err := r.ReadTable(); err == nil || !strings.Contains(err.Error(), "no tables found") {
t.Errorf("ReadTable on empty: %v", err)
}
}
func TestEntityOptions(t *testing.T) {
s := parseSource(t, `
@Entity({ name: "app_users", schema: "auth", database: "main", engine: "InnoDB" })
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', length: 100, nullable: true })
login: string;
@Column({ type: 'numeric', precision: 12, scale: 4 })
balance: number;
@Column({ type: 'boolean' })
active: boolean;
}
@Entity('legacy')
export class Legacy {
@PrimaryGeneratedColumn('increment')
id: number;
@Column('jsonb')
payload: any;
}
`)
user := tableByName(s, "app_users")
if user == nil || user.Schema != "auth" {
t.Fatalf("tables: %+v", s.Tables)
}
if c := user.Columns["id"]; c == nil || !c.AutoIncrement || c.Type != "integer" {
t.Errorf("id: %+v", c)
}
if c := user.Columns["login"]; c == nil || c.Type != "varchar(100)" || c.Length != 100 || c.NotNull {
t.Errorf("login: %+v", c)
}
if c := user.Columns["balance"]; c == nil || c.Type != "numeric(12,4)" {
t.Errorf("balance: %+v", c)
}
if c := user.Columns["active"]; c == nil || c.Type != "boolean" {
t.Errorf("active: %+v", c)
}
if c := tableByName(s, "Legacy").Columns["payload"]; c == nil || c.Type != "jsonb" {
t.Errorf("payload: %+v", c)
}
}
func TestViewEntity(t *testing.T) {
s := parseSource(t, `
@ViewEntity({
name: "active_users",
schema: "reporting",
expression: `+"`"+`SELECT id, email FROM users WHERE active`+"`"+`
})
export class ActiveUsers {
id: number;
email: string;
}
@ViewEntity({ expression: "SELECT 1" })
export class OneView {
n: number;
}
`)
if len(s.Views) != 2 || len(s.Tables) != 0 {
t.Fatalf("views=%d tables=%d", len(s.Views), len(s.Tables))
}
v := s.Views[0]
if v.Name != "active_users" || v.Schema != "reporting" || !strings.Contains(v.Definition, "SELECT id, email FROM users") {
t.Errorf("view: %+v", v)
}
if c := v.Columns["email"]; c == nil || c.Type != "text" {
t.Errorf("view column: %+v", v.Columns)
}
if s.Views[1].Name != "OneView" || s.Views[1].Definition != "SELECT 1" {
t.Errorf("second view: %+v", s.Views[1])
}
}
func TestParseColumnDecorator_IdentityAndGenerated(t *testing.T) {
r := &Reader{}
tbl := models.InitTable("t", "public")
col := models.InitColumn("id", "t", "public")
r.parseColumnDecorator(`@PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' })`, col, tbl)
if !col.IsPrimaryKey || !col.Identity || !col.AutoIncrement {
t.Errorf("identity pk: %+v", col)
}
other := models.InitColumn("seq", "t", "public")
r.parseColumnDecorator(`@Generated('identity')`, other, tbl)
if !other.Identity || other.IdentityGeneration != "BY DEFAULT" {
t.Errorf("@Generated: %+v", other)
}
r.parseColumnDecorator(`@Generated('uuid')`, models.InitColumn("u", "t", "public"), tbl) // no-op, no panic
gen := models.InitColumn("full", "t", "public")
r.parseColumnOptions(`@Column({ type: 'text', generatedType: 'STORED', asExpression: 'a || \'x\'' })`, gen, tbl)
if !gen.Generated || !strings.Contains(gen.GenerationExpression, "a ||") {
t.Errorf("generated column: %+v", gen)
}
}
func TestParseGeneratedIdentity(t *testing.T) {
tests := []struct{ in, want string }{
{`{ generatedIdentity: 'ALWAYS' }`, "ALWAYS"},
{`{ generatedIdentity: 'BY DEFAULT' }`, "BY DEFAULT"},
{`no option`, "BY DEFAULT"},
}
for _, tt := range tests {
if got := parseGeneratedIdentity(tt.in); got != tt.want {
t.Errorf("%q = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestUnescapeSingleQuoted(t *testing.T) {
tests := []struct{ in, want string }{
{"plain", "plain"}, {`it\'s`, "it's"}, {`a\\b`, `a\b`}, {`trailing\`, `trailing\`}, {"", ""},
}
for _, tt := range tests {
if got := unescapeSingleQuoted(tt.in); got != tt.want {
t.Errorf("unescape(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestMatchDecorator(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"@Entity()", "@Entity()", true},
{"@Column() name: string;", "@Column()", true},
{"@Column({ type: 'text' })", "@Column({ type: 'text' })", true},
{`@Column({ asExpression: 'f(a)' }) x: string;`, `@Column({ asExpression: 'f(a)' })`, true},
{"@Generated", "@Generated", true},
{"@Column({ unterminated", "@Column({ unterminated", true},
{"name: string;", "", false},
{"", "", false},
}
for _, tt := range tests {
got, ok := matchDecorator(tt.line)
if got != tt.want || ok != tt.wantOK {
t.Errorf("matchDecorator(%q) = (%q,%v), want (%q,%v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestTypeScriptTypeToSQL(t *testing.T) {
r := &Reader{}
tests := []struct{ in, want string }{
{"string", "text"}, {"number", "integer"}, {"boolean", "boolean"}, {"Date", "timestamp"},
{"any", "jsonb"}, {"string[]", "text"}, {"string | null", "text"}, {"Unknown", "text"},
}
for _, tt := range tests {
if got := r.typeScriptTypeToSQL(tt.in); got != tt.want {
t.Errorf("%q = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestIsRelationField(t *testing.T) {
r := &Reader{}
for _, d := range []string{"@ManyToOne(() => A)", "@OneToMany(() => A, a => a.b)", "@ManyToMany(() => A)", "@OneToOne(() => A)"} {
if !r.isRelationField(fieldInfo{decorators: []string{d}}) {
t.Errorf("%s should be a relation", d)
}
}
if r.isRelationField(fieldInfo{decorators: []string{"@Column()"}}) || r.isRelationField(fieldInfo{}) {
t.Error("non-relation misdetected")
}
}
func TestOneToOne_And_MultiLineDecorators(t *testing.T) {
s := parseSource(t, `
@Entity()
export class Profile {
@PrimaryGeneratedColumn()
id: number;
@Column({
type: 'varchar',
length: 50,
nullable: true,
})
bio: string;
@OneToOne(() => Account)
@JoinColumn()
account: Account;
}
@Entity()
export class Account {
@PrimaryGeneratedColumn()
id: number;
}
`)
p := tableByName(s, "Profile")
if c := p.Columns["bio"]; c == nil || c.Type != "varchar(50)" || c.NotNull {
t.Errorf("multi-line @Column not parsed: %+v", c)
}
}