Files
relspecgo/pkg/writers/mysql/writer.go
T
warkanum 1d93255575 fix(assets): improve error reporting for asset failures
fix(embed): update embedDirectives to return result directly

fix(executor): clean up import order and formatting

fix(syntax_test): change file permission syntax to octal

fix(mysql): update file permission syntax to octal

fix(pgsql): update file permission syntax to octal

fix(sqlex): simplify Walk function parameter handling

fix(transform): adjust identifier comparison logic

fix(template): clean up function formatting and error handling
2026-10-12 22:04:40 +02:00

455 lines
12 KiB
Go

package mysql
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
_ "github.com/go-sql-driver/mysql"
"git.warky.dev/wdevs/relspecgo/pkg/mariadb"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/transform"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
type Writer struct {
options *writers.WriterOptions
writer io.Writer
}
func NewWriter(options *writers.WriterOptions) *Writer { return &Writer{options: options} }
// Statements preflights the whole model, emits conversion diagnostics, and returns
// executable units. No output files or database connections are opened here.
func (w *Writer) Statements(db *models.Database) ([]string, error) {
if w.options == nil {
return nil, fmt.Errorf("writer options are required")
}
if db == nil {
return nil, fmt.Errorf("database is required")
}
if w.options.LowercaseIdentifiers {
var err error
db, err = transform.LowercaseIdentifiers(db)
if err != nil {
return nil, err
}
}
diagnostics := conversionDiagnostics(db)
if len(diagnostics) > 0 && w.options.StrictConversion {
return nil, fmt.Errorf("lossy MySQL conversion rejected:\n- %s", strings.Join(diagnostics, "\n- "))
}
for _, d := range diagnostics {
if w.options.Warning != nil {
w.options.Warning(d)
} else {
fmt.Fprintln(os.Stderr, "warning: MySQL conversion: "+d)
}
}
return databaseStatements(db), nil
}
func (w *Writer) WriteDatabase(db *models.Database) error {
statements, err := w.Statements(db)
if err != nil {
return err
}
if conn, ok := w.options.Metadata["connection_string"].(string); ok && conn != "" {
return w.execute(statements, conn)
}
if w.writer != nil {
return writeStatements(w.writer, statements, db.Name)
}
if w.options.OutputPath == "" {
return writeStatements(os.Stdout, statements, db.Name)
}
f, err := os.Create(w.options.OutputPath)
if err != nil {
return err
}
err = writeStatements(f, statements, db.Name)
closeErr := f.Close()
if err != nil {
return err
}
return closeErr
}
func writeStatements(out io.Writer, statements []string, name string) error {
name = strings.NewReplacer("\n", " ", "\r", " ").Replace(name)
if _, err := fmt.Fprintf(out, "-- Database: %s\n", name); err != nil {
return err
}
if _, err := io.WriteString(out, "-- MySQL table schema; generated by RelSpec\n\n"); err != nil {
return err
}
for _, stmt := range statements {
if _, err := io.WriteString(out, stmt); err != nil {
return err
}
}
return nil
}
func (w *Writer) WriteSchema(s *models.Schema) error {
return w.WriteDatabase(&models.Database{Schemas: []*models.Schema{s}})
}
func (w *Writer) WriteTable(t *models.Table) error {
return w.WriteSchema(&models.Schema{Name: "", Tables: []*models.Table{t}})
}
func qualified(schema, name string) string {
if schema == "" {
return quote(name)
}
return quote(schema) + "." + quote(name)
}
func keys[T any](m map[string]T) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func metaString(m map[string]any, key string) string { v, _ := m[key].(string); return v }
func metaBool(m map[string]any, key string, def bool) bool {
v, ok := m[key].(bool)
if ok {
return v
}
return def
}
func signature(c *models.Column) string {
return fmt.Sprintf("%s/%d/%d/%d", c.Type, c.Length, c.Precision, c.Scale)
}
func columnType(c *models.Column) string {
if native := metaString(c.Metadata, "mysql_type"); native != "" && metaString(c.Metadata, "mysql_type_signature") == signature(c) {
return native
}
typ := mariadb.ConvertCanonicalToMariaDB(c.Type)
switch strings.ToLower(c.Type) {
case "string":
if c.Length > 0 {
typ = fmt.Sprintf("VARCHAR(%d)", c.Length)
}
case "decimal":
if c.Precision > 0 {
typ = fmt.Sprintf("DECIMAL(%d,%d)", c.Precision, c.Scale)
}
case "timestamp", "time":
if c.Precision > 0 {
typ += fmt.Sprintf("(%d)", c.Precision)
}
}
return typ
}
// MySQL string literals use doubled quotes and escaped backslashes. The output
// assumes the server's standard SQL mode (NO_BACKSLASH_ESCAPES is not enabled).
func literal(s string) string {
return "'" + strings.ReplaceAll(strings.ReplaceAll(s, "\\", "\\\\"), "'", "''") + "'"
}
func defaultSQL(c *models.Column) string {
value := fmt.Sprint(c.Default)
if native := metaString(c.Metadata, "mysql_default_sql"); native != "" && metaString(c.Metadata, "mysql_default_value") == value {
return native
}
if strings.HasPrefix(metaString(c.Metadata, "mysql_type"), "bit(") && strings.HasPrefix(value, "b'") && strings.HasSuffix(value, "'") && strings.Trim(value[2:len(value)-1], "01") == "" {
return value
}
if metaBool(c.Metadata, "mysql_default_expression", false) {
if strings.HasPrefix(strings.ToUpper(value), "CURRENT_TIMESTAMP") {
return value
}
return "(" + value + ")"
}
switch strings.ToLower(c.Type) {
case "text", "json", "jsonb", "bytea":
return "(" + literal(value) + ")"
case "string", "uuid", "date", "time", "timestamp", "timestamptz":
return literal(value)
case "bool", "boolean":
if value == "true" {
return "1"
}
if value == "false" {
return "0"
}
}
// Numeric defaults are values, never executable fragments.
if _, err := strconv.ParseFloat(value, 64); err == nil {
return value
}
return literal(value)
}
func indexParts(i *models.Index) string {
if len(i.KeyParts) == 0 {
return quoted(i.Columns)
}
parts := make([]string, 0, len(i.KeyParts))
for _, p := range i.KeyParts {
s := quote(p.Name)
if p.Expression != "" {
s = "(" + p.Expression + ")"
}
if p.Length > 0 {
s += fmt.Sprintf("(%d)", p.Length)
}
if p.Descending {
s += " DESC"
}
parts = append(parts, s)
}
return strings.Join(parts, ", ")
}
func tableStatement(s *models.Schema, t *models.Table) string {
schema := ""
if s != nil {
schema = s.Name
}
cols := make([]*models.Column, 0, len(t.Columns))
for _, c := range t.Columns {
cols = append(cols, c)
}
sort.Slice(cols, func(i, j int) bool {
if cols[i].Sequence != cols[j].Sequence {
return cols[i].Sequence < cols[j].Sequence
}
return cols[i].Name < cols[j].Name
})
defs := []string{}
pk := []string{}
for _, c := range cols {
d := " " + quote(c.Name) + " " + columnType(c)
if charset := metaString(c.Metadata, "mysql_charset"); charset != "" && c.Collation != metaString(t.Metadata, "mysql_collation") {
d += " CHARACTER SET " + quote(charset)
}
if c.Collation != "" && c.Collation != metaString(t.Metadata, "mysql_collation") {
d += " COLLATE " + quote(c.Collation)
}
if c.Generated {
d += " GENERATED ALWAYS AS (" + c.GenerationExpression + ")"
if strings.EqualFold(metaString(c.Metadata, "mysql_generation_storage"), "STORED") {
d += " STORED"
} else {
d += " VIRTUAL"
}
}
if c.NotNull {
d += " NOT NULL"
} else if !c.Generated {
d += " NULL"
}
if c.AutoIncrement {
d += " AUTO_INCREMENT"
}
if c.Default != nil && !c.Generated {
d += " DEFAULT " + defaultSQL(c)
}
if upd := metaString(c.Metadata, "mysql_on_update"); upd != "" {
d += " ON UPDATE " + upd
}
if metaBool(c.Metadata, "mysql_invisible", false) {
d += " INVISIBLE"
}
if c.Comment != "" {
d += " COMMENT " + literal(c.Comment)
}
defs = append(defs, d)
if c.IsPrimaryKey {
pk = append(pk, c.Name)
}
}
for _, name := range keys(t.Constraints) {
c := t.Constraints[name]
if c.Type == models.PrimaryKeyConstraint {
pk = c.Columns
break
}
}
var primaryIndex *models.Index
for name, i := range t.Indexes {
if strings.EqualFold(name, "PRIMARY") {
primaryIndex = i
break
}
}
if i := primaryIndex; i != nil {
defs = append(defs, " PRIMARY KEY ("+indexParts(i)+")")
} else if len(pk) > 0 {
defs = append(defs, " PRIMARY KEY ("+quoted(pk)+")")
}
for _, name := range keys(t.Constraints) {
c := t.Constraints[name]
switch c.Type {
case models.UniqueConstraint:
if t.Indexes[c.Name] == nil {
defs = append(defs, " CONSTRAINT "+quote(c.Name)+" UNIQUE ("+quoted(c.Columns)+")")
}
case models.CheckConstraint:
d := " CONSTRAINT " + quote(c.Name) + " CHECK (" + c.Expression + ")"
if !metaBool(c.Metadata, "mysql_enforced", true) {
d += " NOT ENFORCED"
}
defs = append(defs, d)
}
}
for _, name := range keys(t.Indexes) {
i := t.Indexes[name]
if strings.EqualFold(name, "PRIMARY") {
continue
}
method := strings.ToUpper(i.Type)
if method != "" && method != "BTREE" && method != "HASH" && method != "FULLTEXT" && method != "SPATIAL" {
continue
}
kind := "KEY"
if i.Unique {
kind = "UNIQUE KEY"
}
if method == "FULLTEXT" || method == "SPATIAL" {
kind = method + " KEY"
}
d := " " + kind + " " + quote(i.Name) + " (" + indexParts(i) + ")"
if method == "HASH" {
d += " USING " + method
}
if !metaBool(i.Metadata, "mysql_visible", true) {
d += " INVISIBLE"
}
if i.Comment != "" {
d += " COMMENT " + literal(i.Comment)
}
defs = append(defs, d)
}
engine := metaString(t.Metadata, "mysql_engine")
if engine == "" {
engine = "InnoDB"
}
tail := " ENGINE=" + quote(engine)
if engine == "InnoDB" {
tail = " ENGINE=InnoDB"
}
if coll := metaString(t.Metadata, "mysql_collation"); coll != "" {
tail += " COLLATE=" + quote(coll)
}
if auto := metaString(t.Metadata, "mysql_auto_increment"); auto != "" {
if _, err := strconv.ParseUint(auto, 10, 64); err == nil {
tail += " AUTO_INCREMENT=" + auto
}
}
if t.Comment != "" {
tail += " COMMENT=" + literal(t.Comment)
}
return "CREATE TABLE IF NOT EXISTS " + qualified(schema, t.Name) + " (\n" + strings.Join(defs, ",\n") + "\n)" + tail + ";\n\n"
}
func databaseStatements(db *models.Database) []string {
var statements []string
for _, s := range db.Schemas {
for _, t := range s.Tables {
statements = append(statements, tableStatement(s, t))
}
}
// References are added after all tables, supporting cycles and forward references.
for _, s := range db.Schemas {
for _, t := range s.Tables {
for _, name := range keys(t.Constraints) {
c := t.Constraints[name]
if c.Type != models.ForeignKeyConstraint {
continue
}
rs := c.ReferencedSchema
if rs == "" {
rs = s.Name
}
stmt := "ALTER TABLE " + qualified(s.Name, t.Name) + " ADD CONSTRAINT " + quote(c.Name) + " FOREIGN KEY (" + quoted(c.Columns) + ") REFERENCES " + qualified(rs, c.ReferencedTable) + " (" + quoted(c.ReferencedColumns) + ")"
if validAction(c.OnDelete) && c.OnDelete != "" {
stmt += " ON DELETE " + strings.ToUpper(c.OnDelete)
}
if validAction(c.OnUpdate) && c.OnUpdate != "" {
stmt += " ON UPDATE " + strings.ToUpper(c.OnUpdate)
}
statements = append(statements, stmt+";\n\n")
}
}
}
return statements
}
func (w *Writer) execute(statements []string, conn string) error {
if w.options.AtomicExecution {
return fmt.Errorf("MySQL DDL implicitly commits; atomic execution is unsupported")
}
db, err := sql.Open("mysql", conn)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer db.Close()
session, err := db.Conn(context.Background())
if err != nil {
return fmt.Errorf("failed to execute SQL: %w", err)
}
defer session.Close()
var mode string
if err := session.QueryRowContext(context.Background(), "SELECT @@SESSION.sql_mode").Scan(&mode); err != nil {
return fmt.Errorf("read MySQL SQL mode: %w", err)
}
if mode = unsupportedMode(mode); mode != "" {
return fmt.Errorf("MySQL execution requires standard quotes and backslash escaping; %s is enabled", mode)
}
var failures []string
for n, stmt := range statements {
if _, err := session.ExecContext(context.Background(), stmt); err != nil {
failures = append(failures, fmt.Sprintf("statement %d: %v", n+1, err))
if !w.options.ContinueOnError {
break
}
}
}
if len(failures) > 0 {
return fmt.Errorf("failed to execute SQL: %s", strings.Join(failures, "; "))
}
return nil
}
func quote(s string) string { return "`" + strings.ReplaceAll(s, "`", "``") + "`" }
func quoted(xs []string) string {
out := make([]string, len(xs))
for i, x := range xs {
out[i] = quote(x)
}
return strings.Join(out, ", ")
}
func validAction(action string) bool {
switch strings.ToUpper(action) {
case "", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION":
return true
}
return false
}
func unsupportedMode(mode string) string {
var modes []string
for _, unsupported := range []string{"NO_BACKSLASH_ESCAPES", "ANSI_QUOTES"} {
if strings.Contains(strings.ToUpper(mode), unsupported) {
modes = append(modes, unsupported)
}
}
return strings.Join(modes, ", ")
}