完善MySQL视图备份恢复及完整清单权限校验

保留原始视图定义与编码,按依赖排序导出,并严格识别和重排官方最终视图块;增加运行上下文和权限检查。补充视图、清单、恢复失败保护测试,并修正旧事件、触发器和例程测试的权限前提。

隔离MySQL 5.7和8.0完整专项分别通过176和202个叶子用例,无失败或跳过;后端常规全量、静态检查、模块校验及Linux测试包交叉编译通过。测试产物和凭据未提交。本提交不代表生产部署验收完成。
This commit is contained in:
xuans 2026-09-28 00:35:26 +08:00
parent d95ecfa9a0
commit 777c7169b6
17 changed files with 1564 additions and 13 deletions

View File

@ -15,7 +15,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/database"
)
// backupMySQL 将 MySQL 的基础表、数据、例程、触发器和事件导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。
// backupMySQL 将 MySQL 的基础表、数据、例程、视图、触发器和事件导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。
func backupMySQL(ctx context.Context, cfg config.Config, output string) error {
resolved, err := resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config)
if err != nil {
@ -63,11 +63,15 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output
return err
}
// 元数据会按权限过滤;先证明完整库的读取权限,再信任表和视图清单。
if err := requireMySQLObjectVisibility(ctx, tx, nil, "SELECT", true); err != nil {
return fmt.Errorf("MySQL 无法确认完整表和视图清单:%w", err)
}
rows, err := tx.QueryContext(ctx, "SELECT TABLE_NAME, ENGINE, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME")
if err != nil {
return fmt.Errorf("读取 MySQL 表列表失败:%w", err)
}
var tableNames []string
var tableNames, viewNames []string
for rows.Next() {
var tableName, tableType string
var engine sql.NullString
@ -75,6 +79,10 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output
_ = rows.Close()
return fmt.Errorf("读取 MySQL 表名失败:%w", err)
}
if tableType == "VIEW" {
viewNames = append(viewNames, tableName)
continue
}
if tableType != "BASE TABLE" || !engine.Valid || !strings.EqualFold(engine.String, "InnoDB") {
_ = rows.Close()
return fmt.Errorf("MySQL 一致性备份当前仅支持 InnoDB 基础表,不能静默导出其他对象:%s", tableName)
@ -91,6 +99,11 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output
if err := requireMySQLTriggerVisibility(ctx, tx, tableNames); err != nil {
return err
}
if len(viewNames) > 0 {
if err := requireMySQLObjectVisibility(ctx, tx, viewNames, "SHOW VIEW", false); err != nil {
return err
}
}
routines, err := listMySQLRoutines(ctx, tx)
if err != nil {
return err
@ -109,6 +122,9 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output
if err := dumpMySQLRoutines(ctx, tx, file, routines); err != nil {
return err
}
if err := dumpMySQLViews(ctx, tx, file, viewNames); err != nil {
return err
}
// 必须先恢复所有基础数据,再创建触发器,避免导入时重放业务副作用。
if err := dumpMySQLTriggers(ctx, tx, file); err != nil {
return err
@ -353,10 +369,17 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string)
if err != nil {
return err
}
if err := preflightMySQLEventRestore(ctx, connection, statements); err != nil {
viewPlan, err := prepareMySQLViewRestore(ctx, connection, statements)
if err != nil {
return fmt.Errorf("预检 MySQL 视图恢复依赖失败:%w", err)
}
if err := preflightMySQLEventRestore(ctx, connection, viewPlan.statements); err != nil {
return err
}
for _, statement := range statements {
for index, statement := range viewPlan.statements {
if err := viewPlan.checkContext(ctx, connection, index); err != nil {
return err
}
if _, err := connection.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err)
}

View File

@ -32,11 +32,12 @@ func TestMySQLRealBackupFailureBoundaries(t *testing.T) {
}
})
for _, scenario := range []struct {
name string
ddl string
name string
ddl string
expected string
}{
{name: "reject_nontransactional_table", ddl: "CREATE TABLE %s (id INT PRIMARY KEY) ENGINE=MyISAM"},
{name: "reject_view", ddl: "CREATE VIEW %s AS SELECT 1 AS id"},
{name: "reject_nontransactional_table", ddl: "CREATE TABLE %s (id INT PRIMARY KEY) ENGINE=MyISAM", expected: "仅支持 InnoDB 基础表"},
{name: "reject_view_without_definition_permission", ddl: "CREATE VIEW %s AS SELECT 1 AS id", expected: "SHOW VIEW"},
} {
t.Run(scenario.name, func(t *testing.T) {
cfg := server.Database(t)
@ -52,8 +53,8 @@ func TestMySQLRealBackupFailureBoundaries(t *testing.T) {
original := []byte("原有备份不能因失败被替换\n")
writeMySQLBackupFixture(t, output, original)
err = backupMySQLDatabase(t.Context(), cfg, output)
if err == nil || !strings.Contains(err.Error(), "仅支持 InnoDB 基础表") {
t.Fatalf("非事务对象不能被当作一致性备份成功导出:%v", err)
if err == nil || !strings.Contains(err.Error(), scenario.expected) {
t.Fatalf("非事务表或不可见定义不能被当作完整备份成功导出:%v", err)
}
assertMySQLBackupFile(t, output, original)
assertMySQLBackupAbsent(t, output+".tmp")

View File

@ -0,0 +1,108 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealCatalogVisibility(t *testing.T) {
server := mysqltest.New(t)
scenarios := []string{"schema", "global", "partial_table", "column_only", "empty_without_permission", "hidden_view", "escaped_schema", "shadowed_schema", "shadowed_exact", "unrelated_schema"}
if server.IsMySQL8() {
scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_schema")
}
for _, scenario := range scenarios {
t.Run(scenario, func(t *testing.T) {
cfg := server.Database(t)
parsed, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
schema := quoteMySQLIdentifier(parsed.DBName)
statements := []string{"REVOKE ALL PRIVILEGES ON " + schema + ".* FROM " + account}
if scenario != "empty_without_permission" {
statements = append(statements,
"CREATE TABLE "+schema+".visible_table (id INT, label VARCHAR(20)) ENGINE=InnoDB",
"CREATE TABLE "+schema+".hidden_table (id INT) ENGINE=InnoDB",
"INSERT INTO "+schema+".visible_table VALUES (1, '保留值')",
"INSERT INTO "+schema+".hidden_table VALUES (2)")
}
if scenario == "hidden_view" {
statements = append(statements, "CREATE VIEW "+schema+".hidden_view AS SELECT id FROM "+schema+".hidden_table")
}
switch scenario {
case "empty_without_permission":
statements = append(statements, "GRANT CREATE ON "+schema+".* TO "+account)
case "schema":
statements = append(statements, "GRANT SELECT, TRIGGER, EVENT ON "+schema+".* TO "+account)
case "global":
statements = append(statements, "GRANT SELECT ON *.* TO "+account, "GRANT TRIGGER, EVENT ON "+schema+".* TO "+account)
case "partial_table", "hidden_view":
statements = append(statements, "GRANT SELECT ON "+schema+".visible_table TO "+account)
case "column_only":
statements = append(statements, "GRANT SELECT (id) ON "+schema+".visible_table TO "+account)
case "escaped_schema":
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
statements = append(statements, "GRANT SELECT, TRIGGER, EVENT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
case "shadowed_schema":
statements = append(statements, "GRANT SELECT ON `scert%`.* TO "+account, "GRANT INSERT ON "+schema+".* TO "+account)
case "shadowed_exact":
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
statements = append(statements, "GRANT SELECT ON "+schema+".* TO "+account, "GRANT INSERT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
case "unrelated_schema":
statements = append(statements, "GRANT CREATE ON "+schema+".* TO "+account, "GRANT SELECT ON `unrelated_database`.* TO "+account)
case "active_role", "nested_role", "inactive_role":
role := quoteMySQLIdentifier("catalog_"+scenario) + "@'%'"
statements = append(statements, "CREATE ROLE "+role, "GRANT SELECT, TRIGGER, EVENT ON "+schema+".* TO "+role, "GRANT "+role+" TO "+account)
if scenario == "active_role" {
statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account)
}
if scenario == "inactive_role" {
statements = append(statements, "GRANT CREATE ON "+schema+".* TO "+account)
}
if scenario == "nested_role" {
parent := "'catalog_parent'@'%'"
statements = append(statements, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account)
}
case "partial_revoke", "partial_revoke_with_schema":
statements = append(statements, "SET GLOBAL partial_revokes = ON", "GRANT SELECT ON *.* TO "+account, "REVOKE SELECT ON "+schema+".* FROM "+account, "GRANT TRIGGER, EVENT ON "+schema+".* TO "+account)
if scenario == "partial_revoke_with_schema" {
statements = append(statements, "GRANT SELECT ON "+schema+".* TO "+account)
}
}
for _, statement := range statements {
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
output := filepath.Join(server.Root(), "catalog-"+scenario+".sql")
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
t.Fatal(err)
}
err = backupMySQLDatabase(t.Context(), cfg, output)
allowed := scenario == "schema" || scenario == "global" || scenario == "escaped_schema" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_schema"
if allowed {
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(output)
if err != nil || !strings.Contains(string(data), "INSERT INTO `visible_table`") || !strings.Contains(string(data), "INSERT INTO `hidden_table`") {
t.Fatal("已证明完整读取权限,但未导出全部基础表数据")
}
} else {
if err == nil || !strings.Contains(err.Error(), "完整表和视图清单") || !strings.Contains(err.Error(), "SELECT") {
t.Fatalf("无法证明清单完整时必须拒绝,不能遗漏隐藏对象:%v", err)
}
assertMySQLBackupFile(t, output, []byte("existing backup"))
}
assertMySQLBackupAbsent(t, output+".tmp")
})
}
}

View File

@ -217,7 +217,7 @@ func TestMySQLRealEventVisibility(t *testing.T) {
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
grants = append(grants, "GRANT SELECT, EVENT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
case "shadowed_schema":
grants = append(grants, "GRANT EVENT ON `scert%`.* TO "+account)
grants = append(grants, "GRANT SELECT ON *.* TO "+account, "GRANT EVENT ON `scert%`.* TO "+account)
case "shadowed_exact":
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
grants = append(grants, "GRANT EVENT ON "+schema+".* TO "+account, "GRANT SELECT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)

View File

@ -197,6 +197,40 @@ func TestMySQLRealRoutineVisibility(t *testing.T) {
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
t.Fatal(err)
}
if scenario == "partial_revoke" || scenario == "partial_revoke_with_show" {
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
tx, err := db.DB.BeginTx(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
routines, visibilityErr := listMySQLRoutines(t.Context(), tx)
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if allowed {
if visibilityErr != nil || len(routines) != 1 || routines[0].name != "hidden_routine" {
t.Fatalf("SHOW_ROUTINE 未独立保证例程可见:%v", visibilityErr)
}
} else if visibilityErr == nil || !strings.Contains(visibilityErr.Error(), "例程") {
t.Fatalf("全局 SELECT 被部分撤销后例程权限检查未拒绝:%v", visibilityErr)
}
if err := backupMySQLDatabase(t.Context(), cfg, output); err == nil || !strings.Contains(err.Error(), "SELECT") {
t.Fatalf("例程可见权限不能替代完整表清单的读取权限:%v", err)
}
assertMySQLBackupFile(t, output, []byte("existing backup"))
assertMySQLBackupAbsent(t, output+".tmp")
if !allowed {
return
}
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account); err != nil {
t.Fatal(err)
}
}
err = backupMySQLDatabase(t.Context(), cfg, output)
if allowed {
if err != nil {

View File

@ -38,7 +38,11 @@ func readMySQLStoredDefinition(ctx context.Context, tx *sql.Tx, kind, name, stat
definition[column] = values[i].String
}
}
for _, column := range []string{"sql_mode", statementColumn, "character_set_client", "collation_connection", "Database Collation"} {
required := []string{"sql_mode", statementColumn, "character_set_client", "collation_connection", "Database Collation"}
if kind == "VIEW" {
required = []string{statementColumn, "character_set_client", "collation_connection"}
}
for _, column := range required {
value, exists := definition[column]
if !exists || (value == "" && column != "sql_mode") {
return nil, fmt.Errorf("MySQL 存储对象定义字段不完整(%s %s):%s", kind, name, column)

View File

@ -155,7 +155,7 @@ func TestMySQLRealTriggerPrivileges(t *testing.T) {
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
statements = append(statements, "GRANT SELECT, TRIGGER, EVENT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
case "shadowed_schema":
statements = append(statements, "GRANT TRIGGER ON `scert%`.* TO "+account)
statements = append(statements, "GRANT SELECT ON *.* TO "+account, "GRANT TRIGGER ON `scert%`.* TO "+account)
case "shadowed_exact":
escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_")
statements = append(statements, "GRANT TRIGGER ON "+schema+".* TO "+account, "GRANT SELECT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)

View File

@ -0,0 +1,160 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealViewDependencies(t *testing.T) {
server := mysqltest.New(t)
source := server.Database(t)
dsn, err := mysql.ParseDSN(source.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, dsn.DBName)
external := server.Database(t)
other, err := mysql.ParseDSN(external.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, other.DBName)
if _, err := server.Admin().ExecContext(t.Context(), "CREATE VIEW "+quoteMySQLIdentifier(other.DBName)+".z_external AS SELECT 41 AS id"); err != nil {
t.Fatal(err)
}
db, err := database.Open(t.Context(), source)
if err != nil {
t.Fatal(err)
}
defer db.Close()
conn, err := db.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
statements := []string{
"CREATE TABLE source_values (id INT PRIMARY KEY, label VARCHAR(30), stamp DATE) ENGINE=InnoDB",
"INSERT INTO source_values VALUES (1, ' xx ', '2026-01-02')",
"CREATE VIEW z_data AS SELECT * FROM source_values",
"CREATE VIEW a_expression AS SELECT id, TRIM(BOTH ' ' FROM label) AS label, EXTRACT(YEAR FROM stamp) AS year_value FROM z_data",
"CREATE VIEW a_scalar AS SELECT (SELECT MAX(id) FROM z_data) AS id",
"CREATE VIEW a_join AS SELECT x.id FROM (z_data x JOIN z_data y ON x.id=y.id), z_data z WHERE z.id=x.id",
"CREATE VIEW a_derived AS SELECT id FROM (SELECT id FROM z_data UNION ALL SELECT id FROM z_data) x",
"CREATE VIEW a_external AS SELECT id FROM " + quoteMySQLIdentifier(other.DBName) + ".z_external",
"CREATE VIEW z_external AS SELECT id FROM a_external",
"SET SESSION sql_mode = 'ANSI_QUOTES,NO_BACKSLASH_ESCAPES'",
"CREATE VIEW a_mode AS SELECT 'from z_data' AS `from`, 'join z_external' AS `join`, id FROM z_data",
"SET SESSION sql_mode = ''",
}
if server.IsMySQL8() {
statements = append(statements,
"CREATE VIEW a_cte AS WITH z_data AS (SELECT id FROM z_data) SELECT id FROM z_data",
"CREATE VIEW a_recursive AS WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<3) SELECT n FROM seq",
"CREATE VIEW a_cte_scope AS SELECT id FROM (WITH z_data AS (SELECT id FROM z_data) SELECT id FROM z_data) x")
}
// 超过 InnoDB 基础表列数上限,证明导出不再依赖占位表。
var columns []string
for i := 0; i < 1020; i++ {
columns = append(columns, fmt.Sprintf("%d AS c%d", i, i))
}
statements = append(statements, "CREATE VIEW wide_view AS SELECT "+strings.Join(columns, ","))
for _, statement := range statements {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
original := readMySQLViewDefinitions(t, db.DB)
output := filepath.Join(server.Root(), "complex-views.sql")
if err := backupMySQLDatabase(t.Context(), source, output); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "CREATE TABLE `wide_view`") || strings.Contains(string(data), "DROP TABLE `wide_view`") {
t.Fatal("视图导出仍在创建或删除占位基础表")
}
for _, direction := range []string{"scert", "official"} {
t.Run(direction, func(t *testing.T) {
target := server.Database(t)
parsed, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, parsed.DBName)
parsed.User = dsn.User
target.DSN = parsed.FormatDSN()
for attempt := 0; attempt < 2; attempt++ {
if direction == "official" {
server.Import(t, target, output)
} else if err := restoreMySQL(t.Context(), target, output); err != nil {
t.Fatal(err)
}
}
verify, err := database.Open(t.Context(), target)
if err != nil {
t.Fatal(err)
}
defer verify.Close()
if actual := readMySQLViewDefinitions(t, verify.DB); actual != original {
t.Fatal("复杂视图恢复后的原始定义或列元数据发生变化")
}
for query, expected := range map[string]string{
"SELECT CONCAT(id, '|', label, '|', year_value) FROM a_expression": "1|xx|2026",
"SELECT id FROM a_scalar": "1",
"SELECT id FROM a_join": "1",
"SELECT COUNT(*) FROM a_derived": "2",
"SELECT id FROM z_external": "41",
"SELECT c1019 FROM wide_view": "1019",
} {
checkMySQLInteropValue(t, target, query, expected)
}
if server.IsMySQL8() {
checkMySQLInteropValue(t, target, "SELECT id FROM a_cte", "1")
checkMySQLInteropValue(t, target, "SELECT id FROM a_cte_scope", "1")
checkMySQLInteropValue(t, target, "SELECT SUM(n) FROM a_recursive", "6")
}
})
}
}
func TestMySQLRealInvalidViewPreservesBackup(t *testing.T) {
server := mysqltest.New(t)
cfg := server.Database(t)
dsn, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, dsn.DBName)
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, statement := range []string{
"CREATE TABLE source_values(id INT) ENGINE=InnoDB",
"CREATE VIEW broken_view AS SELECT id FROM source_values",
"DROP TABLE source_values",
} {
if _, err := db.DB.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
output := filepath.Join(server.Root(), "invalid-view.sql")
original := []byte("保留原备份")
writeMySQLBackupFixture(t, output, original)
if err := backupMySQLDatabase(t.Context(), cfg, output); err == nil {
t.Fatal("失效视图不能导出为成功备份")
}
assertMySQLBackupFile(t, output, original)
assertMySQLBackupAbsent(t, output+".tmp")
}

View File

@ -0,0 +1,198 @@
package cli
import (
"fmt"
"maps"
"strings"
)
func orderMySQLViews(views []mysqlView, schema string, lowerCaseNames int) ([]mysqlView, error) {
normalize := func(value string) string {
if lowerCaseNames != 0 {
return strings.ToLower(value)
}
return value
}
index := make(map[string]int, len(views))
for i, view := range views {
key := normalize(view.name)
if _, exists := index[key]; exists {
return nil, fmt.Errorf("MySQL 视图名称重复:%s", view.name)
}
index[key] = i
}
dependencies := make([][]int, len(views))
for i, view := range views {
tokens, err := lexMySQLView(view.definition["Create View"])
if err != nil {
return nil, fmt.Errorf("解析 MySQL 视图失败(%s):%w", view.name, err)
}
// 从 VIEW 之后的 AS 开始分析,不把定义者或可选列清单当作查询。
seenView, start := false, -1
for j, token := range tokens {
seenView = seenView || token.keyword("VIEW")
if seenView && token.keyword("AS") {
start = j + 1
break
}
}
if start < 0 || start >= len(tokens) {
return nil, fmt.Errorf("MySQL 视图查询定义不完整:%s", view.name)
}
seen := make(map[int]bool)
add := func(database, name string) {
if database != "" && normalize(database) != normalize(schema) {
return
}
if target, ok := index[normalize(name)]; ok && !seen[target] {
seen[target] = true
dependencies[i] = append(dependencies[i], target)
}
}
if err := walkMySQLViewQuery(tokens[start:], nil, false, 0, add); err != nil {
return nil, fmt.Errorf("解析 MySQL 视图依赖失败(%s):%w", view.name, err)
}
}
state := make([]byte, len(views))
ordered := make([]mysqlView, 0, len(views))
var visit func(int) error
visit = func(i int) error {
if state[i] == 2 {
return nil
}
if state[i] == 1 {
return fmt.Errorf("MySQL 视图依赖存在循环:%s", views[i].name)
}
state[i] = 1
for _, dependency := range dependencies[i] {
if err := visit(dependency); err != nil {
return err
}
}
state[i] = 2
ordered = append(ordered, views[i])
return nil
}
for i := range views {
if err := visit(i); err != nil {
return nil, err
}
}
return ordered, nil
}
// 只在查询块的 FROM/JOIN 表位置记录依赖;递归分析子查询和括号连接。
// TRIM/EXTRACT 中的 FROM、列名、别名和函数名都不是表位置。
func walkMySQLViewQuery(tokens []mysqlViewToken, inherited map[string]bool, tableGroup bool, depth int, add func(string, string)) error {
if depth > 256 {
return fmt.Errorf("MySQL 视图查询嵌套过深")
}
ctes := maps.Clone(inherited)
if ctes == nil {
ctes = make(map[string]bool)
}
i := 0
if len(tokens) > 0 && tokens[0].keyword("WITH") {
i++
recursive := i < len(tokens) && tokens[i].keyword("RECURSIVE")
if recursive {
i++
}
for {
if i >= len(tokens) || !tokens[i].identifier() {
return fmt.Errorf("MySQL 视图公共表表达式名称缺失")
}
name := strings.ToLower(tokens[i].text)
i++
if i < len(tokens) && tokens[i].text == "(" && tokens[i].kind == 'p' {
end, err := mysqlViewGroupEnd(tokens, i)
if err != nil {
return err
}
i = end + 1
}
if i+1 >= len(tokens) || !tokens[i].keyword("AS") || tokens[i+1].text != "(" || tokens[i+1].kind != 'p' {
return fmt.Errorf("MySQL 视图公共表表达式定义不完整")
}
i++
end, err := mysqlViewGroupEnd(tokens, i)
if err != nil {
return err
}
if recursive {
ctes[name] = true
}
if err := walkMySQLViewQuery(tokens[i+1:end], ctes, false, depth+1, add); err != nil {
return err
}
ctes[name] = true
i = end + 1
if i >= len(tokens) || tokens[i].kind != 'p' || tokens[i].text != "," {
break
}
i++
}
}
selectSeen, inFrom, expectTable := false, tableGroup, tableGroup
for ; i < len(tokens); i++ {
token := tokens[i]
if token.kind == 'p' && token.text == "(" {
end, err := mysqlViewGroupEnd(tokens, i)
if err != nil {
return err
}
if err := walkMySQLViewQuery(tokens[i+1:end], ctes, expectTable, depth+1, add); err != nil {
return err
}
i, expectTable = end, false
continue
}
if token.kind == 'p' && token.text == ")" {
return fmt.Errorf("MySQL 视图查询括号不匹配")
}
switch {
case token.keyword("SELECT"):
selectSeen, inFrom, expectTable = true, false, false
case token.keyword("FROM") && selectSeen:
inFrom, expectTable = true, true
case (token.keyword("JOIN") || token.keyword("STRAIGHT_JOIN")) && inFrom:
expectTable = true
case token.kind == 'p' && token.text == "," && inFrom:
expectTable = true
case token.kind == 'w' && mysqlViewFromBoundary(token.text):
inFrom, expectTable = false, false
case expectTable:
if token.keyword("LATERAL") {
continue
}
if !token.identifier() {
return fmt.Errorf("MySQL 视图表引用格式无法识别")
}
database, name := "", token.text
if i+1 < len(tokens) && tokens[i+1].kind == 'p' && tokens[i+1].text == "." {
if i+2 >= len(tokens) || !tokens[i+2].identifier() {
return fmt.Errorf("MySQL 视图限定表名不完整")
}
database, name = name, tokens[i+2].text
i += 2
}
function := i+1 < len(tokens) && tokens[i+1].kind == 'p' && tokens[i+1].text == "("
if !function && (database != "" || !ctes[strings.ToLower(name)]) {
add(database, name)
}
expectTable = false
}
}
if expectTable {
return fmt.Errorf("MySQL 视图表引用缺失")
}
return nil
}
func mysqlViewFromBoundary(word string) bool {
switch strings.ToUpper(word) {
case "WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "UNION", "EXCEPT", "INTERSECT", "WINDOW", "INTO", "FOR", "LOCK":
return true
}
return false
}

View File

@ -0,0 +1,110 @@
package cli
import (
"reflect"
"strings"
"testing"
)
func TestMySQLViewDependencies(t *testing.T) {
for _, scenario := range []struct {
name, query string
want []string
}{
{"simple", "select * from `z_base`", []string{".z_base"}},
{"qualified", "select * from `db`.`z_base`", []string{"db.z_base"}},
{"join", "select x.id from `a` x left join `b` y on x.id=y.id, `c`", []string{".a", ".b", ".c"}},
{"parenthesized_join", "select * from (`a` join (`b` cross join `c`) on a.id=b.id)", []string{".a", ".b", ".c"}},
{"subquery", "select (select id from `a`) from `b` where exists(select 1 from `c`)", []string{".a", ".b", ".c"}},
{"derived", "select * from (select * from `a` union all select * from `b`) q join `c` on true", []string{".a", ".b", ".c"}},
{"lateral", "select * from `a`, lateral (select * from `b`) q", []string{".a", ".b"}},
{"function_from", "select extract(year from d), trim(both 'a' from x), substring(x from 2) from `base`", []string{".base"}},
{"function_only", "select extract(year from d), trim('from z_base'), `z_base`(1)", nil},
{"strings", "select 'from `a`', 'join c' from `d`", []string{".d"}},
{"quoted_keywords", "select `from`, `join`, `select` from `from` as `join`", []string{".from"}},
{"quoted_punctuation", "select * from `中文``(表).名`", []string{".中文`(表).名"}},
{"comments", "select /* from `a` */ * from `b` -- join `c`\n # from `d`\n", []string{".b"}},
{"hint", "select /*+ NO_MERGE(a) */ * from `b`", []string{".b"}},
{"cte", "with a as (select * from `z`) select * from a", []string{".z"}},
{"cte_chain", "with a(x) as (select id from `z`), b as (select * from a) select * from b join `q` on true", []string{".z", ".q"}},
{"cte_shadow", "with a as (select * from a) select * from a", []string{".a"}},
{"cte_qualified", "with a as (select 1) select * from `db`.`a`", []string{"db.a"}},
{"cte_recursive", "with recursive a(n) as (select 1 union all select n+1 from a where n<3) select * from a", nil},
{"cte_case", "with a as (select 1) select * from A", nil},
{"cte_scope", "select * from (with a as (select * from z) select * from a) q join a on true", []string{".z", ".a"}},
{"cte_inherited", "with a as (select * from z) select * from (select * from a) q", []string{".z"}},
{"json_table", "select * from JSON_TABLE('[1]', '$[*]' columns (x int path '$')) j join z on true", []string{".z"}},
{"window", "select row_number() over (partition by a order by b) from z window w as (order by a)", []string{".z"}},
{"on_subquery", "select * from a join b on exists(select * from c) join d using(id)", []string{".a", ".b", ".c", ".d"}},
} {
t.Run(scenario.name, func(t *testing.T) {
tokens, err := lexMySQLView(scenario.query)
if err != nil {
t.Fatal(err)
}
var got []string
err = walkMySQLViewQuery(tokens, nil, false, 0, func(schema, name string) { got = append(got, schema+"."+name) })
if err != nil || !reflect.DeepEqual(got, scenario.want) {
t.Fatalf("依赖识别错误:得到 %v,预期 %v,错误 %v", got, scenario.want, err)
}
})
}
}
func TestMySQLViewDependencyOrder(t *testing.T) {
view := func(name, query string) mysqlView {
return mysqlView{name: name, definition: map[string]string{"Create View": "CREATE ALGORITHM=MERGE DEFINER=`view`@`localhost` SQL SECURITY INVOKER VIEW " + quoteMySQLIdentifier(name) + " AS " + query}}
}
for _, scenario := range []struct {
name string
views []mysqlView
lower int
want string
}{
{"chain", []mysqlView{view("a", "select * from z"), view("z", "select * from y"), view("y", "select 1")}, 0, "y,z,a"},
{"foreign", []mysqlView{view("a", "select * from other.z"), view("z", "select 1")}, 0, "a,z"},
{"same_schema", []mysqlView{view("a", "select * from current.z"), view("z", "select 1")}, 0, "z,a"},
{"insensitive", []mysqlView{view("a", "select * from CURRENT.Z"), view("z", "select 1")}, 1, "z,a"},
{"preserved_case", []mysqlView{view("a", "select * from CURRENT.Z"), view("z", "select 1")}, 2, "z,a"},
{"sensitive", []mysqlView{view("a", "select * from Z"), view("z", "select 1")}, 0, "a,z"},
{"diamond", []mysqlView{view("a", "select * from b join c using(id)"), view("b", "select * from d"), view("c", "select * from d"), view("d", "select 1")}, 0, "d,b,c,a"},
{"cycle", []mysqlView{view("a", "select * from z"), view("z", "select * from a")}, 0, ""},
{"self", []mysqlView{view("a", "select * from a")}, 0, ""},
{"duplicate", []mysqlView{view("a", "select 1"), view("A", "select 1")}, 1, ""},
{"invalid", []mysqlView{{name: "a", definition: map[string]string{"Create View": "CREATE VIEW a"}}}, 0, ""},
} {
t.Run(scenario.name, func(t *testing.T) {
ordered, err := orderMySQLViews(scenario.views, "current", scenario.lower)
if scenario.want == "" {
if err == nil {
t.Fatal("无效依赖未被拒绝")
}
return
}
if err != nil {
t.Fatal(err)
}
var names []string
for _, item := range ordered {
names = append(names, item.name)
}
if strings.Join(names, ",") != scenario.want {
t.Fatalf("依赖排序错误:%v", names)
}
})
}
}
func TestMySQLViewMalformedDefinition(t *testing.T) {
for _, query := range []string{"select 'unterminated", "select `unterminated", "select /* unfinished", "select /*!50000 1 */", "select * from (a", "select * from a)", "select * from db.", "select * from", "with a select 1", "with a as (select 1", "with", "select * from ,"} {
t.Run(query, func(t *testing.T) {
tokens, err := lexMySQLView(query)
if err == nil {
err = walkMySQLViewQuery(tokens, nil, false, 0, func(string, string) {})
}
if err == nil {
t.Fatal("格式不完整的依赖未被拒绝")
}
})
}
}

View File

@ -0,0 +1,89 @@
package cli
import (
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealViewVisibility(t *testing.T) {
server := mysqltest.New(t)
scenarios := []string{"schema", "global", "all_tables", "one_table", "none", "escaped_schema", "shadowed_schema", "shadowed_exact"}
if server.IsMySQL8() {
scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_table")
}
for _, scenario := range scenarios {
t.Run(scenario, func(t *testing.T) {
cfg := server.Database(t)
dsn, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
schema := quoteMySQLIdentifier(dsn.DBName)
account := mysqlStringLiteral(dsn.User) + "@'127.0.0.1'"
statements := []string{
"CREATE VIEW " + schema + ".a AS SELECT 1 AS id",
"CREATE VIEW " + schema + ".b AS SELECT 2 AS id",
}
switch scenario {
case "schema":
statements = append(statements, "GRANT SHOW VIEW ON "+schema+".* TO "+account)
case "global":
statements = append(statements, "GRANT SHOW VIEW ON *.* TO "+account)
case "all_tables", "one_table":
statements = append(statements, "GRANT SHOW VIEW ON "+schema+".a TO "+account)
if scenario == "all_tables" {
statements = append(statements, "GRANT SHOW VIEW ON "+schema+".b TO "+account)
}
case "escaped_schema":
escaped := strings.ReplaceAll(dsn.DBName, "_", string(rune(92))+"_")
statements = append(statements, "GRANT SELECT, TRIGGER, EVENT, SHOW VIEW ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
case "shadowed_schema":
statements = append(statements, "GRANT SELECT ON *.* TO "+account, "GRANT SHOW VIEW ON `scert%`.* TO "+account)
case "shadowed_exact":
escaped := strings.ReplaceAll(dsn.DBName, "_", string(rune(92))+"_")
statements = append(statements, "GRANT SHOW VIEW ON "+schema+".* TO "+account, "GRANT SELECT, TRIGGER, EVENT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account)
case "active_role", "nested_role", "inactive_role":
role := quoteMySQLIdentifier("view_"+scenario) + "@'%'"
statements = append(statements, "CREATE ROLE "+role, "GRANT SHOW VIEW ON "+schema+".* TO "+role, "GRANT "+role+" TO "+account)
if scenario == "active_role" {
statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account)
}
if scenario == "nested_role" {
parent := "'view_parent'@'%'"
statements = append(statements, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account)
}
case "partial_revoke", "partial_revoke_with_table":
statements = append(statements, "SET GLOBAL partial_revokes = ON", "GRANT SHOW VIEW ON *.* TO "+account, "REVOKE SHOW VIEW ON "+schema+".* FROM "+account)
if scenario == "partial_revoke_with_table" {
statements = append(statements, "GRANT SHOW VIEW ON "+schema+".a TO "+account, "GRANT SHOW VIEW ON "+schema+".b TO "+account)
}
}
for _, statement := range statements {
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
file := filepath.Join(server.Root(), "view-visibility-"+scenario+".sql")
original := []byte("保留原备份")
writeMySQLBackupFixture(t, file, original)
err = backupMySQLDatabase(t.Context(), cfg, file)
allowed := scenario == "schema" || scenario == "global" || scenario == "all_tables" || scenario == "escaped_schema" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_table"
if allowed {
if err != nil {
t.Fatal(err)
}
} else {
if err == nil || !strings.Contains(err.Error(), "SHOW VIEW") {
t.Fatalf("视图定义权限缺失时未明确拒绝:%v", err)
}
assertMySQLBackupFile(t, file, original)
}
assertMySQLBackupAbsent(t, file+".tmp")
})
}
}

View File

@ -0,0 +1,205 @@
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 官方视图创建名称无法识别")
}

View File

@ -0,0 +1,112 @@
package cli
import (
"path/filepath"
"slices"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealViewRestorePlanning(t *testing.T) {
server := mysqltest.New(t)
cfg := server.Database(t)
dsn, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, dsn.DBName)
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
conn, err := db.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
a := mysqlViewRestoreFixture("a", "select * from z")
z := mysqlViewRestoreFixture("z", "select 1 AS id")
input := append(slices.Clone(a), z...)
original := slices.Clone(input)
plan, err := prepareMySQLViewRestore(t.Context(), conn, input)
if err != nil {
t.Fatal(err)
}
want := append(slices.Clone(z), a...)
if !slices.Equal(plan.statements, want) || !slices.Equal(input, original) {
t.Fatal("恢复预检未整块排序,或改写了原始语句")
}
if err := plan.checkContext(t.Context(), conn, 0); err != nil {
t.Fatal(err)
}
for _, boundary := range []string{"INSERT INTO marker VALUES (99)", "SET @unrelated = 1", "USE another_database"} {
t.Run(boundary, func(t *testing.T) {
separated := append(append(slices.Clone(a), boundary), z...)
plan, err := prepareMySQLViewRestore(t.Context(), conn, separated)
if err != nil || !slices.Equal(plan.statements, separated) {
t.Fatalf("视图块越过外部语句被重排:%v", err)
}
})
}
for _, statement := range []string{"SET SESSION sql_mode = 'ANSI_QUOTES'", "SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'", "SET NAMES latin1"} {
t.Run(statement, func(t *testing.T) {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
if err := plan.checkContext(t.Context(), conn, 0); err == nil {
t.Fatal("恢复时的实际语义变更没有被拒绝")
}
if _, err := conn.ExecContext(t.Context(), "SET SESSION sql_mode = ''"); err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(t.Context(), "SET NAMES utf8mb4"); err != nil {
t.Fatal(err)
}
})
}
for _, statement := range []string{"CREATE TABLE marker(value INT) ENGINE=InnoDB", "INSERT INTO marker VALUES (73)"} {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
for _, scenario := range []string{"cycle", "duplicate", "name_mismatch", "invalid_charset"} {
t.Run(scenario, func(t *testing.T) {
blocks := slices.Clone(input)
switch scenario {
case "cycle":
blocks = append(slices.Clone(a), mysqlViewRestoreFixture("z", "select * from a")...)
case "duplicate":
blocks = append(slices.Clone(a), a...)
case "name_mismatch":
blocks[0] = "/*!50001 DROP VIEW IF EXISTS `other` */"
case "invalid_charset":
blocks[4] = "/*!50001 SET character_set_client = unknown_charset */"
}
script := append([]string{"DROP TABLE marker"}, blocks...)
file := filepath.Join(server.Root(), "view-plan-"+scenario+".sql")
writeMySQLBackupFixture(t, file, []byte(strings.Join(script, ";\n")+";\n"))
if err := restoreMySQL(t.Context(), cfg, file); err == nil || !strings.Contains(err.Error(), "预检 MySQL 视图恢复依赖失败") {
t.Fatalf("无效视图恢复计划未在执行任何输入语句前拒绝:%v", err)
}
checkMySQLInteropValue(t, cfg, "SELECT value FROM marker", "73")
})
}
other := server.Database(t)
otherDSN, err := mysql.ParseDSN(other.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, dsn.User, otherDSN.DBName)
if _, err := conn.ExecContext(t.Context(), "USE "+quoteMySQLIdentifier(otherDSN.DBName)); err != nil {
t.Fatal(err)
}
if err := plan.checkContext(t.Context(), conn, 0); err == nil {
t.Fatal("数据库切换后仍使用原库的依赖计划")
}
}

View File

@ -0,0 +1,87 @@
package cli
import (
"fmt"
"slices"
"testing"
)
func mysqlViewRestoreFixture(name, query string) []string {
plain := []string{
"DROP VIEW IF EXISTS " + quoteMySQLIdentifier(name),
"SET @saved_cs_client = @@character_set_client",
"SET @saved_cs_results = @@character_set_results",
"SET @saved_col_connection = @@collation_connection",
"SET character_set_client = utf8mb4",
"SET character_set_results = utf8mb4",
"SET collation_connection = utf8mb4_bin",
"CREATE ALGORITHM=MERGE SQL SECURITY INVOKER VIEW " + quoteMySQLIdentifier(name) + " AS " + query,
"SET character_set_client = @saved_cs_client",
"SET character_set_results = @saved_cs_results",
"SET collation_connection = @saved_col_connection",
}
for i := range plain {
plain[i] = "/*!50001 " + plain[i] + " */"
}
return plain
}
func TestMySQLViewRestoreBlock(t *testing.T) {
statements := mysqlViewRestoreFixture("a", "select * from z")
block, ok := readMySQLViewRestoreBlock(statements)
if !ok || block.name != "a" || block.charset != "utf8mb4" || !slices.Equal(block.statements, statements) {
t.Fatal("完整官方视图块未被原样识别")
}
for _, test := range []struct {
name string
at int
sql string
}{
{"missing_restore", 10, "SELECT 1"},
{"business_statement", 5, "/*!50001 DELETE FROM user_data */"},
{"global_setting", 4, "/*!50001 SET GLOBAL character_set_client = utf8mb4 */"},
{"setting_expression", 4, "/*!50001 SET character_set_client = CONCAT('utf8','mb4') */"},
{"unknown_restore", 8, "/*!50001 SET character_set_client = @another */"},
{"extra_assignment", 8, "/*!50001 SET character_set_client = @saved_cs_client, sql_mode = '' */"},
{"split_identifier", 8, "/*!50001 SET character_set_ client = @saved_cs_client */"},
{"plain_create", 7, "CREATE VIEW `a` AS SELECT 1"},
{"future_version", 7, "/*!99999 CREATE ALGORITHM=MERGE VIEW `a` AS SELECT 1 */"},
{"qualified_drop", 0, "/*!50001 DROP VIEW IF EXISTS `db`.`a` */"},
{"multiple_drops", 0, "/*!50001 DROP VIEW IF EXISTS `a`, `b` */"},
} {
t.Run(test.name, func(t *testing.T) {
changed := slices.Clone(statements)
changed[test.at] = test.sql
if _, ok := readMySQLViewRestoreBlock(changed); ok {
t.Fatal("非闭合官方视图块不能被重排")
}
})
}
for length := 0; length < 11; length++ {
t.Run(fmt.Sprintf("truncated_%d", length), func(t *testing.T) {
if _, ok := readMySQLViewRestoreBlock(statements[:length]); ok {
t.Fatal("截断视图块不能被重排")
}
})
}
}
func TestMySQLViewDumpWrapper(t *testing.T) {
for _, source := range []string{
"/*!50001 CREATE ALGORITHM=MERGE */\n/*!50013 DEFINER=`a`@`b` SQL SECURITY INVOKER */\n/*!50001 VIEW `a` AS SELECT 1 */",
"/*!50001 CREATE ALGORITHM=MERGE VIEW `a` AS SELECT 1 */ /*!50002 WITH LOCAL CHECK OPTION */",
} {
plain, ok := unwrapMySQLViewDumpStatement(source)
if !ok {
t.Fatal("官方版本化定义无法展开")
}
if name, err := mysqlViewCreateName(plain); err != nil || name != "a" {
t.Fatalf("视图名称识别失败:%v", err)
}
}
for _, source := range []string{"", "CREATE VIEW `a` AS SELECT 1", "/*!50001 unfinished", "/*!99999 VIEW a */", "/*!50001SELECT 1 */", "/*!50001 VIEW a */; DELETE FROM t", "/* plain */"} {
if _, ok := unwrapMySQLViewDumpStatement(source); ok {
t.Fatal("未知封装被识别成官方视图定义")
}
}
}

View File

@ -0,0 +1,112 @@
package cli
import (
"fmt"
"strings"
)
type mysqlViewToken struct {
text string
kind byte
}
func (t mysqlViewToken) keyword(value string) bool {
return t.kind == 'w' && strings.EqualFold(t.text, value)
}
func (t mysqlViewToken) identifier() bool { return t.kind == 'w' || t.kind == 'i' }
// 输入仅限 sql_mode 为空且 sql_quote_show_create=1 的服务端规范定义。
// 保留标识符原文;字符串和注释永远不能成为依赖名称。
func lexMySQLView(definition string) ([]mysqlViewToken, error) {
var tokens []mysqlViewToken
for i := 0; i < len(definition); {
c := definition[i]
if c <= ' ' {
i++
continue
}
if c == '#' || (strings.HasPrefix(definition[i:], "--") && i+2 < len(definition) && definition[i+2] <= ' ') {
for i < len(definition) && definition[i] != '\n' {
i++
}
continue
}
if strings.HasPrefix(definition[i:], "/*") {
end := strings.Index(definition[i+2:], "*/")
if end < 0 || strings.HasPrefix(definition[i:], "/*!") {
return nil, fmt.Errorf("MySQL 视图规范定义含未闭合或可执行注释")
}
i += end + 4
continue
}
if c == '`' || c == '\'' || c == '"' {
quote := c
i++
var value strings.Builder
closed := false
for i < len(definition) {
c = definition[i]
i++
if c == quote {
if i < len(definition) && definition[i] == quote {
value.WriteByte(c)
i++
continue
}
closed = true
break
}
if c == '\\' && quote != '`' && i < len(definition) {
value.WriteByte(definition[i])
i++
} else {
value.WriteByte(c)
}
}
if !closed {
return nil, fmt.Errorf("MySQL 视图规范定义含未闭合引用")
}
kind := byte('s')
if quote == '`' {
kind = 'i'
}
tokens = append(tokens, mysqlViewToken{text: value.String(), kind: kind})
continue
}
if mysqlViewWordByte(c) {
start := i
for i < len(definition) && mysqlViewWordByte(definition[i]) {
i++
}
tokens = append(tokens, mysqlViewToken{text: definition[start:i], kind: 'w'})
continue
}
tokens = append(tokens, mysqlViewToken{text: string(c), kind: 'p'})
i++
}
return tokens, nil
}
func mysqlViewWordByte(c byte) bool {
return c >= 128 || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '$'
}
func mysqlViewGroupEnd(tokens []mysqlViewToken, start int) (int, error) {
depth := 0
for i := start; i < len(tokens); i++ {
if tokens[i].kind != 'p' {
continue
}
switch tokens[i].text {
case "(":
depth++
case ")":
depth--
if depth == 0 {
return i, nil
}
}
}
return 0, fmt.Errorf("MySQL 视图规范定义含未闭合括号")
}

View File

@ -0,0 +1,77 @@
package cli
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
)
type mysqlView struct {
name string
definition map[string]string
}
func dumpMySQLViews(ctx context.Context, tx *sql.Tx, output io.Writer, names []string) (result error) {
if len(names) == 0 {
return nil
}
var schema, databaseCollation, sourceMode string
var lowerCaseNames, sourceQuote int
if err := tx.QueryRowContext(ctx, "SELECT DATABASE(), @@collation_database, @@SESSION.sql_mode, @@lower_case_table_names, @@SESSION.sql_quote_show_create").Scan(&schema, &databaseCollation, &sourceMode, &lowerCaseNames, &sourceQuote); err != nil {
return err
}
// 仅解析服务端在固定模式下输出的依赖;创建语句保持原样,不重写用户 SQL。
if _, err := tx.ExecContext(ctx, "SET SESSION sql_mode = '', SESSION sql_quote_show_create = 1"); err != nil {
return err
}
defer func() {
_, err := tx.ExecContext(ctx, "SET SESSION sql_mode = ?, SESSION sql_quote_show_create = ?", sourceMode, sourceQuote)
result = errors.Join(result, err)
}()
views := make([]mysqlView, 0, len(names))
for _, name := range names {
definition, err := readMySQLStoredDefinition(ctx, tx, "VIEW", name, "Create View")
if err != nil {
return err
}
definition["sql_mode"] = ""
definition["Database Collation"] = databaseCollation
var columns int
if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", name).Scan(&columns); err != nil {
return fmt.Errorf("读取 MySQL 视图列失败(%s):%w", name, err)
}
if columns == 0 {
return fmt.Errorf("MySQL 视图列不可见或已失效,拒绝生成不完整备份:%s", name)
}
views = append(views, mysqlView{name: name, definition: definition})
}
views, err := orderMySQLViews(views, schema, lowerCaseNames)
if err != nil {
return err
}
if _, err := fmt.Fprintln(output, `SET @scert_view_sql_mode = @@SESSION.sql_mode;
SET @scert_view_charset = @@SESSION.character_set_client;
SET @scert_view_collation = @@SESSION.collation_connection;
SET SESSION sql_mode = '';
SET SESSION character_set_client = 'utf8mb4';
SET SESSION collation_connection = 'utf8mb4_bin';`); err != nil {
return err
}
// 先按反向依赖删除视图,再正向创建;不创建占位表,也绝不删除同名基础表。
for i := len(views) - 1; i >= 0; i-- {
if _, err := fmt.Fprintf(output, "DROP VIEW IF EXISTS %s;\n", quoteMySQLIdentifier(views[i].name)); err != nil {
return err
}
}
for _, view := range views {
if err := writeMySQLStoredDefinition(ctx, tx, output, view.definition, "Create View", ""); err != nil {
return fmt.Errorf("写入 MySQL 视图失败(%s):%w", view.name, err)
}
}
_, err = fmt.Fprintln(output, `SET SESSION sql_mode = @scert_view_sql_mode;
SET SESSION character_set_client = @scert_view_charset;
SET SESSION collation_connection = @scert_view_collation;`)
return err
}

View File

@ -0,0 +1,231 @@
package cli
import (
"crypto/sha256"
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealNativeViews(t *testing.T) {
server := mysqltest.New(t)
source := server.Database(t)
parsed, err := mysql.ParseDSN(source.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, parsed.User, parsed.DBName)
db, err := database.Open(t.Context(), source)
if err != nil {
t.Fatal(err)
}
defer db.Close()
conn, err := db.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
for _, statement := range []string{
"CREATE TABLE source_values (id INT PRIMARY KEY, amount DECIMAL(25,8), label VARCHAR(80)) ENGINE=InnoDB",
"INSERT INTO source_values VALUES (1, 12345678901234567.12345678, '原始值'), (2, -1.25000000, '负数')",
"CREATE FUNCTION view_label(input_value VARCHAR(80)) RETURNS VARCHAR(100) DETERMINISTIC NO SQL RETURN CONCAT('函数;', input_value)",
"CREATE ALGORITHM=MERGE SQL SECURITY INVOKER VIEW z_base AS SELECT id, amount, label FROM source_values WHERE id > 0 WITH LOCAL CHECK OPTION",
"CREATE ALGORITHM=MERGE SQL SECURITY DEFINER VIEW a_nested AS SELECT id, amount, label FROM z_base WHERE id = 1 WITH CASCADED CHECK OPTION",
"CREATE ALGORITHM=TEMPTABLE SQL SECURITY INVOKER VIEW `中文``汇总` AS SELECT SUM(amount) AS `金额`, view_label(MAX(label)) AS `说明` FROM a_nested",
"SET NAMES latin1 COLLATE latin1_bin",
"CREATE SQL SECURITY INVOKER VIEW `legacy_caf" + string([]byte{0xe9}) + "` AS SELECT 'caf" + string([]byte{0xe9}) + ";;' AS label",
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
} {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
original := readMySQLViewDefinitions(t, db.DB)
output := filepath.Join(server.Root(), "native-views.sql")
if err := backupMySQLDatabase(t.Context(), source, output); err != nil {
t.Fatal(err)
}
official := filepath.Join(server.Root(), "official-views.sql")
server.DumpStoredPrograms(t, source, official)
before, err := os.ReadFile(official)
if err != nil {
t.Fatal(err)
}
originalDigest := sha256.Sum256(before)
t.Run("official_to_official_control", func(t *testing.T) {
target := server.Database(t)
dsn, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, parsed.User, dsn.DBName)
dsn.User = parsed.User
target.DSN = dsn.FormatDSN()
if code := server.ImportErrorCode(t, target, official); code != 1368 {
t.Fatalf("官方原样恢复未复现嵌套检查选项错误:%d", code)
}
t.Log("同一份未经改写的官方导出文件,官方客户端返回 1368;Scert 必须单独验证成功恢复,不能用此失败对照替代")
})
for _, direction := range []string{"scert_to_scert", "scert_to_official", "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)
}
grantMySQLViewAccess(t, server, parsed.User, targetDSN.DBName)
targetDSN.User = parsed.User
target.DSN = targetDSN.FormatDSN()
verify, err := database.Open(t.Context(), target)
if err != nil {
t.Fatal(err)
}
defer verify.Close()
if _, err := verify.DB.ExecContext(t.Context(), "CREATE VIEW a_nested AS SELECT 999 AS wrong_column"); err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
input := output
if direction == "official_to_scert" {
input = official
}
if direction == "scert_to_official" {
server.Import(t, target, input)
} else if err := restoreMySQL(t.Context(), target, input); err != nil {
t.Fatal(err)
}
if actual := readMySQLViewDefinitions(t, verify.DB); actual != original {
t.Fatalf("恢复后的视图定义或列元数据不一致:\n源:%s\n目标:%s", original, actual)
}
checkMySQLInteropValue(t, target, "SELECT CONCAT(id, '|', amount, '|', label) FROM a_nested", "1|12345678901234567.12345678|原始值")
checkMySQLInteropValue(t, target, "SELECT label FROM `legacy_café`", "café;;")
if _, err := verify.DB.ExecContext(t.Context(), "UPDATE a_nested SET label = '更新值' WHERE id = 1"); err != nil {
t.Fatal(err)
}
checkMySQLInteropValue(t, target, "SELECT `说明` FROM `中文``汇总`", "函数;更新值")
if _, err := verify.DB.ExecContext(t.Context(), "UPDATE a_nested SET id = 3 WHERE id = 1"); err == nil {
t.Fatal("恢复后视图的检查选项未阻止越界写入")
}
checkMySQLInteropValue(t, source, "SELECT label FROM source_values WHERE id = 1", "原始值")
}
})
}
after, err := os.ReadFile(official)
if err != nil || sha256.Sum256(after) != originalDigest {
t.Fatal("恢复过程改写了官方原始导出文件")
}
t.Run("existing_table_is_not_deleted", func(t *testing.T) {
target := server.Database(t)
dsn, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLViewAccess(t, server, parsed.User, dsn.DBName)
dsn.User = parsed.User
target.DSN = dsn.FormatDSN()
verify, err := database.Open(t.Context(), target)
if err != nil {
t.Fatal(err)
}
defer verify.Close()
for _, statement := range []string{"CREATE TABLE a_nested (marker INT)", "INSERT INTO a_nested VALUES (73)"} {
if _, err := verify.DB.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
if err := restoreMySQL(t.Context(), target, output); err == nil {
t.Fatal("视图与目标已有基础表同名时没有拒绝恢复")
}
checkMySQLInteropValue(t, target, "SELECT marker FROM a_nested", "73")
})
}
func grantMySQLViewAccess(t *testing.T, server *mysqltest.Server, user, name string) {
t.Helper()
grantMySQLProgramAccess(t, server, user, name)
if _, err := server.Admin().ExecContext(t.Context(), "GRANT CREATE VIEW, SHOW VIEW ON "+quoteMySQLIdentifier(name)+".* TO "+mysqlStringLiteral(user)+"@'127.0.0.1'"); err != nil {
t.Fatal(err)
}
if server.IsMySQL8() {
if _, err := server.Admin().ExecContext(t.Context(), "GRANT SUPER ON *.* TO "+mysqlStringLiteral(user)+"@'127.0.0.1'"); err != nil {
t.Fatal(err)
}
}
}
func readMySQLViewDefinitions(t *testing.T, db *sql.DB) string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME")
if err != nil {
t.Fatal(err)
}
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
rows.Close()
var values []string
for _, name := range names {
var actual, definition, charset, collation string
if err := db.QueryRowContext(t.Context(), "SHOW CREATE VIEW "+quoteMySQLIdentifier(name)).Scan(&actual, &definition, &charset, &collation); err != nil {
t.Fatal(err)
}
values = append(values, actual+"|"+definition+"|"+charset+"|"+collation)
}
columns, err := db.QueryContext(t.Context(), `SELECT JSON_ARRAY(c.TABLE_NAME, c.COLUMN_NAME, c.ORDINAL_POSITION,
c.COLUMN_TYPE, c.IS_NULLABLE, c.CHARACTER_SET_NAME, c.COLLATION_NAME)
FROM information_schema.COLUMNS c JOIN information_schema.VIEWS v
ON c.TABLE_SCHEMA = v.TABLE_SCHEMA AND c.TABLE_NAME = v.TABLE_NAME
WHERE c.TABLE_SCHEMA = DATABASE() ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`)
if err != nil {
t.Fatal(err)
}
defer columns.Close()
for columns.Next() {
var value string
if err := columns.Scan(&value); err != nil {
t.Fatal(err)
}
values = append(values, value)
}
if err := columns.Err(); err != nil {
t.Fatal(err)
}
return strings.Join(values, "\n")
}
func TestMySQLRealViewPermissionFailure(t *testing.T) {
server := mysqltest.New(t)
cfg := server.Database(t)
parsed, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
if _, err := server.Admin().ExecContext(t.Context(), "CREATE VIEW "+quoteMySQLIdentifier(parsed.DBName)+".hidden_definition AS SELECT 1 AS id"); err != nil {
t.Fatal(err)
}
output := filepath.Join(server.Root(), "view-permissions.sql")
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
t.Fatal(err)
}
err = backupMySQLDatabase(t.Context(), cfg, output)
if err == nil || !strings.Contains(err.Error(), "SHOW VIEW") {
t.Fatalf("缺少视图定义可见权限时必须明确拒绝:%v", err)
}
assertMySQLBackupFile(t, output, []byte("existing backup"))
assertMySQLBackupAbsent(t, output+".tmp")
}