支持MySQL官方存储对象备份的分隔符恢复

识别DELIMITER客户端指令,保留复合语句及可执行注释,词法格式错误时拒绝执行部分备份。新增双版本官方导出对照与Scert恢复实库测试,验证触发器顺序、过程、函数、事件调度、权限失败及格式错误保护。仅覆盖本批恢复场景,自有导出对象完整性与生产验收仍未完成。
This commit is contained in:
xuans 2026-09-27 20:35:48 +08:00
parent 67070596fc
commit e6e67197f6
5 changed files with 329 additions and 11 deletions

View File

@ -323,7 +323,11 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string)
return fmt.Errorf("获取 MySQL 恢复会话失败:%w", err) return fmt.Errorf("获取 MySQL 恢复会话失败:%w", err)
} }
defer connection.Close() defer connection.Close()
for _, statement := range splitMySQLStatements(string(script)) { statements, err := splitMySQLStatements(string(script))
if err != nil {
return err
}
for _, statement := range statements {
if _, err := connection.ExecContext(ctx, statement); err != nil { if _, err := connection.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err) return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err)
} }
@ -331,9 +335,11 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string)
return nil return nil
} }
func splitMySQLStatements(script string) []string { func splitMySQLStatements(script string) ([]string, error) {
script = strings.TrimPrefix(script, "\uFEFF")
var statements []string var statements []string
var statement strings.Builder var statement strings.Builder
delimiter := ";"
var quote byte var quote byte
lineComment := false lineComment := false
blockComment := false blockComment := false
@ -392,6 +398,32 @@ func splitMySQLStatements(script string) []string {
} }
continue continue
} }
if (index == 0 || script[index-1] == '\n') && strings.TrimSpace(statement.String()) == "" {
end := strings.IndexByte(script[index:], '\n')
if end < 0 {
end = len(script) - index
}
line := strings.TrimSpace(script[index : index+end])
if len(line) >= len("delimiter") && strings.EqualFold(line[:len("delimiter")], "delimiter") &&
(len(line) == len("delimiter") || line[len("delimiter")] == ' ' || line[len("delimiter")] == '\t') {
value := strings.TrimSpace(line[len("delimiter"):])
if len(value) >= 2 && (value[0] == '\'' || value[0] == '"') && value[len(value)-1] == value[0] {
value = value[1 : len(value)-1]
}
if value == "" || strings.ContainsAny(value, " \t\r\n") || strings.ContainsRune(value, '\\') || strings.ContainsRune(value, '\'') || strings.ContainsRune(value, '"') || strings.ContainsRune(value, '`') {
return nil, fmt.Errorf("MySQL 备份中的 DELIMITER 指令无效")
}
delimiter = value
statement.Reset()
index += end
continue
}
}
if strings.HasPrefix(script[index:], delimiter) {
flush()
index += len(delimiter) - 1
continue
}
if current == '\'' || current == '"' || current == '`' { if current == '\'' || current == '"' || current == '`' {
quote = current quote = current
statement.WriteByte(current) statement.WriteByte(current)
@ -418,12 +450,11 @@ func splitMySQLStatements(script string) []string {
index++ index++
continue continue
} }
if current == ';' {
flush()
continue
}
statement.WriteByte(current) statement.WriteByte(current)
} }
if quote != 0 || blockComment || executableComment {
return nil, fmt.Errorf("MySQL 备份包含未闭合的字符串、标识符或注释")
}
flush() flush()
return statements return statements, nil
} }

View File

@ -64,7 +64,10 @@ func TestMySQLColumnSQLLiteral(t *testing.T) {
func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) { func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) {
script := "-- 注释\nSET FOREIGN_KEY_CHECKS=0;\nINSERT INTO `items` (`value`) VALUES ('a; b'), ('it''s');\n" script := "-- 注释\nSET FOREIGN_KEY_CHECKS=0;\nINSERT INTO `items` (`value`) VALUES ('a; b'), ('it''s');\n"
statements := splitMySQLStatements(script) statements, err := splitMySQLStatements(script)
if err != nil {
t.Fatal(err)
}
if len(statements) != 2 { if len(statements) != 2 {
t.Fatalf("SQL 语句拆分数量不正确:%d %#v", len(statements), statements) t.Fatalf("SQL 语句拆分数量不正确:%d %#v", len(statements), statements)
} }
@ -75,7 +78,10 @@ func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) {
func TestSplitMySQLStatementsKeepsExecutableComments(t *testing.T) { func TestSplitMySQLStatementsKeepsExecutableComments(t *testing.T) {
script := "CREATE TABLE `items` (`id` bigint) /*!50100 SET @value = 'a;b' */;\n" script := "CREATE TABLE `items` (`id` bigint) /*!50100 SET @value = 'a;b' */;\n"
statements := splitMySQLStatements(script) statements, err := splitMySQLStatements(script)
if err != nil {
t.Fatal(err)
}
if len(statements) != 1 { if len(statements) != 1 {
t.Fatalf("带可执行注释的 SQL 语句拆分数量不正确:%d %#v", len(statements), statements) t.Fatalf("带可执行注释的 SQL 语句拆分数量不正确:%d %#v", len(statements), statements)
} }

View File

@ -0,0 +1,205 @@
package cli
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
// 官方导出文件不做改写;用同一隔离账户跨测试库恢复,保留原有定义者。
func TestMySQLRealOfficialStoredPrograms(t *testing.T) {
server := mysqltest.New(t)
// 仅启用本测试新建实例的事件调度,源事件保持禁用,绝不连接共享服务。
if _, err := server.Admin().ExecContext(t.Context(), "SET GLOBAL event_scheduler = ON"); err != nil {
t.Fatal(err)
}
source := server.Database(t)
parsed, err := mysql.ParseDSN(source.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName)
// 独立实例启用二进制日志时,函数创建需要管理权限;不修改全局信任开关。
if server.IsMySQL8() {
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SUPER ON *.* TO "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil {
t.Fatal(err)
}
}
if !server.IsMySQL8() {
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SELECT ON mysql.proc TO "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil {
t.Fatal(err)
}
}
db, err := database.Open(t.Context(), source)
if err != nil {
t.Fatal(err)
}
defer db.Close()
connection, err := db.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer connection.Close()
statements := []string{
"SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'",
"CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB",
"INSERT INTO interop VALUES (1, 10, '原始值')",
"CREATE TRIGGER first_trigger BEFORE INSERT ON interop FOR EACH ROW BEGIN SET NEW.value = NEW.value + 2; SET NEW.label = CONCAT(NEW.label, ';第一步'); END",
"CREATE TRIGGER second_trigger BEFORE INSERT ON interop FOR EACH ROW FOLLOWS first_trigger BEGIN SET NEW.value = NEW.value * 3; SET NEW.label = CONCAT(NEW.label, ';第二步'); END",
"CREATE PROCEDURE change_value(IN target_id INT) BEGIN UPDATE interop SET value = value + 5 WHERE id = target_id; UPDATE interop SET label = CONCAT(label, ';过程') WHERE id = target_id; END",
"CREATE FUNCTION format_value(input_value INT) RETURNS VARCHAR(100) DETERMINISTIC NO SQL BEGIN DECLARE result VARCHAR(100); SET result = CONCAT('函数;', input_value); RETURN result; END",
"CREATE EVENT disabled_event ON SCHEDULE AT '2036-01-01 00:00:00' ON COMPLETION PRESERVE DISABLE DO BEGIN UPDATE interop SET value = value + 100; UPDATE interop SET label = '事件;执行'; END",
}
for _, statement := range statements {
if _, err := connection.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
definition := readMySQLProgramDefinitions(t, db.DB)
output := filepath.Join(server.Root(), "official-programs.sql")
server.DumpStoredPrograms(t, source, output)
for _, direction := range []string{"official_control", "official_to_scert"} {
t.Run(direction, func(t *testing.T) {
target := server.Database(t)
targetDSN, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLProgramAccess(t, server, parsed.User, targetDSN.DBName)
targetDSN.User = parsed.User
target.DSN = targetDSN.FormatDSN()
if direction == "official_control" {
server.Import(t, target, output)
} else if err := restoreMySQL(t.Context(), target, output); err != nil {
t.Fatal(err)
}
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值")
verify, err := database.Open(t.Context(), target)
if err != nil {
t.Fatal(err)
}
defer verify.Close()
if restored := readMySQLProgramDefinitions(t, verify.DB); restored != definition {
t.Fatalf("存储对象的定义、执行顺序、权限上下文或事件状态发生变化:\n源:%s\n恢复:%s", definition, restored)
}
for _, statement := range []string{"INSERT INTO interop VALUES (2, 4, '新行')", "CALL change_value(1)"} {
if _, err := verify.DB.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 2", "18|新行;第一步;第二步")
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "15|原始值;过程")
checkMySQLInteropValue(t, target, "SELECT format_value(7)", "函数;7")
if _, err := verify.DB.ExecContext(t.Context(), "ALTER EVENT disabled_event ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND ENABLE"); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
var value int
for time.Now().Before(deadline) {
if err := verify.DB.QueryRowContext(t.Context(), "SELECT value FROM interop WHERE id = 1").Scan(&value); err != nil {
t.Fatal(err)
}
if value == 115 {
break
}
time.Sleep(25 * time.Millisecond)
}
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "115|事件;执行")
checkMySQLInteropValue(t, source, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值")
})
}
t.Run("insufficient_privileges", func(t *testing.T) {
target := server.Database(t)
err := restoreMySQL(t.Context(), target, output)
var serverError *mysql.MySQLError
if !errors.As(err, &serverError) || (serverError.Number != 1142 && serverError.Number != 1227 && serverError.Number != 1419) {
t.Fatalf("权限不足时必须返回原始数据库错误,不能忽略或提升权限:%v", err)
}
parsedTarget, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
var count int
if err := server.Admin().QueryRowContext(t.Context(), "SELECT COUNT(*) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?", parsedTarget.DBName).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("触发器恢复失败后仍继续恢复了后面的例程")
}
})
t.Run("malformed_script_is_not_executed", func(t *testing.T) {
target := server.Database(t)
invalid := filepath.Join(server.Root(), "malformed-programs.sql")
if err := os.WriteFile(invalid, []byte("CREATE TABLE should_not_exist (id INT);\nDELIMITER\nSELECT 1;"), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreMySQL(t.Context(), target, invalid); err == nil {
t.Fatal("无效分隔符没有阻止恢复")
}
checkMySQLInteropValue(t, target, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()", "0")
})
}
func grantMySQLProgramAccess(t *testing.T, server *mysqltest.Server, user, name string) {
t.Helper()
statement := "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, TRIGGER, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, EVENT ON " + quoteMySQLIdentifier(name) + ".* TO " + mysqlStringLiteral(user) + "@'127.0.0.1'"
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
func readMySQLProgramDefinitions(t *testing.T, db *sql.DB) string {
t.Helper()
queries := []string{
`SELECT JSON_ARRAY(TRIGGER_NAME, EVENT_MANIPULATION, EVENT_OBJECT_TABLE, ACTION_ORDER,
ACTION_STATEMENT, ACTION_ORIENTATION, ACTION_TIMING, SQL_MODE, DEFINER,
CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY TRIGGER_NAME`,
`SELECT JSON_ARRAY(ROUTINE_NAME, ROUTINE_TYPE, DATA_TYPE, DTD_IDENTIFIER, ROUTINE_DEFINITION,
IS_DETERMINISTIC, SQL_DATA_ACCESS, SECURITY_TYPE, SQL_MODE, DEFINER, ROUTINE_COMMENT,
CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_NAME`,
`SELECT JSON_ARRAY(SPECIFIC_NAME, ORDINAL_POSITION, PARAMETER_MODE, PARAMETER_NAME,
DATA_TYPE, DTD_IDENTIFIER, CHARACTER_SET_NAME, COLLATION_NAME, ROUTINE_TYPE)
FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA = DATABASE()
ORDER BY SPECIFIC_NAME, ORDINAL_POSITION`,
`SELECT JSON_ARRAY(EVENT_NAME, DEFINER, TIME_ZONE, EVENT_BODY, EVENT_DEFINITION, EVENT_TYPE,
EXECUTE_AT, SQL_MODE, STATUS, ON_COMPLETION, EVENT_COMMENT,
CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
FROM information_schema.EVENTS WHERE EVENT_SCHEMA = DATABASE() ORDER BY EVENT_NAME`,
}
var definitions []string
for _, query := range queries {
rows, err := db.QueryContext(t.Context(), query)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var definition string
if err := rows.Scan(&definition); err != nil {
t.Fatal(err)
}
definitions = append(definitions, definition)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
}
if len(definitions) != 8 {
t.Fatalf("存储对象及参数数量不完整:得到 %d,期望 8", len(definitions))
}
return strings.Join(definitions, "\n")
}

View File

@ -0,0 +1,67 @@
package cli
import (
"reflect"
"testing"
)
func TestSplitMySQLStatementsDelimiter(t *testing.T) {
for _, item := range []struct {
name string
script string
want []string
}{
{
name: "procedure",
script: "DELIMITER ;;\nCREATE PROCEDURE p() BEGIN SELECT 1; SELECT '值;;分号'; END;;\nDELIMITER ;\nSELECT 2;",
want: []string{"CREATE PROCEDURE p() BEGIN SELECT 1; SELECT '值;;分号'; END", "SELECT 2"},
},
{
name: "quoted_crlf_bom",
script: "\uFEFF-- 注释\r\n dElImItEr '$$'\r\nSELECT '$$', `a$$b`, 'it''s';$$\r\nDELIMITER ;\r\nSELECT 2;",
want: []string{"SELECT '$$', `a$$b`, 'it''s';", "SELECT 2"},
},
{
name: "comments",
script: "DELIMITER //\n/* // */ SELECT 1 # //\n//\nDELIMITER ;\nSELECT/**/2;",
want: []string{"SELECT 1", "SELECT 2"},
},
{
name: "executable_comments",
script: "DELIMITER ;;\n/*!50003 CREATE*/ /*!50003 TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN SET NEW.x = ';;'; END */;;\nDELIMITER ;",
want: []string{"/*!50003 CREATE*/ /*!50003 TRIGGER t BEFORE INSERT ON x FOR EACH ROW BEGIN SET NEW.x = ';;'; END */"},
},
{
name: "keyword_is_not_command",
script: "SELECT delimiter FROM x;\nSELECT 'DELIMITER $$';\n-- DELIMITER $$\nSELECT 2;",
want: []string{"SELECT delimiter FROM x", "SELECT 'DELIMITER $$'", "SELECT 2"},
},
} {
t.Run(item.name, func(t *testing.T) {
got, err := splitMySQLStatements(item.script)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, item.want) {
t.Fatalf("分隔符解析不正确:得到 %#v,期望 %#v", got, item.want)
}
})
}
}
func TestSplitMySQLStatementsRejectsMalformedScript(t *testing.T) {
for _, script := range []string{
"SELECT 1;\nDELIMITER\nSELECT 2;",
"SELECT 1;\nDELIMITER a b\nSELECT 2;",
"SELECT 1;\nDELIMITER " + string(rune(92)) + "\nSELECT 2;",
"SELECT 1; SELECT '未闭合",
"SELECT 1; SELECT `未闭合",
"SELECT 1; /* 未闭合",
"SELECT 1; /*!50003 未闭合",
} {
got, err := splitMySQLStatements(script)
if err == nil || got != nil {
t.Fatalf("格式错误时不应返回可执行的部分语句:%#v,%v", got, err)
}
}
}

View File

@ -18,16 +18,25 @@ import (
// 官方工具仅连接本夹具创建的隔离实例,密码只传入子进程环境,不进入命令行或文件。 // 官方工具仅连接本夹具创建的隔离实例,密码只传入子进程环境,不进入命令行或文件。
func (f *Server) Dump(t *testing.T, cfg config.DatabaseConfig, output string) { func (f *Server) Dump(t *testing.T, cfg config.DatabaseConfig, output string) {
f.dump(t, cfg, output, nil)
}
func (f *Server) DumpStoredPrograms(t *testing.T, cfg config.DatabaseConfig, output string) {
f.dump(t, cfg, output, []string{"--routines", "--events"})
}
func (f *Server) dump(t *testing.T, cfg config.DatabaseConfig, output string, extra []string) {
t.Helper() t.Helper()
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer file.Close() defer file.Close()
f.runTool(t, "mysqldump", cfg, []string{ args := []string{
"--single-transaction", "--no-tablespaces", "--set-gtid-purged=OFF", "--single-transaction", "--no-tablespaces", "--set-gtid-purged=OFF",
"--skip-add-locks", "--hex-blob", "--complete-insert", "--skip-comments", "--skip-extended-insert", "--skip-add-locks", "--hex-blob", "--complete-insert", "--skip-comments", "--skip-extended-insert",
}, nil, file) }
f.runTool(t, "mysqldump", cfg, append(args, extra...), nil, file)
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
t.Fatal(err) t.Fatal(err)
} }