diff --git a/schema/providers.go b/dbProviders/index.go similarity index 96% rename from schema/providers.go rename to dbProviders/index.go index c1b5233..a8c5382 100644 --- a/schema/providers.go +++ b/dbProviders/index.go @@ -1,4 +1,4 @@ -package schema +package providers import "fmt" diff --git a/main.go b/main.go index 0439f9c..1eee5da 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "os" + "strings" + "valkyrie/migration" "valkyrie/schema" ) @@ -22,11 +24,20 @@ func main() { for _, err := range errs { fmt.Println(err) } + os.Exit(1) } - + mig, err := migration.GenerateMigration(schema) + if err != nil { + panic(err) + } b, _ := json.MarshalIndent(schema, "", " ") os.WriteFile("result.json", b, 0644) + if strings.Contains(rawString, `provider = "sqlite"`) { + os.WriteFile("migrate_Sqlite.sql", []byte(mig), 0644) + } else { + os.WriteFile("migrate_Postgres.sql", []byte(mig), 0644) + } fmt.Println(string(b)) } diff --git a/migrate_Postgres.sql b/migrate_Postgres.sql new file mode 100644 index 0000000..7a0b3cf --- /dev/null +++ b/migrate_Postgres.sql @@ -0,0 +1,78 @@ +-- +goose Up +CREATE TYPE "user_roles" AS ENUM ( + 'ADMIN', + 'student', + 'TEACHER' +); + +CREATE TYPE "Clancy" AS ENUM ( + 'VOICES', + 'BLEH' +); + +CREATE TABLE "User" ( + "twistedID" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phoneNum" TEXT NOT NULL, + "role" "user_roles" NOT NULL DEFAULT 'student', + "referredById" TEXT NULL, + CONSTRAINT "User_pkey" PRIMARY KEY ("twistedID"), + CONSTRAINT "User_email_key" UNIQUE ("email"), + CONSTRAINT "User_email_phoneNum_key" UNIQUE ("email", "phoneNum"), + CONSTRAINT "User_referredById_fkey" FOREIGN KEY ("referredById") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "bio" TEXT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Profile_userId_key" UNIQUE ("userId"), + CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Post" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NULL, + "published" BOOLEAN NOT NULL DEFAULT FALSE, + "authorId" TEXT NOT NULL, + CONSTRAINT "Post_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Comment" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id"), + CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Category" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + CONSTRAINT "Category_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Category_name_key" UNIQUE ("name") +); + +CREATE TABLE "CategoryToPost" ( + "postId" TEXT NOT NULL, + "categoryId" INTEGER NOT NULL, + CONSTRAINT "CategoryToPost_pkey" PRIMARY KEY ("postId", "categoryId"), + CONSTRAINT "CategoryToPost_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id"), + CONSTRAINT "CategoryToPost_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") +); + + +-- +goose Down +DROP TABLE IF EXISTS "CategoryToPost"; +DROP TABLE IF EXISTS "Category"; +DROP TABLE IF EXISTS "Comment"; +DROP TABLE IF EXISTS "Post"; +DROP TABLE IF EXISTS "Profile"; +DROP TABLE IF EXISTS "User"; +DROP TYPE IF EXISTS "Clancy"; +DROP TYPE IF EXISTS "user_roles"; diff --git a/migrate_Sqlite.sql b/migrate_Sqlite.sql new file mode 100644 index 0000000..63cdf45 --- /dev/null +++ b/migrate_Sqlite.sql @@ -0,0 +1,68 @@ +-- +goose Up +PRAGMA foreign_keys = ON; + +CREATE TABLE "User" ( + "twistedID" TEXT NOT NULL, + "email" TEXT NOT NULL, + "phoneNum" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'student' CHECK ("role" IN ('ADMIN', 'student', 'TEACHER')), + "referredById" TEXT NULL, + CONSTRAINT "User_pkey" PRIMARY KEY ("twistedID"), + CONSTRAINT "User_email_key" UNIQUE ("email"), + CONSTRAINT "User_email_phoneNum_key" UNIQUE ("email", "phoneNum"), + CONSTRAINT "User_referredById_fkey" FOREIGN KEY ("referredById") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Profile" ( + "id" TEXT NOT NULL, + "bio" TEXT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "Profile_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Profile_userId_key" UNIQUE ("userId"), + CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Post" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NULL, + "published" INTEGER NOT NULL DEFAULT FALSE, + "authorId" TEXT NOT NULL, + CONSTRAINT "Post_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Comment" ( + "id" TEXT NOT NULL, + "text" TEXT NOT NULL, + "postId" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id"), + CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id"), + CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("twistedID") +); + +CREATE TABLE "Category" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + CONSTRAINT "Category_name_key" UNIQUE ("name") +); + +CREATE TABLE "CategoryToPost" ( + "postId" TEXT NOT NULL, + "categoryId" INTEGER NOT NULL, + CONSTRAINT "CategoryToPost_pkey" PRIMARY KEY ("postId", "categoryId"), + CONSTRAINT "CategoryToPost_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id"), + CONSTRAINT "CategoryToPost_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") +); + + +-- +goose Down +PRAGMA foreign_keys = ON; + +DROP TABLE IF EXISTS "CategoryToPost"; +DROP TABLE IF EXISTS "Category"; +DROP TABLE IF EXISTS "Comment"; +DROP TABLE IF EXISTS "Post"; +DROP TABLE IF EXISTS "Profile"; +DROP TABLE IF EXISTS "User"; diff --git a/migration/dialect.go b/migration/dialect.go index df009ed..96d3bcd 100644 --- a/migration/dialect.go +++ b/migration/dialect.go @@ -2,6 +2,7 @@ package migration import ( "strings" + providers "valkyrie/dbProviders" "valkyrie/schema" ) @@ -9,33 +10,45 @@ type Dialect interface { GetSQLType(sf *schema.ScalarField) string GetSQLDefault(dv *schema.DefaultValue, pslType string) string QuoteIdent(name string) string + + GenerateEnum(enum *schema.Enum) string + FormatAutoIncrement(sqlType string) (typeOverride string, extraKeyword string) + FormatSinglePK(tableName, colName string, isAutoInc bool) (inlineSQL string, tableConstraint string) + FormatEnumConstraint(colName string, enum *schema.Enum) string } -func GetDialect(provider schema.DbProvider) Dialect { +func GetDialect(provider providers.DbProvider) Dialect { switch provider { - case schema.Mysql: + + case providers.Mysql: return nil //TODO - case schema.Postgres, schema.Postgresql: + + case providers.Postgres, providers.Postgresql: return &PostgresDialect{} - case schema.Sqlite: + + case providers.Sqlite: return &SqliteDialect{} + default: return nil + } } -func getSQLType(sf *schema.ScalarField, provider schema.DbProvider) string { +func getSQLType(sf *schema.ScalarField, provider providers.DbProvider) string { dialect := GetDialect(provider) if dialect == nil { return strings.ToUpper(sf.SQLType) } + return dialect.GetSQLType(sf) } -func getSQLDefault(dv *schema.DefaultValue, pslType string, provider schema.DbProvider) string { +func getSQLDefault(dv *schema.DefaultValue, pslType string, provider providers.DbProvider) string { dialect := GetDialect(provider) if dialect == nil { return "" } + return dialect.GetSQLDefault(dv, pslType) } diff --git a/migration/dropTablesAndEnums.go b/migration/dropTablesAndEnums.go new file mode 100644 index 0000000..d12adbe --- /dev/null +++ b/migration/dropTablesAndEnums.go @@ -0,0 +1,37 @@ +package migration + +import ( + "fmt" + "strings" + "valkyrie/schema" +) + +type dropProps struct { + schemaDef schema.Schema + dialect Dialect + downBuilder *strings.Builder +} + +func dropTables(schemaDef *schema.Schema, dialect Dialect, downBuilder *strings.Builder) { + for i := len(schemaDef.Models) - 1; i >= 0; i-- { + model := schemaDef.Models[i] + tableName := model.TableName + if tableName == "" { + tableName = model.Name + } + fmt.Fprintf(downBuilder, "DROP TABLE IF EXISTS %s;\n", dialect.QuoteIdent(tableName)) + } +} + +func dropEnums(schemaDef *schema.Schema, dialect Dialect, downBuilder *strings.Builder) { + for i := len(schemaDef.Enums) - 1; i >= 0; i-- { + enum := schemaDef.Enums[i] + name := enum.Name + if enum.TableMapName != "" { + name = enum.TableMapName + } + if dialect.GenerateEnum(enum) != "" { + fmt.Fprintf(downBuilder, "DROP TYPE IF EXISTS %s;\n", dialect.QuoteIdent(name)) + } + } +} diff --git a/migration/generateTablesAndEnums.go b/migration/generateTablesAndEnums.go new file mode 100644 index 0000000..1172685 --- /dev/null +++ b/migration/generateTablesAndEnums.go @@ -0,0 +1,287 @@ +package migration + +import ( + "fmt" + "strings" + "valkyrie/schema" +) + +type tableProps struct { + model *schema.Model + dialect Dialect + dbName string +} + +func generateTables(schemaDef *schema.Schema, dialect Dialect, sb *strings.Builder) { + for _, model := range schemaDef.Models { + tableName := model.TableName + if tableName == "" { + tableName = model.Name + } + + fmt.Fprintf(sb, "CREATE TABLE %s (\n", dialect.QuoteIdent(tableName)) + + // 1. add scalar fields (cols) + columns, tableConstraints := generateScalarFields(model, dialect, tableName) + + // 2. composite PK table constraint + if pkConstraint := generateCompositePK(model, tableName, dialect); pkConstraint != "" { + tableConstraints = append(tableConstraints, pkConstraint) + } + + // 3. composite unique constraints + tableConstraints = append(tableConstraints, generateCompositeUniques(model, tableName, dialect)...) + + // 4. FK table constraints + tableConstraints = append(tableConstraints, generateForeignKeys(model, tableName, dialect)...) + + // Append tableConstraints to cols + columns = append(columns, tableConstraints...) + + sb.WriteString(strings.Join(columns, ",\n")) + sb.WriteString("\n);\n\n") + + generateIndexes(model, tableName, dialect, sb) + + if len(model.Indexes) > 0 { + sb.WriteString("\n") + } + } +} + +func generateEnums(enums []*schema.Enum, dialect Dialect, sb *strings.Builder) { + for _, enum := range enums { + enumDDL := dialect.GenerateEnum(enum) + if enumDDL != "" { + sb.WriteString(enumDDL) + } + } +} + +//---------------------------------------------- + +func generateCompositePK(model *schema.Model, tableName string, dialect Dialect) string { + if len(model.CompositePK) == 0 { + return "" + } + var pkCols []string + for _, pkField := range model.CompositePK { + cName := pkField + for _, sf := range model.ScalarFields { + if sf.Name == pkField { + if sf.ColName != "" { + cName = sf.ColName + } + break + } + } + pkCols = append(pkCols, dialect.QuoteIdent(cName)) + } + pkName := tableName + "_pkey" + return fmt.Sprintf(" CONSTRAINT %s PRIMARY KEY (%s)", dialect.QuoteIdent(pkName), strings.Join(pkCols, ", ")) +} + +func generateCompositeUniques(model *schema.Model, tableName string, dialect Dialect) []string { + var constraints []string + for _, uniq := range model.CompositeUnique { + var uniqCols []string + var uniqColNames []string + for _, uField := range uniq.Fields { + cName := uField + for _, sf := range model.ScalarFields { + if sf.Name == uField { + if sf.ColName != "" { + cName = sf.ColName + } + break + } + } + uniqCols = append(uniqCols, dialect.QuoteIdent(cName)) + uniqColNames = append(uniqColNames, cName) + } + uniqName := uniq.Name + if uniqName == "" { + uniqName = tableName + "_" + strings.Join(uniqColNames, "_") + "_key" + } + constraints = append(constraints, fmt.Sprintf(" CONSTRAINT %s UNIQUE (%s)", dialect.QuoteIdent(uniqName), strings.Join(uniqCols, ", "))) + } + return constraints +} + +func generateForeignKeys(model *schema.Model, tableName string, dialect Dialect) []string { + var constraints []string + for _, rf := range model.RelationFields { + if len(rf.FKFields) > 0 && len(rf.RefFields) > 0 { + var fkCols []string + var fkColNames []string + for _, fkField := range rf.FKFields { + colName := fkField.ColName + if colName == "" { + colName = fkField.Name + } + fkCols = append(fkCols, dialect.QuoteIdent(colName)) + fkColNames = append(fkColNames, colName) + } + + var refCols []string + targetTable := rf.TargetModel.TableName + if targetTable == "" { + targetTable = rf.TargetModel.Name + } + for _, refField := range rf.RefFields { + colName := refField.ColName + if colName == "" { + colName = refField.Name + } + refCols = append(refCols, dialect.QuoteIdent(colName)) + } + + fkName := fmt.Sprintf("%s_%s_fkey", tableName, strings.Join(fkColNames, "_")) + fkConstraint := fmt.Sprintf(" CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s)", + dialect.QuoteIdent(fkName), + strings.Join(fkCols, ", "), + dialect.QuoteIdent(targetTable), + strings.Join(refCols, ", "), + ) + + if rf.OnDelete != "" { + fkConstraint += " ON DELETE " + formatReferentialAction(rf.OnDelete) + } + if rf.OnUpdate != "" { + fkConstraint += " ON UPDATE " + formatReferentialAction(rf.OnUpdate) + } + + constraints = append(constraints, fkConstraint) + } + } + return constraints +} + +func generateIndexes(model *schema.Model, tableName string, dialect Dialect, sb *strings.Builder) { + for _, idx := range model.Indexes { + var idxCols []string + var colNamesForName []string + for _, iField := range idx.Fields { + cName := iField + for _, sf := range model.ScalarFields { + if sf.Name == iField { + cName = sf.ColName + break + } + } + idxCols = append(idxCols, dialect.QuoteIdent(cName)) + colNamesForName = append(colNamesForName, cName) + } + + idxName := idx.Name + if idxName == "" { + idxName = fmt.Sprintf("%s_%s_idx", tableName, strings.Join(colNamesForName, "_")) + } + + fmt.Fprintf(sb, "CREATE INDEX %s ON %s (%s);\n", + dialect.QuoteIdent(idxName), + dialect.QuoteIdent(tableName), + strings.Join(idxCols, ", ")) + } +} + +func generateScalarFields(model *schema.Model, dialect Dialect, tableName string) ([]string, []string) { + var columns []string + var tableConstraints []string + + for _, sf := range model.ScalarFields { + colName := sf.ColName + if colName == "" { + colName = sf.Name + } + + sqlType := dialect.GetSQLType(sf) + + var colParts []string + colParts = append(colParts, dialect.QuoteIdent(colName)) + colParts = append(colParts, sqlType) + + // Handle Auto Increment / Serial + if sf.Default != nil && sf.Default.FuncName == "autoincrement" { + typeOverride, keyword := dialect.FormatAutoIncrement(sqlType) + if typeOverride != "" { + sqlType = typeOverride + colParts[1] = sqlType // Update type in parts + } + if keyword != "" { + colParts = append(colParts, keyword) + } + } + + // Nullability + if sf.Optional { + colParts = append(colParts, "NULL") + } else { + // Serial columns in postgres are implicitly not null, but explicit is good + colParts = append(colParts, "NOT NULL") + } + + // Single field Primary Key + if sf.IsID && len(model.CompositePK) == 0 { + isAutoInc := sf.Default != nil && sf.Default.FuncName == "autoincrement" + inlinePK, tablePK := dialect.FormatSinglePK(tableName, colName, isAutoInc) + if inlinePK != "" { + colParts = append(colParts, inlinePK) + } + if tablePK != "" { + tableConstraints = append(tableConstraints, tablePK) + } + } + + // Single field Unique constraint + if sf.IsUnique { + uniqName := tableName + "_" + colName + "_key" + tableConstraints = append(tableConstraints, fmt.Sprintf(" CONSTRAINT %s UNIQUE (%s)", dialect.QuoteIdent(uniqName), dialect.QuoteIdent(colName))) + } + + // Default value (except autoincrement which we handled) + if sf.Default != nil && sf.Default.FuncName != "autoincrement" { + var defStr string + if sf.EnumRef != nil && sf.Default.Kind == schema.DefaultEnumValue { + dbVal := sf.Default.EnumValue + for _, ev := range sf.EnumRef.ValueMap { + if ev.Name == sf.Default.EnumValue { + dbVal = ev.DBName + break + } + } + defStr = fmt.Sprintf("'%s'", strings.ReplaceAll(dbVal, "'", "''")) + } else { + defStr = dialect.GetSQLDefault(sf.Default, sf.Type) + } + if defStr != "" { + colParts = append(colParts, "DEFAULT "+defStr) + } + } + + // Inline Enum Constraints (e.g. SQLite CHECK constraints) + if sf.EnumRef != nil { + constraint := dialect.FormatEnumConstraint(colName, sf.EnumRef) + if constraint != "" { + colParts = append(colParts, constraint) + } + } + + columns = append(columns, " "+strings.Join(colParts, " ")) + } + + return columns, tableConstraints +} + +func formatReferentialAction(action string) string { + switch strings.ToLower(action) { + case "setnull": + return "SET NULL" + case "setdefault": + return "SET DEFAULT" + case "noaction": + return "NO ACTION" + default: + return strings.ToUpper(action) + } +} diff --git a/migration/migration.go b/migration/migration.go new file mode 100644 index 0000000..30bb622 --- /dev/null +++ b/migration/migration.go @@ -0,0 +1,70 @@ +package migration + +import ( + "fmt" + "strings" + providers "valkyrie/dbProviders" + "valkyrie/schema" +) + +func GenerateUpMigrations(schemaDef *schema.Schema) (string, error) { + + dialect := GetDialect(schemaDef.Datasource.Provider) + if dialect == nil { + return "", fmt.Errorf("unknown provider: %s", schemaDef.Datasource.Provider) + } + + provider := schemaDef.Datasource.Provider + + var sb strings.Builder + + if provider == providers.Sqlite { + sb.WriteString("PRAGMA foreign_keys = ON;\n\n") + } + generateEnums(schemaDef.Enums, dialect, &sb) + + generateTables(schemaDef, dialect, &sb) + + return sb.String(), nil +} + +func GenerateDownMigrations(schemaDef *schema.Schema) (string, error) { + + dialect := GetDialect(schemaDef.Datasource.Provider) + if dialect == nil { + return "", fmt.Errorf("unknown provider: %s", schemaDef.Datasource.Provider) + } + + provider := schemaDef.Datasource.Provider + + var downBuilder strings.Builder + + if provider == providers.Sqlite { + downBuilder.WriteString("PRAGMA foreign_keys = ON;\n\n") + } + + dropTables(schemaDef, dialect, &downBuilder) + dropEnums(schemaDef, dialect, &downBuilder) + + return downBuilder.String(), nil +} + +func GenerateMigration(schemaDef *schema.Schema) (string, error) { + + upSQL, err := GenerateUpMigrations(schemaDef) + if err != nil { + return "", err + } + + downSQL, err := GenerateDownMigrations(schemaDef) + if err != nil { + return "", err + } + + var sb strings.Builder + sb.WriteString("-- +goose Up\n") + sb.WriteString(upSQL) + sb.WriteString("\n-- +goose Down\n") + sb.WriteString(downSQL) + return sb.String(), nil +} diff --git a/migration/migration_test.go b/migration/migration_test.go new file mode 100644 index 0000000..dd976e9 --- /dev/null +++ b/migration/migration_test.go @@ -0,0 +1,320 @@ +package migration + +import ( + "strings" + "testing" + "valkyrie/schema" +) + +func TestGenerateMigrationsPostgres(t *testing.T) { + input := ` + datasource db { + provider = "postgresql" + } + + enum Role { + USER + ADMIN + } + + model User { + id Int @id @default(autoincrement()) + email String @unique + role Role @default(USER) + + @@map("users") + } + + model Post { + id String @id @default(uuid()) + title String + published Boolean @default(false) + authorId Int + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + } + ` + + schemaDef, errs := schema.ParseSchema(input) + if len(errs) > 0 { + t.Fatalf("parser errors: %v", errs) + } + + sql, err := GenerateUpMigrations(schemaDef) + if err != nil { + t.Fatalf("failed to generate DDL: %v", err) + } + + // Verify enums + if !strings.Contains(sql, "CREATE TYPE \"Role\" AS ENUM") { + t.Errorf("expected CREATE TYPE for Role enum, got:\n%s", sql) + } + + // Verify users table + if !strings.Contains(sql, "CREATE TABLE \"users\"") { + t.Errorf("expected CREATE TABLE users, got:\n%s", sql) + } + if !strings.Contains(sql, "\"id\" SERIAL NOT NULL") && !strings.Contains(sql, "\"id\" BIGSERIAL NOT NULL") { + t.Errorf("expected id serial/bigserial column, got:\n%s", sql) + } + if !strings.Contains(sql, "CONSTRAINT \"users_pkey\" PRIMARY KEY (\"id\")") { + t.Errorf("expected primary key constraint users_pkey, got:\n%s", sql) + } + if !strings.Contains(sql, "\"role\" \"Role\" NOT NULL DEFAULT 'USER'") { + t.Errorf("expected role enum column, got:\n%s", sql) + } + + // Verify foreign keys and relation onDelete + if !strings.Contains(sql, "FOREIGN KEY (\"authorId\") REFERENCES \"users\" (\"id\") ON DELETE CASCADE") { + t.Errorf("expected foreign key on authorId referencing users(id) ON DELETE CASCADE, got:\n%s", sql) + } +} + +func TestGenerateMigrationsSQLite(t *testing.T) { + input := ` + datasource db { + provider = "sqlite" + + } + + enum Role { + USER + ADMIN + } + + model User { + id Int @id @default(autoincrement()) + email String @unique + role Role @default(USER) + + @@map("users") + } + ` + + schemaDef, errs := schema.ParseSchema(input) + if len(errs) > 0 { + t.Fatalf("parser errors: %v", errs) + } + + sql, err := GenerateUpMigrations(schemaDef) + if err != nil { + t.Fatalf("failed to generate DDL: %v", err) + } + + // In SQLite, there shouldn't be CREATE TYPE + if strings.Contains(sql, "CREATE TYPE") { + t.Errorf("expected no CREATE TYPE for SQLite, got:\n%s", sql) + } + + // SQLite check constraints + if !strings.Contains(sql, "CHECK (\"role\" IN ('USER', 'ADMIN'))") { + t.Errorf("expected SQLite enum CHECK constraint, got:\n%s", sql) + } + // SQLite autoincrement + if !strings.Contains(sql, "\"id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT") { + t.Errorf("expected INTEGER PRIMARY KEY AUTOINCREMENT for SQLite, got:\n%s", sql) + } +} + +func TestGenerateGooseMigrations(t *testing.T) { + input := ` + datasource db { + provider = "postgresql" + } + + enum Role { + USER + ADMIN + } + + model User { + id Int @id @default(autoincrement()) + email String @unique + role Role @default(USER) + + @@map("users") + } + ` + schemaDef, errs := schema.ParseSchema(input) + if len(errs) > 0 { + t.Fatalf("parser errors: %v", errs) + } + + sql, err := GenerateMigration(schemaDef) + if err != nil { + t.Fatalf("failed to generate Goose DDL: %v", err) + } + + if !strings.Contains(sql, "-- +goose Up") { + t.Errorf("expected -- +goose Up directive, got:\n%s", sql) + } + if !strings.Contains(sql, "-- +goose Down") { + t.Errorf("expected -- +goose Down directive, got:\n%s", sql) + } + if !strings.Contains(sql, "DROP TABLE IF EXISTS \"users\";") { + t.Errorf("expected table drop, got:\n%s", sql) + } + if !strings.Contains(sql, "DROP TYPE IF EXISTS \"Role\";") { + t.Errorf("expected enum drop, got:\n%s", sql) + } +} + +func TestViciousMigrationEdgeCases(t *testing.T) { + schemaTemplate := ` + datasource db { + provider = "PROVIDER" + } + + enum CustomRole { + SUPER_ADMIN @map("super_admin") + REGULAR_USER @map("regular_user") + + @@map("custom_roles_enum") + } + + model CustomUser { + id Int @id @default(autoincrement()) @map("user_id") + email String @unique @map("user_email") + phone String? @map("user_phone") + role CustomRole @default(REGULAR_USER) @map("user_role") + + // Self-referential relation + managerId Int? @map("manager_id") + manager CustomUser? @relation("UserToManager", fields: [managerId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + @@unique([email, phone]) + @@map("users_table") + } + + model Category { + id Int @id @default(autoincrement()) @map("cat_id") + name String @unique @map("cat_name") + + @@map("categories_table") + } + + model CategoryToUser { + userId Int @map("fk_user_id") + categoryId Int @map("fk_cat_id") + + user CustomUser @relation(fields: [userId], references: [id], onDelete: Cascade) + category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) + + @@id([userId, categoryId]) + @@map("user_categories") + } + ` + + t.Run("postgresql", func(t *testing.T) { + input := strings.Replace(schemaTemplate, "PROVIDER", "postgresql", 1) + schemaDef, errs := schema.ParseSchema(input) + if len(errs) > 0 { + t.Fatalf("parser errors: %v", errs) + } + + sql, err := GenerateUpMigrations(schemaDef) + if err != nil { + t.Fatalf("failed to generate Postgres DDL: %v", err) + } + + // 1. Verify mapped custom enum type and mapped value strings + if !strings.Contains(sql, "CREATE TYPE \"custom_roles_enum\" AS ENUM") { + t.Errorf("expected CREATE TYPE for custom_roles_enum, got:\n%s", sql) + } + if !strings.Contains(sql, "'super_admin'") || !strings.Contains(sql, "'regular_user'") { + t.Errorf("expected custom enum values in creation block, got:\n%s", sql) + } + + // 2. Verify users_table mapped columns & types + if !strings.Contains(sql, "CREATE TABLE \"users_table\"") { + t.Errorf("expected CREATE TABLE users_table, got:\n%s", sql) + } + if !strings.Contains(sql, "\"user_id\" SERIAL NOT NULL") && !strings.Contains(sql, "\"user_id\" BIGSERIAL NOT NULL") { + t.Errorf("expected mapped user_id serial/bigserial type, got:\n%s", sql) + } + if !strings.Contains(sql, "\"user_role\" \"custom_roles_enum\" NOT NULL DEFAULT 'regular_user'") { + t.Errorf("expected mapped user_role using custom_roles_enum type and default value, got:\n%s", sql) + } + + // 3. Verify composite unique constraints on users_table + if !strings.Contains(sql, "CONSTRAINT \"users_table_user_email_user_phone_key\" UNIQUE (\"user_email\", \"user_phone\")") { + t.Errorf("expected composite unique key for users_table using mapped column names, got:\n%s", sql) + } + + // 4. Verify self-referential foreign key constraint on users_table + if !strings.Contains(sql, "CONSTRAINT \"users_table_manager_id_fkey\" FOREIGN KEY (\"manager_id\") REFERENCES \"users_table\" (\"user_id\") ON DELETE SET NULL ON UPDATE CASCADE") { + t.Errorf("expected self-referential foreign key on users_table, got:\n%s", sql) + } + + // 5. Verify user_categories table PK and FKs using mapped column names + if !strings.Contains(sql, "CONSTRAINT \"user_categories_pkey\" PRIMARY KEY (\"fk_user_id\", \"fk_cat_id\")") { + t.Errorf("expected composite primary key on user_categories using mapped names, got:\n%s", sql) + } + if !strings.Contains(sql, "CONSTRAINT \"user_categories_fk_user_id_fkey\" FOREIGN KEY (\"fk_user_id\") REFERENCES \"users_table\" (\"user_id\") ON DELETE CASCADE") { + t.Errorf("expected foreign key on user_categories pointing to users_table(user_id) with CASCADE, got:\n%s", sql) + } + if !strings.Contains(sql, "CONSTRAINT \"user_categories_fk_cat_id_fkey\" FOREIGN KEY (\"fk_cat_id\") REFERENCES \"categories_table\" (\"cat_id\") ON DELETE RESTRICT") { + t.Errorf("expected foreign key on user_categories pointing to categories_table(cat_id) with RESTRICT, got:\n%s", sql) + } + + // 6. Verify Down Goose migrations dropping enums and tables in correct reverse order + gooseSQL, err := GenerateMigration(schemaDef) + if err != nil { + t.Fatalf("failed to generate Goose migrations: %v", err) + } + + if !strings.Contains(gooseSQL, "DROP TABLE IF EXISTS \"user_categories\";") { + t.Errorf("expected dropping dependent table user_categories, got:\n%s", gooseSQL) + } + if !strings.Contains(gooseSQL, "DROP TYPE IF EXISTS \"custom_roles_enum\";") { + t.Errorf("expected dropping custom roles enum type in down block, got:\n%s", gooseSQL) + } + }) + + t.Run("sqlite", func(t *testing.T) { + input := strings.Replace(schemaTemplate, "PROVIDER", "sqlite", 1) + schemaDef, errs := schema.ParseSchema(input) + if len(errs) > 0 { + t.Fatalf("parser errors: %v", errs) + } + + sql, err := GenerateUpMigrations(schemaDef) + if err != nil { + t.Fatalf("failed to generate SQLite DDL: %v", err) + } + + // 1. Verify SQLite enums are inline CHECK constraints rather than custom types + if strings.Contains(sql, "CREATE TYPE") { + t.Errorf("expected no CREATE TYPE for SQLite, got:\n%s", sql) + } + if !strings.Contains(sql, "CHECK (\"user_role\" IN ('super_admin', 'regular_user'))") { + t.Errorf("expected inline CHECK constraint on SQLite enum column, got:\n%s", sql) + } + + // 2. Verify single PK autoincrement handling on SQLite + if !strings.Contains(sql, "\"user_id\" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT") { + t.Errorf("expected INTEGER PRIMARY KEY AUTOINCREMENT for user_id in users_table, got:\n%s", sql) + } + + // 3. Verify self-referential foreign key constraint on users_table + if !strings.Contains(sql, "CONSTRAINT \"users_table_manager_id_fkey\" FOREIGN KEY (\"manager_id\") REFERENCES \"users_table\" (\"user_id\") ON DELETE SET NULL ON UPDATE CASCADE") { + t.Errorf("expected SQLite self-referential foreign key, got:\n%s", sql) + } + + // 4. Verify composite primary key and foreign keys on user_categories using mapped column names + if !strings.Contains(sql, "CONSTRAINT \"user_categories_pkey\" PRIMARY KEY (\"fk_user_id\", \"fk_cat_id\")") { + t.Errorf("expected composite primary key on user_categories, got:\n%s", sql) + } + if !strings.Contains(sql, "CONSTRAINT \"user_categories_fk_user_id_fkey\" FOREIGN KEY (\"fk_user_id\") REFERENCES \"users_table\" (\"user_id\") ON DELETE CASCADE") { + t.Errorf("expected foreign key referencing users_table(user_id), got:\n%s", sql) + } + + // 5. Verify goose down migrations have PRAGMA foreign_keys = ON; + gooseSQL, err := GenerateMigration(schemaDef) + if err != nil { + t.Fatalf("failed to generate Goose Down SQL: %v", err) + } + if !strings.Contains(gooseSQL, "PRAGMA foreign_keys = ON;") { + t.Errorf("expected PRAGMA foreign_keys = ON; in Down migration, got:\n%s", gooseSQL) + } + }) +} diff --git a/migration/postgresDialect.go b/migration/postgresDialect.go index b140c2c..255e480 100644 --- a/migration/postgresDialect.go +++ b/migration/postgresDialect.go @@ -54,3 +54,30 @@ func (d PostgresDialect) GetSQLDefault(dv *schema.DefaultValue, pslType string) return "" } + +func (d PostgresDialect) GenerateEnum(enum *schema.Enum) string { + name := enum.Name + if enum.TableMapName != "" { + name = enum.TableMapName + } + var quotedValues []string + for _, val := range enum.ValueMap { + quotedValues = append(quotedValues, fmt.Sprintf(" '%s'", val.DBName)) + } + return fmt.Sprintf("CREATE TYPE %s AS ENUM (\n%s\n);\n\n", + d.QuoteIdent(name), strings.Join(quotedValues, ",\n")) +} + +func (PostgresDialect) FormatAutoIncrement(sqlType string) (string, string) { + if sqlType == "BIGINT" { + return "BIGSERIAL", "" + } + return "SERIAL", "" +} + +func (d PostgresDialect) FormatSinglePK(tableName, colName string, isAutoInc bool) (string, string) { + pkName := tableName + "_pkey" + return "", fmt.Sprintf(" CONSTRAINT %s PRIMARY KEY (%s)", d.QuoteIdent(pkName), d.QuoteIdent(colName)) +} + +func (d PostgresDialect) FormatEnumConstraint(colName string, enum *schema.Enum) string { return "" } diff --git a/migration/sqliteDialect.go b/migration/sqliteDialect.go index eda0f29..8107f7f 100644 --- a/migration/sqliteDialect.go +++ b/migration/sqliteDialect.go @@ -58,3 +58,27 @@ func (SqliteDialect) GetSQLDefault(dv *schema.DefaultValue, pslType string) stri return "" } + +func (SqliteDialect) GenerateEnum(enum *schema.Enum) string { + return "" //will be inlined using CHECK +} + +func (SqliteDialect) FormatAutoIncrement(sqlType string) (string, string) { + return "INTEGER", "" // SQLite autoincrement requires INTEGER type +} + +func (d SqliteDialect) FormatSinglePK(tableName, colName string, isAutoInc bool) (string, string) { + if isAutoInc { + return "PRIMARY KEY AUTOINCREMENT", "" + } + pkName := tableName + "_pkey" + return "", fmt.Sprintf(" CONSTRAINT %s PRIMARY KEY (%s)", d.QuoteIdent(pkName), d.QuoteIdent(colName)) +} + +func (d SqliteDialect) FormatEnumConstraint(colName string, enum *schema.Enum) string { + var enumVals []string + for _, ev := range enum.ValueMap { + enumVals = append(enumVals, fmt.Sprintf("'%s'", ev.DBName)) + } + return fmt.Sprintf("CHECK (%s IN (%s))", d.QuoteIdent(colName), strings.Join(enumVals, ", ")) +} diff --git a/result.json b/result.json index 94a675c..e1566bc 100644 --- a/result.json +++ b/result.json @@ -1,7 +1,7 @@ { "Datasource": { "Name": "db", - "Provider": "" + "Provider": "postgres" }, "Models": [ { @@ -33,7 +33,7 @@ { "Name": "id", "Args": null, - "Line": 14, + "Line": 19, "Col": 24 }, { @@ -47,7 +47,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 28 }, { @@ -61,7 +61,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 45 } ] @@ -83,7 +83,7 @@ { "Name": "unique", "Args": null, - "Line": 15, + "Line": 20, "Col": 24 } ] @@ -136,7 +136,7 @@ } } ], - "Line": 17, + "Line": 22, "Col": 24 } ], @@ -255,7 +255,7 @@ { "Name": "id", "Args": null, - "Line": 14, + "Line": 19, "Col": 24 }, { @@ -269,7 +269,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 28 }, { @@ -283,7 +283,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 45 } ] @@ -336,7 +336,7 @@ } } ], - "Line": 26, + "Line": 31, "Col": 5 } ], @@ -381,7 +381,7 @@ { "Name": "id", "Args": null, - "Line": 30, + "Line": 35, "Col": 20 }, { @@ -395,7 +395,7 @@ } } ], - "Line": 30, + "Line": 35, "Col": 24 } ] @@ -432,7 +432,7 @@ { "Name": "unique", "Args": null, - "Line": 32, + "Line": 37, "Col": 20 } ] @@ -462,7 +462,7 @@ { "Name": "unique", "Args": null, - "Line": 32, + "Line": 37, "Col": 20 } ] @@ -494,7 +494,7 @@ { "Name": "id", "Args": null, - "Line": 14, + "Line": 19, "Col": 24 }, { @@ -508,7 +508,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 28 }, { @@ -522,7 +522,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 45 } ] @@ -572,7 +572,7 @@ { "Name": "id", "Args": null, - "Line": 37, + "Line": 42, "Col": 33 }, { @@ -586,7 +586,7 @@ } } ], - "Line": 37, + "Line": 42, "Col": 37 } ] @@ -654,7 +654,7 @@ } } ], - "Line": 40, + "Line": 45, "Col": 33 } ] @@ -724,7 +724,7 @@ { "Name": "id", "Args": null, - "Line": 14, + "Line": 19, "Col": 24 }, { @@ -738,7 +738,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 28 }, { @@ -752,7 +752,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 45 } ] @@ -834,7 +834,7 @@ { "Name": "id", "Args": null, - "Line": 48, + "Line": 53, "Col": 21 }, { @@ -848,7 +848,7 @@ } } ], - "Line": 48, + "Line": 53, "Col": 25 } ] @@ -948,7 +948,7 @@ { "Name": "id", "Args": null, - "Line": 37, + "Line": 42, "Col": 33 }, { @@ -962,7 +962,7 @@ } } ], - "Line": 37, + "Line": 42, "Col": 37 } ] @@ -1025,7 +1025,7 @@ { "Name": "id", "Args": null, - "Line": 14, + "Line": 19, "Col": 24 }, { @@ -1039,7 +1039,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 28 }, { @@ -1053,7 +1053,7 @@ } } ], - "Line": 14, + "Line": 19, "Col": 45 } ] @@ -1103,7 +1103,7 @@ { "Name": "id", "Args": null, - "Line": 57, + "Line": 62, "Col": 28 }, { @@ -1117,7 +1117,7 @@ } } ], - "Line": 57, + "Line": 62, "Col": 32 } ] @@ -1139,7 +1139,7 @@ { "Name": "unique", "Args": null, - "Line": 58, + "Line": 63, "Col": 28 } ] @@ -1252,7 +1252,7 @@ { "Name": "id", "Args": null, - "Line": 37, + "Line": 42, "Col": 33 }, { @@ -1266,7 +1266,7 @@ } } ], - "Line": 37, + "Line": 42, "Col": 37 } ] @@ -1329,7 +1329,7 @@ { "Name": "id", "Args": null, - "Line": 57, + "Line": 62, "Col": 28 }, { @@ -1343,7 +1343,7 @@ } } ], - "Line": 57, + "Line": 62, "Col": 32 } ] @@ -1380,7 +1380,7 @@ } } ], - "Line": 68, + "Line": 73, "Col": 5 } ], @@ -1415,18 +1415,25 @@ } ], "TableMapName": "user_roles" - } - ], - "Errors": [ + }, { - "Severity": 0, - "Message": "Mysql is not supported yet", - "Pos": { - "Line": 2, - "Col": 5, - "Offset": 0 - }, - "Source": "resolver" + "Name": "Clancy", + "Values": [ + "VOICES", + "BLEH" + ], + "ValueMap": [ + { + "Name": "VOICES", + "DBName": "VOICES" + }, + { + "Name": "BLEH", + "DBName": "BLEH" + } + ], + "TableMapName": "" } - ] + ], + "Errors": null } \ No newline at end of file diff --git a/schema.prisma b/schema.prisma index c1596f2..1798ea2 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1,5 +1,5 @@ datasource db { - provider = "mysql" + provider = "postgres" } enum UserRole { @@ -10,6 +10,11 @@ enum UserRole { @@map("user_roles") } +enum Clancy { + VOICES + BLEH +} + model User { id String @id @default(cuid()) @map("twistedID") email String @unique diff --git a/schema/resolver.go b/schema/resolver.go index 798911b..0f6cecb 100644 --- a/schema/resolver.go +++ b/schema/resolver.go @@ -2,6 +2,7 @@ package schema import ( "fmt" + providers "valkyrie/dbProviders" ) type PSLTypeMapping struct { @@ -95,7 +96,7 @@ func (r *Resolver) resolveDatasource(schema *Schema) { switch kv.Key { case "provider": if kv.Value.Type == ValLiteral { - prov, err := ParseDbProvider(kv.Value.Scalar) + prov, err := providers.ParseDbProvider(kv.Value.Scalar) if err != nil { r.errorf(kv.Line, kv.Col, "%s", err.Error()) } else { @@ -372,10 +373,32 @@ func (r *Resolver) buildRelationField(fd astFieldDecl, owner, target *Model) *Re if rf.RelationName == "" { rf.RelationName = synthesizeRelationName(owner.Name, target.Name) } - + r.validateRelation(rf, owner.Name, fd.Line, fd.Col) return rf } +func (r *Resolver) validateRelation(rf *RelationField, ownerName string, line, col int) { + if len(rf.FKFields) != len(rf.RefFields) { + r.errorf(line, col, + "relation %q on model %q has a mismatched number of fields (%d) and references (%d)", + rf.RelationName, ownerName, len(rf.FKFields), len(rf.RefFields), + ) + return + } + + for i, fk := range rf.FKFields { + ref := rf.RefFields[i] + if fk.Type != ref.Type { + r.errorf(line, col, + "type mismatch in relation %q on model %q: field %q (%s) references %q.%q (%s)", + rf.RelationName, ownerName, + fk.Name, fk.Type, + rf.TargetModelName, ref.Name, ref.Type, + ) + } + } +} + func identOrLiteral(v Value) string { if v.Type == ValIdent || v.Type == ValLiteral { return v.Scalar diff --git a/schema/schema.go b/schema/schema.go index ee3e945..a53cdf2 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -3,6 +3,7 @@ package schema import ( "encoding/json" "fmt" + providers "valkyrie/dbProviders" ) type Severity int @@ -62,7 +63,7 @@ type Schema struct { type Datasource struct { Name string - Provider DbProvider + Provider providers.DbProvider } type Enum struct {