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
### 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
1195 lines
32 KiB
Go
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(>idSet)
|
|
}
|
|
|
|
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
|
|
}
|