保留原始视图定义与编码,按依赖排序导出,并严格识别和重排官方最终视图块;增加运行上下文和权限检查。补充视图、清单、恢复失败保护测试,并修正旧事件、触发器和例程测试的权限前提。 隔离MySQL 5.7和8.0完整专项分别通过176和202个叶子用例,无失败或跳过;后端常规全量、静态检查、模块校验及Linux测试包交叉编译通过。测试产物和凭据未提交。本提交不代表生产部署验收完成。
206 lines
7.3 KiB
Go
206 lines
7.3 KiB
Go
package cli
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
type mysqlViewRestoreBlock struct {
|
||
statements []string
|
||
name string
|
||
definition string
|
||
charset string
|
||
}
|
||
|
||
type mysqlViewRestorePlan struct {
|
||
statements []string
|
||
boundaries map[int]string
|
||
}
|
||
|
||
func (p mysqlViewRestorePlan) checkContext(ctx context.Context, conn *sql.Conn, index int) error {
|
||
expected, exists := p.boundaries[index]
|
||
if !exists {
|
||
return nil
|
||
}
|
||
var schema, mode, charset string
|
||
if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), @@SESSION.sql_mode, @@SESSION.character_set_client").Scan(&schema, &mode, &charset); err != nil {
|
||
return err
|
||
}
|
||
if schema != expected {
|
||
return fmt.Errorf("MySQL 官方视图恢复期间数据库发生切换,拒绝使用其他库的依赖顺序")
|
||
}
|
||
if charset != "utf8mb4" && charset != "utf8mb3" && charset != "utf8" {
|
||
return fmt.Errorf("MySQL 官方视图删除名称的实际客户端编码不是 UTF-8,拒绝调整执行顺序")
|
||
}
|
||
for _, value := range strings.Split(mode, ",") {
|
||
if value == "ANSI_QUOTES" || value == "NO_BACKSLASH_ESCAPES" {
|
||
return fmt.Errorf("MySQL 官方视图恢复的实际 SQL 模式与规范解析模式不一致,拒绝调整执行顺序")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
var mysqlViewDumpSetting = regexp.MustCompile(`(?i)^SET\s+(character_set_client|character_set_results|collation_connection)\s*=\s*([a-z0-9_]+)\s*$`)
|
||
|
||
// 只调整连续的官方最终视图块;块内 SQL 字节、字符集保存与恢复语句均不改写。
|
||
// 不跨越业务语句、未知 SET、USE 或其他 DDL,更不在失败后重放任意 SQL。
|
||
// 所有解析和编码检查均在执行恢复文件前完成,出错时不会执行其中的删除语句。
|
||
func prepareMySQLViewRestore(ctx context.Context, conn *sql.Conn, statements []string) (mysqlViewRestorePlan, error) {
|
||
plan := mysqlViewRestorePlan{boundaries: make(map[int]string)}
|
||
var schema string
|
||
var lowerCaseNames int
|
||
var prepared []string
|
||
for i := 0; i < len(statements); {
|
||
var blocks []mysqlViewRestoreBlock
|
||
for {
|
||
block, ok := readMySQLViewRestoreBlock(statements[i:])
|
||
if !ok {
|
||
break
|
||
}
|
||
blocks = append(blocks, block)
|
||
i += len(block.statements)
|
||
}
|
||
if len(blocks) == 0 {
|
||
prepared = append(prepared, statements[i])
|
||
i++
|
||
continue
|
||
}
|
||
if schema == "" {
|
||
if err := conn.QueryRowContext(ctx, "SELECT DATABASE(), @@lower_case_table_names").Scan(&schema, &lowerCaseNames); err != nil {
|
||
return plan, err
|
||
}
|
||
}
|
||
views := make([]mysqlView, 0, len(blocks))
|
||
byName := make(map[string]mysqlViewRestoreBlock, len(blocks))
|
||
for _, block := range blocks {
|
||
// 官方文件的 DROP 名称用导出编码,CREATE 使用视图原客户端编码。
|
||
// 交给服务器解码并检查往返,不以本机 UTF-8 猜测 Latin-1 等字节。
|
||
charset := quoteMySQLIdentifier(block.charset)
|
||
var definition string
|
||
var roundTrip []byte
|
||
if err := conn.QueryRowContext(ctx, "SELECT CONVERT(CAST(? AS BINARY) USING "+charset+")", []byte(block.definition)).Scan(&definition); err != nil {
|
||
return plan, fmt.Errorf("解码 MySQL 官方视图定义失败:%w", err)
|
||
}
|
||
if err := conn.QueryRowContext(ctx, "SELECT CAST(CONVERT(? USING "+charset+") AS BINARY)", definition).Scan(&roundTrip); err != nil {
|
||
return plan, err
|
||
}
|
||
if !bytes.Equal(roundTrip, []byte(block.definition)) {
|
||
return plan, fmt.Errorf("MySQL 官方视图定义不能无损解码")
|
||
}
|
||
name, err := mysqlViewCreateName(definition)
|
||
if err != nil || name != block.name {
|
||
return plan, fmt.Errorf("MySQL 官方视图块的删除与创建名称不一致,拒绝调整执行顺序")
|
||
}
|
||
views = append(views, mysqlView{name: name, definition: map[string]string{"Create View": definition}})
|
||
byName[name] = block
|
||
}
|
||
ordered, err := orderMySQLViews(views, schema, lowerCaseNames)
|
||
if err != nil {
|
||
return plan, err
|
||
}
|
||
plan.boundaries[len(prepared)] = schema
|
||
for _, view := range ordered {
|
||
prepared = append(prepared, byName[view.name].statements...)
|
||
}
|
||
}
|
||
plan.statements = prepared
|
||
return plan, nil
|
||
}
|
||
|
||
// mysqldump 最终视图块固定包含删除、六条环境保存/设置、创建及三条恢复语句。
|
||
// 严格识别这一闭合结构,不能仅凭 CREATE VIEW 就改变用户脚本的时序。
|
||
func readMySQLViewRestoreBlock(statements []string) (mysqlViewRestoreBlock, bool) {
|
||
if len(statements) < 11 {
|
||
return mysqlViewRestoreBlock{}, false
|
||
}
|
||
plain := make([]string, 11)
|
||
for i := range plain {
|
||
var ok bool
|
||
plain[i], ok = unwrapMySQLViewDumpStatement(statements[i])
|
||
if !ok {
|
||
return mysqlViewRestoreBlock{}, false
|
||
}
|
||
}
|
||
tokens, err := lexMySQLView(plain[0])
|
||
if err != nil || len(tokens) != 5 || !tokens[0].keyword("DROP") || !tokens[1].keyword("VIEW") || !tokens[2].keyword("IF") || !tokens[3].keyword("EXISTS") || tokens[4].kind != 'i' {
|
||
return mysqlViewRestoreBlock{}, false
|
||
}
|
||
for i, variable := range []string{"character_set_client", "character_set_results", "collation_connection"} {
|
||
saved := []string{"saved_cs_client", "saved_cs_results", "saved_col_connection"}[i]
|
||
if !mysqlViewDumpAssignment(plain[i+1], "@"+saved, "@@"+variable) || !mysqlViewDumpAssignment(plain[i+8], variable, "@"+saved) {
|
||
return mysqlViewRestoreBlock{}, false
|
||
}
|
||
match := mysqlViewDumpSetting.FindStringSubmatch(plain[i+4])
|
||
if len(match) == 0 || !strings.EqualFold(match[1], variable) {
|
||
return mysqlViewRestoreBlock{}, false
|
||
}
|
||
}
|
||
create, err := lexMySQLView(plain[7])
|
||
if err != nil || len(create) < 4 || !create[0].keyword("CREATE") || !create[1].keyword("ALGORITHM") || create[2].text != "=" {
|
||
return mysqlViewRestoreBlock{}, false
|
||
}
|
||
charset := mysqlViewDumpSetting.FindStringSubmatch(plain[4])[2]
|
||
return mysqlViewRestoreBlock{statements: statements[:11], name: tokens[4].text, definition: plain[7], charset: charset}, true
|
||
}
|
||
|
||
func mysqlViewDumpAssignment(statement, left, right string) bool {
|
||
// 仅允许无字符串和无注释的固定赋值;空白只在词法边界忽略。
|
||
tokens, err := lexMySQLView(statement)
|
||
if err != nil || len(tokens) == 0 || !tokens[0].keyword("SET") {
|
||
return false
|
||
}
|
||
expected, err := lexMySQLView("SET " + left + "=" + right)
|
||
if err != nil || len(expected) != len(tokens) {
|
||
return false
|
||
}
|
||
for i, token := range tokens {
|
||
if token.kind != expected[i].kind || !strings.EqualFold(token.text, expected[i].text) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func unwrapMySQLViewDumpStatement(statement string) (string, bool) {
|
||
var plain strings.Builder
|
||
for rest := strings.TrimSpace(statement); rest != ""; {
|
||
if !strings.HasPrefix(rest, "/*!") || len(rest) < 11 {
|
||
return "", false
|
||
}
|
||
version, err := strconv.Atoi(rest[3:8])
|
||
if err != nil || version < 50001 || version > 50013 || rest[8] > ' ' {
|
||
return "", false
|
||
}
|
||
end := strings.Index(rest[9:], "*/")
|
||
if end < 0 {
|
||
return "", false
|
||
}
|
||
end += 9
|
||
plain.WriteString(rest[9:end])
|
||
plain.WriteByte(' ')
|
||
rest = strings.TrimSpace(rest[end+2:])
|
||
}
|
||
return strings.TrimSpace(plain.String()), plain.Len() > 0
|
||
}
|
||
|
||
func mysqlViewCreateName(definition string) (string, error) {
|
||
tokens, err := lexMySQLView(definition)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
for i, token := range tokens {
|
||
if token.keyword("VIEW") {
|
||
if i+2 < len(tokens) && tokens[i+1].kind == 'i' && tokens[i+2].keyword("AS") {
|
||
return tokens[i+1].text, nil
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return "", fmt.Errorf("MySQL 官方视图创建名称无法识别")
|
||
}
|