Files
dbbackup/internal/engine/native/mysql.go
Alexander Renz d10f334508
Some checks failed
CI/CD / Test (push) Has been cancelled
CI/CD / Integration Tests (push) Has been cancelled
CI/CD / Native Engine Tests (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
CI/CD / Build Binary (push) Has been cancelled
CI/CD / Test Release Build (push) Has been cancelled
CI/CD / Release Binaries (push) Has been cancelled
v5.7.7: DR Drill MariaDB fixes, SMTP notifications, verify paths
### Fixed (5.7.3 - 5.7.7)
- MariaDB binlog position bug (4 vs 5 columns)
- Notify test command ENV variable reading
- SMTP 250 Ok response treated as error
- Verify command absolute path handling
- DR Drill for modern MariaDB containers:
  - Use mariadb-admin/mariadb client
  - TCP instead of socket connections
  - DROP DATABASE before restore

### Improved
- Better --password flag error message
- PostgreSQL peer auth fallback logging
- Binlog warnings at DEBUG level
2026-02-03 13:42:02 +01:00

1195 lines
32 KiB
Go

package native
import (
"bufio"
"context"
"database/sql"
"fmt"
"io"
"math"
"strings"
"time"
"dbbackup/internal/logger"
"github.com/go-sql-driver/mysql"
)
// MySQLNativeEngine implements pure Go MySQL backup/restore
type MySQLNativeEngine struct {
db *sql.DB
cfg *MySQLNativeConfig
log logger.Logger
}
type MySQLNativeConfig struct {
// Connection
Host string
Port int
User string
Password string
Database string
Socket string
SSLMode string
// Backup options
Format string // sql
Compression int // 0-9
SingleTransaction bool
LockTables bool
Routines bool
Triggers bool
Events bool
// Schema options
SchemaOnly bool
DataOnly bool
IncludeDatabase []string
ExcludeDatabase []string
IncludeTable []string
ExcludeTable []string
// Advanced options
AddDropTable bool
CreateOptions bool
DisableKeys bool
ExtendedInsert bool
HexBlob bool
QuickDump bool
// PITR options
MasterData int // 0=disabled, 1=CHANGE MASTER, 2=commented
FlushLogs bool
DeleteMasterLogs bool
}
// MySQLDatabaseObject represents a MySQL database object
type MySQLDatabaseObject struct {
Database string
Name string
Type string // table, view, procedure, function, trigger, event
Engine string // InnoDB, MyISAM, etc.
CreateSQL string
Dependencies []string
}
// MySQLTableInfo contains table metadata
type MySQLTableInfo struct {
Name string
Engine string
Collation string
RowCount int64
DataLength int64
IndexLength int64
AutoIncrement *int64
CreateTime *time.Time
UpdateTime *time.Time
}
// BinlogPosition represents MySQL binary log position
type BinlogPosition struct {
File string
Position int64
GTIDSet string
}
// NewMySQLNativeEngine creates a new native MySQL engine
func NewMySQLNativeEngine(cfg *MySQLNativeConfig, log logger.Logger) (*MySQLNativeEngine, error) {
engine := &MySQLNativeEngine{
cfg: cfg,
log: log,
}
return engine, nil
}
// Connect establishes database connection
func (e *MySQLNativeEngine) Connect(ctx context.Context) error {
dsn := e.buildDSN()
db, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("failed to open MySQL connection: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return fmt.Errorf("failed to ping MySQL server: %w", err)
}
e.db = db
return nil
}
// Backup performs native MySQL backup
func (e *MySQLNativeEngine) Backup(ctx context.Context, outputWriter io.Writer) (*BackupResult, error) {
startTime := time.Now()
result := &BackupResult{
Format: "sql",
}
e.log.Info("Starting native MySQL backup", "database", e.cfg.Database)
// Get binlog position for PITR
binlogPos, err := e.getBinlogPosition(ctx)
if err != nil {
// Only warn about binlog errors if it's not "no rows" (binlog disabled) or permission errors
errStr := err.Error()
if strings.Contains(errStr, "no rows in result set") {
e.log.Debug("Binary logging not enabled on this server, skipping binlog position capture")
} else if strings.Contains(errStr, "Access denied") || strings.Contains(errStr, "BINLOG MONITOR") {
e.log.Debug("Insufficient privileges for binlog position (PITR requires BINLOG MONITOR or SUPER privilege)")
} else {
e.log.Warn("Failed to get binlog position", "error", err)
}
}
// Start transaction for consistent backup
var tx *sql.Tx
if e.cfg.SingleTransaction {
tx, err = e.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Set transaction isolation
if _, err := tx.ExecContext(ctx, "SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ"); err != nil {
return nil, fmt.Errorf("failed to set isolation level: %w", err)
}
if _, err := tx.ExecContext(ctx, "START TRANSACTION WITH CONSISTENT SNAPSHOT"); err != nil {
return nil, fmt.Errorf("failed to start consistent snapshot: %w", err)
}
}
// Write SQL header
if err := e.writeSQLHeader(outputWriter, binlogPos); err != nil {
return nil, err
}
// Get databases to backup
databases, err := e.getDatabases(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get databases: %w", err)
}
// Backup each database
for _, database := range databases {
if !e.shouldIncludeDatabase(database) {
continue
}
e.log.Debug("Backing up database", "database", database)
if err := e.backupDatabase(ctx, outputWriter, database, tx, result); err != nil {
return nil, fmt.Errorf("failed to backup database %s: %w", database, err)
}
}
// Write SQL footer
if err := e.writeSQLFooter(outputWriter); err != nil {
return nil, err
}
result.Duration = time.Since(startTime)
return result, nil
}
// backupDatabase backs up a single database
func (e *MySQLNativeEngine) backupDatabase(ctx context.Context, w io.Writer, database string, tx *sql.Tx, result *BackupResult) error {
// Write database header
if err := e.writeDatabaseHeader(w, database); err != nil {
return err
}
// Get database objects
objects, err := e.getDatabaseObjects(ctx, database)
if err != nil {
return fmt.Errorf("failed to get database objects: %w", err)
}
// Create database
if !e.cfg.DataOnly {
createSQL, err := e.getDatabaseCreateSQL(ctx, database)
if err != nil {
return fmt.Errorf("failed to get database create SQL: %w", err)
}
if _, err := w.Write([]byte(createSQL + "\n")); err != nil {
return err
}
// Use database
useSQL := fmt.Sprintf("USE `%s`;\n\n", database)
if _, err := w.Write([]byte(useSQL)); err != nil {
return err
}
}
// Backup tables (schema and data)
tables := e.filterObjectsByType(objects, "table")
// Schema first
if !e.cfg.DataOnly {
for _, table := range tables {
if err := e.backupTableSchema(ctx, w, database, table.Name); err != nil {
return fmt.Errorf("failed to backup table schema %s: %w", table.Name, err)
}
result.ObjectsProcessed++
}
}
// Then data
if !e.cfg.SchemaOnly {
for _, table := range tables {
bytesWritten, err := e.backupTableData(ctx, w, database, table.Name, tx)
if err != nil {
return fmt.Errorf("failed to backup table data %s: %w", table.Name, err)
}
result.BytesProcessed += bytesWritten
}
}
// Backup other objects
if !e.cfg.DataOnly {
if e.cfg.Routines {
if err := e.backupRoutines(ctx, w, database); err != nil {
return fmt.Errorf("failed to backup routines: %w", err)
}
}
if e.cfg.Triggers {
if err := e.backupTriggers(ctx, w, database); err != nil {
return fmt.Errorf("failed to backup triggers: %w", err)
}
}
if e.cfg.Events {
if err := e.backupEvents(ctx, w, database); err != nil {
return fmt.Errorf("failed to backup events: %w", err)
}
}
}
return nil
}
// backupTableData exports table data using SELECT INTO OUTFILE equivalent
func (e *MySQLNativeEngine) backupTableData(ctx context.Context, w io.Writer, database, table string, tx *sql.Tx) (int64, error) {
// Get table info
tableInfo, err := e.getTableInfo(ctx, database, table)
if err != nil {
return 0, err
}
// Skip empty tables
if tableInfo.RowCount == 0 {
return 0, nil
}
// Write table data header
header := fmt.Sprintf("--\n-- Dumping data for table `%s`\n--\n\n", table)
if e.cfg.DisableKeys {
header += fmt.Sprintf("/*!40000 ALTER TABLE `%s` DISABLE KEYS */;\n", table)
}
if _, err := w.Write([]byte(header)); err != nil {
return 0, err
}
// Get column information
columns, err := e.getTableColumns(ctx, database, table)
if err != nil {
return 0, err
}
// Build SELECT query
selectSQL := fmt.Sprintf("SELECT %s FROM `%s`.`%s`",
strings.Join(columns, ", "), database, table)
// Execute query using transaction if available
var rows *sql.Rows
if tx != nil {
rows, err = tx.QueryContext(ctx, selectSQL)
} else {
rows, err = e.db.QueryContext(ctx, selectSQL)
}
if err != nil {
return 0, fmt.Errorf("failed to query table data: %w", err)
}
defer rows.Close()
// Process rows in batches and generate INSERT statements
var bytesWritten int64
var insertValues []string
const batchSize = 1000
rowCount := 0
for rows.Next() {
// Scan row values
values, err := e.scanRowValues(rows, len(columns))
if err != nil {
return bytesWritten, err
}
// Format values for INSERT
valueStr := e.formatInsertValues(values)
insertValues = append(insertValues, valueStr)
rowCount++
// Write batch when full
if rowCount >= batchSize {
if err := e.writeInsertBatch(w, database, table, columns, insertValues, &bytesWritten); err != nil {
return bytesWritten, err
}
insertValues = insertValues[:0]
rowCount = 0
}
}
// Write remaining batch
if rowCount > 0 {
if err := e.writeInsertBatch(w, database, table, columns, insertValues, &bytesWritten); err != nil {
return bytesWritten, err
}
}
// Write table data footer
footer := ""
if e.cfg.DisableKeys {
footer = fmt.Sprintf("/*!40000 ALTER TABLE `%s` ENABLE KEYS */;\n", table)
}
footer += "\n"
written, err := w.Write([]byte(footer))
if err != nil {
return bytesWritten, err
}
bytesWritten += int64(written)
return bytesWritten, rows.Err()
}
// Helper methods
func (e *MySQLNativeEngine) buildDSN() string {
cfg := mysql.Config{
User: e.cfg.User,
Passwd: e.cfg.Password,
Net: "tcp",
Addr: fmt.Sprintf("%s:%d", e.cfg.Host, e.cfg.Port),
DBName: e.cfg.Database,
// Performance settings
Timeout: 30 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
// Auth settings - required for MariaDB unix_socket auth
AllowNativePasswords: true,
AllowOldPasswords: true,
// Character set
Params: map[string]string{
"charset": "utf8mb4",
"parseTime": "true",
"loc": "Local",
},
}
// Use socket if specified
if e.cfg.Socket != "" {
cfg.Net = "unix"
cfg.Addr = e.cfg.Socket
}
// SSL configuration
if e.cfg.SSLMode != "" {
switch strings.ToLower(e.cfg.SSLMode) {
case "disable", "disabled":
cfg.TLSConfig = "false"
case "require", "required":
cfg.TLSConfig = "true"
default:
cfg.TLSConfig = "preferred"
}
}
return cfg.FormatDSN()
}
func (e *MySQLNativeEngine) getBinlogPosition(ctx context.Context) (*BinlogPosition, error) {
var file string
var position int64
var binlogDoDB, binlogIgnoreDB sql.NullString
var executedGtidSet sql.NullString // MySQL 5.6+ has 5th column
// Try MySQL 8.0.22+ syntax first, then fall back to legacy
// Note: MySQL 8.0.22+ uses SHOW BINARY LOG STATUS
// MySQL 5.6+ has 5 columns: File, Position, Binlog_Do_DB, Binlog_Ignore_DB, Executed_Gtid_Set
// MariaDB has 4 columns: File, Position, Binlog_Do_DB, Binlog_Ignore_DB
row := e.db.QueryRowContext(ctx, "SHOW BINARY LOG STATUS")
err := row.Scan(&file, &position, &binlogDoDB, &binlogIgnoreDB, &executedGtidSet)
if err != nil {
// Fall back to legacy syntax for older MySQL/MariaDB versions
row = e.db.QueryRowContext(ctx, "SHOW MASTER STATUS")
// Try 5 columns first (MySQL 5.6+)
err = row.Scan(&file, &position, &binlogDoDB, &binlogIgnoreDB, &executedGtidSet)
if err != nil {
// MariaDB only has 4 columns
row = e.db.QueryRowContext(ctx, "SHOW MASTER STATUS")
if err = row.Scan(&file, &position, &binlogDoDB, &binlogIgnoreDB); err != nil {
return nil, fmt.Errorf("failed to get binlog status: %w", err)
}
}
}
// Try to get GTID set (MySQL 5.6+ / MariaDB 10.0+)
var gtidSet string
if executedGtidSet.Valid && executedGtidSet.String != "" {
gtidSet = executedGtidSet.String
} else if row := e.db.QueryRowContext(ctx, "SELECT @@global.gtid_executed"); row != nil {
row.Scan(&gtidSet)
}
return &BinlogPosition{
File: file,
Position: position,
GTIDSet: gtidSet,
}, nil
}
// Additional helper methods (stubs for brevity)
func (e *MySQLNativeEngine) writeSQLHeader(w io.Writer, binlogPos *BinlogPosition) error {
header := fmt.Sprintf(`/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
-- MySQL dump generated by dbbackup native engine
-- Host: %s Database: %s
-- ------------------------------------------------------
-- Server version: TBD
`, e.cfg.Host, e.cfg.Database)
if binlogPos != nil && e.cfg.MasterData > 0 {
comment := ""
if e.cfg.MasterData == 2 {
comment = "-- "
}
header += fmt.Sprintf("\n%sCHANGE MASTER TO MASTER_LOG_FILE='%s', MASTER_LOG_POS=%d;\n\n",
comment, binlogPos.File, binlogPos.Position)
}
_, err := w.Write([]byte(header))
return err
}
func (e *MySQLNativeEngine) getDatabases(ctx context.Context) ([]string, error) {
if e.cfg.Database != "" {
return []string{e.cfg.Database}, nil
}
rows, err := e.db.QueryContext(ctx, "SHOW DATABASES")
if err != nil {
return nil, err
}
defer rows.Close()
var databases []string
for rows.Next() {
var db string
if err := rows.Scan(&db); err != nil {
return nil, err
}
// Skip system databases
if db != "information_schema" && db != "mysql" && db != "performance_schema" && db != "sys" {
databases = append(databases, db)
}
}
return databases, rows.Err()
}
func (e *MySQLNativeEngine) shouldIncludeDatabase(database string) bool {
// Skip system databases
if database == "information_schema" || database == "mysql" ||
database == "performance_schema" || database == "sys" {
return false
}
// Apply include/exclude filters if configured
if len(e.cfg.IncludeDatabase) > 0 {
for _, included := range e.cfg.IncludeDatabase {
if database == included {
return true
}
}
return false
}
for _, excluded := range e.cfg.ExcludeDatabase {
if database == excluded {
return false
}
}
return true
}
func (e *MySQLNativeEngine) getDatabaseObjects(ctx context.Context, database string) ([]MySQLDatabaseObject, error) {
var objects []MySQLDatabaseObject
// Get tables
tables, err := e.getTables(ctx, database)
if err != nil {
return nil, fmt.Errorf("failed to get tables: %w", err)
}
objects = append(objects, tables...)
// Get views
views, err := e.getViews(ctx, database)
if err != nil {
return nil, fmt.Errorf("failed to get views: %w", err)
}
objects = append(objects, views...)
return objects, nil
}
// getTables retrieves all tables in database
func (e *MySQLNativeEngine) getTables(ctx context.Context, database string) ([]MySQLDatabaseObject, error) {
query := `
SELECT table_name, engine, table_collation
FROM information_schema.tables
WHERE table_schema = ? AND table_type = 'BASE TABLE'
ORDER BY table_name`
rows, err := e.db.QueryContext(ctx, query, database)
if err != nil {
return nil, err
}
defer rows.Close()
var objects []MySQLDatabaseObject
for rows.Next() {
var tableName, engine, collation sql.NullString
if err := rows.Scan(&tableName, &engine, &collation); err != nil {
return nil, err
}
obj := MySQLDatabaseObject{
Database: database,
Name: tableName.String,
Type: "table",
Engine: engine.String,
}
objects = append(objects, obj)
}
return objects, rows.Err()
}
// getViews retrieves all views in database
func (e *MySQLNativeEngine) getViews(ctx context.Context, database string) ([]MySQLDatabaseObject, error) {
query := `
SELECT table_name
FROM information_schema.views
WHERE table_schema = ?
ORDER BY table_name`
rows, err := e.db.QueryContext(ctx, query, database)
if err != nil {
return nil, err
}
defer rows.Close()
var objects []MySQLDatabaseObject
for rows.Next() {
var viewName string
if err := rows.Scan(&viewName); err != nil {
return nil, err
}
obj := MySQLDatabaseObject{
Database: database,
Name: viewName,
Type: "view",
}
objects = append(objects, obj)
}
return objects, rows.Err()
}
func (e *MySQLNativeEngine) filterObjectsByType(objects []MySQLDatabaseObject, objType string) []MySQLDatabaseObject {
var filtered []MySQLDatabaseObject
for _, obj := range objects {
if obj.Type == objType {
filtered = append(filtered, obj)
}
}
return filtered
}
func (e *MySQLNativeEngine) getDatabaseCreateSQL(ctx context.Context, database string) (string, error) {
query := "SHOW CREATE DATABASE " + fmt.Sprintf("`%s`", database)
row := e.db.QueryRowContext(ctx, query)
var dbName, createSQL string
if err := row.Scan(&dbName, &createSQL); err != nil {
return "", err
}
return createSQL + ";", nil
}
func (e *MySQLNativeEngine) writeDatabaseHeader(w io.Writer, database string) error {
header := fmt.Sprintf("\n--\n-- Database: `%s`\n--\n\n", database)
_, err := w.Write([]byte(header))
return err
}
func (e *MySQLNativeEngine) backupTableSchema(ctx context.Context, w io.Writer, database, table string) error {
query := "SHOW CREATE TABLE " + fmt.Sprintf("`%s`.`%s`", database, table)
row := e.db.QueryRowContext(ctx, query)
var tableName, createSQL string
if err := row.Scan(&tableName, &createSQL); err != nil {
return err
}
// Write table header
header := fmt.Sprintf("\n--\n-- Table structure for table `%s`\n--\n\n", table)
if _, err := w.Write([]byte(header)); err != nil {
return err
}
// Add DROP TABLE if configured
if e.cfg.AddDropTable {
dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS `%s`;\n", table)
if _, err := w.Write([]byte(dropSQL)); err != nil {
return err
}
}
// Write CREATE TABLE
createSQL += ";\n\n"
if _, err := w.Write([]byte(createSQL)); err != nil {
return err
}
return nil
}
func (e *MySQLNativeEngine) getTableInfo(ctx context.Context, database, table string) (*MySQLTableInfo, error) {
query := `
SELECT table_name, engine, table_collation, table_rows,
data_length, index_length, auto_increment,
create_time, update_time
FROM information_schema.tables
WHERE table_schema = ? AND table_name = ?`
row := e.db.QueryRowContext(ctx, query, database, table)
var info MySQLTableInfo
var autoInc sql.NullInt64
var createTime, updateTime sql.NullTime
var collation sql.NullString
err := row.Scan(&info.Name, &info.Engine, &collation, &info.RowCount,
&info.DataLength, &info.IndexLength, &autoInc, &createTime, &updateTime)
if err != nil {
return nil, err
}
info.Collation = collation.String
if autoInc.Valid {
info.AutoIncrement = &autoInc.Int64
}
if createTime.Valid {
info.CreateTime = &createTime.Time
}
if updateTime.Valid {
info.UpdateTime = &updateTime.Time
}
return &info, nil
}
func (e *MySQLNativeEngine) getTableColumns(ctx context.Context, database, table string) ([]string, error) {
query := `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?
ORDER BY ordinal_position`
rows, err := e.db.QueryContext(ctx, query, database, table)
if err != nil {
return nil, err
}
defer rows.Close()
var columns []string
for rows.Next() {
var columnName string
if err := rows.Scan(&columnName); err != nil {
return nil, err
}
columns = append(columns, fmt.Sprintf("`%s`", columnName))
}
return columns, rows.Err()
}
func (e *MySQLNativeEngine) scanRowValues(rows *sql.Rows, columnCount int) ([]interface{}, error) {
// Create slice to hold column values
values := make([]interface{}, columnCount)
valuePtrs := make([]interface{}, columnCount)
// Initialize value pointers
for i := range values {
valuePtrs[i] = &values[i]
}
// Scan row into value pointers
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
return values, nil
}
func (e *MySQLNativeEngine) formatInsertValues(values []interface{}) string {
var formattedValues []string
for _, value := range values {
if value == nil {
formattedValues = append(formattedValues, "NULL")
} else {
switch v := value.(type) {
case string:
// Properly escape string values using MySQL escaping rules
formattedValues = append(formattedValues, e.escapeString(v))
case []byte:
// Handle binary data based on configuration
if len(v) == 0 {
formattedValues = append(formattedValues, "''")
} else if e.cfg.HexBlob {
formattedValues = append(formattedValues, fmt.Sprintf("0x%X", v))
} else {
// Check if it's printable text or binary
if e.isPrintableBinary(v) {
escaped := e.escapeBinaryString(string(v))
formattedValues = append(formattedValues, escaped)
} else {
// Force hex encoding for true binary data
formattedValues = append(formattedValues, fmt.Sprintf("0x%X", v))
}
}
case time.Time:
// Format timestamps properly with microseconds if needed
if v.Nanosecond() != 0 {
formattedValues = append(formattedValues, fmt.Sprintf("'%s'", v.Format("2006-01-02 15:04:05.999999")))
} else {
formattedValues = append(formattedValues, fmt.Sprintf("'%s'", v.Format("2006-01-02 15:04:05")))
}
case bool:
if v {
formattedValues = append(formattedValues, "1")
} else {
formattedValues = append(formattedValues, "0")
}
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
// Integer types - no quotes
formattedValues = append(formattedValues, fmt.Sprintf("%v", v))
case float32, float64:
// Float types - no quotes, handle NaN and Inf
var floatVal float64
if f32, ok := v.(float32); ok {
floatVal = float64(f32)
} else {
floatVal = v.(float64)
}
if math.IsNaN(floatVal) {
formattedValues = append(formattedValues, "NULL")
} else if math.IsInf(floatVal, 0) {
formattedValues = append(formattedValues, "NULL")
} else {
formattedValues = append(formattedValues, fmt.Sprintf("%v", v))
}
default:
// Other types - convert to string and escape
str := fmt.Sprintf("%v", v)
formattedValues = append(formattedValues, e.escapeString(str))
}
}
}
return "(" + strings.Join(formattedValues, ",") + ")"
}
// isPrintableBinary checks if binary data contains mostly printable characters
func (e *MySQLNativeEngine) isPrintableBinary(data []byte) bool {
if len(data) == 0 {
return true
}
printableCount := 0
for _, b := range data {
if b >= 32 && b <= 126 || b == '\n' || b == '\r' || b == '\t' {
printableCount++
}
}
// Consider it printable if more than 80% are printable chars
return float64(printableCount)/float64(len(data)) > 0.8
}
// escapeBinaryString escapes binary data when treating as string
func (e *MySQLNativeEngine) escapeBinaryString(s string) string {
// Use MySQL-style escaping for binary strings
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "'", "\\'")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
s = strings.ReplaceAll(s, "\x00", "\\0")
s = strings.ReplaceAll(s, "\x1a", "\\Z")
return fmt.Sprintf("'%s'", s)
}
func (e *MySQLNativeEngine) writeInsertBatch(w io.Writer, database, table string, columns []string, values []string, bytesWritten *int64) error {
if len(values) == 0 {
return nil
}
var insertSQL string
if e.cfg.ExtendedInsert {
// Use extended INSERT syntax for better performance
insertSQL = fmt.Sprintf("INSERT INTO `%s`.`%s` (%s) VALUES\n%s;\n",
database, table, strings.Join(columns, ","), strings.Join(values, ",\n"))
} else {
// Use individual INSERT statements
var statements []string
for _, value := range values {
stmt := fmt.Sprintf("INSERT INTO `%s`.`%s` (%s) VALUES %s;",
database, table, strings.Join(columns, ","), value)
statements = append(statements, stmt)
}
insertSQL = strings.Join(statements, "\n") + "\n"
}
written, err := w.Write([]byte(insertSQL))
if err != nil {
return err
}
*bytesWritten += int64(written)
return nil
}
func (e *MySQLNativeEngine) backupRoutines(ctx context.Context, w io.Writer, database string) error {
query := `
SELECT routine_name, routine_type
FROM information_schema.routines
WHERE routine_schema = ? AND routine_type IN ('FUNCTION', 'PROCEDURE')
ORDER BY routine_name`
rows, err := e.db.QueryContext(ctx, query, database)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var routineName, routineType string
if err := rows.Scan(&routineName, &routineType); err != nil {
return err
}
// Get routine definition
var showCmd string
if routineType == "FUNCTION" {
showCmd = "SHOW CREATE FUNCTION"
} else {
showCmd = "SHOW CREATE PROCEDURE"
}
defRow := e.db.QueryRowContext(ctx, fmt.Sprintf("%s `%s`.`%s`", showCmd, database, routineName))
var name, createSQL, charset, collation sql.NullString
if err := defRow.Scan(&name, &createSQL, &charset, &collation); err != nil {
continue // Skip routines we can't read
}
// Write routine header (capitalize first letter manually to avoid deprecated strings.Title)
routineTypeLower := strings.ToLower(routineType)
routineTypeTitle := strings.ToUpper(routineTypeLower[:1]) + routineTypeLower[1:]
header := fmt.Sprintf("\n--\n-- %s `%s`\n--\n\n", routineTypeTitle, routineName)
if _, err := w.Write([]byte(header)); err != nil {
return err
}
// Write DROP statement
dropSQL := fmt.Sprintf("DROP %s IF EXISTS `%s`;\n", routineType, routineName)
if _, err := w.Write([]byte(dropSQL)); err != nil {
return err
}
// Write CREATE statement
if _, err := w.Write([]byte(createSQL.String + ";\n\n")); err != nil {
return err
}
}
return rows.Err()
}
func (e *MySQLNativeEngine) backupTriggers(ctx context.Context, w io.Writer, database string) error {
query := `
SELECT trigger_name
FROM information_schema.triggers
WHERE trigger_schema = ?
ORDER BY trigger_name`
rows, err := e.db.QueryContext(ctx, query, database)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var triggerName string
if err := rows.Scan(&triggerName); err != nil {
return err
}
// Get trigger definition
defRow := e.db.QueryRowContext(ctx, fmt.Sprintf("SHOW CREATE TRIGGER `%s`.`%s`", database, triggerName))
var name, createSQL, charset, collation sql.NullString
if err := defRow.Scan(&name, &createSQL, &charset, &collation); err != nil {
continue // Skip triggers we can't read
}
// Write trigger
header := fmt.Sprintf("\n--\n-- Trigger `%s`\n--\n\n", triggerName)
if _, err := w.Write([]byte(header + createSQL.String + ";\n\n")); err != nil {
return err
}
}
return rows.Err()
}
func (e *MySQLNativeEngine) backupEvents(ctx context.Context, w io.Writer, database string) error {
query := `
SELECT event_name
FROM information_schema.events
WHERE event_schema = ?
ORDER BY event_name`
rows, err := e.db.QueryContext(ctx, query, database)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var eventName string
if err := rows.Scan(&eventName); err != nil {
return err
}
// Get event definition
defRow := e.db.QueryRowContext(ctx, fmt.Sprintf("SHOW CREATE EVENT `%s`.`%s`", database, eventName))
var name, createSQL, charset, collation sql.NullString
if err := defRow.Scan(&name, &createSQL, &charset, &collation); err != nil {
continue // Skip events we can't read
}
// Write event
header := fmt.Sprintf("\n--\n-- Event `%s`\n--\n\n", eventName)
if _, err := w.Write([]byte(header + createSQL.String + ";\n\n")); err != nil {
return err
}
}
return rows.Err()
}
func (e *MySQLNativeEngine) writeSQLFooter(w io.Writer) error {
footer := `/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed
`
_, err := w.Write([]byte(footer))
return err
}
// escapeString properly escapes a string value for MySQL SQL
func (e *MySQLNativeEngine) escapeString(s string) string {
// Use MySQL-style escaping
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "'", "\\'")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "\\r")
s = strings.ReplaceAll(s, "\t", "\\t")
s = strings.ReplaceAll(s, "\x00", "\\0")
s = strings.ReplaceAll(s, "\x1a", "\\Z")
return fmt.Sprintf("'%s'", s)
}
// Name returns the engine name
func (e *MySQLNativeEngine) Name() string {
return "MySQL Native Engine"
}
// Version returns the engine version
func (e *MySQLNativeEngine) Version() string {
return "1.0.0-native"
}
// SupportedFormats returns list of supported backup formats
func (e *MySQLNativeEngine) SupportedFormats() []string {
return []string{"sql"}
}
// SupportsParallel returns true if parallel processing is supported
func (e *MySQLNativeEngine) SupportsParallel() bool {
return false // TODO: Implement multi-threaded dumping
}
// SupportsIncremental returns true if incremental backups are supported
func (e *MySQLNativeEngine) SupportsIncremental() bool {
return false // TODO: Implement binary log-based incremental backups
}
// SupportsPointInTime returns true if point-in-time recovery is supported
func (e *MySQLNativeEngine) SupportsPointInTime() bool {
return true // Binary log position tracking implemented
}
// SupportsStreaming returns true if streaming backups are supported
func (e *MySQLNativeEngine) SupportsStreaming() bool {
return true
}
// CheckConnection verifies database connectivity
func (e *MySQLNativeEngine) CheckConnection(ctx context.Context) error {
if e.db == nil {
return fmt.Errorf("not connected")
}
return e.db.PingContext(ctx)
}
// ValidateConfiguration checks if configuration is valid
func (e *MySQLNativeEngine) ValidateConfiguration() error {
if e.cfg.Host == "" && e.cfg.Socket == "" {
return fmt.Errorf("either host or socket is required")
}
if e.cfg.User == "" {
return fmt.Errorf("user is required")
}
if e.cfg.Host != "" && e.cfg.Port <= 0 {
return fmt.Errorf("invalid port: %d", e.cfg.Port)
}
return nil
}
// Restore performs native MySQL restore
func (e *MySQLNativeEngine) Restore(ctx context.Context, inputReader io.Reader, targetDB string) error {
e.log.Info("Starting native MySQL restore", "target", targetDB)
// Use database if specified
if targetDB != "" {
// Escape backticks to prevent SQL injection
safeDB := strings.ReplaceAll(targetDB, "`", "``")
if _, err := e.db.ExecContext(ctx, "USE `"+safeDB+"`"); err != nil {
return fmt.Errorf("failed to use database %s: %w", targetDB, err)
}
}
// Read and execute SQL script
scanner := bufio.NewScanner(inputReader)
var sqlBuffer strings.Builder
for scanner.Scan() {
line := scanner.Text()
// Skip comments and empty lines
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") || strings.HasPrefix(trimmed, "/*") {
continue
}
sqlBuffer.WriteString(line)
sqlBuffer.WriteString("\n")
// Execute statement if it ends with semicolon
if strings.HasSuffix(trimmed, ";") {
stmt := sqlBuffer.String()
sqlBuffer.Reset()
if _, err := e.db.ExecContext(ctx, stmt); err != nil {
e.log.Warn("Failed to execute statement", "error", err, "statement", stmt[:100])
// Continue with next statement (non-fatal errors)
}
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading input: %w", err)
}
e.log.Info("Native MySQL restore completed")
return nil
}
func (e *MySQLNativeEngine) Close() error {
if e.db != nil {
return e.db.Close()
}
return nil
}