完善MySQL视图备份恢复及完整清单权限校验
保留原始视图定义与编码,按依赖排序导出,并严格识别和重排官方最终视图块;增加运行上下文和权限检查。补充视图、清单、恢复失败保护测试,并修正旧事件、触发器和例程测试的权限前提。 隔离MySQL 5.7和8.0完整专项分别通过176和202个叶子用例,无失败或跳过;后端常规全量、静态检查、模块校验及Linux测试包交叉编译通过。测试产物和凭据未提交。本提交不代表生产部署验收完成。
This commit is contained in:
parent
d95ecfa9a0
commit
777c7169b6
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
108
backend/internal/cli/mysql_catalog_integration_test.go
Normal file
108
backend/internal/cli/mysql_catalog_integration_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
160
backend/internal/cli/mysql_view_complex_integration_test.go
Normal file
160
backend/internal/cli/mysql_view_complex_integration_test.go
Normal 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")
|
||||
}
|
||||
198
backend/internal/cli/mysql_view_dependencies.go
Normal file
198
backend/internal/cli/mysql_view_dependencies.go
Normal 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
|
||||
}
|
||||
110
backend/internal/cli/mysql_view_dependencies_test.go
Normal file
110
backend/internal/cli/mysql_view_dependencies_test.go
Normal 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("格式不完整的依赖未被拒绝")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
205
backend/internal/cli/mysql_view_restore.go
Normal file
205
backend/internal/cli/mysql_view_restore.go
Normal 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 官方视图创建名称无法识别")
|
||||
}
|
||||
112
backend/internal/cli/mysql_view_restore_integration_test.go
Normal file
112
backend/internal/cli/mysql_view_restore_integration_test.go
Normal 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("数据库切换后仍使用原库的依赖计划")
|
||||
}
|
||||
}
|
||||
87
backend/internal/cli/mysql_view_restore_test.go
Normal file
87
backend/internal/cli/mysql_view_restore_test.go
Normal 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("未知封装被识别成官方视图定义")
|
||||
}
|
||||
}
|
||||
}
|
||||
112
backend/internal/cli/mysql_view_tokens.go
Normal file
112
backend/internal/cli/mysql_view_tokens.go
Normal 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 视图规范定义含未闭合括号")
|
||||
}
|
||||
77
backend/internal/cli/mysql_views.go
Normal file
77
backend/internal/cli/mysql_views.go
Normal 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
|
||||
}
|
||||
231
backend/internal/cli/mysql_views_integration_test.go
Normal file
231
backend/internal/cli/mysql_views_integration_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user