mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-14 18:44:09 +08:00
🐛 fix(data-import): 统一外部 SQL 导入并处理 GTID 冲突
- 将数据库右键入口统一到导入工作台,保留失败后继续选项并规避 macOS 闪退 - 导入前检测 GTID_PURGED 与目标 GTID_EXECUTED,冲突时默认零写入阻断 - 支持跳过 GTID 语句或按 MySQL 版本重置 GTID 历史 - 补齐 Wails 绑定、六种语言文案和前后端回归测试
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 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": "対象 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}} のパスワードを解析できません",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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 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": "无法检查目标 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}} 的密码解析失败",
|
||||
|
||||
@@ -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 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": "無法檢查目標 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}} 的密碼解析失敗",
|
||||
|
||||
Reference in New Issue
Block a user