🐛 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

@@ -31,7 +31,8 @@ const createRunnerState = (
});
const mocks = vi.hoisted(() => ({
importDatabaseSQL: vi.fn(),
preflightDatabaseSQLImport: vi.fn(),
importDatabaseSQLWithOptions: vi.fn(),
cancelSQLFileExecution: vi.fn(),
run: vi.fn(),
cancel: vi.fn(),
@@ -50,7 +51,8 @@ vi.mock('./common/ResizableDraggableModal', () => ({
}));
vi.mock('../../wailsjs/go/app/App', () => ({
ImportDatabaseSQL: mocks.importDatabaseSQL,
PreflightDatabaseSQLImport: mocks.preflightDatabaseSQLImport,
ImportDatabaseSQLWithOptions: mocks.importDatabaseSQLWithOptions,
CancelSQLFileExecution: mocks.cancelSQLFileExecution,
}));
@@ -73,6 +75,8 @@ vi.mock('antd', async () => {
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
const Progress = (props: Record<string, unknown>) => React.createElement('mock-progress', props);
const Radio = ({ children, ...props }: any) => React.createElement('mock-radio', props, children);
Radio.Group = ({ children, ...props }: any) => React.createElement('mock-radio-group', props, children);
const Paragraph = ({ children, ...props }: any) => <p {...props}>{children}</p>;
const Text = ({ children, ...props }: any) => <span {...props}>{children}</span>;
const Title = ({ children, ...props }: any) => <h3 {...props}>{children}</h3>;
@@ -80,6 +84,7 @@ vi.mock('antd', async () => {
Alert,
Button,
Progress,
Radio,
Typography: { Paragraph, Text, Title },
};
});
@@ -121,7 +126,13 @@ describe('DatabaseImportExecutionPanel', () => {
mocks.state = createRunnerState();
mocks.isRunning = false;
mocks.lastRunOptions = null;
mocks.importDatabaseSQL.mockReset();
mocks.preflightDatabaseSQLImport.mockReset();
mocks.preflightDatabaseSQLImport.mockResolvedValue({
success: true,
data: { requiresGTIDDecision: false },
message: '',
});
mocks.importDatabaseSQLWithOptions.mockReset();
mocks.cancelSQLFileExecution.mockReset();
mocks.reset.mockReset();
mocks.modalConfirm.mockReset();
@@ -148,13 +159,13 @@ describe('DatabaseImportExecutionPanel', () => {
it('waits for an explicit start action and reports the full RPC lifetime as running', async () => {
let resolveImport!: (value: { success: boolean; message: string }) => void;
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
mocks.importDatabaseSQLWithOptions.mockReturnValue(new Promise((resolve) => {
resolveImport = resolve;
}));
const onRunningChange = vi.fn();
const renderer = await renderPanel({ onRunningChange });
expect(mocks.importDatabaseSQL).not.toHaveBeenCalled();
expect(mocks.importDatabaseSQLWithOptions).not.toHaveBeenCalled();
const startButton = renderer.root.findByProps({
'data-database-import-start-action': 'true',
});
@@ -164,12 +175,18 @@ describe('DatabaseImportExecutionPanel', () => {
await Promise.resolve();
});
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
expect(mocks.preflightDatabaseSQLImport).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
'app',
'/tmp/database.sql',
);
expect(mocks.importDatabaseSQLWithOptions).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
'app',
'/tmp/database.sql',
'database-import-job-1',
false,
'reject',
);
expect(onRunningChange).toHaveBeenLastCalledWith(true);
expect(renderer.root.findAllByProps({
@@ -184,7 +201,7 @@ describe('DatabaseImportExecutionPanel', () => {
});
it('passes an explicit continue-on-error choice to the database import RPC', async () => {
mocks.importDatabaseSQL.mockResolvedValue({
mocks.importDatabaseSQLWithOptions.mockResolvedValue({
success: false,
data: { completed: true, failed: 1 },
message: 'completed with errors',
@@ -198,18 +215,65 @@ describe('DatabaseImportExecutionPanel', () => {
await Promise.resolve();
});
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
expect(mocks.importDatabaseSQLWithOptions).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
'app',
'/tmp/database.sql',
'database-import-job-1',
true,
'reject',
);
});
it('requires a GTID conflict choice before creating the import task', async () => {
mocks.preflightDatabaseSQLImport.mockResolvedValue({
success: true,
data: {
containsMySQLGTIDPurged: true,
targetGTIDExecutedNonEmpty: true,
requiresGTIDDecision: true,
serverVersion: '8.4.3',
},
message: '',
});
mocks.importDatabaseSQLWithOptions.mockResolvedValue({ success: true, message: 'done' });
const renderer = await renderPanel();
await act(async () => {
renderer.root.findByProps({
'data-database-import-start-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.modalConfirm).toHaveBeenCalledOnce();
expect(mocks.run).not.toHaveBeenCalled();
const confirmOptions = mocks.modalConfirm.mock.calls[0][0];
const modeSelector = confirmOptions.content.props.children.find(
(child: any) => child?.props?.['data-mysql-gtid-mode-selector'] === 'true',
);
await act(async () => {
modeSelector.props.onChange({ target: { value: 'reset' } });
await confirmOptions.onOk();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.importDatabaseSQLWithOptions).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
'app',
'/tmp/database.sql',
'database-import-job-1',
false,
'reset',
);
});
it('cancels the active SQL import with the runner job id', async () => {
let resolveImport!: (value: { success: boolean; message: string }) => void;
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
mocks.importDatabaseSQLWithOptions.mockReturnValue(new Promise((resolve) => {
resolveImport = resolve;
}));
mocks.cancelSQLFileExecution.mockResolvedValue({ success: true });

View File

@@ -1,12 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Progress, Typography } from 'antd';
import { Alert, Button, Progress, Radio, Typography } from 'antd';
import {
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons';
import { CancelSQLFileExecution, ImportDatabaseSQL } from '../../wailsjs/go/app/App';
import {
CancelSQLFileExecution,
ImportDatabaseSQLWithOptions,
PreflightDatabaseSQLImport,
} from '../../wailsjs/go/app/App';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import type { SavedConnection } from '../types';
@@ -31,6 +35,61 @@ type DatabaseImportExecutionPanelProps = {
onRunningChange?: (running: boolean) => void;
};
type MySQLGTIDImportMode = 'reject' | 'skip' | 'reset';
const requestMySQLGTIDImportMode = (
translate: typeof defaultTranslate,
): Promise<Exclude<MySQLGTIDImportMode, 'reject'> | null> => new Promise((resolve) => {
let selectedMode: Exclude<MySQLGTIDImportMode, 'reject'> = 'skip';
let settled = false;
const settle = (value: Exclude<MySQLGTIDImportMode, 'reject'> | null) => {
if (settled) return;
settled = true;
resolve(value);
};
Modal.confirm({
title: translate('data_import.workbench.gtid.title'),
width: 560,
content: (
<div style={{ display: 'grid', gap: 14 }}>
<Paragraph style={{ margin: 0 }}>
{translate('data_import.workbench.gtid.description')}
</Paragraph>
<Radio.Group
data-mysql-gtid-mode-selector="true"
defaultValue="skip"
onChange={(event) => {
selectedMode = event.target.value === 'reset' ? 'reset' : 'skip';
}}
style={{ display: 'grid', gap: 12 }}
>
<div>
<Radio value="skip">
<Text strong>{translate('data_import.workbench.gtid.option.skip')}</Text>
</Radio>
<Text type="secondary" style={{ display: 'block', margin: '4px 0 0 24px' }}>
{translate('data_import.workbench.gtid.option.skip_description')}
</Text>
</div>
<div>
<Radio value="reset">
<Text strong>{translate('data_import.workbench.gtid.option.reset')}</Text>
</Radio>
<Text type="danger" style={{ display: 'block', margin: '4px 0 0 24px' }}>
{translate('data_import.workbench.gtid.option.reset_description')}
</Text>
</div>
</Radio.Group>
</div>
),
okText: translate('data_import.workbench.gtid.action.continue'),
cancelText: translate('common.cancel'),
onOk: () => settle(selectedMode),
onCancel: () => settle(null),
});
});
const getFileName = (filePath: string): string => {
const parts = String(filePath || '').split(/[\\/]/);
return parts[parts.length - 1] || filePath;
@@ -58,6 +117,8 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
const [executionPending, setExecutionPending] = useState(false);
const [executionStarted, setExecutionStarted] = useState(false);
const [preflightError, setPreflightError] = useState('');
const [cancelRequested, setCancelRequested] = useState(false);
const lastReportedRunningRef = useRef<boolean | null>(null);
const {
@@ -69,6 +130,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
} = useSQLFileExecutionRunner({ showToast: false });
const taskRunning = isRunning || executionPending;
const canCancelExecution = isRunning || executionStarted;
const progressPercent = Math.max(0, Math.min(100, Number(state.percent) || 0));
const terminal = state.status === 'done'
|| state.status === 'cancelled'
@@ -117,18 +179,42 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
if (!approved) return;
setExecutionPending(true);
setCancelRequested(false);
setPreflightError('');
try {
let mysqlGTIDMode: MySQLGTIDImportMode = 'reject';
const preflightResult = await PreflightDatabaseSQLImport(
connectionConfig as any,
String(dbName || '').trim(),
filePath,
);
if (!preflightResult?.success) {
setPreflightError(
preflightResult?.message || t('data_import.workbench.gtid.preflight_failed'),
);
return;
}
const preflightData = preflightResult.data as {
requiresGTIDDecision?: unknown;
} | undefined;
if (preflightData?.requiresGTIDDecision === true) {
const selectedMode = await requestMySQLGTIDImportMode(t);
if (!selectedMode) return;
mysqlGTIDMode = selectedMode;
}
setExecutionStarted(true);
await runSQLFileExecutionWithProgress({
title: getFileName(filePath),
filePath,
fileSizeMB,
run: async (jobId) => {
const result = await ImportDatabaseSQL(
const result = await ImportDatabaseSQLWithOptions(
connectionConfig as any,
String(dbName || '').trim(),
filePath,
jobId,
continueOnError,
mysqlGTIDMode,
);
// Reaching EOF with recorded statement errors is a completed import,
// not a transport/fatal failure. Preserve the counters and render it
@@ -148,6 +234,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
} catch {
// The shared runner already records and displays the RPC error state.
} finally {
setExecutionStarted(false);
setExecutionPending(false);
}
}, [
@@ -163,14 +250,14 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
]);
const requestCancel = useCallback(async () => {
if (!taskRunning || cancelRequested) return;
if (!canCancelExecution || cancelRequested) return;
setCancelRequested(true);
try {
await cancelExecution();
} catch {
setCancelRequested(false);
}
}, [cancelExecution, cancelRequested, taskRunning]);
}, [canCancelExecution, cancelExecution, cancelRequested]);
const resetProgress = useCallback(() => {
if (taskRunning) return;
@@ -251,6 +338,14 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
? t('data_import.workbench.notice.continue_on_error')
: t('data_import.workbench.notice.stop_on_error')}
/>
{preflightError ? (
<Alert
data-database-import-preflight-error="true"
type="error"
showIcon
message={preflightError}
/>
) : null}
<Alert
type="info"
showIcon
@@ -344,7 +439,7 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
) : null}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 16 }}>
{taskRunning ? (
{canCancelExecution ? (
<Button
data-database-import-cancel-action="true"
danger
@@ -362,7 +457,8 @@ const DatabaseImportExecutionPanel: React.FC<DatabaseImportExecutionPanelProps>
data-database-import-start-action="true"
type="primary"
icon={terminal ? <ReloadOutlined /> : <PlayCircleOutlined />}
disabled={!connectionConfig || !String(filePath || '').trim()}
loading={executionPending}
disabled={executionPending || !connectionConfig || !String(filePath || '').trim()}
onClick={requestStartImport}
>
{terminal

View File

@@ -184,6 +184,7 @@ import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors
import {
DATA_IMPORT_WORKBENCH_TAB_ID,
resolveDataImportWorkbenchLaunchTab,
type BuildDataImportWorkbenchTabInput,
} from '../utils/dataImportTab';
import { useExportProgressDialog } from './ExportProgressModal';
import { getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts';
@@ -1544,6 +1545,11 @@ const Sidebar: React.FC<{
void refreshGlobalExternalSQLRootNode(false);
}, [refreshGlobalExternalSQLRootNode]);
const openDataImportWorkbench = useCallback((input: BuildDataImportWorkbenchTabInput) => {
const existingImportTab = tabs.find((tab) => tab.id === DATA_IMPORT_WORKBENCH_TAB_ID);
addTab(resolveDataImportWorkbenchLaunchTab(existingImportTab, input));
}, [addTab, tabs]);
const {
handleRunSQLFile,
handleOpenSQLFileFromToolbar,
@@ -1567,6 +1573,7 @@ const Sidebar: React.FC<{
connectionIds,
selectedNodesRef,
addTab,
openDataImportWorkbench,
saveExternalSQLDirectory,
deleteExternalSQLDirectory,
updateRecentSQLFilePath,
@@ -3643,12 +3650,8 @@ const Sidebar: React.FC<{
).trim();
const mode = node?.type === 'database' ? 'database' : 'table';
const existingImportTab = tabs.find((tab) => tab.id === DATA_IMPORT_WORKBENCH_TAB_ID);
addTab(resolveDataImportWorkbenchLaunchTab(
existingImportTab,
{ connectionId, dbName, tableName, mode },
));
}, [activeContext?.connectionId, activeContext?.dbName, activeTabId, addTab, tabs]);
openDataImportWorkbench({ connectionId, dbName, tableName, mode });
}, [activeContext?.connectionId, activeContext?.dbName, activeTabId, openDataImportWorkbench, tabs]);
const handleOpenSlowQueryWorkbench = useCallback(() => {
if (!activeTabHasConnection || !activeTab?.connectionId) return;

View File

@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { launchDatabaseSQLImportWorkbench } from './SidebarExternalSqlWorkflow';
describe('SidebarExternalSqlWorkflow database SQL import entry', () => {
it('opens the unified database import workbench without selecting a file', () => {
const openDataImportWorkbench = vi.fn();
const launched = launchDatabaseSQLImportWorkbench({
type: 'database',
title: 'app',
dataRef: { id: 'mysql-1', dbName: 'app' },
}, openDataImportWorkbench);
expect(launched).toBe(true);
expect(openDataImportWorkbench).toHaveBeenCalledOnce();
expect(openDataImportWorkbench).toHaveBeenCalledWith({
connectionId: 'mysql-1',
dbName: 'app',
mode: 'database',
});
});
});

View File

@@ -15,6 +15,7 @@ import {
setExternalSQLFileBinding,
} from '../../utils/externalSqlTree';
import { buildSQLFileExecutionWorkbenchTab } from '../../utils/sqlFileExecutionTab';
import type { BuildDataImportWorkbenchTabInput } from '../../utils/dataImportTab';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { filterVisibleDatabaseNames } from '../../utils/databaseVisibility';
import { getDataSourceCapabilities } from '../../utils/dataSourceCapabilities';
@@ -76,6 +77,7 @@ type UseSidebarExternalSqlWorkflowOptions = {
connectionIds: string[];
selectedNodesRef: React.MutableRefObject<any[]>;
addTab: (tab: any) => void;
openDataImportWorkbench: (input: BuildDataImportWorkbenchTabInput) => void;
saveExternalSQLDirectory: (directory: ExternalSQLDirectory) => void;
deleteExternalSQLDirectory: (directoryId: string) => void;
updateRecentSQLFilePath: (previousPath: string, nextPath: string) => void;
@@ -88,6 +90,23 @@ type UseSidebarExternalSqlWorkflowOptions = {
getActiveContext: () => ActiveExecutionContext;
};
export const launchDatabaseSQLImportWorkbench = (
node: any,
openDataImportWorkbench: (input: BuildDataImportWorkbenchTabInput) => void,
): boolean => {
const connectionId = node?.type === 'connection'
? String(node?.key || '').trim()
: String(node?.dataRef?.id || '').trim();
if (!connectionId) return false;
openDataImportWorkbench({
connectionId,
dbName: String(node?.dataRef?.dbName || '').trim(),
mode: 'database',
});
return true;
};
type ExternalSQLFileModalProps = {
open: boolean;
mode: ExternalSQLFileModalMode;
@@ -419,6 +438,7 @@ export const useSidebarExternalSqlWorkflow = ({
connectionIds,
selectedNodesRef,
addTab,
openDataImportWorkbench,
saveExternalSQLDirectory,
deleteExternalSQLDirectory,
updateRecentSQLFilePath,
@@ -529,32 +549,9 @@ export const useSidebarExternalSqlWorkflow = ({
return true;
}, [addTab, connections]);
const handleRunSQLFile = async (node: any) => {
const connectionId = node.type === 'connection'
? String(node.key || '').trim()
: String(node?.dataRef?.id || '').trim();
const dbName = String(node?.dataRef?.dbName || '').trim();
if (!connectionId) {
const handleRunSQLFile = (node: any) => {
if (!launchDatabaseSQLImportWorkbench(node, openDataImportWorkbench)) {
message.warning(t('sidebar.message.select_connection_or_database_first'));
return;
}
const res = await selectSQLFileForExecution();
if (res.success) {
const data = normalizeSQLFileDialogData(res.data);
if (!data.filePath) {
message.error(t('sidebar.message.sql_file_path_incomplete'));
return;
}
openSQLFileExecutionWorkbench({
connectionId,
dbName: dbName,
filePath: data.filePath,
fileName: data.fileName,
fileSizeMB: data.fileSizeMB,
});
} else if (res.message !== '已取消') {
message.error(t('sidebar.message.read_file_failed', { error: res.message }));
}
};

View File

@@ -309,6 +309,8 @@ export function ImportDataWithProgressOptions(arg1:connection.ConnectionConfig,a
export function ImportDatabaseSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:boolean):Promise<connection.QueryResult>;
export function ImportDatabaseSQLWithOptions(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string,arg5:boolean,arg6:string):Promise<connection.QueryResult>;
export function ImportLegacyConnections(arg1:Array<connection.SavedConnectionInput>):Promise<Array<connection.SavedConnectionView>>;
export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
@@ -453,6 +455,8 @@ export function OpenSQLFile():Promise<connection.QueryResult>;
export function OpenSavedQueryDirectory():Promise<connection.QueryResult>;
export function PreflightDatabaseSQLImport(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function PreviewChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:connection.ChangeSet):Promise<connection.QueryResult>;
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;

View File

@@ -602,6 +602,10 @@ export function ImportDatabaseSQL(arg1, arg2, arg3, arg4, arg5) {
return window['go']['app']['App']['ImportDatabaseSQL'](arg1, arg2, arg3, arg4, arg5);
}
export function ImportDatabaseSQLWithOptions(arg1, arg2, arg3, arg4, arg5, arg6) {
return window['go']['app']['App']['ImportDatabaseSQLWithOptions'](arg1, arg2, arg3, arg4, arg5, arg6);
}
export function ImportLegacyConnections(arg1) {
return window['go']['app']['App']['ImportLegacyConnections'](arg1);
}
@@ -890,6 +894,10 @@ export function OpenSavedQueryDirectory() {
return window['go']['app']['App']['OpenSavedQueryDirectory']();
}
export function PreflightDatabaseSQLImport(arg1, arg2, arg3) {
return window['go']['app']['App']['PreflightDatabaseSQLImport'](arg1, arg2, arg3);
}
export function PreviewChanges(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['PreviewChanges'](arg1, arg2, arg3, arg4);
}

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.

View File

@@ -4514,6 +4514,14 @@
"data_import.workbench.error_policy.stop_description": "Empfohlen. Beim ersten SQL-Fehler stoppen, ohne den fehlgeschlagenen Batch zu wiederholen. Nicht transaktionale Tabellen können Teilschreibvorgänge behalten.",
"data_import.workbench.error_policy.stop_table_description": "Empfohlen. Batch-Schreibvorgänge verwenden und beim ersten fehlgeschlagenen Batch ohne Wiederholung stoppen. Wenn die API einen Fehler meldet, kann der Batch teilweise geschrieben worden sein; prüfen Sie die Zieltabelle.",
"data_import.workbench.error_policy.title": "Fehlerbehandlung",
"data_import.workbench.gtid.action.continue": "Import fortsetzen",
"data_import.workbench.gtid.description": "Die SQL-Datei setzt GTID_PURGED, das Ziel enthält jedoch bereits einen GTID_EXECUTED-Verlauf. Wählen Sie das weitere Vorgehen. Es wurde noch keine SQL-Anweisung aus der Datei ausgeführt.",
"data_import.workbench.gtid.option.reset": "GTID-Verlauf des Ziels zurücksetzen",
"data_import.workbench.gtid.option.reset_description": "Führt vor dem Import RESET MASTER (MySQL vor 8.4) oder RESET BINARY LOGS AND GTIDS (MySQL ab 8.4) aus. Vorhandene Binärprotokolle und der GTID-Verlauf werden gelöscht.",
"data_import.workbench.gtid.option.skip": "GTID_PURGED-Anweisungen überspringen",
"data_import.workbench.gtid.option.skip_description": "Importiert Schema und Daten, ohne den GTID-Verlauf des Ziels zu ändern. Für den Import in eine bestehende Instanz empfohlen.",
"data_import.workbench.gtid.preflight_failed": "Der GTID-Status des MySQL-Ziels konnte nicht geprüft werden.",
"data_import.workbench.gtid.title": "MySQL-GTID-Konflikt erkannt",
"data_import.workbench.helper.file_formats": "Unterstützt CSV-, JSON- und XLSX-Dateien.",
"data_import.workbench.helper.sql_file": "Unterstützt .sql- und .sql.gz-Dateien. Die Dateiauswahl startet den Import nicht automatisch.",
"data_import.workbench.label.connection": "Verbindung",
@@ -5735,6 +5743,10 @@
"file.backend.error.import_stopped_on_error": "Der Tabellenimport wurde wegen eines Fehlers gestoppt. {{imported}} Zeilen wurden bestätigt importiert und {{failed}} Fehler protokolliert: {{detail}}",
"file.backend.error.import_unsupported_format": "Nicht unterstütztes Dateiformat",
"file.backend.error.invalid_export_mode": "Ungültiger Exportmodus",
"file.backend.error.mysql_gtid_decision_required": "Die SQL-Datei enthält GTID_PURGED und das Ziel bereits einen GTID_EXECUTED-Verlauf. Wählen Sie, ob die GTID-Anweisungen übersprungen, der GTID-Verlauf zurückgesetzt oder der Vorgang abgebrochen werden soll.",
"file.backend.error.mysql_gtid_mode_invalid": "Ungültiger MySQL-GTID-Importmodus",
"file.backend.error.mysql_gtid_preflight_failed": "Der GTID-Status des MySQL-Ziels konnte nicht geprüft werden: {{detail}}",
"file.backend.error.mysql_gtid_reset_failed": "Der GTID-Verlauf des MySQL-Ziels konnte nicht zurückgesetzt werden: {{detail}}",
"file.backend.error.mysql_workbench_no_connections": "Im XML wurden keine gültigen Verbindungskonfigurationen gefunden",
"file.backend.error.mysql_workbench_parse_failed": "MySQL Workbench-XML konnte nicht geparst werden: {{detail}}",
"file.backend.error.navicat_connection_password_parse_failed": "Passwort für Verbindung {{name}} konnte nicht verarbeitet werden",

View File

@@ -4514,6 +4514,14 @@
"data_import.workbench.error_policy.stop_description": "Recommended. Stop at the first SQL error without replaying the failed batch. Non-transactional tables may still keep partial writes.",
"data_import.workbench.error_policy.stop_table_description": "Recommended. Use batch writes and stop at the first failed batch without replaying it. A batch may have been partially written when its API returns an error; verify the target table.",
"data_import.workbench.error_policy.title": "Error handling",
"data_import.workbench.gtid.action.continue": "Continue import",
"data_import.workbench.gtid.description": "This SQL file sets GTID_PURGED, but the target already has GTID_EXECUTED history. Choose how to proceed. No SQL file statement has been executed.",
"data_import.workbench.gtid.option.reset": "Reset target GTID history",
"data_import.workbench.gtid.option.reset_description": "Runs RESET MASTER (MySQL before 8.4) or RESET BINARY LOGS AND GTIDS (MySQL 8.4 or later) before importing. This removes existing binary logs and GTID history.",
"data_import.workbench.gtid.option.skip": "Skip GTID_PURGED statements",
"data_import.workbench.gtid.option.skip_description": "Imports schema and data without changing the target GTID history. Recommended when importing into an existing instance.",
"data_import.workbench.gtid.preflight_failed": "Unable to check the target MySQL GTID state.",
"data_import.workbench.gtid.title": "MySQL GTID conflict detected",
"data_import.workbench.helper.file_formats": "Supports CSV, JSON, and XLSX files.",
"data_import.workbench.helper.sql_file": "Supports .sql and .sql.gz files. Selecting a file does not start the import automatically.",
"data_import.workbench.label.connection": "Connection",
@@ -5735,6 +5743,10 @@
"file.backend.error.import_stopped_on_error": "Table import stopped on error. {{imported}} rows were confirmed imported and {{failed}} error(s) were recorded: {{detail}}",
"file.backend.error.import_unsupported_format": "Unsupported file format",
"file.backend.error.invalid_export_mode": "Invalid export mode",
"file.backend.error.mysql_gtid_decision_required": "The SQL file contains GTID_PURGED and the target already has GTID_EXECUTED history. Choose whether to skip the GTID statements, reset GTID history, or cancel.",
"file.backend.error.mysql_gtid_mode_invalid": "Invalid MySQL GTID import mode",
"file.backend.error.mysql_gtid_preflight_failed": "Unable to check the target MySQL GTID state: {{detail}}",
"file.backend.error.mysql_gtid_reset_failed": "Unable to reset the target MySQL GTID history: {{detail}}",
"file.backend.error.mysql_workbench_no_connections": "No valid connection profiles were found in the XML",
"file.backend.error.mysql_workbench_parse_failed": "Failed to parse MySQL Workbench XML: {{detail}}",
"file.backend.error.navicat_connection_password_parse_failed": "Failed to parse password for connection {{name}}",

View File

@@ -4514,6 +4514,14 @@
"data_import.workbench.error_policy.stop_description": "推奨。最初の SQL エラーで停止し、失敗したバッチを再実行しません。非トランザクションテーブルでは部分書き込みが残る可能性があります。",
"data_import.workbench.error_policy.stop_table_description": "推奨。バッチ書き込みを使用し、最初の失敗バッチで再実行せず停止します。API がエラーを返した時点で一部が書き込まれている可能性があるため、対象テーブルを確認してください。",
"data_import.workbench.error_policy.title": "エラー処理",
"data_import.workbench.gtid.action.continue": "インポートを続行",
"data_import.workbench.gtid.description": "SQL ファイルに GTID_PURGED が含まれていますが、対象には既に GTID_EXECUTED の履歴があります。処理方法を選択してください。ファイル内の SQL 文はまだ実行されていません。",
"data_import.workbench.gtid.option.reset": "対象の GTID 履歴をリセット",
"data_import.workbench.gtid.option.reset_description": "インポート前に RESET MASTERMySQL 8.4 より前)または RESET BINARY LOGS AND GTIDSMySQL 8.4 以降)を実行します。既存のバイナリログと GTID 履歴は削除されます。",
"data_import.workbench.gtid.option.skip": "GTID_PURGED 文をスキップ",
"data_import.workbench.gtid.option.skip_description": "対象の GTID 履歴を変更せずにスキーマとデータをインポートします。既存のインスタンスへのインポートに推奨します。",
"data_import.workbench.gtid.preflight_failed": "対象 MySQL の GTID 状態を確認できませんでした。",
"data_import.workbench.gtid.title": "MySQL GTID の競合を検出しました",
"data_import.workbench.helper.file_formats": "CSV、JSON、XLSX ファイルに対応しています。",
"data_import.workbench.helper.sql_file": ".sql と .sql.gz ファイルに対応しています。ファイルを選択してもインポートは自動的に開始されません。",
"data_import.workbench.label.connection": "接続",
@@ -5735,6 +5743,10 @@
"file.backend.error.import_stopped_on_error": "テーブルデータのインポートはエラーで停止しました。{{imported}} 行のインポートを確認し、{{failed}} 件のエラーを記録しました: {{detail}}",
"file.backend.error.import_unsupported_format": "サポートされていないファイル形式です",
"file.backend.error.invalid_export_mode": "無効なエクスポートモードです",
"file.backend.error.mysql_gtid_decision_required": "SQL ファイルに GTID_PURGED が含まれ、対象には既に GTID_EXECUTED の履歴があります。GTID 文をスキップするか、GTID 履歴をリセットするか、キャンセルしてください。",
"file.backend.error.mysql_gtid_mode_invalid": "MySQL GTID インポートモードが無効です",
"file.backend.error.mysql_gtid_preflight_failed": "対象 MySQL の GTID 状態を確認できませんでした: {{detail}}",
"file.backend.error.mysql_gtid_reset_failed": "対象 MySQL の GTID 履歴をリセットできませんでした: {{detail}}",
"file.backend.error.mysql_workbench_no_connections": "XML 内に有効な接続設定が見つかりません",
"file.backend.error.mysql_workbench_parse_failed": "MySQL Workbench XML の解析に失敗しました: {{detail}}",
"file.backend.error.navicat_connection_password_parse_failed": "接続 {{name}} のパスワードを解析できません",

View File

@@ -4514,6 +4514,14 @@
"data_import.workbench.error_policy.stop_description": "Рекомендуется. Остановиться при первой ошибке SQL без повторного выполнения сбойного пакета. В нетранзакционных таблицах могут сохраниться частичные записи.",
"data_import.workbench.error_policy.stop_table_description": "Рекомендуется. Использовать пакетную запись и остановиться при первой ошибке пакета без его повтора. Если API вернул ошибку, пакет мог быть записан частично; проверьте целевую таблицу.",
"data_import.workbench.error_policy.title": "Обработка ошибок",
"data_import.workbench.gtid.action.continue": "Продолжить импорт",
"data_import.workbench.gtid.description": "SQL-файл задаёт GTID_PURGED, но в целевом экземпляре уже есть история GTID_EXECUTED. Выберите способ продолжения. Ни одна инструкция из файла ещё не выполнена.",
"data_import.workbench.gtid.option.reset": "Сбросить историю GTID цели",
"data_import.workbench.gtid.option.reset_description": "Перед импортом выполняется RESET MASTER (MySQL до 8.4) или RESET BINARY LOGS AND GTIDS (MySQL 8.4 и новее). Существующие бинарные журналы и история GTID будут удалены.",
"data_import.workbench.gtid.option.skip": "Пропустить инструкции GTID_PURGED",
"data_import.workbench.gtid.option.skip_description": "Импортирует схему и данные без изменения истории GTID цели. Рекомендуется для импорта в существующий экземпляр.",
"data_import.workbench.gtid.preflight_failed": "Не удалось проверить состояние GTID целевого MySQL.",
"data_import.workbench.gtid.title": "Обнаружен конфликт MySQL GTID",
"data_import.workbench.helper.file_formats": "Поддерживаются файлы CSV, JSON и XLSX.",
"data_import.workbench.helper.sql_file": "Поддерживаются файлы .sql и .sql.gz. Выбор файла не запускает импорт автоматически.",
"data_import.workbench.label.connection": "Подключение",
@@ -5735,6 +5743,10 @@
"file.backend.error.import_stopped_on_error": "Импорт таблицы остановлен из-за ошибки. Подтвержден импорт {{imported}} строк, зарегистрировано ошибок: {{failed}}. {{detail}}",
"file.backend.error.import_unsupported_format": "Неподдерживаемый формат файла",
"file.backend.error.invalid_export_mode": "Недопустимый режим экспорта",
"file.backend.error.mysql_gtid_decision_required": "SQL-файл содержит GTID_PURGED, а в целевом экземпляре уже есть история GTID_EXECUTED. Выберите пропуск инструкций GTID, сброс истории GTID или отмену.",
"file.backend.error.mysql_gtid_mode_invalid": "Недопустимый режим импорта MySQL GTID",
"file.backend.error.mysql_gtid_preflight_failed": "Не удалось проверить состояние GTID целевого MySQL: {{detail}}",
"file.backend.error.mysql_gtid_reset_failed": "Не удалось сбросить историю GTID целевого MySQL: {{detail}}",
"file.backend.error.mysql_workbench_no_connections": "В XML не найдены допустимые конфигурации подключений",
"file.backend.error.mysql_workbench_parse_failed": "Не удалось разобрать XML MySQL Workbench: {{detail}}",
"file.backend.error.navicat_connection_password_parse_failed": "Не удалось разобрать пароль для подключения {{name}}",

View File

@@ -4514,6 +4514,14 @@
"data_import.workbench.error_policy.stop_description": "推荐。首个 SQL 错误会立即停止且不逐条重放;非事务表仍可能保留部分写入。",
"data_import.workbench.error_policy.stop_table_description": "推荐。使用批量写入,首个失败批次立即停止且不重放;批接口返回错误时,该批次可能已部分写入,请核对目标表。",
"data_import.workbench.error_policy.title": "错误处理",
"data_import.workbench.gtid.action.continue": "继续导入",
"data_import.workbench.gtid.description": "SQL 文件包含 GTID_PURGED但目标实例已有 GTID_EXECUTED 历史。请选择处理方式;当前尚未执行任何文件内 SQL 语句。",
"data_import.workbench.gtid.option.reset": "重置目标 GTID 历史",
"data_import.workbench.gtid.option.reset_description": "导入前执行 RESET MASTERMySQL 8.4 之前)或 RESET BINARY LOGS AND GTIDSMySQL 8.4 及以上)。这会清除现有二进制日志和 GTID 历史。",
"data_import.workbench.gtid.option.skip": "跳过 GTID_PURGED 语句",
"data_import.workbench.gtid.option.skip_description": "导入结构和数据,不修改目标实例的 GTID 历史。导入到已有实例时推荐使用。",
"data_import.workbench.gtid.preflight_failed": "无法检查目标 MySQL 的 GTID 状态。",
"data_import.workbench.gtid.title": "检测到 MySQL GTID 冲突",
"data_import.workbench.helper.file_formats": "支持 CSV、JSON 和 XLSX 文件。",
"data_import.workbench.helper.sql_file": "支持 .sql 和 .sql.gz 文件;选择文件后不会自动开始导入。",
"data_import.workbench.label.connection": "连接",
@@ -5735,6 +5743,10 @@
"file.backend.error.import_stopped_on_error": "表数据导入遇错停止。已确认导入 {{imported}} 行,记录 {{failed}} 个错误:{{detail}}",
"file.backend.error.import_unsupported_format": "不支持的文件格式",
"file.backend.error.invalid_export_mode": "无效的导出模式",
"file.backend.error.mysql_gtid_decision_required": "SQL 文件包含 GTID_PURGED且目标实例已有 GTID_EXECUTED 历史。请选择跳过 GTID 语句、重置 GTID 历史或取消。",
"file.backend.error.mysql_gtid_mode_invalid": "无效的 MySQL GTID 导入模式",
"file.backend.error.mysql_gtid_preflight_failed": "无法检查目标 MySQL 的 GTID 状态:{{detail}}",
"file.backend.error.mysql_gtid_reset_failed": "无法重置目标 MySQL 的 GTID 历史:{{detail}}",
"file.backend.error.mysql_workbench_no_connections": "未在 XML 中找到有效的连接配置",
"file.backend.error.mysql_workbench_parse_failed": "解析 MySQL Workbench XML 失败: {{detail}}",
"file.backend.error.navicat_connection_password_parse_failed": "连接 {{name}} 的密码解析失败",

View File

@@ -4514,6 +4514,14 @@
"data_import.workbench.error_policy.stop_description": "建議。遇到第一個 SQL 錯誤便立即停止且不逐條重放;非交易式資料表仍可能保留部分寫入。",
"data_import.workbench.error_policy.stop_table_description": "建議。使用批次寫入,第一個失敗批次會立即停止且不重放;批次介面回傳錯誤時,該批次可能已部分寫入,請核對目標資料表。",
"data_import.workbench.error_policy.title": "錯誤處理",
"data_import.workbench.gtid.action.continue": "繼續匯入",
"data_import.workbench.gtid.description": "SQL 檔案包含 GTID_PURGED但目標執行個體已有 GTID_EXECUTED 歷史。請選擇處理方式;目前尚未執行任何檔案內 SQL 語句。",
"data_import.workbench.gtid.option.reset": "重設目標 GTID 歷史",
"data_import.workbench.gtid.option.reset_description": "匯入前執行 RESET MASTERMySQL 8.4 之前)或 RESET BINARY LOGS AND GTIDSMySQL 8.4 及以上)。這會清除現有二進位日誌與 GTID 歷史。",
"data_import.workbench.gtid.option.skip": "略過 GTID_PURGED 語句",
"data_import.workbench.gtid.option.skip_description": "匯入結構與資料,不變更目標執行個體的 GTID 歷史。匯入到既有執行個體時建議使用。",
"data_import.workbench.gtid.preflight_failed": "無法檢查目標 MySQL 的 GTID 狀態。",
"data_import.workbench.gtid.title": "偵測到 MySQL GTID 衝突",
"data_import.workbench.helper.file_formats": "支援 CSV、JSON 和 XLSX 檔案。",
"data_import.workbench.helper.sql_file": "支援 .sql 與 .sql.gz 檔案;選擇檔案後不會自動開始匯入。",
"data_import.workbench.label.connection": "連線",
@@ -5735,6 +5743,10 @@
"file.backend.error.import_stopped_on_error": "資料表匯入遇錯停止。已確認匯入 {{imported}} 列,記錄 {{failed}} 個錯誤:{{detail}}",
"file.backend.error.import_unsupported_format": "不支援的檔案格式",
"file.backend.error.invalid_export_mode": "無效的匯出模式",
"file.backend.error.mysql_gtid_decision_required": "SQL 檔案包含 GTID_PURGED且目標執行個體已有 GTID_EXECUTED 歷史。請選擇略過 GTID 語句、重設 GTID 歷史或取消。",
"file.backend.error.mysql_gtid_mode_invalid": "無效的 MySQL GTID 匯入模式",
"file.backend.error.mysql_gtid_preflight_failed": "無法檢查目標 MySQL 的 GTID 狀態:{{detail}}",
"file.backend.error.mysql_gtid_reset_failed": "無法重設目標 MySQL 的 GTID 歷史:{{detail}}",
"file.backend.error.mysql_workbench_no_connections": "未在 XML 中找到有效的連線設定",
"file.backend.error.mysql_workbench_parse_failed": "解析 MySQL Workbench XML 失敗: {{detail}}",
"file.backend.error.navicat_connection_password_parse_failed": "連線 {{name}} 的密碼解析失敗",