Table.Columns/Constraints/Indexes/Relationships are Go maps, and every writer, reader, diff, inspector, and merge code path that iterated them directly was subject to Go's randomized map order, so identical input could produce different output (or a different in-report violation/diff order) on every run. Most visibly this showed up as bun/gorm `unique:` struct tags changing order across consecutive `make models` runs with no source change. Fixed by sorting map iteration (by Sequence then Name, or alphabetically for string-keyed maps) everywhere the order affects generated output or first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb, pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx, prisma, and typeorm readers; the shared models.GetPrimaryKey/ GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the TUI column/relationship pickers in pkg/ui.
597 lines
15 KiB
Go
597 lines
15 KiB
Go
package prisma
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
)
|
|
|
|
// Writer implements the writers.Writer interface for Prisma schema format
|
|
type Writer struct {
|
|
options *writers.WriterOptions
|
|
}
|
|
|
|
// NewWriter creates a new Prisma writer with the given options
|
|
func NewWriter(options *writers.WriterOptions) *Writer {
|
|
return &Writer{
|
|
options: options,
|
|
}
|
|
}
|
|
|
|
// WriteDatabase writes a Database model to Prisma schema format
|
|
func (w *Writer) WriteDatabase(db *models.Database) error {
|
|
content := w.databaseToPrisma(db)
|
|
|
|
if w.options.OutputPath != "" {
|
|
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
|
|
}
|
|
|
|
fmt.Print(content)
|
|
return nil
|
|
}
|
|
|
|
// WriteSchema writes a Schema model to Prisma schema format
|
|
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
|
// Create temporary database for schema
|
|
db := models.InitDatabase("database")
|
|
db.Schemas = []*models.Schema{schema}
|
|
|
|
return w.WriteDatabase(db)
|
|
}
|
|
|
|
// WriteTable writes a Table model to Prisma schema format
|
|
func (w *Writer) WriteTable(table *models.Table) error {
|
|
// Create temporary schema and database for table
|
|
schema := models.InitSchema(table.Schema)
|
|
schema.Tables = []*models.Table{table}
|
|
|
|
return w.WriteSchema(schema)
|
|
}
|
|
|
|
// databaseToPrisma converts a Database to Prisma schema format string
|
|
func (w *Writer) databaseToPrisma(db *models.Database) string {
|
|
var sb strings.Builder
|
|
|
|
// Write datasource block
|
|
sb.WriteString(w.generateDatasource(db))
|
|
sb.WriteString("\n")
|
|
|
|
// Write generator block
|
|
sb.WriteString(w.generateGenerator(db))
|
|
sb.WriteString("\n")
|
|
|
|
// Process all schemas (typically just one in Prisma)
|
|
for _, schema := range db.Schemas {
|
|
// Write enums
|
|
if len(schema.Enums) > 0 {
|
|
for _, enum := range schema.Enums {
|
|
sb.WriteString(w.enumToPrisma(enum))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Identify join tables for implicit M2M
|
|
joinTables := w.identifyJoinTables(schema)
|
|
|
|
// Write models (excluding join tables)
|
|
for _, table := range schema.Tables {
|
|
if joinTables[table.Name] {
|
|
continue // Skip join tables
|
|
}
|
|
sb.WriteString(w.tableToPrisma(table, schema, joinTables))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// generateDatasource generates the datasource block
|
|
func (w *Writer) generateDatasource(db *models.Database) string {
|
|
provider := "postgresql"
|
|
|
|
// Map database type to Prisma provider
|
|
switch db.DatabaseType {
|
|
case models.PostgresqlDatabaseType:
|
|
provider = "postgresql"
|
|
case models.MSSQLDatabaseType:
|
|
provider = "sqlserver"
|
|
case models.SqlLiteDatabaseType:
|
|
provider = "sqlite"
|
|
case "mysql":
|
|
provider = "mysql"
|
|
}
|
|
|
|
return fmt.Sprintf(`datasource db {
|
|
provider = "%s"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
`, provider)
|
|
}
|
|
|
|
// generateGenerator generates the generator block
|
|
func (w *Writer) generateGenerator(db *models.Database) string {
|
|
if w.usePrisma7Generator(db) {
|
|
return `generator client {
|
|
provider = "prisma-client"
|
|
output = "./generated"
|
|
}
|
|
`
|
|
}
|
|
|
|
return `generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
`
|
|
}
|
|
|
|
func (w *Writer) usePrisma7Generator(db *models.Database) bool {
|
|
if w.options != nil && w.options.Prisma7 {
|
|
return true
|
|
}
|
|
return db != nil && db.SourceFormat == "prisma7"
|
|
}
|
|
|
|
// enumToPrisma converts an Enum to Prisma enum block
|
|
func (w *Writer) enumToPrisma(enum *models.Enum) string {
|
|
var sb strings.Builder
|
|
|
|
fmt.Fprintf(&sb, "enum %s {\n", enum.Name)
|
|
for _, value := range enum.Values {
|
|
fmt.Fprintf(&sb, " %s\n", value)
|
|
}
|
|
sb.WriteString("}\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// identifyJoinTables identifies tables that are join tables for M2M relations
|
|
func (w *Writer) identifyJoinTables(schema *models.Schema) map[string]bool {
|
|
joinTables := make(map[string]bool)
|
|
|
|
for _, table := range schema.Tables {
|
|
// Check if this is a join table:
|
|
// 1. Starts with _ (Prisma convention)
|
|
// 2. Has exactly 2 FK constraints
|
|
// 3. Has composite PK with those 2 columns
|
|
// 4. Has no other columns except the FK columns
|
|
|
|
if !strings.HasPrefix(table.Name, "_") {
|
|
continue
|
|
}
|
|
|
|
fks := table.GetForeignKeys()
|
|
if len(fks) != 2 {
|
|
continue
|
|
}
|
|
|
|
// Check if columns are only the FK columns
|
|
if len(table.Columns) != 2 {
|
|
continue
|
|
}
|
|
|
|
// Check if both FK columns are part of PK
|
|
pkCols := 0
|
|
for _, col := range table.Columns {
|
|
if col.IsPrimaryKey {
|
|
pkCols++
|
|
}
|
|
}
|
|
|
|
if pkCols == 2 {
|
|
joinTables[table.Name] = true
|
|
}
|
|
}
|
|
|
|
return joinTables
|
|
}
|
|
|
|
// tableToPrisma converts a Table to Prisma model block
|
|
func (w *Writer) tableToPrisma(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
|
var sb strings.Builder
|
|
|
|
fmt.Fprintf(&sb, "model %s {\n", table.Name)
|
|
|
|
// Collect columns to write
|
|
columns := make([]*models.Column, 0, len(table.Columns))
|
|
for _, col := range table.Columns {
|
|
columns = append(columns, col)
|
|
}
|
|
|
|
// Sort columns for consistent output
|
|
sort.Slice(columns, func(i, j int) bool {
|
|
return columns[i].Name < columns[j].Name
|
|
})
|
|
|
|
// Write scalar fields
|
|
for _, col := range columns {
|
|
// Skip if this column is part of a relation that will be output as array field
|
|
if w.isRelationColumn(col, table) {
|
|
// We'll output this with the relation field
|
|
continue
|
|
}
|
|
|
|
sb.WriteString(w.columnToField(col, table, schema))
|
|
}
|
|
|
|
// Write relation fields
|
|
sb.WriteString(w.generateRelationFields(table, schema, joinTables))
|
|
|
|
// Write block attributes (@@id, @@unique, @@index)
|
|
sb.WriteString(w.generateBlockAttributes(table))
|
|
|
|
sb.WriteString("}\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// columnToField converts a Column to a Prisma field definition
|
|
func (w *Writer) columnToField(col *models.Column, table *models.Table, schema *models.Schema) string {
|
|
var sb strings.Builder
|
|
|
|
// Field name
|
|
fmt.Fprintf(&sb, " %s", col.Name)
|
|
|
|
// Field type
|
|
prismaType := w.sqlTypeToPrisma(col.Type, schema)
|
|
fmt.Fprintf(&sb, " %s", prismaType)
|
|
|
|
// Optional modifier
|
|
if !col.NotNull && !col.IsPrimaryKey {
|
|
sb.WriteString("?")
|
|
}
|
|
|
|
// Field attributes
|
|
attributes := w.generateFieldAttributes(col, table)
|
|
if attributes != "" {
|
|
sb.WriteString(" ")
|
|
sb.WriteString(attributes)
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// sqlTypeToPrisma converts SQL types to Prisma types
|
|
func (w *Writer) sqlTypeToPrisma(sqlType string, schema *models.Schema) string {
|
|
// Check if it's an enum
|
|
for _, enum := range schema.Enums {
|
|
if strings.EqualFold(sqlType, enum.Name) {
|
|
return enum.Name
|
|
}
|
|
}
|
|
|
|
// Standard type mapping
|
|
typeMap := map[string]string{
|
|
"text": "String",
|
|
"varchar": "String",
|
|
"character varying": "String",
|
|
"char": "String",
|
|
"boolean": "Boolean",
|
|
"bool": "Boolean",
|
|
"integer": "Int",
|
|
"int": "Int",
|
|
"int4": "Int",
|
|
"bigint": "BigInt",
|
|
"int8": "BigInt",
|
|
"double precision": "Float",
|
|
"float": "Float",
|
|
"float8": "Float",
|
|
"decimal": "Decimal",
|
|
"numeric": "Decimal",
|
|
"timestamp": "DateTime",
|
|
"timestamptz": "DateTime",
|
|
"date": "DateTime",
|
|
"jsonb": "Json",
|
|
"json": "Json",
|
|
"bytea": "Bytes",
|
|
}
|
|
|
|
for sqlPattern, prismaType := range typeMap {
|
|
if strings.Contains(strings.ToLower(sqlType), sqlPattern) {
|
|
return prismaType
|
|
}
|
|
}
|
|
|
|
// Default to String for unknown types
|
|
return "String"
|
|
}
|
|
|
|
// generateFieldAttributes generates field attributes like @id, @unique, @default
|
|
func (w *Writer) generateFieldAttributes(col *models.Column, table *models.Table) string {
|
|
attrs := make([]string, 0)
|
|
|
|
// @id
|
|
if col.IsPrimaryKey {
|
|
// Check if this is part of a composite key
|
|
pkCount := 0
|
|
for _, c := range table.Columns {
|
|
if c.IsPrimaryKey {
|
|
pkCount++
|
|
}
|
|
}
|
|
if pkCount == 1 {
|
|
attrs = append(attrs, "@id")
|
|
}
|
|
}
|
|
|
|
// @unique
|
|
if w.hasUniqueConstraint(col.Name, table) {
|
|
attrs = append(attrs, "@unique")
|
|
}
|
|
|
|
// @default
|
|
if col.AutoIncrement {
|
|
attrs = append(attrs, "@default(autoincrement())")
|
|
} else if col.Default != nil {
|
|
defaultAttr := w.formatDefaultValue(col.Default)
|
|
if defaultAttr != "" {
|
|
attrs = append(attrs, fmt.Sprintf("@default(%s)", defaultAttr))
|
|
}
|
|
}
|
|
|
|
// @updatedAt (check comment)
|
|
if strings.Contains(col.Comment, "@updatedAt") {
|
|
attrs = append(attrs, "@updatedAt")
|
|
}
|
|
|
|
return strings.Join(attrs, " ")
|
|
}
|
|
|
|
// formatDefaultValue formats a default value for Prisma
|
|
func (w *Writer) formatDefaultValue(defaultValue any) string {
|
|
switch v := defaultValue.(type) {
|
|
case string:
|
|
if v == "now()" {
|
|
return "now()"
|
|
} else if v == "gen_random_uuid()" {
|
|
return "uuid()"
|
|
} else if strings.Contains(strings.ToLower(v), "uuid") {
|
|
return "uuid()"
|
|
} else {
|
|
// String literal
|
|
return fmt.Sprintf(`"%s"`, v)
|
|
}
|
|
case bool:
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case int, int64, int32:
|
|
return fmt.Sprintf("%v", v)
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// hasUniqueConstraint checks if a column has a unique constraint
|
|
func (w *Writer) hasUniqueConstraint(colName string, table *models.Table) bool {
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.UniqueConstraint &&
|
|
len(constraint.Columns) == 1 &&
|
|
constraint.Columns[0] == colName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isRelationColumn checks if a column is a FK column
|
|
func (w *Writer) isRelationColumn(col *models.Column, table *models.Table) bool {
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
for _, fkCol := range constraint.Columns {
|
|
if fkCol == col.Name {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// generateRelationFields generates relation fields and their FK columns
|
|
func (w *Writer) generateRelationFields(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
|
var sb strings.Builder
|
|
|
|
// Get all FK constraints
|
|
fks := table.GetForeignKeys()
|
|
|
|
for _, fk := range fks {
|
|
// Generate the FK scalar field
|
|
for _, fkCol := range fk.Columns {
|
|
if col, exists := table.Columns[fkCol]; exists {
|
|
sb.WriteString(w.columnToField(col, table, schema))
|
|
}
|
|
}
|
|
|
|
// Generate the relation field
|
|
relationType := fk.ReferencedTable
|
|
isOptional := false
|
|
|
|
// Check if FK column is nullable
|
|
for _, fkCol := range fk.Columns {
|
|
if col, exists := table.Columns[fkCol]; exists {
|
|
if !col.NotNull {
|
|
isOptional = true
|
|
}
|
|
}
|
|
}
|
|
|
|
relationName := relationType
|
|
if strings.HasSuffix(strings.ToLower(relationName), "s") {
|
|
relationName = relationName[:len(relationName)-1]
|
|
}
|
|
|
|
fmt.Fprintf(&sb, " %s %s", strings.ToLower(relationName), relationType)
|
|
|
|
if isOptional {
|
|
sb.WriteString("?")
|
|
}
|
|
|
|
// @relation attribute
|
|
relationAttr := w.generateRelationAttribute(fk)
|
|
if relationAttr != "" {
|
|
sb.WriteString(" ")
|
|
sb.WriteString(relationAttr)
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Generate inverse relations (arrays) for tables that reference this one
|
|
sb.WriteString(w.generateInverseRelations(table, schema, joinTables))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// generateRelationAttribute generates the @relation(...) attribute
|
|
func (w *Writer) generateRelationAttribute(fk *models.Constraint) string {
|
|
parts := make([]string, 0)
|
|
|
|
// fields
|
|
fieldsStr := strings.Join(fk.Columns, ", ")
|
|
parts = append(parts, fmt.Sprintf("fields: [%s]", fieldsStr))
|
|
|
|
// references
|
|
referencesStr := strings.Join(fk.ReferencedColumns, ", ")
|
|
parts = append(parts, fmt.Sprintf("references: [%s]", referencesStr))
|
|
|
|
// onDelete
|
|
if fk.OnDelete != "" {
|
|
parts = append(parts, fmt.Sprintf("onDelete: %s", fk.OnDelete))
|
|
}
|
|
|
|
// onUpdate
|
|
if fk.OnUpdate != "" {
|
|
parts = append(parts, fmt.Sprintf("onUpdate: %s", fk.OnUpdate))
|
|
}
|
|
|
|
return fmt.Sprintf("@relation(%s)", strings.Join(parts, ", "))
|
|
}
|
|
|
|
// generateInverseRelations generates array fields for reverse relationships
|
|
func (w *Writer) generateInverseRelations(table *models.Table, schema *models.Schema, joinTables map[string]bool) string {
|
|
var sb strings.Builder
|
|
|
|
// Find all tables that have FKs pointing to this table
|
|
for _, otherTable := range schema.Tables {
|
|
if otherTable.Name == table.Name {
|
|
continue
|
|
}
|
|
|
|
// Check if this is a join table
|
|
if joinTables[otherTable.Name] {
|
|
// Handle implicit M2M
|
|
if w.isJoinTableFor(otherTable, table.Name) {
|
|
// Find the other side of the M2M
|
|
for _, fk := range otherTable.GetForeignKeys() {
|
|
if fk.ReferencedTable != table.Name {
|
|
// This is the other side
|
|
otherSide := fk.ReferencedTable
|
|
fmt.Fprintf(&sb, " %ss %s[]\n",
|
|
strings.ToLower(otherSide), otherSide)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Regular one-to-many inverse relation
|
|
for _, fk := range otherTable.GetForeignKeys() {
|
|
if fk.ReferencedTable == table.Name {
|
|
// This table is referenced by otherTable
|
|
pluralName := otherTable.Name
|
|
if !strings.HasSuffix(pluralName, "s") {
|
|
pluralName += "s"
|
|
}
|
|
|
|
fmt.Fprintf(&sb, " %s %s[]\n",
|
|
strings.ToLower(pluralName), otherTable.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// isJoinTableFor checks if a table is a join table involving the specified model
|
|
func (w *Writer) isJoinTableFor(joinTable *models.Table, modelName string) bool {
|
|
for _, fk := range joinTable.GetForeignKeys() {
|
|
if fk.ReferencedTable == modelName {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// generateBlockAttributes generates block-level attributes like @@id, @@unique, @@index
|
|
func (w *Writer) generateBlockAttributes(table *models.Table) string {
|
|
var sb strings.Builder
|
|
|
|
// @@id for composite primary key
|
|
pkCols := make([]string, 0)
|
|
for _, col := range table.Columns {
|
|
if col.IsPrimaryKey {
|
|
pkCols = append(pkCols, col.Name)
|
|
}
|
|
}
|
|
|
|
if len(pkCols) > 1 {
|
|
sort.Strings(pkCols)
|
|
fmt.Fprintf(&sb, " @@id([%s])\n", strings.Join(pkCols, ", "))
|
|
}
|
|
|
|
// @@unique for multi-column unique constraints
|
|
for _, constraint := range sortConstraints(table.Constraints) {
|
|
if constraint.Type == models.UniqueConstraint && len(constraint.Columns) > 1 {
|
|
fmt.Fprintf(&sb, " @@unique([%s])\n", strings.Join(constraint.Columns, ", "))
|
|
}
|
|
}
|
|
|
|
// @@index for indexes
|
|
for _, index := range sortIndexes(table.Indexes) {
|
|
if !index.Unique { // Unique indexes are handled by @@unique
|
|
fmt.Fprintf(&sb, " @@index([%s])\n", strings.Join(index.Columns, ", "))
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// sortConstraints returns constraints sorted by Sequence then Name for deterministic output.
|
|
func sortConstraints(constraints map[string]*models.Constraint) []*models.Constraint {
|
|
result := make([]*models.Constraint, 0, len(constraints))
|
|
for _, c := range constraints {
|
|
result = append(result, c)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
|
return result[i].Sequence < result[j].Sequence
|
|
}
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
return result
|
|
}
|
|
|
|
// sortIndexes returns indexes sorted by Sequence then Name for deterministic output.
|
|
func sortIndexes(indexes map[string]*models.Index) []*models.Index {
|
|
result := make([]*models.Index, 0, len(indexes))
|
|
for _, idx := range indexes {
|
|
result = append(result, idx)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
if result[i].Sequence > 0 && result[j].Sequence > 0 {
|
|
return result[i].Sequence < result[j].Sequence
|
|
}
|
|
return result[i].Name < result[j].Name
|
|
})
|
|
return result
|
|
}
|