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.
274 lines
6.0 KiB
Go
274 lines
6.0 KiB
Go
package graphql
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
)
|
|
|
|
type Writer struct {
|
|
options *writers.WriterOptions
|
|
}
|
|
|
|
func NewWriter(options *writers.WriterOptions) *Writer {
|
|
return &Writer{
|
|
options: options,
|
|
}
|
|
}
|
|
|
|
func (w *Writer) WriteDatabase(db *models.Database) error {
|
|
content := w.databaseToGraphQL(db)
|
|
|
|
if w.options.OutputPath != "" {
|
|
return os.WriteFile(w.options.OutputPath, []byte(content), 0644)
|
|
}
|
|
|
|
fmt.Print(content)
|
|
return nil
|
|
}
|
|
|
|
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
|
db := models.InitDatabase(schema.Name)
|
|
db.Schemas = []*models.Schema{schema}
|
|
return w.WriteDatabase(db)
|
|
}
|
|
|
|
func (w *Writer) WriteTable(table *models.Table) error {
|
|
schema := models.InitSchema(table.Schema)
|
|
schema.Tables = []*models.Table{table}
|
|
db := models.InitDatabase(schema.Name)
|
|
db.Schemas = []*models.Schema{schema}
|
|
return w.WriteDatabase(db)
|
|
}
|
|
|
|
func (w *Writer) databaseToGraphQL(db *models.Database) string {
|
|
var sb strings.Builder
|
|
|
|
// Header comment
|
|
if w.shouldIncludeComments() {
|
|
sb.WriteString("# Generated GraphQL Schema\n")
|
|
if db.Name != "" {
|
|
fmt.Fprintf(&sb, "# Database: %s\n", db.Name)
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Custom scalar declarations
|
|
if w.shouldIncludeScalarDeclarations() {
|
|
scalars := w.collectCustomScalars(db)
|
|
if len(scalars) > 0 {
|
|
for _, scalar := range scalars {
|
|
fmt.Fprintf(&sb, "scalar %s\n", scalar)
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Enum definitions
|
|
for _, schema := range db.Schemas {
|
|
for _, enum := range schema.Enums {
|
|
sb.WriteString(w.enumToGraphQL(enum))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// Type definitions
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
// Skip join tables (tables with only PK+FK columns)
|
|
if w.isJoinTable(table) {
|
|
continue
|
|
}
|
|
|
|
sb.WriteString(w.tableToGraphQL(table, db, schema))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (w *Writer) shouldIncludeComments() bool {
|
|
if w.options.Metadata != nil {
|
|
if include, ok := w.options.Metadata["includeComments"].(bool); ok {
|
|
return include
|
|
}
|
|
}
|
|
return true // Default to true
|
|
}
|
|
|
|
func (w *Writer) shouldIncludeScalarDeclarations() bool {
|
|
if w.options.Metadata != nil {
|
|
if include, ok := w.options.Metadata["includeScalarDeclarations"].(bool); ok {
|
|
return include
|
|
}
|
|
}
|
|
return true // Default to true
|
|
}
|
|
|
|
func (w *Writer) collectCustomScalars(db *models.Database) []string {
|
|
scalarsNeeded := make(map[string]bool)
|
|
|
|
for _, schema := range db.Schemas {
|
|
for _, table := range schema.Tables {
|
|
for _, col := range table.Columns {
|
|
if scalar := w.sqlTypeToCustomScalar(col.Type); scalar != "" {
|
|
scalarsNeeded[scalar] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert to sorted slice
|
|
scalars := make([]string, 0, len(scalarsNeeded))
|
|
for scalar := range scalarsNeeded {
|
|
scalars = append(scalars, scalar)
|
|
}
|
|
sort.Strings(scalars)
|
|
|
|
return scalars
|
|
}
|
|
|
|
func (w *Writer) isJoinTable(table *models.Table) bool {
|
|
// A join table typically has:
|
|
// 1. Exactly 2 FK constraints
|
|
// 2. Composite primary key on those FK columns
|
|
// 3. No other columns
|
|
|
|
fkCount := 0
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
fkCount++
|
|
}
|
|
}
|
|
|
|
if fkCount != 2 {
|
|
return false
|
|
}
|
|
|
|
// Check if all columns are either PKs or FKs
|
|
for _, col := range table.Columns {
|
|
isFKColumn := false
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
for _, fkCol := range constraint.Columns {
|
|
if fkCol == col.Name {
|
|
isFKColumn = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !isFKColumn && !col.IsPrimaryKey {
|
|
// Found a column that's neither PK nor FK
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (w *Writer) enumToGraphQL(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()
|
|
}
|
|
|
|
func (w *Writer) tableToGraphQL(table *models.Table, db *models.Database, schema *models.Schema) string {
|
|
var sb strings.Builder
|
|
|
|
// Type name
|
|
typeName := table.Name
|
|
|
|
// Description comment
|
|
if w.shouldIncludeComments() && (table.Description != "" || table.Comment != "") {
|
|
desc := table.Description
|
|
if desc == "" {
|
|
desc = table.Comment
|
|
}
|
|
fmt.Fprintf(&sb, "# %s\n", desc)
|
|
}
|
|
|
|
fmt.Fprintf(&sb, "type %s {\n", typeName)
|
|
|
|
// Collect and categorize fields
|
|
var idFields, scalarFields, relationFields []string
|
|
|
|
for _, column := range table.Columns {
|
|
// Skip FK columns (they become relation fields)
|
|
if w.isForeignKeyColumn(column, table) {
|
|
continue
|
|
}
|
|
|
|
gqlType := w.sqlTypeToGraphQL(column.Type, column, table, schema)
|
|
if gqlType == "" {
|
|
continue // Skip if type couldn't be mapped
|
|
}
|
|
|
|
// Determine nullability
|
|
if column.NotNull {
|
|
gqlType += "!"
|
|
}
|
|
|
|
field := fmt.Sprintf(" %s: %s", column.Name, gqlType)
|
|
|
|
if column.IsPrimaryKey {
|
|
idFields = append(idFields, field)
|
|
} else {
|
|
scalarFields = append(scalarFields, field)
|
|
}
|
|
}
|
|
|
|
// Add relation fields
|
|
relationFields = w.generateRelationFields(table, db, schema)
|
|
|
|
// Write fields in order: ID (sorted), scalars (sorted), relations (sorted)
|
|
sort.Strings(idFields)
|
|
for _, field := range idFields {
|
|
sb.WriteString(field + "\n")
|
|
}
|
|
|
|
sort.Strings(scalarFields)
|
|
for _, field := range scalarFields {
|
|
sb.WriteString(field + "\n")
|
|
}
|
|
|
|
if len(relationFields) > 0 {
|
|
if len(scalarFields) > 0 || len(idFields) > 0 {
|
|
sb.WriteString("\n") // Blank line before relations
|
|
}
|
|
sort.Strings(relationFields)
|
|
for _, field := range relationFields {
|
|
sb.WriteString(field + "\n")
|
|
}
|
|
}
|
|
|
|
sb.WriteString("}\n")
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (w *Writer) isForeignKeyColumn(column *models.Column, table *models.Table) bool {
|
|
for _, constraint := range table.Constraints {
|
|
if constraint.Type == models.ForeignKeyConstraint {
|
|
for _, fkCol := range constraint.Columns {
|
|
if fkCol == column.Name {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|