mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-02 04:31:32 +08:00
🔧 chore(dev): 合并 open issue backlog 修复分支
- 合并已按 issue 拆分提交的 backlog 修复与 SQL 结果集同步能力 - 解决 DataGrid、Sidebar 以及 legacy WebKit 存储迁移测试的合并冲突 - 保留 dev 分支当前结构并移除已废弃的 issue backlog 跟踪文档
This commit is contained in:
@@ -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(); }}
|
||||
/>
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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 → Kingbase;SQL 结果集模式暂不支持)
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: `新建查询`,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
10
frontend/src/components/dataGridTemporal.test.ts
Normal file
10
frontend/src/components/dataGridTemporal.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
59
frontend/src/components/dataGridTemporal.ts
Normal file
59
frontend/src/components/dataGridTemporal.ts
Normal 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;
|
||||
};
|
||||
67
frontend/src/components/dataSyncRequest.test.ts
Normal file
67
frontend/src/components/dataSyncRequest.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
85
frontend/src/components/dataSyncRequest.ts
Normal file
85
frontend/src/components/dataSyncRequest.ts
Normal 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 } : {}),
|
||||
};
|
||||
};
|
||||
@@ -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`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
11
frontend/src/utils/aiMarkdown.test.ts
Normal file
11
frontend/src/utils/aiMarkdown.test.ts
Normal 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```',
|
||||
);
|
||||
});
|
||||
});
|
||||
13
frontend/src/utils/aiMarkdown.ts
Normal file
13
frontend/src/utils/aiMarkdown.ts
Normal 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;
|
||||
};
|
||||
9
frontend/src/utils/objectQueryTemplates.test.ts
Normal file
9
frontend/src/utils/objectQueryTemplates.test.ts
Normal 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";');
|
||||
});
|
||||
});
|
||||
9
frontend/src/utils/objectQueryTemplates.ts
Normal file
9
frontend/src/utils/objectQueryTemplates.ts
Normal 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)};`;
|
||||
};
|
||||
13
frontend/src/utils/windowStateUi.test.ts
Normal file
13
frontend/src/utils/windowStateUi.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
11
frontend/src/utils/windowStateUi.ts
Normal file
11
frontend/src/utils/windowStateUi.ts
Normal 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';
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/9440,HTTP=8123/8443):%s", strings.Join(failures, ";"))
|
||||
return fmt.Errorf("连接建立后验证失败(可检查 ClickHouse 端口与协议是否匹配:Native=9000/9440,HTTP=%s):%s", clickHouseHTTPPortHint, strings.Join(failures, ";"))
|
||||
}
|
||||
|
||||
func (c *ClickHouseDB) Close() error {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
97
internal/db/scan_rows_test.go
Normal file
97
internal/db/scan_rows_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
461
internal/sync/source_query_sync.go
Normal file
461
internal/sync/source_query_sync.go
Normal 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
|
||||
}
|
||||
177
internal/sync/source_query_sync_test.go
Normal file
177
internal/sync/source_query_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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, "", "开始同步")
|
||||
|
||||
Reference in New Issue
Block a user