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
455 lines
12 KiB
Go
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, ", ")
|
|
}
|