From 651eec1617ec8725667dad0e90838d1cf07fbaa6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 17 Apr 2026 16:31:55 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(sync):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?SQL=20=E7=BB=93=E6=9E=9C=E9=9B=86=E6=95=B0=E6=8D=AE=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同步引擎新增查询结果集同步分支,支持单目标表差异分析、预览与执行 - 数据同步工作台增加 SQL 结果集模式,并补充目标表与查询校验 - 补充后端同步链路与前端请求构造回归测试,并更新 backlog 记录 Fixes #321 --- .../2026-04-11-issue-backlog-tracking.md | 11 + frontend/src/components/DataSyncModal.tsx | 192 ++++++-- .../src/components/dataSyncRequest.test.ts | 67 +++ frontend/src/components/dataSyncRequest.ts | 85 ++++ internal/sync/analyze.go | 3 + internal/sync/preview.go | 3 + internal/sync/source_query_sync.go | 461 ++++++++++++++++++ internal/sync/source_query_sync_test.go | 177 +++++++ internal/sync/sync_engine.go | 4 + 9 files changed, 957 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/dataSyncRequest.test.ts create mode 100644 frontend/src/components/dataSyncRequest.ts create mode 100644 internal/sync/source_query_sync.go create mode 100644 internal/sync/source_query_sync_test.go diff --git a/docs/issues/2026-04-11-issue-backlog-tracking.md b/docs/issues/2026-04-11-issue-backlog-tracking.md index 50a2be9..2e210df 100644 --- a/docs/issues/2026-04-11-issue-backlog-tracking.md +++ b/docs/issues/2026-04-11-issue-backlog-tracking.md @@ -172,6 +172,17 @@ - 处理:将 MySQL 分支拆分为 rename 与 redefine 两条路径。列名发生变化时使用 `CHANGE COLUMN 原列名 新列定义`,其余类型/默认值/注释/自增等普通变更继续走 `MODIFY COLUMN`,保留原有位置子句(`FIRST` / `AFTER`)。 - 验证:补充 `frontend/src/components/tableDesignerSchemaSql.test.ts` 回归测试,覆盖 MySQL 重命名列时必须生成 `CHANGE COLUMN` 而不是 `MODIFY COLUMN`,并执行 `frontend` 下 `npm exec vitest run src/components/tableDesignerSchemaSql.test.ts` 与 `npm run build`。 +### #375 + +- 复核结论:该问题已在 `origin/dev` 落地,不应继续作为待修复 backlog 处理。 +- 已有关联提交:`7378966 fix(mysql): 表列表排除视图 refs bug#375`、`c631fee fix(ui): 表概览排除视图 refs bug#375`。 +- 后续动作:本地重复修复提交不计入有效成果,整理分支时剔除;后续 issue 一律先核对 `gh` timeline 与 `origin/dev` 关联提交,再决定是否动手。 + +### #321 + +- 根因:现有数据同步链路只支持“按源表列表”推进,前端无法录入源 SQL;后端 `Analyze / Preview / RunSync` 也默认从源表 `SELECT *` 读取数据,不能把查询结果集当作同步源。 +- 处理:新增 `sourceQuery` 同步分支。前端 `DataSyncModal` 增加“按 SQL 结果集同步”模式,限定为“源 SQL -> 单个已存在目标表”;后端在 `Analyze / Preview / RunSync` 中直接执行源 SQL,并按目标表主键复用现有差异计算、预览与应用逻辑。 +- 验证:新增 `internal/sync/source_query_sync_test.go` 与 `frontend/src/components/dataSyncRequest.test.ts`,并执行 `go test ./internal/sync -count=1`、`frontend` 下 `npm exec vitest run src/components/dataSyncRequest.test.ts`、`npm run build`。 ### #330 - 根因:查询结果表格已经支持拖拽调整列宽,但 resize handle 没有提供双击自适应逻辑,导致用户只能靠手工拖拽慢慢试宽度。 diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 9c01443..5d74b7c 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -8,10 +8,12 @@ import { EventsOn } from '../../wailsjs/runtime/runtime'; import { normalizeOpacityForPlatform, resolveAppearanceValues } 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 }; @@ -213,6 +215,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, // Step 2: Tables const [allTables, setAllTables] = useState([]); const [selectedTables, setSelectedTables] = useState([]); + const [sourceDatasetMode, setSourceDatasetMode] = useState('table'); + const [sourceQuery, setSourceQuery] = useState(''); // Options const [workflowType, setWorkflowType] = useState('sync'); @@ -293,7 +297,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'); @@ -341,6 +348,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(''); @@ -386,10 +415,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 : []; @@ -397,6 +428,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); @@ -414,7 +452,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"); @@ -431,18 +470,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); @@ -484,17 +525,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: syncContent, - 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); @@ -511,6 +554,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; @@ -549,19 +597,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); @@ -627,6 +677,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]); @@ -859,7 +910,13 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, + + + void }> = ({ open, ? '当前为“跨库迁移”模式:适合将表迁移到另一数据源,可自动建表并导入数据。' : '当前为“数据同步”模式:适合目标表已存在时做增量同步或覆盖导入。'} /> + {isSourceQueryMode && ( + + )} @@ -885,7 +950,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, - @@ -908,12 +973,12 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, )} - setAutoAddColumns(e.target.checked)}> - 自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase) + setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}> + 自动补齐目标表缺失字段(当前支持 MySQL 目标及 MySQL → Kingbase;SQL 结果集模式暂不支持) - setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only'}> + setCreateIndexes(e.target.checked)} disabled={!isMigrationWorkflow || targetTableStrategy === 'existing_only' || isSourceQueryMode}> 自动迁移可兼容的普通索引/唯一索引(仅自动建表模式生效) @@ -949,21 +1014,56 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, {currentStep === 1 && (
-
- 请选择需要同步的表: - setShowSameTables(e.target.checked)}> - 显示相同表 - -
- ({ 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 && ( + <> +
+ 请选择需要同步的表: + setShowSameTables(e.target.checked)}> + 显示相同表 + +
+ ({ 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 && ( +
+ + +