mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
🐛 fix(data-import): 统一外部 SQL 导入并处理 GTID 冲突
- 将数据库右键入口统一到导入工作台,保留失败后继续选项并规避 macOS 闪退 - 导入前检测 GTID_PURGED 与目标 GTID_EXECUTED,冲突时默认零写入阻断 - 支持跳过 GTID 语句或按 MySQL 版本重置 GTID 历史 - 补齐 Wails 绑定、六种语言文案和前后端回归测试
This commit is contained in:
262
internal/app/database_sql_import_gtid.go
Normal file
262
internal/app/database_sql_import_gtid.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type mysqlGTIDImportMode string
|
||||
|
||||
const (
|
||||
mysqlGTIDImportModeReject mysqlGTIDImportMode = "reject"
|
||||
mysqlGTIDImportModeSkip mysqlGTIDImportMode = "skip"
|
||||
mysqlGTIDImportModeReset mysqlGTIDImportMode = "reset"
|
||||
)
|
||||
|
||||
var errMySQLGTIDStatementFound = errors.New("MySQL GTID_PURGED statement found")
|
||||
|
||||
type mysqlGTIDTargetState struct {
|
||||
GTIDExecuted string
|
||||
ServerVersion string
|
||||
}
|
||||
|
||||
func normalizeMySQLGTIDImportMode(raw string) (mysqlGTIDImportMode, error) {
|
||||
mode := mysqlGTIDImportMode(strings.ToLower(strings.TrimSpace(raw)))
|
||||
if mode == "" {
|
||||
mode = mysqlGTIDImportModeReject
|
||||
}
|
||||
switch mode {
|
||||
case mysqlGTIDImportModeReject, mysqlGTIDImportModeSkip, mysqlGTIDImportModeReset:
|
||||
return mode, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported MySQL GTID import mode %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func isMySQLGTIDImportConfig(config connection.ConnectionConfig) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(config.Type), "mysql")
|
||||
}
|
||||
|
||||
func skipMySQLGTIDLeadingTrivia(text string) int {
|
||||
position := 0
|
||||
for position < len(text) {
|
||||
switch {
|
||||
case strings.ContainsRune(" \t\r\n\f", rune(text[position])):
|
||||
position++
|
||||
case strings.HasPrefix(text[position:], "--") || strings.HasPrefix(text[position:], "#"):
|
||||
lineEnd := strings.IndexByte(text[position:], '\n')
|
||||
if lineEnd < 0 {
|
||||
return len(text)
|
||||
}
|
||||
position += lineEnd + 1
|
||||
case strings.HasPrefix(text[position:], "/*!"):
|
||||
return position
|
||||
case strings.HasPrefix(text[position:], "/*"):
|
||||
commentEnd := strings.Index(text[position+2:], "*/")
|
||||
if commentEnd < 0 {
|
||||
return len(text)
|
||||
}
|
||||
position += commentEnd + 4
|
||||
default:
|
||||
return position
|
||||
}
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
func unwrapMySQLExecutableStatement(statement string) string {
|
||||
text := strings.TrimSpace(statement)
|
||||
for text != "" {
|
||||
start := skipMySQLGTIDLeadingTrivia(text)
|
||||
if start >= len(text) {
|
||||
return ""
|
||||
}
|
||||
text = strings.TrimSpace(text[start:])
|
||||
if !strings.HasPrefix(text, "/*!") {
|
||||
return text
|
||||
}
|
||||
end := strings.LastIndex(text, "*/")
|
||||
if end < 3 || strings.TrimSpace(text[end+2:]) != "" {
|
||||
return ""
|
||||
}
|
||||
inner := text[3:end]
|
||||
versionEnd := 0
|
||||
for versionEnd < len(inner) && inner[versionEnd] >= '0' && inner[versionEnd] <= '9' {
|
||||
versionEnd++
|
||||
}
|
||||
text = strings.TrimSpace(inner[versionEnd:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isMySQLGTIDPurgedStatement(statement string) bool {
|
||||
text := unwrapMySQLExecutableStatement(statement)
|
||||
keyword, position := nextSQLKeyword(text, 0)
|
||||
if keyword != "set" {
|
||||
return false
|
||||
}
|
||||
|
||||
position = skipSQLTrivia(text, position)
|
||||
if !strings.HasPrefix(text[position:], "@@") {
|
||||
scope, scopeEnd := nextSQLKeyword(text, position)
|
||||
if scope != "global" {
|
||||
return false
|
||||
}
|
||||
position = scopeEnd
|
||||
} else {
|
||||
position += 2
|
||||
scope, scopeEnd := nextSQLKeyword(text, position)
|
||||
if scope != "global" {
|
||||
return false
|
||||
}
|
||||
position = scopeEnd
|
||||
}
|
||||
|
||||
position = skipSQLTrivia(text, position)
|
||||
if position < len(text) && text[position] == '.' {
|
||||
position++
|
||||
}
|
||||
variable, variableEnd := nextSQLKeyword(text, position)
|
||||
if variable != "gtid_purged" {
|
||||
return false
|
||||
}
|
||||
position = skipSQLTrivia(text, variableEnd)
|
||||
return strings.HasPrefix(text[position:], "=") || strings.HasPrefix(text[position:], ":=")
|
||||
}
|
||||
|
||||
func inspectMySQLGTIDSQLFile(filePath string) (bool, error) {
|
||||
source, err := OpenSQLImportSource(filePath, SQLImportSourceOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, scanErr := StreamSQLFileWithOptions(source, SQLStreamOptions{
|
||||
DBType: "mysql",
|
||||
MaxStatementBytes: DefaultSQLImportMaxStatementBytes,
|
||||
}, func(_ int, statement string) error {
|
||||
if isMySQLGTIDPurgedStatement(statement) {
|
||||
return errMySQLGTIDStatementFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
closeErr := source.Close()
|
||||
if errors.Is(scanErr, errMySQLGTIDStatementFound) {
|
||||
scanErr = nil
|
||||
if closeErr != nil {
|
||||
return false, closeErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if scanErr != nil {
|
||||
return false, scanErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return false, closeErr
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func queryMySQLGTIDTargetState(database db.Database) (mysqlGTIDTargetState, error) {
|
||||
rows, _, err := database.Query("SELECT @@GLOBAL.GTID_EXECUTED AS gtid_executed, VERSION() AS server_version")
|
||||
if err != nil {
|
||||
return mysqlGTIDTargetState{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return mysqlGTIDTargetState{}, errors.New("MySQL GTID status query returned no rows")
|
||||
}
|
||||
return mysqlGTIDTargetState{
|
||||
GTIDExecuted: mysqlGTIDResultText(rows[0], "gtid_executed"),
|
||||
ServerVersion: mysqlGTIDResultText(rows[0], "server_version"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mysqlGTIDResultText(row map[string]interface{}, expectedKey string) string {
|
||||
for key, value := range row {
|
||||
if strings.EqualFold(strings.TrimSpace(key), expectedKey) && value != nil {
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mysqlGTIDResetStatement(serverVersion string) (string, error) {
|
||||
version := strings.TrimSpace(serverVersion)
|
||||
parts := strings.SplitN(version, ".", 3)
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("unrecognized MySQL server version %q", serverVersion)
|
||||
}
|
||||
major, majorErr := strconv.Atoi(parts[0])
|
||||
minor, minorErr := strconv.Atoi(parts[1])
|
||||
if majorErr != nil || minorErr != nil {
|
||||
return "", fmt.Errorf("unrecognized MySQL server version %q", serverVersion)
|
||||
}
|
||||
if major > 8 || (major == 8 && minor >= 4) {
|
||||
return "RESET BINARY LOGS AND GTIDS", nil
|
||||
}
|
||||
return "RESET MASTER", nil
|
||||
}
|
||||
|
||||
func buildMySQLGTIDPreflightPayload(containsGTID bool, state mysqlGTIDTargetState) map[string]interface{} {
|
||||
targetNonEmpty := strings.TrimSpace(state.GTIDExecuted) != ""
|
||||
return map[string]interface{}{
|
||||
"containsMySQLGTIDPurged": containsGTID,
|
||||
"targetGTIDExecutedNonEmpty": targetNonEmpty,
|
||||
"serverVersion": state.ServerVersion,
|
||||
"requiresGTIDDecision": containsGTID && targetNonEmpty,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) validateDatabaseSQLImportAccess(config connection.ConnectionConfig) error {
|
||||
for _, protection := range []connectionProtectionKey{
|
||||
connectionProtectionDataImport,
|
||||
connectionProtectionStructureEdit,
|
||||
connectionProtectionScriptExecution,
|
||||
} {
|
||||
if err := ensureConnectionAllowsActionWithText(
|
||||
config,
|
||||
protection,
|
||||
"connection.backend.action.import_data",
|
||||
a.appText,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !isDataImportSQLDialectSupported(config) {
|
||||
return errors.New(a.appText("data_import.capability.reason.database_type_unsupported", nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) PreflightDatabaseSQLImport(config connection.ConnectionConfig, dbName string, filePath string) connection.QueryResult {
|
||||
if err := a.validateDatabaseSQLImportAccess(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.file_path_empty", nil)}
|
||||
}
|
||||
if !isMySQLGTIDImportConfig(config) {
|
||||
return connection.QueryResult{Success: true, Data: buildMySQLGTIDPreflightPayload(false, mysqlGTIDTargetState{})}
|
||||
}
|
||||
|
||||
containsGTID, err := inspectMySQLGTIDSQLFile(filePath)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.open_file_failed", map[string]any{"detail": err.Error()})}
|
||||
}
|
||||
if !containsGTID {
|
||||
return connection.QueryResult{Success: true, Data: buildMySQLGTIDPreflightPayload(false, mysqlGTIDTargetState{})}
|
||||
}
|
||||
|
||||
database, err := a.getDatabase(normalizeRunConfig(config, dbName))
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.mysql_gtid_preflight_failed", map[string]any{"detail": sanitizeSQLFileExecutionErr(err)})}
|
||||
}
|
||||
state, err := queryMySQLGTIDTargetState(database)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.mysql_gtid_preflight_failed", map[string]any{"detail": sanitizeSQLFileExecutionErr(err)})}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: buildMySQLGTIDPreflightPayload(true, state)}
|
||||
}
|
||||
225
internal/app/database_sql_import_gtid_test.go
Normal file
225
internal/app/database_sql_import_gtid_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type fakeMySQLGTIDImportDB struct {
|
||||
*fakeSQLFileBatchDB
|
||||
gtidExecuted string
|
||||
serverVersion string
|
||||
queryCalls []string
|
||||
}
|
||||
|
||||
func (database *fakeMySQLGTIDImportDB) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
database.queryCalls = append(database.queryCalls, query)
|
||||
return []map[string]interface{}{{
|
||||
"gtid_executed": database.gtidExecuted,
|
||||
"server_version": database.serverVersion,
|
||||
}}, []string{"gtid_executed", "server_version"}, nil
|
||||
}
|
||||
|
||||
func writeMySQLGTIDImportFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
filePath := filepath.Join(t.TempDir(), "mysqldump.sql")
|
||||
content := strings.Join([]string{
|
||||
"SET @OLD_SQL_MODE=@@SQL_MODE;",
|
||||
"SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'c289d954-7f57-11f1-99ab-fa163e2df103:1-618405';",
|
||||
"CREATE TABLE demo(id INT);",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write GTID import fixture: %v", err)
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func newMySQLGTIDImportTestApp(t *testing.T, database *fakeMySQLGTIDImportDB) *App {
|
||||
t.Helper()
|
||||
originalNewDatabaseFunc := newDatabaseFunc
|
||||
t.Cleanup(func() { newDatabaseFunc = originalNewDatabaseFunc })
|
||||
newDatabaseFunc = func(string) (db.Database, error) { return database, nil }
|
||||
app := NewApp()
|
||||
app.configDir = t.TempDir()
|
||||
return app
|
||||
}
|
||||
|
||||
func TestIsMySQLGTIDPurgedStatement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statement string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "mysqldump assignment with version expression",
|
||||
statement: "SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'server:1-9'",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "global assignment with spaces",
|
||||
statement: "/* header */ SET GLOBAL GTID_PURGED := 'server:1-9'",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "executable version comment",
|
||||
statement: "/*!80000 SET @@GLOBAL.GTID_PURGED='server:1-9' */",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "string literal is not an assignment",
|
||||
statement: "SELECT 'SET @@GLOBAL.GTID_PURGED=server:1-9'",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "session variable is unrelated",
|
||||
statement: "SET @GTID_PURGED='server:1-9'",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isMySQLGTIDPurgedStatement(test.statement); got != test.want {
|
||||
t.Fatalf("isMySQLGTIDPurgedStatement(%q) = %t, want %t", test.statement, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightDatabaseSQLImportReportsGTIDDecisionBeforeExecution(t *testing.T) {
|
||||
database := &fakeMySQLGTIDImportDB{
|
||||
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
|
||||
gtidExecuted: "existing-server:1-10",
|
||||
serverVersion: "8.0.39",
|
||||
}
|
||||
app := newMySQLGTIDImportTestApp(t, database)
|
||||
result := app.PreflightDatabaseSQLImport(
|
||||
connection.ConnectionConfig{Type: "mysql"},
|
||||
"app",
|
||||
writeMySQLGTIDImportFixture(t),
|
||||
)
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("preflight failed: %#v", result)
|
||||
}
|
||||
payload, ok := result.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("preflight payload type = %T, want map[string]interface{}", result.Data)
|
||||
}
|
||||
if payload["containsMySQLGTIDPurged"] != true || payload["targetGTIDExecutedNonEmpty"] != true || payload["requiresGTIDDecision"] != true {
|
||||
t.Fatalf("unexpected GTID preflight payload: %#v", payload)
|
||||
}
|
||||
if database.execCalls != 0 || database.batchCalls != 0 {
|
||||
t.Fatalf("preflight executed database statements: exec=%d batch=%d", database.execCalls, database.batchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDatabaseSQLRejectsGTIDConflictBeforeAnyStatementByDefault(t *testing.T) {
|
||||
database := &fakeMySQLGTIDImportDB{
|
||||
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
|
||||
gtidExecuted: "existing-server:1-10",
|
||||
serverVersion: "8.0.39",
|
||||
}
|
||||
app := newMySQLGTIDImportTestApp(t, database)
|
||||
result := app.ImportDatabaseSQL(
|
||||
connection.ConnectionConfig{Type: "mysql"},
|
||||
"app",
|
||||
writeMySQLGTIDImportFixture(t),
|
||||
"gtid-default-reject",
|
||||
false,
|
||||
)
|
||||
|
||||
if result.Success {
|
||||
t.Fatalf("GTID-conflicting import unexpectedly succeeded: %#v", result)
|
||||
}
|
||||
payload, ok := result.Data.(map[string]interface{})
|
||||
if !ok || payload["requiresGTIDDecision"] != true {
|
||||
t.Fatalf("unexpected conflict payload: %#v", result.Data)
|
||||
}
|
||||
if database.execCalls != 0 || database.batchCalls != 0 {
|
||||
t.Fatalf("conflicting import had side effects before prompting: exec=%d batch=%d queries=%#v", database.execCalls, database.batchCalls, database.execQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDatabaseSQLWithGTIDSkipOmitsPurgedAssignment(t *testing.T) {
|
||||
database := &fakeMySQLGTIDImportDB{
|
||||
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
|
||||
gtidExecuted: "existing-server:1-10",
|
||||
serverVersion: "8.0.39",
|
||||
}
|
||||
app := newMySQLGTIDImportTestApp(t, database)
|
||||
result := app.ImportDatabaseSQLWithOptions(
|
||||
connection.ConnectionConfig{Type: "mysql"},
|
||||
"app",
|
||||
writeMySQLGTIDImportFixture(t),
|
||||
"gtid-skip",
|
||||
false,
|
||||
"skip",
|
||||
)
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("skip-GTID import failed: %#v", result)
|
||||
}
|
||||
for _, query := range database.execQueries {
|
||||
if strings.Contains(strings.ToUpper(query), "GTID_PURGED") {
|
||||
t.Fatalf("skip mode executed GTID_PURGED: %#v", database.execQueries)
|
||||
}
|
||||
}
|
||||
if strings.Join(database.execQueries, "\n") != "SET @OLD_SQL_MODE=@@SQL_MODE\nCREATE TABLE demo(id INT)" {
|
||||
t.Fatalf("unexpected statements after GTID skip: %#v", database.execQueries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDatabaseSQLWithGTIDResetRunsVersionCompatibleResetFirst(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverVersion string
|
||||
resetSQL string
|
||||
}{
|
||||
{name: "MySQL 8.0", serverVersion: "8.0.39", resetSQL: "RESET MASTER"},
|
||||
{name: "MySQL 8.4", serverVersion: "8.4.3", resetSQL: "RESET BINARY LOGS AND GTIDS"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := &fakeMySQLGTIDImportDB{
|
||||
fakeSQLFileBatchDB: &fakeSQLFileBatchDB{},
|
||||
gtidExecuted: "existing-server:1-10",
|
||||
serverVersion: test.serverVersion,
|
||||
}
|
||||
app := newMySQLGTIDImportTestApp(t, database)
|
||||
result := app.ImportDatabaseSQLWithOptions(
|
||||
connection.ConnectionConfig{Type: "mysql"},
|
||||
"app",
|
||||
writeMySQLGTIDImportFixture(t),
|
||||
"gtid-reset-"+strings.ReplaceAll(test.serverVersion, ".", "-"),
|
||||
false,
|
||||
"reset",
|
||||
)
|
||||
|
||||
if !result.Success {
|
||||
t.Fatalf("reset-GTID import failed: %#v", result)
|
||||
}
|
||||
if len(database.execQueries) < 2 || database.execQueries[0] != test.resetSQL {
|
||||
t.Fatalf("reset was not the first database statement: %#v", database.execQueries)
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(strings.Join(database.execQueries[1:], "\n")), "GTID_PURGED") {
|
||||
t.Fatalf("reset mode did not execute GTID_PURGED after reset: %#v", database.execQueries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLImportOptionsHashSeparatesMySQLGTIDModes(t *testing.T) {
|
||||
reject := buildSQLImportOptionsHashWithGTIDMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff, mysqlGTIDImportModeReject)
|
||||
skip := buildSQLImportOptionsHashWithGTIDMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff, mysqlGTIDImportModeSkip)
|
||||
reset := buildSQLImportOptionsHashWithGTIDMode(false, DefaultSQLImportMaxStatementBytes, sqlFileTransactionModeOff, mysqlGTIDImportModeReset)
|
||||
if reject == skip || reject == reset || skip == reset {
|
||||
t.Fatalf("GTID import modes produced identical options hashes: reject=%s skip=%s reset=%s", reject, skip, reset)
|
||||
}
|
||||
}
|
||||
@@ -107,3 +107,26 @@ func buildSQLImportOptionsHashWithTransactionMode(continueOnError bool, maxState
|
||||
TransactionMode: string(transactionMode),
|
||||
})
|
||||
}
|
||||
|
||||
func buildSQLImportOptionsHashWithGTIDMode(continueOnError bool, maxStatementBytes int64, transactionMode sqlFileTransactionMode, gtidMode mysqlGTIDImportMode) string {
|
||||
if gtidMode == "" {
|
||||
return buildSQLImportOptionsHashWithTransactionMode(continueOnError, maxStatementBytes, transactionMode)
|
||||
}
|
||||
if maxStatementBytes <= 0 {
|
||||
maxStatementBytes = DefaultSQLImportMaxStatementBytes
|
||||
}
|
||||
if transactionMode != sqlFileTransactionModeSingle {
|
||||
transactionMode = sqlFileTransactionModeOff
|
||||
}
|
||||
return hashImportJobContract(struct {
|
||||
ContinueOnError bool `json:"continueOnError"`
|
||||
MaxStatementBytes int64 `json:"maxStatementBytes"`
|
||||
TransactionMode string `json:"transactionMode"`
|
||||
MySQLGTIDMode string `json:"mysqlGTIDMode"`
|
||||
}{
|
||||
ContinueOnError: continueOnError,
|
||||
MaxStatementBytes: maxStatementBytes,
|
||||
TransactionMode: string(transactionMode),
|
||||
MySQLGTIDMode: string(gtidMode),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ type sqlFileExecutionOptions struct {
|
||||
PreflightEachStatement bool
|
||||
TransactionMode sqlFileTransactionMode
|
||||
StatementGuard func(index int, stmt string) error
|
||||
SkipStatement func(index int, stmt string) bool
|
||||
Text fileBackendTextFunc
|
||||
OnProgress func(sqlFileExecutionProgress)
|
||||
}
|
||||
@@ -103,6 +104,8 @@ type sqlFileExecutionPolicy struct {
|
||||
TransactionMode sqlFileTransactionMode
|
||||
ForceFullPreflight bool
|
||||
StatementGuard func(index int, stmt string) error
|
||||
SkipStatement func(index int, stmt string) bool
|
||||
MySQLGTIDMode mysqlGTIDImportMode
|
||||
}
|
||||
|
||||
type sqlFileExecutionResult struct {
|
||||
@@ -2246,6 +2249,9 @@ func executeSQLFileSingleTransactionStream(ctx context.Context, dbInst db.Databa
|
||||
return err
|
||||
}
|
||||
}
|
||||
if options.SkipStatement != nil && options.SkipStatement(index, stmt) {
|
||||
return nil
|
||||
}
|
||||
if err := validateSQLFileSingleTransactionStatement(options.DBType, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2615,6 +2621,9 @@ func executeSQLFileStream(ctx context.Context, dbInst db.Database, reader io.Rea
|
||||
return err
|
||||
}
|
||||
}
|
||||
if options.SkipStatement != nil && options.SkipStatement(index, stmt) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if supportsBatch && !safeSequentialContinue && userTransactionDepth == 0 && !mysqlAutocommitDisabled && !mysqlTablesLocked && isSQLFileBatchableWriteStatement(options.DBType, stmt) {
|
||||
stmtBytes := len(stmt)
|
||||
@@ -3032,24 +3041,39 @@ func buildSQLFileExecutionPayload(executed, failed int, outcome string) map[stri
|
||||
// ImportDatabaseSQL restores a database from a SQL file while honoring the
|
||||
// connection protections that apply to destructive import workflows.
|
||||
func (a *App) ImportDatabaseSQL(config connection.ConnectionConfig, dbName string, filePath string, jobID string, continueOnError bool) connection.QueryResult {
|
||||
for _, protection := range []connectionProtectionKey{
|
||||
connectionProtectionDataImport,
|
||||
connectionProtectionStructureEdit,
|
||||
connectionProtectionScriptExecution,
|
||||
} {
|
||||
if err := ensureConnectionAllowsActionWithText(
|
||||
config,
|
||||
protection,
|
||||
"connection.backend.action.import_data",
|
||||
a.appText,
|
||||
); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return a.importDatabaseSQLWithGTIDMode(config, dbName, filePath, jobID, continueOnError, mysqlGTIDImportModeReject)
|
||||
}
|
||||
|
||||
func (a *App) ImportDatabaseSQLWithOptions(config connection.ConnectionConfig, dbName string, filePath string, jobID string, continueOnError bool, mysqlGTIDMode string) connection.QueryResult {
|
||||
mode, err := normalizeMySQLGTIDImportMode(mysqlGTIDMode)
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.mysql_gtid_mode_invalid", nil)}
|
||||
}
|
||||
if !isDataImportSQLDialectSupported(config) {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("data_import.capability.reason.database_type_unsupported", nil)}
|
||||
return a.importDatabaseSQLWithGTIDMode(config, dbName, filePath, jobID, continueOnError, mode)
|
||||
}
|
||||
|
||||
func (a *App) importDatabaseSQLWithGTIDMode(config connection.ConnectionConfig, dbName string, filePath string, jobID string, continueOnError bool, mode mysqlGTIDImportMode) connection.QueryResult {
|
||||
if err := a.validateDatabaseSQLImportAccess(config); err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return a.executeSQLFileWithStatementLimitPolicy(config, dbName, filePath, jobID, continueOnError, DefaultSQLImportMaxStatementBytes, true)
|
||||
if !isMySQLGTIDImportConfig(config) {
|
||||
mode = ""
|
||||
}
|
||||
return a.executeSQLFileWithStatementLimitPolicyContextWithPolicy(
|
||||
context.Background(),
|
||||
config,
|
||||
dbName,
|
||||
filePath,
|
||||
jobID,
|
||||
continueOnError,
|
||||
DefaultSQLImportMaxStatementBytes,
|
||||
true,
|
||||
"sql_file",
|
||||
sqlFileExecutionPolicy{
|
||||
TransactionMode: sqlFileTransactionModeOff,
|
||||
MySQLGTIDMode: mode,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {
|
||||
@@ -3123,6 +3147,25 @@ func (a *App) executeSQLFileWithStatementLimitPolicyContextWithPolicy(parent con
|
||||
return connection.QueryResult{Success: false, Message: "single-transaction SQL-file execution cannot prove atomicity for this database type"}
|
||||
}
|
||||
}
|
||||
containsMySQLGTIDPurged := false
|
||||
if policy.MySQLGTIDMode != "" && isMySQLGTIDImportConfig(config) {
|
||||
policy.ForceFullPreflight = true
|
||||
originalGuard := policy.StatementGuard
|
||||
policy.StatementGuard = func(index int, statement string) error {
|
||||
if isMySQLGTIDPurgedStatement(statement) {
|
||||
containsMySQLGTIDPurged = true
|
||||
}
|
||||
if originalGuard != nil {
|
||||
return originalGuard(index, statement)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if policy.MySQLGTIDMode == mysqlGTIDImportModeSkip {
|
||||
policy.SkipStatement = func(_ int, statement string) bool {
|
||||
return isMySQLGTIDPurgedStatement(statement)
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxStatementBytes <= 0 {
|
||||
maxStatementBytes = DefaultSQLImportMaxStatementBytes
|
||||
}
|
||||
@@ -3168,7 +3211,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicyContextWithPolicy(parent con
|
||||
TargetFingerprint: buildImportTargetFingerprint(config, dbName, ""),
|
||||
ConnectionID: config.ID,
|
||||
DatabaseName: dbName,
|
||||
OptionsHash: buildSQLImportOptionsHashWithTransactionMode(continueOnError, maxStatementBytes, policy.TransactionMode),
|
||||
OptionsHash: buildSQLImportOptionsHashWithGTIDMode(continueOnError, maxStatementBytes, policy.TransactionMode, policy.MySQLGTIDMode),
|
||||
})
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -3316,6 +3359,42 @@ func (a *App) executeSQLFileWithStatementLimitPolicyContextWithPolicy(parent con
|
||||
}
|
||||
}
|
||||
}
|
||||
if containsMySQLGTIDPurged {
|
||||
switch policy.MySQLGTIDMode {
|
||||
case mysqlGTIDImportModeReject:
|
||||
state, stateErr := queryMySQLGTIDTargetState(dbInst)
|
||||
if stateErr != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.mysql_gtid_preflight_failed", map[string]any{"detail": sanitizeSQLFileExecutionErr(stateErr)})}
|
||||
}
|
||||
if strings.TrimSpace(state.GTIDExecuted) != "" {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Data: buildMySQLGTIDPreflightPayload(true, state),
|
||||
Message: a.appText("file.backend.error.mysql_gtid_decision_required", nil),
|
||||
}
|
||||
}
|
||||
case mysqlGTIDImportModeReset:
|
||||
state, stateErr := queryMySQLGTIDTargetState(dbInst)
|
||||
if stateErr != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.mysql_gtid_preflight_failed", map[string]any{"detail": sanitizeSQLFileExecutionErr(stateErr)})}
|
||||
}
|
||||
resetStatement, resetErr := mysqlGTIDResetStatement(state.ServerVersion)
|
||||
if resetErr != nil {
|
||||
return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.mysql_gtid_preflight_failed", map[string]any{"detail": sanitizeSQLFileExecutionErr(resetErr)})}
|
||||
}
|
||||
mayHaveDatabaseSideEffects = true
|
||||
if _, resetErr = execSQLFileStatement(ctx, dbInst, resetStatement); resetErr != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Data: map[string]interface{}{
|
||||
"gtidResetAttempted": true,
|
||||
"outcomeUnknown": db.IsWriteOutcomeUnknown(resetErr) || db.IsAmbiguousWriteResponse(resetErr),
|
||||
},
|
||||
Message: a.appText("file.backend.error.mysql_gtid_reset_failed", map[string]any{"detail": sanitizeSQLFileExecutionErr(resetErr)}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalSize := preparedSource.rawSize
|
||||
totalSizeKnown := true
|
||||
@@ -3388,6 +3467,7 @@ func (a *App) executeSQLFileWithStatementLimitPolicyContextWithPolicy(parent con
|
||||
ContinueOnError: continueOnError,
|
||||
TransactionMode: policy.TransactionMode,
|
||||
StatementGuard: policy.StatementGuard,
|
||||
SkipStatement: policy.SkipStatement,
|
||||
// Keep the callback guard even after a full small-file preflight so a
|
||||
// source replacement between the two opens cannot send client commands
|
||||
// to the database.
|
||||
|
||||
Reference in New Issue
Block a user