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 != "" { sb.WriteString(fmt.Sprintf("# 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 { sb.WriteString(fmt.Sprintf("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 sb.WriteString(fmt.Sprintf("enum %s {\n", enum.Name)) for _, value := range enum.Values { sb.WriteString(fmt.Sprintf(" %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 } sb.WriteString(fmt.Sprintf("# %s\n", desc)) } sb.WriteString(fmt.Sprintf("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, scalars (sorted), relations (sorted) 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 }