🔧 chore(dev): 合并 open issue backlog 修复分支

- 合并已按 issue 拆分提交的 backlog 修复与 SQL 结果集同步能力
- 解决 DataGrid、Sidebar 以及 legacy WebKit 存储迁移测试的合并冲突
- 保留 dev 分支当前结构并移除已废弃的 issue backlog 跟踪文档
This commit is contained in:
Syngnat
2026-04-17 17:52:14 +08:00
42 changed files with 2094 additions and 181 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
import TabManager from './components/TabManager';
@@ -69,6 +69,7 @@ import {
isShortcutMatch,
normalizeShortcutCombo,
} from './utils/shortcuts';
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './utils/windowStateUi';
import {
SIDEBAR_UTILITY_ITEM_KEYS,
resolveAIEntryPlacement,
@@ -168,6 +169,9 @@ function App() {
const effectiveUiScale = Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Number(uiScale) || DEFAULT_UI_SCALE));
const effectiveFontSize = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, Math.round(Number(fontSize) || DEFAULT_FONT_SIZE)));
const tokenFontSize = Math.round(effectiveFontSize * effectiveUiScale);
const titleBarToggleIconKey = resolveTitleBarToggleIconKey(
windowState === 'fullscreen' ? 'fullscreen' : (windowState === 'maximized' ? 'maximized' : 'normal')
);
const tokenFontSizeSM = Math.max(10, Math.round(tokenFontSize * 0.86));
const tokenFontSizeLG = Math.max(tokenFontSize + 1, Math.round(tokenFontSize * 1.14));
const tokenControlHeight = Math.max(24, Math.round(32 * effectiveUiScale));
@@ -639,7 +643,7 @@ function App() {
});
if (isMaximised) {
if (reason !== 'ratio-change' && !hasViewportScaleDrift) {
if (!shouldToggleMaximisedWindowForScaleFix(reason, hasViewportScaleDrift)) {
window.dispatchEvent(new Event('resize'));
lastFixAt = Date.now();
return;
@@ -2167,19 +2171,34 @@ function App() {
}, [securityUpdateRepairSource]);
const handleTitleBarWindowToggle = async () => {
const syncWindowStateFromRuntime = async () => {
try {
const [isFullscreen, isMaximised] = await Promise.all([
WindowIsFullscreen().catch(() => false),
WindowIsMaximised().catch(() => false),
]);
useStore.getState().setWindowState(isFullscreen ? 'fullscreen' : (isMaximised ? 'maximized' : 'normal'));
} catch {
// ignore
}
};
try {
void emitWindowDiagnostic('action:titlebar-toggle:before');
if (await WindowIsFullscreen()) {
await WindowUnfullscreen();
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen');
return;
}
if (useNativeMacWindowControls && isMacRuntime) {
await WindowFullscreen();
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen');
return;
}
await WindowToggleMaximise();
await syncWindowStateFromRuntime();
void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise');
} catch (_) {
// ignore
@@ -2583,7 +2602,7 @@ function App() {
/>
<Button
type="text"
icon={<BorderOutlined />}
icon={titleBarToggleIconKey === 'restore' ? <SwitcherOutlined /> : <BorderOutlined />}
style={{ height: '100%', borderRadius: 0, width: titleBarButtonWidth }}
onClick={() => { void handleTitleBarWindowToggle(); }}
/>

View File

@@ -51,6 +51,15 @@ import {
import { calculateAutoFitColumnWidth } from './dataGridAutoWidth';
import { buildSelectedCellClipboardText } from './dataGridSelectionCopy';
import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap';
import {
TEMPORAL_FORMATS,
formatFromDayjs,
getTemporalPickerType,
isTemporalColumnType,
parseToDayjs,
resolveTemporalEditorSaveValue,
type TemporalPickerType,
} from './dataGridTemporal';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -167,51 +176,6 @@ const normalizeDateTimeString = (val: string) => {
return normalized;
};
const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
// 根据列类型返回 DatePicker 的 picker 模式
type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return null;
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
const base = raw.split(/[ (]/)[0];
if (base === 'date') return 'date';
if (base === 'time') return 'time';
if (base === 'year') return 'year';
return null;
};
const TEMPORAL_FORMATS: Record<string, string> = {
datetime: 'YYYY-MM-DD HH:mm:ss',
date: 'YYYY-MM-DD',
time: 'HH:mm:ss',
year: 'YYYY',
};
// 将字符串值转为 dayjs 对象(用于 DatePicker无效值返回 null
const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
if (val === null || val === undefined || val === '') return null;
const str = String(val).trim();
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null; // 无效日期
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
const d = dayjs(str, fmt);
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
};
// 将 dayjs 对象格式化为对应格式字符串
const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
if (!val || !val.isValid()) return '';
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
return val.format(fmt);
};
// --- Helper: Format Value ---
const formatCellValue = (val: any) => {
try {
@@ -640,17 +604,14 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
setEditing(!editing);
};
const save = async () => {
const save = async (pickerValue?: dayjs.Dayjs | null) => {
try {
if (!form || !editing) return;
const fieldName = getCellFieldName(record, dataIndex);
await form.validateFields([fieldName]);
let nextValue = form.getFieldValue(fieldName);
// 日期时间类型: 将 dayjs 对象转回格式化字符串
if (isDateTimeField && nextValue && dayjs.isDayjs(nextValue)) {
nextValue = formatFromDayjs(nextValue as dayjs.Dayjs, pickerType);
} else if (isDateTimeField && !nextValue) {
nextValue = null;
if (isDateTimeField) {
nextValue = resolveTemporalEditorSaveValue(nextValue, pickerValue, pickerType);
}
toggleEdit();
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
@@ -689,9 +650,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
ref={inputRef}
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
onChange={() => setTimeout(save, 0)}
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
onBlur={() => setTimeout(() => { void save(); }, 0)}
needConfirm={false}
/>
) : pickerType === 'datetime' ? (
@@ -712,7 +673,7 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
}}
></a>
)}
onOk={() => setTimeout(save, 0)}
onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)}
onOpenChange={(open) => {
pickerOpenRef.current = open;
lockTableScroll(open);
@@ -732,17 +693,17 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
style={{ width: '100%' }}
format={TEMPORAL_FORMATS[pickerType]}
picker={pickerType as any}
onChange={() => setTimeout(save, 0)}
onChange={(value) => setTimeout(() => { void save(value); }, 0)}
onOpenChange={lockTableScroll}
onBlur={() => setTimeout(save, 0)}
onBlur={() => setTimeout(() => { void save(); }, 0)}
needConfirm={false}
/>
)
) : (
<Input
ref={inputRef}
onPressEnter={save}
onBlur={save}
onPressEnter={() => { void save(); }}
onBlur={() => { void save(); }}
onFocus={(e) => {
try {
(e.target as HTMLInputElement)?.select?.();

View File

@@ -8,10 +8,12 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert';
import { buildDataSyncRequest, type SourceDatasetMode, validateDataSyncSelection } from './dataSyncRequest';
const { Title, Text } = Typography;
const { Step } = Steps;
const { Option } = Select;
const { TextArea } = Input;
type SyncLogEvent = { jobId: string; level?: string; message?: string; ts?: number };
type SyncProgressEvent = { jobId: string; percent?: number; current?: number; total?: number; table?: string; stage?: string };
@@ -24,6 +26,7 @@ type TableDiffSummary = {
updates?: number;
deletes?: number;
same?: number;
schemaDiffCount?: number;
message?: string;
targetTableExists?: boolean;
plannedAction?: string;
@@ -123,6 +126,15 @@ const buildSqlPreview = (
? previewData.columnTypes as Record<string, string>
: {};
const statements: string[] = [];
const schemaStatements = Array.isArray(previewData.schemaStatements)
? previewData.schemaStatements
.map((item: any) => String(item || '').trim())
.filter((item: string) => item.length > 0)
: [];
schemaStatements.forEach((statement: string) => {
statements.push(statement.endsWith(';') ? statement : `${statement};`);
});
const insertRows = Array.isArray(previewData.inserts) ? previewData.inserts : [];
const updateRows = Array.isArray(previewData.updates) ? previewData.updates : [];
@@ -204,6 +216,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
// Step 2: Tables
const [allTables, setAllTables] = useState<string[]>([]);
const [selectedTables, setSelectedTables] = useState<string[]>([]);
const [sourceDatasetMode, setSourceDatasetMode] = useState<SourceDatasetMode>('table');
const [sourceQuery, setSourceQuery] = useState<string>('');
// Options
const [workflowType, setWorkflowType] = useState<WorkflowType>('sync');
@@ -284,7 +298,10 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setTargetConnId('');
setSourceDb('');
setTargetDb('');
setAllTables([]);
setSelectedTables([]);
setSourceDatasetMode('table');
setSourceQuery('');
setWorkflowType('sync');
setSyncContent('data');
setSyncMode('insert_update');
@@ -332,6 +349,28 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
}
}, [workflowType]);
useEffect(() => {
if (sourceDatasetMode !== 'query') return;
if (workflowType !== 'sync') {
setWorkflowType('sync');
}
if (syncContent !== 'data') {
setSyncContent('data');
}
if (targetTableStrategy !== 'existing_only') {
setTargetTableStrategy('existing_only');
}
if (createIndexes) {
setCreateIndexes(false);
}
if (autoAddColumns) {
setAutoAddColumns(false);
}
if (selectedTables.length > 1) {
setSelectedTables(selectedTables.slice(0, 1));
}
}, [sourceDatasetMode, workflowType, syncContent, targetTableStrategy, createIndexes, autoAddColumns, selectedTables]);
const handleSourceConnChange = async (connId: string) => {
setSourceConnId(connId);
setSourceDb('');
@@ -377,10 +416,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setLoading(true);
try {
const conn = connections.find(c => c.id === sourceConnId);
const connId = isSourceQueryMode ? targetConnId : sourceConnId;
const dbName = isSourceQueryMode ? targetDb : sourceDb;
const conn = connections.find(c => c.id === connId);
if (conn) {
const config = normalizeConnConfig(conn, sourceDb);
const res = await DBGetTables(config as any, sourceDb);
const config = normalizeConnConfig(conn, dbName);
const res = await DBGetTables(config as any, dbName);
if (res.success) {
// DBGetTables returns [{Table: "name"}, ...]
const tableRows = Array.isArray(res.data) ? res.data : [];
@@ -388,6 +429,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
.map((row: any) => row?.Table || row?.table || row?.TABLE_NAME || Object.values(row || {})[0])
.filter((name: any) => typeof name === 'string' && name.trim() !== '');
setAllTables(tables as string[]);
setSelectedTables(prev => {
const existing = prev.filter((name) => tables.includes(name));
if (isSourceQueryMode) {
return existing.slice(0, 1);
}
return existing;
});
setCurrentStep(1);
} else {
message.error(res.message);
@@ -405,7 +453,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
};
const analyzeDiff = async () => {
if (selectedTables.length === 0) return;
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) return message.error(selectionError);
if (!sourceConnId || !targetConnId) return message.error("Select connections first");
if (!sourceDb || !targetDb) return message.error("Select databases first");
@@ -422,18 +471,20 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
autoScrollRef.current = true;
setSyncProgress({ percent: 0, current: 0, total: selectedTables.length, table: '', stage: '差异分析' });
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: "insert_update",
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
mongoCollectionName,
jobId,
};
});
try {
const res = await DataSyncAnalyze(config as any);
@@ -475,17 +526,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
setPreviewLoading(true);
setPreviewData(null);
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: "data",
mode: "insert_update",
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode: "insert_update",
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
};
mongoCollectionName,
});
try {
const res = await DataSyncPreview(config as any, table, 200);
@@ -502,6 +555,11 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
};
const runSync = async () => {
const selectionError = validateDataSyncSelection({ sourceDatasetMode, selectedTables, sourceQuery, syncContent });
if (selectionError) {
message.error(selectionError);
return;
}
if (syncContent !== 'schema' && diffTables.length === 0) {
message.error("请先对比差异,再开始同步");
return;
@@ -540,19 +598,21 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
stage: '准备开始',
});
const config = {
const config = buildDataSyncRequest({
sourceConfig: normalizeConnConfig(sConn, sourceDb),
targetConfig: normalizeConnConfig(tConn, targetDb),
tables: selectedTables,
content: syncContent,
mode: syncMode,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName: mongoCollectionName.trim(),
mongoCollectionName,
tableOptions,
jobId,
};
});
try {
const res = await DataSync(config as any);
@@ -596,6 +656,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
const ops = tableOptions[previewTable] || { insert: true, update: true, delete: false };
return buildSqlPreview(previewData, previewTable, targetType, ops);
}, [previewData, previewTable, targetConnId, connections, tableOptions]);
const previewHasSchemaStatements = useMemo(
() => Array.isArray(previewData?.schemaStatements) && previewData.schemaStatements.length > 0,
[previewData],
);
const previewSchemaWarnings = useMemo(
() => Array.isArray(previewData?.schemaWarnings) ? previewData.schemaWarnings as string[] : [],
[previewData],
);
const previewHasDataDiff = useMemo(
() => Number(previewData?.totalInserts || 0) + Number(previewData?.totalUpdates || 0) + Number(previewData?.totalDeletes || 0) > 0,
[previewData],
);
const analysisWarnings = useMemo(() => {
const items: string[] = [];
@@ -606,6 +678,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
return Array.from(new Set(items));
}, [diffTables]);
const isSourceQueryMode = sourceDatasetMode === 'query';
const isMigrationWorkflow = workflowType === 'migration';
const sourceConn = useMemo(() => connections.find(c => c.id === sourceConnId), [connections, sourceConnId]);
const targetConn = useMemo(() => connections.find(c => c.id === targetConnId), [connections, targetConnId]);
@@ -838,7 +911,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Form.Item label="功能类型">
<Select value={workflowType} onChange={setWorkflowType}>
<Option value="sync"></Option>
<Option value="migration"></Option>
<Option value="migration" disabled={isSourceQueryMode}></Option>
</Select>
</Form.Item>
<Form.Item label="源数据方式">
<Select value={sourceDatasetMode} onChange={setSourceDatasetMode}>
<Option value="table"></Option>
<Option value="query"> SQL </Option>
</Select>
</Form.Item>
<Alert
@@ -849,11 +928,19 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。'
: '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'}
/>
{isSourceQueryMode && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="SQL 结果集同步当前只支持:源端自定义 SQL -> 单个已存在目标表;查询结果需包含目标表主键列。"
/>
)}
<Form.Item label={isMigrationWorkflow ? '迁移内容' : '同步内容'}>
<Select value={syncContent} onChange={setSyncContent}>
<Option value="data"></Option>
<Option value="schema"></Option>
<Option value="both"> + </Option>
<Option value="schema" disabled={isSourceQueryMode}></Option>
<Option value="both" disabled={isSourceQueryMode}> + </Option>
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '迁移模式' : '同步模式'}>
@@ -864,7 +951,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Select>
</Form.Item>
<Form.Item label={isMigrationWorkflow ? '目标表处理策略' : '目标表要求'}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow}>
<Select value={targetTableStrategy} onChange={setTargetTableStrategy} disabled={!isMigrationWorkflow || isSourceQueryMode}>
<Option value="existing_only">使</Option>
<Option value="auto_create_if_missing"></Option>
<Option value="smart"></Option>
@@ -887,12 +974,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
</Form.Item>
)}
<Form.Item>
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)}>
MySQL MySQL Kingbase
<Checkbox checked={autoAddColumns} onChange={(e) => setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}>
MySQL MySQL KingbaseSQL
</Checkbox>
</Form.Item>
<Form.Item>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}>
<Checkbox checked={createIndexes} onChange={(e) => setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}>
/
</Checkbox>
</Form.Item>
@@ -928,21 +1015,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={quietPanelStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
/>
{!isSourceQueryMode && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text type="secondary"></Text>
<Checkbox checked={showSameTables} onChange={(e) => setShowSameTables(e.target.checked)}>
</Checkbox>
</div>
<Transfer
dataSource={allTables.map(t => ({ key: t, title: t }))}
titles={['源表', '已选表']}
targetKeys={selectedTables}
onChange={(keys) => setSelectedTables(keys as string[])}
render={item => item.title}
listStyle={{ width: 390, height: 320, marginTop: 0, borderRadius: 14, overflow: 'hidden' }}
locale={{ itemUnit: '项', itemsUnit: '项', searchPlaceholder: '搜索表…', notFoundContent: '暂无数据' }}
/>
</>
)}
{isSourceQueryMode && (
<Form layout="vertical">
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="请输入源查询 SQL并选择一个目标表。差异分析会直接基于该结果集与目标表对比。"
/>
<Form.Item label="源查询 SQL">
<TextArea
value={sourceQuery}
onChange={(e) => setSourceQuery(e.target.value)}
rows={8}
placeholder="例如SELECT id, name, email FROM users WHERE status = 'active'"
spellCheck={false}
/>
</Form.Item>
<Form.Item label="目标表">
<Select
value={selectedTables[0]}
onChange={(value) => setSelectedTables(value ? [value] : [])}
showSearch
allowClear
placeholder="请选择一个目标表"
optionFilterProp="children"
>
{allTables.map((table) => <Option key={table} value={table}>{table}</Option>)}
</Select>
</Form.Item>
</Form>
)}
</div>
{diffTables.length > 0 && (
@@ -1061,8 +1183,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
render: (_: any, r: any) => {
const can = !!r.canSync;
const hasDiff = Number(r.inserts || 0) + Number(r.updates || 0) + Number(r.deletes || 0) > 0;
const hasSchemaDiff = Number(r.schemaDiffCount || 0) > 0;
return (
<Button size="small" disabled={!can || !hasDiff || analyzing} onClick={() => openPreview(r.table)}>
<Button size="small" disabled={!can || !(hasDiff || hasSchemaDiff) || analyzing} onClick={() => openPreview(r.table)}>
</Button>
);
@@ -1134,14 +1257,14 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
{currentStep === 1 && (
<>
<Button onClick={() => setCurrentStep(0)} style={{ marginRight: 8 }}></Button>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing} style={{ marginRight: 8 }}>
<Button onClick={analyzeDiff} loading={loading} disabled={syncContent === 'schema' || selectedTables.length === 0 || analyzing || (isSourceQueryMode && !sourceQuery.trim())} style={{ marginRight: 8 }}>
</Button>
<Button
type="primary"
onClick={runSync}
loading={loading}
disabled={selectedTables.length === 0 || (syncContent !== 'schema' && diffTables.length === 0)}
disabled={selectedTables.length === 0 || (isSourceQueryMode && !sourceQuery.trim()) || (syncContent !== 'schema' && diffTables.length === 0)}
>
</Button>
@@ -1169,12 +1292,59 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Alert
type="info"
showIcon
message={`插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`}
message={
previewHasDataDiff
? `插入 ${previewData.totalInserts || 0},更新 ${previewData.totalUpdates || 0},删除 ${previewData.totalDeletes || 0}(预览最多展示 200 条/类型)`
: (previewData.schemaSummary || `检测到 ${previewSql.statementCount} 条结构变更语句`)
}
/>
{previewSchemaWarnings.length > 0 && (
<Alert
style={{ marginTop: 12 }}
type="warning"
showIcon
message="结构预览包含风险或降级项"
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{previewSchemaWarnings.slice(0, 8).map((item) => <li key={item}>{item}</li>)}
{previewSchemaWarnings.length > 8 && <li> {previewSchemaWarnings.length - 8} </li>}
</ul>
}
/>
)}
<Divider />
<Tabs
items={[
{
...(previewHasSchemaStatements ? [{
key: 'schema',
label: `结构(${Array.isArray(previewData.schemaStatements) ? previewData.schemaStatements.length : 0})`,
children: (
<div>
<Text type="secondary">
{previewData.schemaSummary || '以下为本次结构同步计划执行的语句。'}
</Text>
<pre
style={{
marginTop: 8,
marginBottom: 0,
padding: 10,
border: '1px solid #f0f0f0',
borderRadius: 6,
background: '#fafafa',
maxHeight: 420,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}}
>
{Array.isArray(previewData.schemaStatements) && previewData.schemaStatements.length > 0
? previewData.schemaStatements.join('\n')
: '-- 当前表结构无可执行变更'}
</pre>
</div>
)
}] : []),
...(previewHasDataDiff ? [{
key: 'insert',
label: `插入(${previewData.totalInserts || 0})`,
children: (
@@ -1274,7 +1444,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
/>
</div>
)
},
}] : []),
{
key: 'sql',
label: `SQL(${previewSql.statementCount})`,
@@ -1283,10 +1453,18 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
<Alert
type="info"
showIcon
message="SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
message={
previewHasDataDiff
? "SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,用于审核确认。"
: "SQL 预览展示将执行的结构变更语句,用于审核确认。"
}
/>
<div style={{ marginTop: 8, marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary"> {previewSql.statementCount} 200 /</Text>
<Text type="secondary">
{previewHasDataDiff
? `${previewSql.statementCount} 条语句(预览数据最多 200 条/类型)`
: `${previewSql.statementCount} 条结构变更语句`}
</Text>
<Button
size="small"
disabled={!previewSql.sqlText}
@@ -1315,7 +1493,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open,
wordBreak: 'break-word'
}}
>
{previewSql.sqlText || '-- 当前勾选范围下无 SQL 可预览'}
{previewSql.sqlText || (previewHasDataDiff ? '-- 当前勾选范围下无 SQL 可预览' : '-- 当前表结构无可执行变更')}
</pre>
</div>
)

View File

@@ -1228,7 +1228,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, field);
const res = await (window as any).go.app.App.RedisDeleteHashField(buildRpcConnectionConfig(config), selectedKey, [field]);
if (res.success) {
message.success('删除成功');
loadKeyValue(selectedKey);

View File

@@ -48,6 +48,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
const { Search } = Input;
@@ -3556,7 +3557,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
icon: <ConsoleSqlOutlined />,
onClick: () => {
const tableName = String(node.dataRef?.tableName || '').trim();
const queryTemplate = tableName ? `SELECT * FROM ${tableName};` : 'SELECT * FROM ';
const queryTemplate = buildTableSelectQuery(getMetadataDialect(node.dataRef as SavedConnection), tableName);
addTab({
id: `query-${Date.now()}`,
title: `新建查询`,

View File

@@ -8,6 +8,7 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
interface TableOverviewProps {
tab: TabData;
@@ -167,6 +168,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const metadataDialect = useMemo(
() => getMetadataDialect(connection?.config?.type || '', connection?.config?.driver),
[connection?.config?.driver, connection?.config?.type]
);
const autoFetchVisible = useAutoFetchVisibility();
const loadData = useCallback(async () => {
@@ -181,11 +186,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const dialect = getMetadataDialect(connection.config.type, connection.config.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const sql = buildTableStatusSQL(metadataDialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
setTables(parseTableStats(metadataDialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
@@ -194,7 +198,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
} finally {
setLoading(false);
}
}, [connection, tab.dbName]);
}, [connection, metadataDialect, tab.dbName]);
useEffect(() => {
if (!autoFetchVisible) {
@@ -487,7 +491,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
@@ -573,7 +577,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },

View File

@@ -8,6 +8,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { AIChatMessage, AIToolCall } from '../../types';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import { normalizeAiMarkdown } from '../../utils/aiMarkdown';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -27,6 +28,7 @@ const MemoizedMarkdown = React.memo(({
activeConnectionId?: string;
activeDbName?: string;
}) => {
const normalizedContent = React.useMemo(() => normalizeAiMarkdown(content), [content]);
// 缓存 components 对象,避免每次渲染都生成新的函数引用击穿内部子组件的 memo
const components = React.useMemo(() => ({
code({ node, inline, className, children, ...props }: any) {
@@ -46,7 +48,7 @@ const MemoizedMarkdown = React.memo(({
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{content}
{normalizedContent}
</ReactMarkdown>
);
});

View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
import { resolveTemporalEditorSaveValue } from './dataGridTemporal';
describe('dataGridTemporal helpers', () => {
it('prefers the picker selected date when form store has not caught up yet', () => {
expect(resolveTemporalEditorSaveValue(undefined, dayjs('2026-04-12'), 'date')).toBe('2026-04-12');
});
});

View File

@@ -0,0 +1,59 @@
import dayjs from 'dayjs';
export type TemporalPickerType = 'datetime' | 'date' | 'time' | 'year' | null;
export const TEMPORAL_FORMATS: Record<string, string> = {
datetime: 'YYYY-MM-DD HH:mm:ss',
date: 'YYYY-MM-DD',
time: 'HH:mm:ss',
year: 'YYYY',
};
export const isTemporalColumnType = (columnType?: string): boolean => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return false;
if (raw.includes('datetime') || raw.includes('timestamp')) return true;
const base = raw.split(/[ (]/)[0];
return base === 'date' || base === 'time' || base === 'year';
};
export const getTemporalPickerType = (columnType?: string): TemporalPickerType => {
const raw = String(columnType || '').trim().toLowerCase();
if (!raw) return null;
if (raw.includes('datetime') || raw.includes('timestamp')) return 'datetime';
const base = raw.split(/[ (]/)[0];
if (base === 'date') return 'date';
if (base === 'time') return 'time';
if (base === 'year') return 'year';
return null;
};
export const parseToDayjs = (val: any, pickerType: TemporalPickerType): dayjs.Dayjs | null => {
if (val === null || val === undefined || val === '') return null;
const str = String(val).trim();
if (!str || /^0{4}-0{2}-0{2}/.test(str)) return null;
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
const d = dayjs(str, fmt);
return d.isValid() ? d : dayjs(str).isValid() ? dayjs(str) : null;
};
export const formatFromDayjs = (val: dayjs.Dayjs | null, pickerType: TemporalPickerType): string => {
if (!val || !val.isValid()) return '';
const fmt = TEMPORAL_FORMATS[pickerType || 'datetime'];
return val.format(fmt);
};
export const resolveTemporalEditorSaveValue = (
formValue: any,
pickerValue: dayjs.Dayjs | null | undefined,
pickerType: TemporalPickerType,
): string | null | any => {
const value = pickerValue !== undefined ? pickerValue : formValue;
if (value && dayjs.isDayjs(value)) {
return formatFromDayjs(value as dayjs.Dayjs, pickerType);
}
if (!value) {
return null;
}
return value;
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { buildDataSyncRequest, validateDataSyncSelection } from './dataSyncRequest';
describe('validateDataSyncSelection', () => {
it('requires source query and single target table in query mode', () => {
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: [],
sourceQuery: '',
syncContent: 'data',
})).toBe('请输入源查询 SQL');
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: [],
sourceQuery: 'select 1',
syncContent: 'data',
})).toBe('SQL 结果集同步需要选择一个目标表');
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: ['users', 'orders'],
sourceQuery: 'select 1',
syncContent: 'data',
})).toBe('SQL 结果集同步需要选择一个目标表');
});
it('forces data-only in query mode', () => {
expect(validateDataSyncSelection({
sourceDatasetMode: 'query',
selectedTables: ['users'],
sourceQuery: 'select 1',
syncContent: 'both',
})).toBe('SQL 结果集同步仅支持仅同步数据');
});
});
describe('buildDataSyncRequest', () => {
it('normalizes query mode payload for backend', () => {
const payload = buildDataSyncRequest({
sourceConfig: { type: 'mysql' },
targetConfig: { type: 'mysql' },
selectedTables: ['users'],
sourceDatasetMode: 'query',
sourceQuery: ' SELECT id, name FROM active_users ',
syncContent: 'both',
syncMode: 'insert_update',
autoAddColumns: true,
targetTableStrategy: 'smart',
createIndexes: true,
mongoCollectionName: ' ',
jobId: 'job-1',
tableOptions: { users: { insert: true, update: true, delete: false } },
});
expect(payload).toMatchObject({
tables: ['users'],
sourceQuery: 'SELECT id, name FROM active_users',
content: 'data',
mode: 'insert_update',
autoAddColumns: false,
targetTableStrategy: 'existing_only',
createIndexes: false,
jobId: 'job-1',
});
});
});

View File

@@ -0,0 +1,85 @@
export type SourceDatasetMode = 'table' | 'query';
type SyncContent = 'data' | 'schema' | 'both';
type TargetTableStrategy = 'existing_only' | 'auto_create_if_missing' | 'smart';
type BuildDataSyncRequestParams = {
sourceConfig: any;
targetConfig: any;
selectedTables: string[];
sourceDatasetMode: SourceDatasetMode;
sourceQuery: string;
syncContent: SyncContent;
syncMode: string;
autoAddColumns: boolean;
targetTableStrategy: TargetTableStrategy;
createIndexes: boolean;
mongoCollectionName: string;
jobId?: string;
tableOptions?: Record<string, any>;
};
type ValidateDataSyncSelectionParams = {
sourceDatasetMode: SourceDatasetMode;
selectedTables: string[];
sourceQuery: string;
syncContent: SyncContent;
};
export const validateDataSyncSelection = ({
sourceDatasetMode,
selectedTables,
sourceQuery,
syncContent,
}: ValidateDataSyncSelectionParams): string | null => {
if (sourceDatasetMode === 'query') {
if (!String(sourceQuery || '').trim()) {
return '请输入源查询 SQL';
}
if (selectedTables.length !== 1) {
return 'SQL 结果集同步需要选择一个目标表';
}
if (syncContent !== 'data') {
return 'SQL 结果集同步仅支持仅同步数据';
}
return null;
}
if (selectedTables.length === 0) {
return '请选择至少一张表';
}
return null;
};
export const buildDataSyncRequest = ({
sourceConfig,
targetConfig,
selectedTables,
sourceDatasetMode,
sourceQuery,
syncContent,
syncMode,
autoAddColumns,
targetTableStrategy,
createIndexes,
mongoCollectionName,
jobId,
tableOptions,
}: BuildDataSyncRequestParams) => {
const isQueryMode = sourceDatasetMode === 'query';
return {
sourceConfig,
targetConfig,
tables: selectedTables,
sourceQuery: isQueryMode ? String(sourceQuery || '').trim() : undefined,
content: isQueryMode ? 'data' : syncContent,
mode: syncMode,
autoAddColumns: isQueryMode ? false : autoAddColumns,
targetTableStrategy: isQueryMode ? 'existing_only' : targetTableStrategy,
createIndexes: isQueryMode ? false : createIndexes,
mongoCollectionName: String(mongoCollectionName || '').trim(),
...(jobId ? { jobId } : {}),
...(tableOptions ? { tableOptions } : {}),
};
};

View File

@@ -51,4 +51,16 @@ describe('tableDesignerSchemaSql', () => {
expect(sql).not.toContain('AFTER');
expect(sql).not.toContain(' FIRST');
});
it('uses mysql change column syntax when renaming a column', () => {
const sql = buildAlterTablePreviewSql(buildInput({
dbType: 'mysql',
originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'varchar(64)', nullable: 'YES' })],
columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'varchar(64)', nullable: 'YES' })],
}));
expect(sql).toContain('CHANGE COLUMN `name` `display_name` varchar(64) NULL');
expect(sql).toContain('FIRST');
expect(sql).not.toContain('MODIFY COLUMN `display_name`');
});
});

View File

@@ -140,14 +140,21 @@ const buildMySqlAlterPreviewSql = (input: BuildAlterTablePreviewInput): string =
return;
}
if (
curr.name !== orig.name ||
const definitionChanged =
curr.type !== orig.type ||
curr.nullable !== orig.nullable ||
curr.default !== orig.default ||
(curr.comment || '') !== (orig.comment || '') ||
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement)
) {
Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement);
if (curr.name !== orig.name) {
alters.push(
`CHANGE COLUMN ${quoteIdentifierPart(orig.name, 'mysql')} ${colDef} ${positionSql}`.trim(),
);
return;
}
if (definitionChanged) {
alters.push(`MODIFY COLUMN ${colDef} ${positionSql}`.trim());
}
});

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { normalizeAiMarkdown } from './aiMarkdown';
describe('normalizeAiMarkdown', () => {
it('inserts a missing newline after the fenced code language marker', () => {
expect(normalizeAiMarkdown('```sqlSELECT COUNT(*) AS order_count\nFROM customer_order;\n```')).toBe(
'```sql\nSELECT COUNT(*) AS order_count\nFROM customer_order;\n```',
);
});
});

View File

@@ -0,0 +1,13 @@
export const normalizeAiMarkdown = (content: string): string => {
let text = String(content || '').replace(/\r\n/g, '\n');
const knownFenceLanguages = [
'sql', 'mermaid', 'json', 'javascript', 'typescript', 'ts', 'js', 'tsx', 'jsx',
'bash', 'sh', 'shell', 'python', 'py', 'go', 'java', 'yaml', 'yml', 'html', 'css',
'xml', 'markdown', 'md', 'text', 'plaintext', 'vue', 'php', 'ruby', 'rust', 'toml',
'ini', 'diff',
];
const fencePattern = new RegExp(`(^|\\n)\`\`\`(${knownFenceLanguages.join('|')})([^\\n])`, 'gi');
text = text.replace(fencePattern, '$1```$2\n$3');
text = text.replace(/([^\n])```(?=\n|$)/g, '$1\n```');
return text;
};

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildTableSelectQuery } from './objectQueryTemplates';
describe('buildTableSelectQuery', () => {
it('quotes uppercase postgres table names in new query templates', () => {
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
});
});

View File

@@ -0,0 +1,9 @@
import { quoteQualifiedIdent } from './sql';
export const buildTableSelectQuery = (dbType: string, tableName: string): string => {
const normalizedTableName = String(tableName || '').trim();
if (!normalizedTableName) {
return 'SELECT * FROM ';
}
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
};

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { resolveTitleBarToggleIconKey, shouldToggleMaximisedWindowForScaleFix } from './windowStateUi';
describe('windowStateUi', () => {
it('does not re-toggle a maximized window on activation when focus returns', () => {
expect(shouldToggleMaximisedWindowForScaleFix('activation', true)).toBe(false);
});
it('switches the titlebar toggle icon to restore when the window is maximized', () => {
expect(resolveTitleBarToggleIconKey('maximized')).toBe('restore');
});
});

View File

@@ -0,0 +1,11 @@
export type WindowVisualState = 'normal' | 'maximized' | 'fullscreen';
export type WindowScaleFixReason = 'activation' | 'ratio-change';
export type TitleBarToggleIconKey = 'maximize' | 'restore';
export const shouldToggleMaximisedWindowForScaleFix = (
reason: WindowScaleFixReason,
hasViewportScaleDrift: boolean,
): boolean => reason === 'ratio-change' && hasViewportScaleDrift;
export const resolveTitleBarToggleIconKey = (windowState: WindowVisualState): TitleBarToggleIconKey =>
windowState === 'maximized' ? 'restore' : 'maximize';

View File

@@ -153,7 +153,7 @@ export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
export function RedisConnect(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisDeleteHashField(arg1:connection.ConnectionConfig,arg2:string,arg3:any):Promise<connection.QueryResult>;
export function RedisDeleteKeys(arg1:connection.ConnectionConfig,arg2:Array<string>):Promise<connection.QueryResult>;

View File

@@ -849,6 +849,7 @@ export namespace sync {
sourceConfig: connection.ConnectionConfig;
targetConfig: connection.ConnectionConfig;
tables: string[];
sourceQuery?: string;
content?: string;
mode: string;
jobId?: string;
@@ -867,6 +868,7 @@ export namespace sync {
this.sourceConfig = this.convertValues(source["sourceConfig"], connection.ConnectionConfig);
this.targetConfig = this.convertValues(source["targetConfig"], connection.ConnectionConfig);
this.tables = source["tables"];
this.sourceQuery = source["sourceQuery"];
this.content = source["content"];
this.mode = source["mode"];
this.jobId = source["jobId"];

View File

@@ -283,7 +283,15 @@ func (p *AnthropicProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C
respBody, err := p.doRequest(ctx, body)
if err != nil {
return nil, err
if len(req.Tools) > 0 && isHTTP400Error(err) {
body.Tools = nil
respBody, err = p.doRequest(ctx, body)
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
defer respBody.Close()
@@ -366,7 +374,15 @@ func (p *AnthropicProvider) ChatStream(ctx context.Context, req ai.ChatRequest,
respBody, err := p.doRequest(ctx, body)
if err != nil {
return err
if len(req.Tools) > 0 && isHTTP400Error(err) {
body.Tools = nil
respBody, err = p.doRequest(ctx, body)
if err != nil {
return err
}
} else {
return err
}
}
defer respBody.Close()

View File

@@ -1,8 +1,15 @@
package provider
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestNormalizeAnthropicMessagesURL_AppendsMessagesSuffix(t *testing.T) {
@@ -55,3 +62,147 @@ func TestApplyAnthropicAuthHeaders_UsesBearerForDashScopeCompatibleAnthropic(t *
t.Fatalf("expected no anthropic-version header for DashScope, got %q", got)
}
}
func TestAnthropicProviderChatRetriesWithoutToolsOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.URL.Path != "/v1/messages" {
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal request body failed: %v", err)
}
if _, hasTools := payload["tools"]; hasTools {
http.Error(w, `{"error":{"message":"tools unsupported"}}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
}))
defer server.Close()
providerInstance, err := NewAnthropicProvider(ai.ProviderConfig{
Type: "anthropic",
Name: "test-anthropic",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "claude-test",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
resp, err := providerInstance.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "get_tables",
Description: "test tool",
Parameters: map[string]interface{}{
"type": "object",
},
},
}},
})
if err != nil {
t.Fatalf("expected chat fallback to succeed, got %v", err)
}
if resp.Content != "pong" {
t.Fatalf("expected fallback content %q, got %q", "pong", resp.Content)
}
if requestCount != 2 {
t.Fatalf("expected 2 requests (with tools then fallback), got %d", requestCount)
}
}
func TestAnthropicProviderChatStreamRetriesWithoutToolsOnHTTP400(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.URL.Path != "/v1/messages" {
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("read request body failed: %v", err)
}
defer r.Body.Close()
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal request body failed: %v", err)
}
if _, hasTools := payload["tools"]; hasTools {
http.Error(w, `{"error":{"message":"tools unsupported"}}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(strings.Join([]string{
`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`,
``,
`data: {"type":"message_stop"}`,
``,
}, "\n")))
}))
defer server.Close()
providerInstance, err := NewAnthropicProvider(ai.ProviderConfig{
Type: "anthropic",
Name: "test-anthropic",
APIKey: "sk-test",
BaseURL: server.URL,
Model: "claude-test",
MaxTokens: 64,
Temperature: 0.1,
})
if err != nil {
t.Fatalf("create provider failed: %v", err)
}
var chunks []ai.StreamChunk
err = providerInstance.ChatStream(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
Tools: []ai.Tool{{
Type: "function",
Function: ai.ToolFunction{
Name: "get_tables",
Description: "test tool",
Parameters: map[string]interface{}{
"type": "object",
},
},
}},
}, func(chunk ai.StreamChunk) {
chunks = append(chunks, chunk)
})
if err != nil {
t.Fatalf("expected stream fallback to succeed, got %v", err)
}
if requestCount != 2 {
t.Fatalf("expected 2 requests (with tools then fallback), got %d", requestCount)
}
if len(chunks) < 2 {
t.Fatalf("expected content and done chunks, got %#v", chunks)
}
if chunks[0].Content != "pong" {
t.Fatalf("expected first chunk content %q, got %#v", "pong", chunks[0])
}
if !chunks[len(chunks)-1].Done {
t.Fatalf("expected final done chunk, got %#v", chunks[len(chunks)-1])
}
}

View File

@@ -9,7 +9,6 @@ import (
"fmt"
"os"
"path/filepath"
stdRuntime "runtime"
"strings"
"unicode/utf16"
"unicode/utf8"
@@ -44,7 +43,7 @@ func currentBuildType(ctx context.Context) string {
}
func shouldAttemptLegacyWebKitStorageMigration(buildType string) bool {
return stdRuntime.GOOS == "darwin" && strings.EqualFold(strings.TrimSpace(buildType), "dev")
return runtimeGOOS() == "darwin" && strings.EqualFold(strings.TrimSpace(buildType), "dev")
}
func migrateLegacyWebKitStorageIfNeeded(a *App) error {

View File

@@ -13,6 +13,8 @@ import (
)
func TestMigrateLegacyWebKitStorageIfNeededImportsConnectionsForDevBuild(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
homeDir := t.TempDir()
@@ -81,6 +83,8 @@ func TestMigrateLegacyWebKitStorageIfNeededImportsConnectionsForDevBuild(t *test
}
func TestMigrateLegacyWebKitStorageIfNeededSkipsWhenConnectionsFileAlreadyExists(t *testing.T) {
withTestGOOS(t, "darwin")
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
homeDir := t.TempDir()

View File

@@ -18,8 +18,8 @@ import (
// Redis client cache
var (
redisCache = make(map[string]redis.RedisClient)
redisCacheMu sync.Mutex
redisCache = make(map[string]redis.RedisClient)
redisCacheMu sync.Mutex
newRedisClientFunc = redis.NewRedisClient
)
@@ -539,16 +539,62 @@ func (a *App) RedisKeyExists(config connection.ConnectionConfig, key string) con
return connection.QueryResult{Success: true, Data: map[string]bool{"exists": exists}}
}
func normalizeRedisStringArgs(raw any, argName string) ([]string, error) {
switch v := raw.(type) {
case nil:
return nil, fmt.Errorf("%s 不能为空", argName)
case string:
text := strings.TrimSpace(v)
if text == "" {
return nil, fmt.Errorf("%s 不能为空", argName)
}
return []string{text}, nil
case []string:
items := make([]string, 0, len(v))
for _, item := range v {
text := strings.TrimSpace(item)
if text == "" {
continue
}
items = append(items, text)
}
if len(items) == 0 {
return nil, fmt.Errorf("%s 不能为空", argName)
}
return items, nil
case []interface{}:
items := make([]string, 0, len(v))
for _, item := range v {
text := strings.TrimSpace(fmt.Sprintf("%v", item))
if text == "" || text == "<nil>" {
continue
}
items = append(items, text)
}
if len(items) == 0 {
return nil, fmt.Errorf("%s 不能为空", argName)
}
return items, nil
default:
return nil, fmt.Errorf("%s 类型无效", argName)
}
}
// RedisDeleteHashField deletes fields from a hash
func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult {
func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields any) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := client.DeleteHashField(key, fields...); err != nil {
logger.Error(err, "RedisDeleteHashField 删除失败key=%s fields=%v", key, fields)
normalizedFields, err := normalizeRedisStringArgs(fields, "fields")
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := client.DeleteHashField(key, normalizedFields...); err != nil {
logger.Error(err, "RedisDeleteHashField 删除失败key=%s fields=%v", key, normalizedFields)
return connection.QueryResult{Success: false, Message: err.Error()}
}

View File

@@ -9,7 +9,9 @@ import (
)
type capturingRedisClient struct {
connectConfig connection.ConnectionConfig
connectConfig connection.ConnectionConfig
deletedHashKey string
deletedHashFields []string
}
func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error {
@@ -45,13 +47,21 @@ func (c *capturingRedisClient) GetString(key string) (string, error) { return ""
func (c *capturingRedisClient) SetString(key, value string, ttl int64) error { return nil }
func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) { return map[string]string{}, nil }
func (c *capturingRedisClient) GetHash(key string) (map[string]string, error) {
return map[string]string{}, nil
}
func (c *capturingRedisClient) SetHashField(key, field, value string) error { return nil }
func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error { return nil }
func (c *capturingRedisClient) DeleteHashField(key string, fields ...string) error {
c.deletedHashKey = key
c.deletedHashFields = append([]string(nil), fields...)
return nil
}
func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) { return nil, nil }
func (c *capturingRedisClient) GetList(key string, start, stop int64) ([]string, error) {
return nil, nil
}
func (c *capturingRedisClient) ListPush(key string, values ...string) error { return nil }
@@ -83,7 +93,9 @@ func (c *capturingRedisClient) StreamDelete(key string, ids ...string) (int64, e
func (c *capturingRedisClient) ExecuteCommand(args []string) (interface{}, error) { return nil, nil }
func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) { return map[string]string{}, nil }
func (c *capturingRedisClient) GetServerInfo() (map[string]string, error) {
return map[string]string{}, nil
}
func (c *capturingRedisClient) GetDatabases() ([]redislib.RedisDBInfo, error) { return nil, nil }
@@ -109,7 +121,7 @@ func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error
func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) {
testCases := []struct {
name string
name string
savedConfig connection.ConnectionConfig
runtimeConfig connection.ConnectionConfig
assertResolved func(t *testing.T, got connection.ConnectionConfig)
@@ -426,3 +438,75 @@ func TestRedisConnectRetriesLegacyDefaultRootUserWithoutUsernameAfterAuthFailure
t.Fatalf("expected fallback Redis connect attempt to clear legacy root user, got %q", connectCalls[1].User)
}
}
func TestRedisDeleteHashFieldAcceptsSingleStringField(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
CloseAllRedisClients()
client := &capturingRedisClient{}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisDeleteHashField(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "profile", "nickname")
if !result.Success {
t.Fatalf("RedisDeleteHashField returned failure: %+v", result)
}
if client.deletedHashKey != "profile" {
t.Fatalf("expected hash key profile, got %q", client.deletedHashKey)
}
if len(client.deletedHashFields) != 1 || client.deletedHashFields[0] != "nickname" {
t.Fatalf("expected one deleted hash field nickname, got %v", client.deletedHashFields)
}
}
func TestRedisDeleteHashFieldAcceptsStringSlice(t *testing.T) {
app := NewAppWithSecretStore(newFakeAppSecretStore())
app.configDir = t.TempDir()
CloseAllRedisClients()
client := &capturingRedisClient{}
originalNewRedisClientFunc := newRedisClientFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
defer func() {
newRedisClientFunc = originalNewRedisClientFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
CloseAllRedisClients()
}()
newRedisClientFunc = func() redislib.RedisClient {
return client
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
result := app.RedisDeleteHashField(connection.ConnectionConfig{
Type: "redis",
Host: "redis.local",
Port: 6379,
}, "profile", []string{"nickname", "avatar"})
if !result.Success {
t.Fatalf("RedisDeleteHashField returned failure: %+v", result)
}
if client.deletedHashKey != "profile" {
t.Fatalf("expected hash key profile, got %q", client.deletedHashKey)
}
if len(client.deletedHashFields) != 2 || client.deletedHashFields[0] != "nickname" || client.deletedHashFields[1] != "avatar" {
t.Fatalf("unexpected deleted hash fields: %v", client.deletedHashFields)
}
}

View File

@@ -493,6 +493,19 @@ func fetchLatestRelease() (*githubRelease, error) {
}
func expectedAssetName(goos, goarch, version string) (string, error) {
executablePath := ""
if goos == "linux" {
if path, err := os.Executable(); err == nil {
if resolved, resolveErr := filepath.EvalSymlinks(path); resolveErr == nil && strings.TrimSpace(resolved) != "" {
path = resolved
}
executablePath = path
}
}
return expectedAssetNameForExecutable(goos, goarch, version, executablePath)
}
func expectedAssetNameForExecutable(goos, goarch, version, executablePath string) (string, error) {
version = strings.TrimSpace(version)
version = strings.TrimPrefix(version, "v")
version = strings.TrimPrefix(version, "V")
@@ -517,12 +530,26 @@ func expectedAssetName(goos, goarch, version string) (string, error) {
}
case "linux":
if goarch == "amd64" {
return fmt.Sprintf("GoNavi-%s-Linux-Amd64.tar.gz", version), nil
return fmt.Sprintf("GoNavi-%s-Linux-Amd64%s.tar.gz", version, resolveLinuxReleaseArtifactSuffix(executablePath)), nil
}
}
return "", fmt.Errorf("当前平台暂不支持在线更新:%s/%s", goos, goarch)
}
func resolveLinuxReleaseArtifactSuffix(executablePath string) string {
normalizedPath := strings.ToLower(strings.TrimSpace(executablePath))
if normalizedPath == "" {
return ""
}
normalizedPath = strings.ReplaceAll(normalizedPath, "\\", "/")
compactPath := strings.ReplaceAll(normalizedPath, "_", "")
compactPath = strings.ReplaceAll(compactPath, "-", "")
if strings.Contains(normalizedPath, "webkit41") || strings.Contains(compactPath, "webkit241") || strings.Contains(compactPath, "webkit41") {
return "-WebKit41"
}
return ""
}
func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) {
for _, asset := range assets {
if asset.Name == name {
@@ -1195,8 +1222,12 @@ while kill -0 $PID 2>/dev/null; do
done
TMPDIR=$(mktemp -d)
tar -xzf "$ARCHIVE" -C "$TMPDIR"
NEWBIN="$TMPDIR/GoNavi"
TARGET_NAME="$(basename "$TARGET")"
NEWBIN="$TMPDIR/$TARGET_NAME"
if [ ! -f "$NEWBIN" ]; then
NEWBIN=$(find "$TMPDIR" -type f -name "$TARGET_NAME" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then
NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)
fi
if [ -z "$NEWBIN" ] || [ ! -f "$NEWBIN" ]; then

View File

@@ -3,6 +3,7 @@ package app
import (
"errors"
stdRuntime "runtime"
"strings"
"testing"
)
@@ -158,3 +159,41 @@ func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) {
t.Fatalf("expected silent check to skip error logging, got %d", logged)
}
}
func TestExpectedAssetNameForExecutableUsesLinuxWebKit41Suffix(t *testing.T) {
assetName, err := expectedAssetNameForExecutable(
"linux",
"amd64",
"v0.6.5",
"/opt/GoNavi/gonavi-build-linux-amd64-webkit41",
)
if err != nil {
t.Fatalf("expectedAssetNameForExecutable returned error: %v", err)
}
want := "GoNavi-0.6.5-Linux-Amd64-WebKit41.tar.gz"
if assetName != want {
t.Fatalf("unexpected linux webkit41 asset name: got %q want %q", assetName, want)
}
}
func TestBuildLinuxScriptPrefersTargetExecutableBasename(t *testing.T) {
script := buildLinuxScript(
"/tmp/GoNavi-0.6.5-Linux-Amd64-WebKit41.tar.gz",
"/opt/GoNavi/gonavi-build-linux-amd64-webkit41",
"/tmp/.gonavi-update-linux-0.6.5",
12345,
)
mustContain := []string{
`TARGET_NAME="$(basename "$TARGET")"`,
`NEWBIN="$TMPDIR/$TARGET_NAME"`,
`NEWBIN=$(find "$TMPDIR" -type f -name "$TARGET_NAME" | head -n 1)`,
`NEWBIN=$(find "$TMPDIR" -type f -name "GoNavi" | head -n 1)`,
}
for _, want := range mustContain {
if !strings.Contains(script, want) {
t.Fatalf("linux update script missing required token: %s\nscript:\n%s", want, script)
}
}
}

View File

@@ -26,6 +26,7 @@ const (
defaultClickHouseUser = "default"
defaultClickHouseDatabase = "default"
minClickHouseReadTimeout = 5 * time.Minute
clickHouseHTTPPortHint = "8123/8132/8443"
)
type ClickHouseDB struct {
@@ -133,12 +134,21 @@ func detectClickHouseProtocol(config connection.ConnectionConfig) clickhouse.Pro
if strings.HasPrefix(uriText, "http://") || strings.HasPrefix(uriText, "https://") {
return clickhouse.HTTP
}
if config.Port == 8123 || config.Port == 8443 {
if isClickHouseHTTPPort(config.Port) {
return clickhouse.HTTP
}
return clickhouse.Native
}
func isClickHouseHTTPPort(port int) bool {
switch port {
case 8123, 8132, 8443:
return true
default:
return false
}
}
func isClickHouseProtocolMismatch(err error) bool {
if err == nil {
return false
@@ -246,14 +256,14 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
logger.Warnf("ClickHouse SSL 优先连接失败,已回退至明文连接")
}
if pIdx > 0 {
logger.Warnf("ClickHouse 已自动切换连接协议为 %s常见于 8123/8443 HTTP 端口)", protocol.String())
logger.Warnf("ClickHouse 已自动切换连接协议为 %s常见于 %s HTTP 端口)", protocol.String(), clickHouseHTTPPortHint)
}
return nil
}
}
_ = c.Close()
return fmt.Errorf("连接建立后验证失败(可检查 ClickHouse 端口与协议是否匹配Native=9000/9440HTTP=8123/8443%s", strings.Join(failures, ""))
return fmt.Errorf("连接建立后验证失败(可检查 ClickHouse 端口与协议是否匹配Native=9000/9440HTTP=%s%s", clickHouseHTTPPortHint, strings.Join(failures, ""))
}
func (c *ClickHouseDB) Close() error {

View File

@@ -12,6 +12,10 @@ import (
"sync"
"testing"
"time"
"GoNavi-Wails/internal/connection"
clickhouse "github.com/ClickHouse/clickhouse-go/v2"
)
const fakeClickHouseDriverName = "gonavi-fake-clickhouse"
@@ -129,6 +133,65 @@ func TestClickHouseGetDatabasesFallsBackToCurrentDatabase(t *testing.T) {
}
}
func TestDetectClickHouseProtocolTreatsHTTPPortsAsHTTP(t *testing.T) {
tests := []struct {
name string
config connection.ConnectionConfig
expected clickhouse.Protocol
}{
{
name: "http uri",
config: connection.ConnectionConfig{
URI: "http://127.0.0.1:8132/default",
},
expected: clickhouse.HTTP,
},
{
name: "default http port",
config: connection.ConnectionConfig{
Port: 8123,
},
expected: clickhouse.HTTP,
},
{
name: "alternate http port 8132",
config: connection.ConnectionConfig{
Port: 8132,
},
expected: clickhouse.HTTP,
},
{
name: "https port",
config: connection.ConnectionConfig{
Port: 8443,
},
expected: clickhouse.HTTP,
},
{
name: "native port",
config: connection.ConnectionConfig{
Port: 9000,
},
expected: clickhouse.Native,
},
{
name: "native tls port",
config: connection.ConnectionConfig{
Port: 9440,
},
expected: clickhouse.Native,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if protocol := detectClickHouseProtocol(tt.config); protocol != tt.expected {
t.Fatalf("expected protocol %s, got %s", tt.expected.String(), protocol.String())
}
})
}
}
type fakeClickHouseDriver struct{}
func (fakeClickHouseDriver) Open(name string) (driver.Conn, error) {

View File

@@ -2,6 +2,7 @@ package db
import (
"database/sql"
"fmt"
"GoNavi-Wails/internal/connection"
)
@@ -11,6 +12,7 @@ func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
if err != nil {
return nil, nil, err
}
columns = ensureUniqueQueryColumnNames(columns)
colTypes, err := rows.ColumnTypes()
if err != nil || len(colTypes) != len(columns) {
@@ -47,6 +49,46 @@ func scanRows(rows *sql.Rows) ([]map[string]interface{}, []string, error) {
return resultData, columns, nil
}
func ensureUniqueQueryColumnNames(columns []string) []string {
if len(columns) == 0 {
return columns
}
uniqueColumns := make([]string, len(columns))
taken := make(map[string]struct{}, len(columns))
nextSuffix := make(map[string]int, len(columns))
for idx, column := range columns {
base := column
if base == "" {
base = fmt.Sprintf("column_%d", idx+1)
}
candidate := base
if _, exists := taken[candidate]; exists {
suffix := nextSuffix[base]
if suffix < 2 {
suffix = 2
}
for {
candidate = fmt.Sprintf("%s_%d", base, suffix)
if _, exists := taken[candidate]; !exists {
break
}
suffix++
}
nextSuffix[base] = suffix + 1
} else {
nextSuffix[base] = 2
}
uniqueColumns[idx] = candidate
taken[candidate] = struct{}{}
}
return uniqueColumns
}
// scanMultiRows 遍历 sql.Rows 中的所有结果集,将每个结果集作为 ResultSetData 返回。
// 利用 rows.NextResultSet() 支持一次 query 返回多个结果集的场景。
func scanMultiRows(rows *sql.Rows) ([]connection.ResultSetData, error) {

View File

@@ -0,0 +1,97 @@
package db
import (
"context"
"database/sql"
"database/sql/driver"
"io"
"reflect"
"sync"
"testing"
)
const scanRowsDuplicateDriverName = "gonavi-scan-rows-duplicate"
var registerScanRowsDuplicateDriverOnce sync.Once
type scanRowsDuplicateDriver struct{}
func (scanRowsDuplicateDriver) Open(name string) (driver.Conn, error) {
return scanRowsDuplicateConn{}, nil
}
type scanRowsDuplicateConn struct{}
func (scanRowsDuplicateConn) Prepare(query string) (driver.Stmt, error) { return nil, driver.ErrSkip }
func (scanRowsDuplicateConn) Close() error { return nil }
func (scanRowsDuplicateConn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (scanRowsDuplicateConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
return &scanRowsDuplicateRows{
columns: []string{"id", "id", "name"},
rows: [][]driver.Value{
{int64(1), int64(2), "alice"},
},
}, nil
}
var _ driver.QueryerContext = (*scanRowsDuplicateConn)(nil)
type scanRowsDuplicateRows struct {
columns []string
rows [][]driver.Value
index int
}
func (r *scanRowsDuplicateRows) Columns() []string { return append([]string(nil), r.columns...) }
func (r *scanRowsDuplicateRows) Close() error { return nil }
func (r *scanRowsDuplicateRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
return io.EOF
}
row := r.rows[r.index]
for idx := range dest {
if idx < len(row) {
dest[idx] = row[idx]
}
}
r.index++
return nil
}
func TestScanRowsRenamesDuplicateColumns(t *testing.T) {
t.Parallel()
registerScanRowsDuplicateDriverOnce.Do(func() {
sql.Register(scanRowsDuplicateDriverName, scanRowsDuplicateDriver{})
})
dbConn, err := sql.Open(scanRowsDuplicateDriverName, "")
if err != nil {
t.Fatalf("open duplicate scan rows db failed: %v", err)
}
defer dbConn.Close()
rows, err := dbConn.QueryContext(context.Background(), "SELECT 1")
if err != nil {
t.Fatalf("query duplicate scan rows db failed: %v", err)
}
defer rows.Close()
data, columns, err := scanRows(rows)
if err != nil {
t.Fatalf("scanRows returned error: %v", err)
}
wantColumns := []string{"id", "id_2", "name"}
if !reflect.DeepEqual(columns, wantColumns) {
t.Fatalf("unexpected columns: got=%v want=%v", columns, wantColumns)
}
if len(data) != 1 {
t.Fatalf("expected one row, got=%d", len(data))
}
if data[0]["id"] != int64(1) || data[0]["id_2"] != int64(2) || data[0]["name"] != "alice" {
t.Fatalf("unexpected row data: %#v", data[0])
}
}

View File

@@ -7,6 +7,8 @@ import (
"database/sql"
"database/sql/driver"
"fmt"
"io"
"reflect"
"strings"
"sync"
"testing"
@@ -24,9 +26,10 @@ var (
)
type tdengineRecordingState struct {
mu sync.Mutex
queries []string
execErr error
mu sync.Mutex
queries []string
execErr error
queryResults map[string]tdengineQueryResult
}
func (s *tdengineRecordingState) snapshotQueries() []string {
@@ -37,6 +40,12 @@ func (s *tdengineRecordingState) snapshotQueries() []string {
return queries
}
type tdengineQueryResult struct {
columns []string
rows [][]driver.Value
err error
}
type tdengineRecordingDriver struct{}
func (tdengineRecordingDriver) Open(name string) (driver.Conn, error) {
@@ -78,6 +87,50 @@ func (c *tdengineRecordingConn) ExecContext(_ context.Context, query string, arg
var _ driver.ExecerContext = (*tdengineRecordingConn)(nil)
func (c *tdengineRecordingConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
if len(args) > 0 {
return nil, fmt.Errorf("unexpected query args: %d", len(args))
}
c.state.mu.Lock()
defer c.state.mu.Unlock()
c.state.queries = append(c.state.queries, query)
if result, ok := c.state.queryResults[query]; ok {
if result.err != nil {
return nil, result.err
}
return &tdengineRecordingRows{columns: result.columns, rows: result.rows}, nil
}
return &tdengineRecordingRows{}, nil
}
var _ driver.QueryerContext = (*tdengineRecordingConn)(nil)
type tdengineRecordingRows struct {
columns []string
rows [][]driver.Value
index int
}
func (r *tdengineRecordingRows) Columns() []string {
return append([]string(nil), r.columns...)
}
func (r *tdengineRecordingRows) Close() error { return nil }
func (r *tdengineRecordingRows) Next(dest []driver.Value) error {
if r.index >= len(r.rows) {
return io.EOF
}
row := r.rows[r.index]
for idx := range dest {
if idx < len(row) {
dest[idx] = row[idx]
}
}
r.index++
return nil
}
func openTDengineRecordingDB(t *testing.T) (*sql.DB, *tdengineRecordingState) {
t.Helper()
registerTDengineRecordingDriverOnce.Do(func() {
@@ -87,7 +140,7 @@ func openTDengineRecordingDB(t *testing.T) (*sql.DB, *tdengineRecordingState) {
tdengineRecordingDriverMu.Lock()
tdengineRecordingDriverSeq++
dsn := fmt.Sprintf("tdengine-recording-%d", tdengineRecordingDriverSeq)
state := &tdengineRecordingState{}
state := &tdengineRecordingState{queryResults: map[string]tdengineQueryResult{}}
tdengineRecordingDriverStates[dsn] = state
tdengineRecordingDriverMu.Unlock()
@@ -166,3 +219,35 @@ func TestTDengineApplyChanges_RejectsMixedUpdatesWithoutPartialWrite(t *testing.
t.Fatalf("期望拒绝 mixed changes 时不执行任何 SQL实际=%#v", queries)
}
}
func TestTDengineGetTablesIncludesSuperTables(t *testing.T) {
t.Parallel()
dbConn, state := openTDengineRecordingDB(t)
state.mu.Lock()
state.queryResults["SHOW TABLES FROM `metrics`"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"d001"},
{"d002"},
},
}
state.queryResults["SHOW STABLES FROM `metrics`"] = tdengineQueryResult{
columns: []string{"name"},
rows: [][]driver.Value{
{"meters"},
},
}
state.mu.Unlock()
td := &TDengineDB{conn: dbConn}
tables, err := td.GetTables("metrics")
if err != nil {
t.Fatalf("GetTables returned error: %v", err)
}
want := []string{"d001", "d002", "meters"}
if !reflect.DeepEqual(tables, want) {
t.Fatalf("unexpected tables: got=%v want=%v", tables, want)
}
}

View File

@@ -202,13 +202,17 @@ func (t *TDengineDB) GetDatabases() ([]string, error) {
}
func (t *TDengineDB) GetTables(dbName string) ([]string, error) {
queries := make([]string, 0, 2)
queries := make([]string, 0, 4)
if strings.TrimSpace(dbName) != "" {
queries = append(queries, fmt.Sprintf("SHOW TABLES FROM `%s`", escapeBacktickIdent(dbName)))
queries = append(queries, fmt.Sprintf("SHOW STABLES FROM `%s`", escapeBacktickIdent(dbName)))
}
queries = append(queries, "SHOW TABLES")
queries = append(queries, "SHOW STABLES")
var lastErr error
tableSet := make(map[string]struct{})
tables := make([]string, 0)
for _, query := range queries {
data, _, err := t.Query(query)
if err != nil {
@@ -216,17 +220,35 @@ func (t *TDengineDB) GetTables(dbName string) ([]string, error) {
continue
}
var tables []string
for _, row := range data {
if val, ok := getValueFromRow(row, "table_name", "tablename", "name", "Table", "table"); ok {
tables = append(tables, fmt.Sprintf("%v", val))
tableName := strings.TrimSpace(fmt.Sprintf("%v", val))
if tableName == "" {
continue
}
if _, exists := tableSet[tableName]; exists {
continue
}
tableSet[tableName] = struct{}{}
tables = append(tables, tableName)
continue
}
for _, val := range row {
tables = append(tables, fmt.Sprintf("%v", val))
tableName := strings.TrimSpace(fmt.Sprintf("%v", val))
if tableName == "" {
break
}
if _, exists := tableSet[tableName]; exists {
break
}
tableSet[tableName] = struct{}{}
tables = append(tables, tableName)
break
}
}
}
if len(tables) > 0 {
sort.Strings(tables)
return tables, nil
}

View File

@@ -14,6 +14,7 @@ type TableDiffSummary struct {
Updates int `json:"updates"`
Deletes int `json:"deletes"`
Same int `json:"same"`
SchemaDiffCount int `json:"schemaDiffCount,omitempty"`
Message string `json:"message,omitempty"`
HasSchema bool `json:"hasSchema,omitempty"`
TargetTableExists bool `json:"targetTableExists,omitempty"`
@@ -38,6 +39,9 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
if isMongoToRedisKeyspacePair(config) {
return s.analyzeMongoToRedis(config)
}
if hasSourceQuery(config) {
return s.analyzeSourceQuery(config)
}
contentRaw := strings.ToLower(strings.TrimSpace(config.Content))
syncSchema := false
@@ -109,6 +113,7 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
summary.UnsupportedObjects = append(summary.UnsupportedObjects, plan.UnsupportedObjects...)
summary.IndexesToCreate = plan.IndexesToCreate
summary.IndexesSkipped = plan.IndexesSkipped
summary.SchemaDiffCount = len(plan.PreDataSQL) + len(plan.PostDataSQL)
if !plan.TargetTableExists && !plan.AutoCreate {
summary.Message = firstNonEmpty(plan.PlannedAction, "目标表不存在,无法执行同步")
@@ -118,7 +123,11 @@ func (s *SyncEngine) Analyze(config SyncConfig) SyncAnalyzeResult {
if !syncData {
summary.CanSync = true
summary.Message = firstNonEmpty(plan.PlannedAction, "仅同步结构,未执行数据差异分析")
if summary.SchemaDiffCount > 0 {
summary.Message = firstNonEmpty(plan.PlannedAction, fmt.Sprintf("检测到 %d 条结构变更", summary.SchemaDiffCount))
} else {
summary.Message = firstNonEmpty(plan.PlannedAction, "仅同步结构,未执行数据差异分析")
}
result.Tables = append(result.Tables, summary)
return
}

View File

@@ -19,15 +19,18 @@ type PreviewUpdateRow struct {
}
type TableDiffPreview struct {
Table string `json:"table"`
PKColumn string `json:"pkColumn"`
ColumnTypes map[string]string `json:"columnTypes,omitempty"`
TotalInserts int `json:"totalInserts"`
TotalUpdates int `json:"totalUpdates"`
TotalDeletes int `json:"totalDeletes"`
Inserts []PreviewRow `json:"inserts"`
Updates []PreviewUpdateRow `json:"updates"`
Deletes []PreviewRow `json:"deletes"`
Table string `json:"table"`
PKColumn string `json:"pkColumn"`
ColumnTypes map[string]string `json:"columnTypes,omitempty"`
SchemaSummary string `json:"schemaSummary,omitempty"`
SchemaWarnings []string `json:"schemaWarnings,omitempty"`
SchemaStatements []string `json:"schemaStatements,omitempty"`
TotalInserts int `json:"totalInserts"`
TotalUpdates int `json:"totalUpdates"`
TotalDeletes int `json:"totalDeletes"`
Inserts []PreviewRow `json:"inserts"`
Updates []PreviewUpdateRow `json:"updates"`
Deletes []PreviewRow `json:"deletes"`
}
func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (TableDiffPreview, error) {
@@ -43,6 +46,9 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
if isMongoToRedisKeyspacePair(config) {
return s.previewMongoToRedis(config, tableName, limit)
}
if hasSourceQuery(config) {
return s.previewSourceQuery(config, limit)
}
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
if err != nil {
@@ -70,6 +76,19 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
if !plan.TargetTableExists && !plan.AutoCreate {
return TableDiffPreview{}, errors.New(firstNonEmpty(plan.PlannedAction, "目标表不存在,无法预览差异"))
}
schemaStatements := make([]string, 0, len(plan.PreDataSQL)+len(plan.PostDataSQL))
schemaStatements = append(schemaStatements, plan.PreDataSQL...)
schemaStatements = append(schemaStatements, plan.PostDataSQL...)
contentRaw := strings.ToLower(strings.TrimSpace(config.Content))
if contentRaw == "schema" {
return TableDiffPreview{
Table: tableName,
SchemaSummary: firstNonEmpty(plan.PlannedAction, "仅同步结构"),
SchemaWarnings: append([]string(nil), plan.Warnings...),
SchemaStatements: append([]string(nil), schemaStatements...),
}, nil
}
pkCols := make([]string, 0, 2)
for _, c := range cols {
@@ -111,15 +130,18 @@ func (s *SyncEngine) Preview(config SyncConfig, tableName string, limit int) (Ta
}
out := TableDiffPreview{
Table: tableName,
PKColumn: pkCol,
ColumnTypes: make(map[string]string, len(cols)),
TotalInserts: 0,
TotalUpdates: 0,
TotalDeletes: 0,
Inserts: make([]PreviewRow, 0),
Updates: make([]PreviewUpdateRow, 0),
Deletes: make([]PreviewRow, 0),
Table: tableName,
PKColumn: pkCol,
ColumnTypes: make(map[string]string, len(cols)),
SchemaSummary: firstNonEmpty(plan.PlannedAction, "结构预览"),
SchemaWarnings: append([]string(nil), plan.Warnings...),
SchemaStatements: append([]string(nil), schemaStatements...),
TotalInserts: 0,
TotalUpdates: 0,
TotalDeletes: 0,
Inserts: make([]PreviewRow, 0),
Updates: make([]PreviewUpdateRow, 0),
Deletes: make([]PreviewRow, 0),
}
for _, col := range cols {
name := strings.ToLower(strings.TrimSpace(col.Name))

View File

@@ -127,12 +127,42 @@ func buildSchemaMigrationPlanLegacy(config SyncConfig, tableName string, sourceD
if len(missing) > 0 {
plan.Warnings = append(plan.Warnings, fmt.Sprintf("目标表缺失字段 %d 个:%s", len(missing), strings.Join(missing, ", ")))
}
if config.AutoAddColumns && isMySQLLikeSourceType(sourceType) && normalizeMigrationDBType(targetType) == "kingbase" {
addSQL, addWarnings := buildMySQLToKingbaseAddColumnSQL(plan.TargetQueryTable, sourceCols, targetCols)
plan.PreDataSQL = append(plan.PreDataSQL, addSQL...)
plan.Warnings = append(plan.Warnings, addWarnings...)
if len(addSQL) > 0 {
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(addSQL))
if len(missing) == 0 {
plan.PlannedAction = "表结构已一致"
} else if config.AutoAddColumns && supportsAutoAddColumnsForPair(sourceType, targetType) {
targetSet := make(map[string]struct{}, len(targetCols))
for _, col := range targetCols {
key := strings.ToLower(strings.TrimSpace(col.Name))
if key == "" {
continue
}
targetSet[key] = struct{}{}
}
for _, col := range sourceCols {
key := strings.ToLower(strings.TrimSpace(col.Name))
if key == "" {
continue
}
if _, ok := targetSet[key]; ok {
continue
}
addSQL, err := buildAddColumnSQLForPair(sourceType, targetType, plan.TargetQueryTable, col)
if err != nil {
plan.Warnings = append(plan.Warnings, fmt.Sprintf("字段 %s 自动补齐 SQL 生成失败:%v", col.Name, err))
continue
}
plan.PreDataSQL = append(plan.PreDataSQL, addSQL)
}
if len(plan.PreDataSQL) > 0 {
plan.PlannedAction = fmt.Sprintf("补齐缺失字段(%d)后导入", len(plan.PreDataSQL))
} else {
plan.PlannedAction = fmt.Sprintf("目标表缺失字段(%d),但未生成可执行补齐 SQL", len(missing))
}
} else {
if config.AutoAddColumns {
plan.PlannedAction = fmt.Sprintf("目标表缺失字段(%d),当前库对暂不支持自动补齐", len(missing))
} else {
plan.PlannedAction = fmt.Sprintf("目标表缺失字段(%d),未开启自动补齐", len(missing))
}
}
if strategy != "existing_only" {

View File

@@ -266,6 +266,54 @@ func TestBuildPGLikeToMySQLPlan_AutoCreateWhenTargetMissing(t *testing.T) {
}
}
func TestBuildSchemaMigrationPlan_MySQLToMySQLAddsMissingColumnsForExistingTarget(t *testing.T) {
t.Parallel()
sourceDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"shop.users": {
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "name", Type: "varchar(128)", Nullable: "YES"},
{Name: "aaaa", Type: "varchar(255)", Nullable: "YES"},
},
},
}
targetDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"app.users": {
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "name", Type: "varchar(128)", Nullable: "YES"},
},
},
}
cfg := SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "shop"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
TargetTableStrategy: "existing_only",
AutoAddColumns: true,
}
plan, sourceCols, targetCols, err := buildSchemaMigrationPlan(cfg, "users", sourceDB, targetDB)
if err != nil {
t.Fatalf("buildSchemaMigrationPlan returned error: %v", err)
}
if len(sourceCols) != 3 || len(targetCols) != 2 {
t.Fatalf("unexpected source/target columns: %d / %d", len(sourceCols), len(targetCols))
}
if !plan.TargetTableExists {
t.Fatalf("expected target table to exist")
}
if len(plan.PreDataSQL) != 1 {
t.Fatalf("expected one pre-data SQL statement, got=%v", plan.PreDataSQL)
}
if !strings.Contains(plan.PreDataSQL[0], "ALTER TABLE `app`.`users` ADD COLUMN `aaaa` varchar(255) NULL") {
t.Fatalf("unexpected add-column SQL: %v", plan.PreDataSQL)
}
if !strings.Contains(plan.PlannedAction, "补齐缺失字段(1)") {
t.Fatalf("unexpected planned action: %s", plan.PlannedAction)
}
}
func TestBuildMySQLToPGLikeCreateTablePlan_GeneratesPostgresDDL(t *testing.T) {
t.Parallel()

View File

@@ -0,0 +1,461 @@
package sync
import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"fmt"
"strings"
)
type sourceQuerySyncContext struct {
TableName string
TargetSchema string
TargetTable string
TargetQueryTable string
TargetType string
TargetCols []connection.ColumnDefinition
PKColumn string
SourceRows []map[string]interface{}
TargetRows []map[string]interface{}
}
func hasSourceQuery(config SyncConfig) bool {
return strings.TrimSpace(config.SourceQuery) != ""
}
func validateSourceQuerySyncConfig(config SyncConfig) (string, error) {
sourceQuery := strings.TrimSpace(config.SourceQuery)
if sourceQuery == "" {
return "", fmt.Errorf("源查询 SQL 不能为空")
}
content := strings.ToLower(strings.TrimSpace(config.Content))
if content != "" && content != "data" {
return "", fmt.Errorf("SQL 结果集同步当前仅支持“仅同步数据”")
}
if len(config.Tables) != 1 {
return "", fmt.Errorf("SQL 结果集同步要求且仅允许选择一个目标表")
}
tableName := strings.TrimSpace(config.Tables[0])
if tableName == "" {
return "", fmt.Errorf("目标表不能为空")
}
return tableName, nil
}
func resolveTargetQueryTable(config SyncConfig, tableName string) (string, string, string, string) {
targetType := resolveMigrationDBType(config.TargetConfig)
targetSchema, targetTable := normalizeSchemaAndTable(targetType, config.TargetConfig.Database, tableName)
targetQueryTable := qualifiedNameForQuery(targetType, targetSchema, targetTable, tableName)
return targetType, targetSchema, targetTable, targetQueryTable
}
func resolveSinglePKColumn(cols []connection.ColumnDefinition) (string, error) {
pkCols := make([]string, 0, 2)
for _, col := range cols {
if col.Key == "PRI" || col.Key == "PK" {
pkCols = append(pkCols, col.Name)
}
}
if len(pkCols) == 0 {
return "", fmt.Errorf("目标表无主键,不支持基于 SQL 结果集的差异分析")
}
if len(pkCols) > 1 {
return "", fmt.Errorf("目标表为复合主键(%s暂不支持基于 SQL 结果集的差异分析", strings.Join(pkCols, ","))
}
return pkCols[0], nil
}
func loadSourceQuerySyncContext(config SyncConfig, sourceDB db.Database, targetDB db.Database, needTargetRows bool, requirePK bool) (sourceQuerySyncContext, error) {
tableName, err := validateSourceQuerySyncConfig(config)
if err != nil {
return sourceQuerySyncContext{}, err
}
targetType, targetSchema, targetTable, targetQueryTable := resolveTargetQueryTable(config, tableName)
targetCols, err := targetDB.GetColumns(targetSchema, targetTable)
if err != nil {
return sourceQuerySyncContext{}, fmt.Errorf("获取目标表字段失败: %w", err)
}
if len(targetCols) == 0 {
return sourceQuerySyncContext{}, fmt.Errorf("目标表 %s 不存在或未读取到字段定义", tableName)
}
sourceRows, _, err := sourceDB.Query(strings.TrimSpace(config.SourceQuery))
if err != nil {
return sourceQuerySyncContext{}, fmt.Errorf("执行源查询失败: %w", err)
}
ctx := sourceQuerySyncContext{
TableName: tableName,
TargetSchema: targetSchema,
TargetTable: targetTable,
TargetQueryTable: targetQueryTable,
TargetType: targetType,
TargetCols: targetCols,
SourceRows: sourceRows,
TargetRows: make([]map[string]interface{}, 0),
}
if requirePK {
pkColumn, err := resolveSinglePKColumn(targetCols)
if err != nil {
return sourceQuerySyncContext{}, err
}
ctx.PKColumn = pkColumn
}
if needTargetRows {
targetRows, _, err := targetDB.Query(fmt.Sprintf("SELECT * FROM %s", quoteQualifiedIdentByType(targetType, targetQueryTable)))
if err != nil {
return sourceQuerySyncContext{}, fmt.Errorf("读取目标表失败: %w", err)
}
ctx.TargetRows = targetRows
}
return ctx, nil
}
func diffRowsByPK(pkCol string, sourceRows, targetRows []map[string]interface{}) ([]map[string]interface{}, []connection.UpdateRow, []map[string]interface{}, int) {
targetMap := make(map[string]map[string]interface{}, len(targetRows))
for _, row := range targetRows {
if row[pkCol] == nil {
continue
}
pkVal := strings.TrimSpace(fmt.Sprintf("%v", row[pkCol]))
if pkVal == "" || pkVal == "<nil>" {
continue
}
targetMap[pkVal] = row
}
sourcePKSet := make(map[string]struct{}, len(sourceRows))
inserts := make([]map[string]interface{}, 0)
updates := make([]connection.UpdateRow, 0)
same := 0
for _, sourceRow := range sourceRows {
if sourceRow[pkCol] == nil {
continue
}
pkVal := strings.TrimSpace(fmt.Sprintf("%v", sourceRow[pkCol]))
if pkVal == "" || pkVal == "<nil>" {
continue
}
sourcePKSet[pkVal] = struct{}{}
if targetRow, exists := targetMap[pkVal]; exists {
changes := make(map[string]interface{})
for key, value := range sourceRow {
if fmt.Sprintf("%v", value) != fmt.Sprintf("%v", targetRow[key]) {
changes[key] = value
}
}
if len(changes) == 0 {
same++
continue
}
updates = append(updates, connection.UpdateRow{
Keys: map[string]interface{}{pkCol: sourceRow[pkCol]},
Values: changes,
})
continue
}
inserts = append(inserts, sourceRow)
}
deletes := make([]map[string]interface{}, 0)
for pkVal, row := range targetMap {
if _, exists := sourcePKSet[pkVal]; exists {
continue
}
deletes = append(deletes, map[string]interface{}{pkCol: row[pkCol]})
}
return inserts, updates, deletes, same
}
func buildTargetColumnSet(cols []connection.ColumnDefinition) map[string]struct{} {
targetColSet := make(map[string]struct{}, len(cols))
for _, col := range cols {
lowerName := strings.ToLower(strings.TrimSpace(col.Name))
if lowerName == "" {
continue
}
targetColSet[lowerName] = struct{}{}
}
return targetColSet
}
func applyQuerySourceColumnFilter(changeSet connection.ChangeSet, targetCols []connection.ColumnDefinition) connection.ChangeSet {
targetColSet := buildTargetColumnSet(targetCols)
changeSet.Inserts = filterInsertRows(changeSet.Inserts, targetColSet)
changeSet.Updates = filterUpdateRows(changeSet.Updates, targetColSet)
return changeSet
}
func (s *SyncEngine) analyzeSourceQuery(config SyncConfig) SyncAnalyzeResult {
result := SyncAnalyzeResult{Success: true, Tables: []TableDiffSummary{}}
tableName, err := validateSourceQuerySyncConfig(config)
if err != nil {
return SyncAnalyzeResult{Success: false, Message: err.Error()}
}
totalTables := 1
s.progress(config.JobID, 0, totalTables, tableName, "差异分析开始")
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
if err != nil {
return SyncAnalyzeResult{Success: false, Message: "初始化源数据库驱动失败: " + err.Error()}
}
targetDB, err := newSyncDatabase(config.TargetConfig.Type)
if err != nil {
return SyncAnalyzeResult{Success: false, Message: "初始化目标数据库驱动失败: " + err.Error()}
}
if err := sourceDB.Connect(config.SourceConfig); err != nil {
return SyncAnalyzeResult{Success: false, Message: "源数据库连接失败: " + err.Error()}
}
defer sourceDB.Close()
if err := targetDB.Connect(config.TargetConfig); err != nil {
return SyncAnalyzeResult{Success: false, Message: "目标数据库连接失败: " + err.Error()}
}
defer targetDB.Close()
summary := TableDiffSummary{
Table: tableName,
CanSync: false,
}
ctx, err := loadSourceQuerySyncContext(config, sourceDB, targetDB, true, true)
if err != nil {
summary.Message = err.Error()
result.Tables = append(result.Tables, summary)
result.Message = "已完成 1 个目标表的差异分析"
s.progress(config.JobID, totalTables, totalTables, tableName, "差异分析完成")
return result
}
inserts, updates, deletes, same := diffRowsByPK(ctx.PKColumn, ctx.SourceRows, ctx.TargetRows)
summary.CanSync = true
summary.PKColumn = ctx.PKColumn
summary.Inserts = len(inserts)
summary.Updates = len(updates)
summary.Deletes = len(deletes)
summary.Same = same
summary.TargetTableExists = true
summary.Message = "SQL 结果集差异分析完成"
result.Tables = append(result.Tables, summary)
result.Message = "已完成 1 个目标表的差异分析"
s.progress(config.JobID, totalTables, totalTables, tableName, "差异分析完成")
return result
}
func (s *SyncEngine) previewSourceQuery(config SyncConfig, limit int) (TableDiffPreview, error) {
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
if err != nil {
return TableDiffPreview{}, fmt.Errorf("初始化源数据库驱动失败: %w", err)
}
targetDB, err := newSyncDatabase(config.TargetConfig.Type)
if err != nil {
return TableDiffPreview{}, fmt.Errorf("初始化目标数据库驱动失败: %w", err)
}
if err := sourceDB.Connect(config.SourceConfig); err != nil {
return TableDiffPreview{}, fmt.Errorf("源数据库连接失败: %w", err)
}
defer sourceDB.Close()
if err := targetDB.Connect(config.TargetConfig); err != nil {
return TableDiffPreview{}, fmt.Errorf("目标数据库连接失败: %w", err)
}
defer targetDB.Close()
ctx, err := loadSourceQuerySyncContext(config, sourceDB, targetDB, true, true)
if err != nil {
return TableDiffPreview{}, err
}
inserts, updates, deletes, _ := diffRowsByPK(ctx.PKColumn, ctx.SourceRows, ctx.TargetRows)
out := TableDiffPreview{
Table: ctx.TableName,
PKColumn: ctx.PKColumn,
ColumnTypes: make(map[string]string, len(ctx.TargetCols)),
SchemaSummary: "SQL 结果集同步预览",
TotalInserts: len(inserts),
TotalUpdates: len(updates),
TotalDeletes: len(deletes),
Inserts: make([]PreviewRow, 0, minInt(limit, len(inserts))),
Updates: make([]PreviewUpdateRow, 0, minInt(limit, len(updates))),
Deletes: make([]PreviewRow, 0, minInt(limit, len(deletes))),
}
for _, col := range ctx.TargetCols {
name := strings.ToLower(strings.TrimSpace(col.Name))
typ := strings.TrimSpace(col.Type)
if name == "" || typ == "" {
continue
}
out.ColumnTypes[name] = typ
}
for idx, row := range inserts {
if idx >= limit {
break
}
pk := strings.TrimSpace(fmt.Sprintf("%v", row[ctx.PKColumn]))
out.Inserts = append(out.Inserts, PreviewRow{PK: pk, Row: row})
}
for idx, update := range updates {
if idx >= limit {
break
}
pk := strings.TrimSpace(fmt.Sprintf("%v", update.Keys[ctx.PKColumn]))
targetRow := map[string]interface{}{}
for _, row := range ctx.TargetRows {
if fmt.Sprintf("%v", row[ctx.PKColumn]) == fmt.Sprintf("%v", update.Keys[ctx.PKColumn]) {
targetRow = row
break
}
}
sourceRow := map[string]interface{}{}
for _, row := range ctx.SourceRows {
if fmt.Sprintf("%v", row[ctx.PKColumn]) == fmt.Sprintf("%v", update.Keys[ctx.PKColumn]) {
sourceRow = row
break
}
}
changedColumns := make([]string, 0, len(update.Values))
for column := range update.Values {
changedColumns = append(changedColumns, column)
}
out.Updates = append(out.Updates, PreviewUpdateRow{
PK: pk,
ChangedColumns: changedColumns,
Source: sourceRow,
Target: targetRow,
})
}
for idx, row := range deletes {
if idx >= limit {
break
}
pk := strings.TrimSpace(fmt.Sprintf("%v", row[ctx.PKColumn]))
out.Deletes = append(out.Deletes, PreviewRow{PK: pk, Row: row})
}
return out, nil
}
func (s *SyncEngine) runSourceQuerySync(config SyncConfig) SyncResult {
result := SyncResult{Success: true, Logs: []string{}}
tableName, err := validateSourceQuerySyncConfig(config)
if err != nil {
return s.fail(config.JobID, 1, result, err.Error())
}
totalTables := 1
tableMode := normalizeSyncMode(config.Mode)
s.progress(config.JobID, 0, totalTables, tableName, "开始同步")
s.appendLog(config.JobID, &result, "info", fmt.Sprintf("同步来源SQL 结果集 -> 目标表 %s模式%s", tableName, tableMode))
sourceDB, err := newSyncDatabase(config.SourceConfig.Type)
if err != nil {
return s.fail(config.JobID, totalTables, result, "初始化源数据库驱动失败: "+err.Error())
}
targetDB, err := newSyncDatabase(config.TargetConfig.Type)
if err != nil {
return s.fail(config.JobID, totalTables, result, "初始化目标数据库驱动失败: "+err.Error())
}
if err := sourceDB.Connect(config.SourceConfig); err != nil {
return s.fail(config.JobID, totalTables, result, "源数据库连接失败: "+err.Error())
}
defer sourceDB.Close()
if err := targetDB.Connect(config.TargetConfig); err != nil {
return s.fail(config.JobID, totalTables, result, "目标数据库连接失败: "+err.Error())
}
defer targetDB.Close()
opts := TableOptions{Insert: true, Update: true, Delete: false}
if config.TableOptions != nil {
if configured, ok := config.TableOptions[tableName]; ok {
opts = configured
}
}
if !opts.Insert && !opts.Update && !opts.Delete {
s.appendLog(config.JobID, &result, "info", fmt.Sprintf("目标表 %s 未勾选任何操作,已跳过", tableName))
s.progress(config.JobID, totalTables, totalTables, tableName, "同步完成")
return result
}
needTargetRows := tableMode == "insert_update"
requirePK := tableMode == "insert_update"
ctx, err := loadSourceQuerySyncContext(config, sourceDB, targetDB, needTargetRows, requirePK)
if err != nil {
return s.fail(config.JobID, totalTables, result, err.Error())
}
inserts := make([]map[string]interface{}, 0)
updates := make([]connection.UpdateRow, 0)
deletes := make([]map[string]interface{}, 0)
if tableMode == "insert_update" {
inserts, updates, deletes, _ = diffRowsByPK(ctx.PKColumn, ctx.SourceRows, ctx.TargetRows)
inserts = filterRowsByPKSelection(ctx.PKColumn, inserts, opts.Insert, opts.SelectedInsertPKs)
updates = filterUpdatesByPKSelection(ctx.PKColumn, updates, opts.Update, opts.SelectedUpdatePKs)
deletes = filterRowsByPKSelection(ctx.PKColumn, deletes, opts.Delete, opts.SelectedDeletePKs)
} else {
inserts = ctx.SourceRows
if !opts.Insert {
inserts = nil
}
if tableMode == "full_overwrite" {
s.progress(config.JobID, 0, totalTables, tableName, "清空目标表")
clearSQL := fmt.Sprintf("DELETE FROM %s", quoteQualifiedIdentByType(ctx.TargetType, ctx.TargetQueryTable))
if ctx.TargetType == "mysql" {
clearSQL = fmt.Sprintf("TRUNCATE TABLE %s", quoteQualifiedIdentByType(ctx.TargetType, ctx.TargetQueryTable))
}
if _, err := targetDB.Exec(clearSQL); err != nil {
return s.fail(config.JobID, totalTables, result, "清空目标表失败: "+err.Error())
}
}
}
changeSet := applyQuerySourceColumnFilter(connection.ChangeSet{
Inserts: inserts,
Updates: updates,
Deletes: deletes,
}, ctx.TargetCols)
if len(changeSet.Inserts) == 0 && len(changeSet.Updates) == 0 && len(changeSet.Deletes) == 0 {
s.appendLog(config.JobID, &result, "info", "SQL 结果集与目标表一致,无需应用变更")
result.TablesSynced++
s.progress(config.JobID, totalTables, totalTables, tableName, "同步完成")
return result
}
applyTableName := ctx.TargetTable
switch ctx.TargetType {
case "postgres", "kingbase", "highgo", "vastbase", "sqlserver":
applyTableName = ctx.TargetQueryTable
}
applier, ok := targetDB.(db.BatchApplier)
if !ok {
return s.fail(config.JobID, totalTables, result, "目标驱动不支持应用数据变更 (ApplyChanges)")
}
if err := applier.ApplyChanges(applyTableName, changeSet); err != nil {
return s.fail(config.JobID, totalTables, result, "应用 SQL 结果集变更失败: "+err.Error())
}
result.TablesSynced++
result.RowsInserted += len(changeSet.Inserts)
result.RowsUpdated += len(changeSet.Updates)
result.RowsDeleted += len(changeSet.Deletes)
s.appendLog(config.JobID, &result, "info", fmt.Sprintf("SQL 结果集同步完成:插入=%d 更新=%d 删除=%d", len(changeSet.Inserts), len(changeSet.Updates), len(changeSet.Deletes)))
s.progress(config.JobID, totalTables, totalTables, tableName, "同步完成")
return result
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,177 @@
package sync
import (
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"reflect"
"testing"
)
type fakeQuerySyncTargetDB struct {
fakeMigrationDB
appliedTable string
appliedChanges connection.ChangeSet
}
func (f *fakeQuerySyncTargetDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
f.appliedTable = tableName
f.appliedChanges = changes
return nil
}
var _ db.BatchApplier = (*fakeQuerySyncTargetDB)(nil)
func TestAnalyze_SourceQueryUsesQueryResultAsSourceDataset(t *testing.T) {
sourceDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"app.users": {
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
},
},
queryData: map[string][]map[string]interface{}{
"SELECT id, name FROM active_users": {
{"id": 1, "name": "Alice New"},
{"id": 2, "name": "Bob"},
},
},
}
targetDB := &fakeQuerySyncTargetDB{
fakeMigrationDB: fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"app.users": {
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
},
},
queryData: map[string][]map[string]interface{}{
"SELECT * FROM `app`.`users`": {
{"id": 1, "name": "Alice Old"},
{"id": 3, "name": "Carol"},
},
},
},
}
oldFactory := newSyncDatabase
defer func() { newSyncDatabase = oldFactory }()
callCount := 0
newSyncDatabase = func(dbType string) (db.Database, error) {
callCount++
if callCount == 1 {
return sourceDB, nil
}
return targetDB, nil
}
engine := NewSyncEngine(Reporter{})
result := engine.Analyze(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
Tables: []string{"users"},
Mode: "insert_update",
SourceQuery: "SELECT id, name FROM active_users",
})
if !result.Success {
t.Fatalf("Analyze 返回失败: %+v", result)
}
if len(result.Tables) != 1 {
t.Fatalf("expected one table summary, got %d", len(result.Tables))
}
summary := result.Tables[0]
if summary.PKColumn != "id" {
t.Fatalf("expected PKColumn=id, got %q", summary.PKColumn)
}
if !summary.CanSync {
t.Fatalf("expected summary can sync, got %+v", summary)
}
if summary.Inserts != 1 || summary.Updates != 1 || summary.Deletes != 1 {
t.Fatalf("unexpected diff summary: %+v", summary)
}
}
func TestRunSync_SourceQueryAppliesDiffAgainstTargetTable(t *testing.T) {
sourceDB := &fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"app.users": {
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
},
},
queryData: map[string][]map[string]interface{}{
"SELECT id, name FROM active_users": {
{"id": 1, "name": "Alice New"},
{"id": 2, "name": "Bob"},
},
},
}
targetDB := &fakeQuerySyncTargetDB{
fakeMigrationDB: fakeMigrationDB{
columns: map[string][]connection.ColumnDefinition{
"app.users": {
{Name: "id", Type: "bigint", Nullable: "NO", Key: "PRI"},
{Name: "name", Type: "varchar(64)", Nullable: "YES"},
},
},
queryData: map[string][]map[string]interface{}{
"SELECT * FROM `app`.`users`": {
{"id": 1, "name": "Alice Old"},
{"id": 3, "name": "Carol"},
},
},
},
}
oldFactory := newSyncDatabase
defer func() { newSyncDatabase = oldFactory }()
callCount := 0
newSyncDatabase = func(dbType string) (db.Database, error) {
callCount++
if callCount == 1 {
return sourceDB, nil
}
return targetDB, nil
}
engine := NewSyncEngine(Reporter{})
result := engine.RunSync(SyncConfig{
SourceConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
TargetConfig: connection.ConnectionConfig{Type: "mysql", Database: "app"},
Tables: []string{"users"},
Mode: "insert_update",
SourceQuery: "SELECT id, name FROM active_users",
TableOptions: map[string]TableOptions{
"users": {Insert: true, Update: true, Delete: true},
},
})
if !result.Success {
t.Fatalf("RunSync 返回失败: %+v", result)
}
if result.TablesSynced != 1 || result.RowsInserted != 1 || result.RowsUpdated != 1 || result.RowsDeleted != 1 {
t.Fatalf("unexpected sync result: %+v", result)
}
if targetDB.appliedTable != "users" {
t.Fatalf("expected applied table users, got %q", targetDB.appliedTable)
}
wantInserts := []map[string]interface{}{{"id": 2, "name": "Bob"}}
if !reflect.DeepEqual(targetDB.appliedChanges.Inserts, wantInserts) {
t.Fatalf("unexpected inserts: got=%v want=%v", targetDB.appliedChanges.Inserts, wantInserts)
}
wantUpdates := []connection.UpdateRow{{
Keys: map[string]interface{}{"id": 1},
Values: map[string]interface{}{"name": "Alice New"},
}}
if !reflect.DeepEqual(targetDB.appliedChanges.Updates, wantUpdates) {
t.Fatalf("unexpected updates: got=%v want=%v", targetDB.appliedChanges.Updates, wantUpdates)
}
wantDeletes := []map[string]interface{}{{"id": 3}}
if !reflect.DeepEqual(targetDB.appliedChanges.Deletes, wantDeletes) {
t.Fatalf("unexpected deletes: got=%v want=%v", targetDB.appliedChanges.Deletes, wantDeletes)
}
}

View File

@@ -15,6 +15,7 @@ type SyncConfig struct {
SourceConfig connection.ConnectionConfig `json:"sourceConfig"`
TargetConfig connection.ConnectionConfig `json:"targetConfig"`
Tables []string `json:"tables"`
SourceQuery string `json:"sourceQuery,omitempty"`
Content string `json:"content,omitempty"` // "data", "schema", "both"
Mode string `json:"mode"` // "insert_update", "insert_only", "full_overwrite"
JobID string `json:"jobId,omitempty"`
@@ -54,6 +55,9 @@ func (s *SyncEngine) RunSync(config SyncConfig) SyncResult {
if isMongoToRedisKeyspacePair(config) {
return s.runMongoToRedisSync(config, result)
}
if hasSourceQuery(config) {
return s.runSourceQuerySync(config)
}
totalTables := len(config.Tables)
s.progress(config.JobID, 0, totalTables, "", "开始同步")