🐛 fix(data-import): 统一外部 SQL 导入并处理 GTID 冲突

- 将数据库右键入口统一到导入工作台,保留失败后继续选项并规避 macOS 闪退
- 导入前检测 GTID_PURGED 与目标 GTID_EXECUTED,冲突时默认零写入阻断
- 支持跳过 GTID 语句或按 MySQL 版本重置 GTID 历史
- 补齐 Wails 绑定、六种语言文案和前后端回归测试
This commit is contained in:
Syngnat
2026-08-11 15:42:18 +08:00
parent baccc6b85d
commit bd5fde990d
17 changed files with 921 additions and 64 deletions

View 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)}
}

View 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)
}
}

View File

@@ -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),
})
}

View File

@@ -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.