mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-18 21:04:16 +08:00
✨ feat(sync): 按数据源组合约束迁移能力
- 展示源端到目标端的完整、部分、规划中和不支持状态 - 按能力控制自动建表、补列、索引及继续执行入口 - 支持数据库列表为空时手工输入并补齐六语言提示
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
DBGetTables,
|
||||
DataSync,
|
||||
DataSyncAnalyze,
|
||||
DataSyncCapability,
|
||||
DataSyncPreview,
|
||||
} from "../../wailsjs/go/app/App";
|
||||
import { SavedConnection } from "../types";
|
||||
@@ -76,6 +77,11 @@ import {
|
||||
startDataSyncBackgroundTask,
|
||||
useDataSyncBackgroundTask,
|
||||
} from "./dataSyncBackgroundTask";
|
||||
import {
|
||||
resolveDataSyncCapabilityPresentation,
|
||||
type DataSyncCapabilitySnapshot,
|
||||
} from "./dataSyncCapability";
|
||||
import { resolveDataSyncDatabaseSelection } from "./dataSyncDatabaseSelection";
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
const { Option } = Select;
|
||||
@@ -413,6 +419,11 @@ const DataSyncModal: React.FC<{
|
||||
const [targetSchema, setTargetSchema] = useState<string>("");
|
||||
const [targetSchemaLoading, setTargetSchemaLoading] =
|
||||
useState<boolean>(false);
|
||||
const [migrationCapability, setMigrationCapability] =
|
||||
useState<DataSyncCapabilitySnapshot | null>(null);
|
||||
const [migrationCapabilityStatus, setMigrationCapabilityStatus] = useState<
|
||||
"idle" | "loading" | "ready" | "error"
|
||||
>("idle");
|
||||
|
||||
// Step 2: Tables
|
||||
const [allTables, setAllTables] = useState<string[]>([]);
|
||||
@@ -713,17 +724,22 @@ const DataSyncModal: React.FC<{
|
||||
return;
|
||||
}
|
||||
if (workflowType === "migration") {
|
||||
const supportsAutoCreate = migrationCapability?.supportsAutoCreate === true;
|
||||
if (syncMode === "insert_update") {
|
||||
setSyncMode("insert_only");
|
||||
}
|
||||
if (syncContent === "schema") {
|
||||
setSyncContent("both");
|
||||
}
|
||||
if (targetTableStrategy === "existing_only") {
|
||||
if (supportsAutoCreate && targetTableStrategy === "existing_only") {
|
||||
setTargetTableStrategy("smart");
|
||||
} else if (!supportsAutoCreate && targetTableStrategy !== "existing_only") {
|
||||
setTargetTableStrategy("existing_only");
|
||||
}
|
||||
if (!createIndexes) {
|
||||
if (supportsAutoCreate && !createIndexes) {
|
||||
setCreateIndexes(true);
|
||||
} else if (!supportsAutoCreate && createIndexes) {
|
||||
setCreateIndexes(false);
|
||||
}
|
||||
} else {
|
||||
if (targetTableStrategy !== "existing_only") {
|
||||
@@ -742,6 +758,7 @@ const DataSyncModal: React.FC<{
|
||||
syncMode,
|
||||
targetTableStrategy,
|
||||
createIndexes,
|
||||
migrationCapability?.supportsAutoCreate,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -750,6 +767,12 @@ const DataSyncModal: React.FC<{
|
||||
}
|
||||
}, [syncContent, autoAddColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (migrationCapability?.supportsAutoAddColumns === false && autoAddColumns) {
|
||||
setAutoAddColumns(false);
|
||||
}
|
||||
}, [migrationCapability?.supportsAutoAddColumns, autoAddColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceDatasetMode !== "query") return;
|
||||
if (workflowType !== "sync") {
|
||||
@@ -794,18 +817,17 @@ const DataSyncModal: React.FC<{
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (requestSeq !== sourceDatabaseRequestSeqRef.current) return;
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setSourceDbs(
|
||||
dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter(
|
||||
(name: any) => typeof name === "string" && name.trim() !== "",
|
||||
),
|
||||
);
|
||||
}
|
||||
const selection = resolveDataSyncDatabaseSelection(
|
||||
conn.config,
|
||||
res.success && Array.isArray(res.data) ? res.data : [],
|
||||
);
|
||||
setSourceDbs(selection.options);
|
||||
setSourceDb(selection.preferred);
|
||||
} catch (e: any) {
|
||||
if (requestSeq !== sourceDatabaseRequestSeqRef.current) return;
|
||||
const selection = resolveDataSyncDatabaseSelection(conn.config, []);
|
||||
setSourceDbs(selection.options);
|
||||
setSourceDb(selection.preferred);
|
||||
message.error(
|
||||
tr("data_sync.message.fetch_source_databases_failed_detail", {
|
||||
detail: e?.message || String(e),
|
||||
@@ -836,18 +858,17 @@ const DataSyncModal: React.FC<{
|
||||
try {
|
||||
const res = await DBGetDatabases(normalizeConnConfig(conn) as any);
|
||||
if (requestSeq !== targetDatabaseRequestSeqRef.current) return;
|
||||
if (res.success) {
|
||||
const dbRows = Array.isArray(res.data) ? res.data : [];
|
||||
setTargetDbs(
|
||||
dbRows
|
||||
.map((r: any) => r?.Database || r?.database || r?.username)
|
||||
.filter(
|
||||
(name: any) => typeof name === "string" && name.trim() !== "",
|
||||
),
|
||||
);
|
||||
}
|
||||
const selection = resolveDataSyncDatabaseSelection(
|
||||
conn.config,
|
||||
res.success && Array.isArray(res.data) ? res.data : [],
|
||||
);
|
||||
setTargetDbs(selection.options);
|
||||
setTargetDb(selection.preferred);
|
||||
} catch (e: any) {
|
||||
if (requestSeq !== targetDatabaseRequestSeqRef.current) return;
|
||||
const selection = resolveDataSyncDatabaseSelection(conn.config, []);
|
||||
setTargetDbs(selection.options);
|
||||
setTargetDb(selection.preferred);
|
||||
message.error(
|
||||
tr("data_sync.message.fetch_target_databases_failed_detail", {
|
||||
detail: e?.message || String(e),
|
||||
@@ -871,6 +892,21 @@ const DataSyncModal: React.FC<{
|
||||
|
||||
const nextToTables = async () => {
|
||||
if (!sourceConnId || !targetConnId) return message.error(tr('data_sync.message.select_connections_first'));
|
||||
if (!isSourceQueryMode && migrationCapabilityStatus === "loading") {
|
||||
return message.info(tr("data_sync.capability.loading"));
|
||||
}
|
||||
if (!isSourceQueryMode && migrationCapabilityStatus === "error") {
|
||||
return message.error(tr("data_sync.capability.load_failed"));
|
||||
}
|
||||
if (
|
||||
!isSourceQueryMode &&
|
||||
migrationCapability &&
|
||||
!migrationCapability.canExecute
|
||||
) {
|
||||
return message.error(
|
||||
resolveDataSyncCapabilityPresentation(migrationCapability, tr).message,
|
||||
);
|
||||
}
|
||||
if (!sourceDb) return message.error(tr('data_sync.message.select_source_database'));
|
||||
if (!targetDb) return message.error(tr('data_sync.message.select_target_database'));
|
||||
if (!ensureTargetSchemaSelected()) return;
|
||||
@@ -1320,6 +1356,28 @@ const DataSyncModal: React.FC<{
|
||||
() => connections.find((c) => c.id === targetConnId),
|
||||
[connections, targetConnId],
|
||||
);
|
||||
const capabilityPresentation = useMemo(
|
||||
() =>
|
||||
migrationCapability
|
||||
? resolveDataSyncCapabilityPresentation(migrationCapability, tr)
|
||||
: null,
|
||||
[migrationCapability, i18nLanguage],
|
||||
);
|
||||
const capabilityStatusPresentation = useMemo(() => {
|
||||
if (migrationCapabilityStatus === "loading") {
|
||||
return {
|
||||
alertType: "info" as const,
|
||||
message: tr("data_sync.capability.loading"),
|
||||
};
|
||||
}
|
||||
if (migrationCapabilityStatus === "error") {
|
||||
return {
|
||||
alertType: "error" as const,
|
||||
message: tr("data_sync.capability.load_failed"),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [migrationCapabilityStatus, i18nLanguage]);
|
||||
const targetDialect = useMemo(
|
||||
() =>
|
||||
resolveSqlDialect(
|
||||
@@ -1339,6 +1397,46 @@ const DataSyncModal: React.FC<{
|
||||
isMigrationWorkflow &&
|
||||
((sourceType === "redis" && targetType === "mongodb") ||
|
||||
(sourceType === "mongodb" && targetType === "redis"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceConn || !targetConn) {
|
||||
setMigrationCapability(null);
|
||||
setMigrationCapabilityStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setMigrationCapability(null);
|
||||
setMigrationCapabilityStatus("loading");
|
||||
void DataSyncCapability(
|
||||
{
|
||||
type: String(sourceConn.config?.type || ""),
|
||||
driver: String(sourceConn.config?.driver || ""),
|
||||
oceanBaseProtocol: String(sourceConn.config?.oceanBaseProtocol || ""),
|
||||
} as any,
|
||||
{
|
||||
type: String(targetConn.config?.type || ""),
|
||||
driver: String(targetConn.config?.driver || ""),
|
||||
oceanBaseProtocol: String(targetConn.config?.oceanBaseProtocol || ""),
|
||||
} as any,
|
||||
)
|
||||
.then((capability) => {
|
||||
if (!cancelled) {
|
||||
setMigrationCapability(capability as DataSyncCapabilitySnapshot);
|
||||
setMigrationCapabilityStatus("ready");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setMigrationCapability(null);
|
||||
setMigrationCapabilityStatus("error");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sourceConn, targetConn]);
|
||||
const defaultMongoCollectionName = useMemo(() => {
|
||||
if (sourceType === "redis" && targetType === "mongodb") {
|
||||
return `redis_db_${resolveRedisDbIndex(sourceDb || sourceConn?.config?.database)}_keys`;
|
||||
@@ -1713,13 +1811,21 @@ const DataSyncModal: React.FC<{
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={tr("data_sync.field.database")}>
|
||||
<Select value={sourceDb} onChange={setSourceDb} showSearch>
|
||||
{sourceDbs.map((d) => (
|
||||
<Option key={d} value={d}>
|
||||
{d}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{sourceDbs.length > 0 ? (
|
||||
<Select value={sourceDb} onChange={setSourceDb} showSearch>
|
||||
{sourceDbs.map((d) => (
|
||||
<Option key={d} value={d}>
|
||||
{d}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={sourceDb}
|
||||
onChange={(event) => setSourceDb(event.target.value)}
|
||||
placeholder={tr("data_sync.placeholder.database_manual")}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
@@ -1764,13 +1870,21 @@ const DataSyncModal: React.FC<{
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={tr("data_sync.field.database")}>
|
||||
<Select value={targetDb} onChange={setTargetDb} showSearch>
|
||||
{targetDbs.map((d) => (
|
||||
<Option key={d} value={d}>
|
||||
{d}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{targetDbs.length > 0 ? (
|
||||
<Select value={targetDb} onChange={setTargetDb} showSearch>
|
||||
{targetDbs.map((d) => (
|
||||
<Option key={d} value={d}>
|
||||
{d}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={targetDb}
|
||||
onChange={(event) => setTargetDb(event.target.value)}
|
||||
placeholder={tr("data_sync.placeholder.database_manual")}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
{targetSupportsSchemaSelection && (
|
||||
<Form.Item label={tr("data_sync.field.schema")}>
|
||||
@@ -1946,17 +2060,28 @@ const DataSyncModal: React.FC<{
|
||||
<Select
|
||||
value={targetTableStrategy}
|
||||
onChange={setTargetTableStrategy}
|
||||
disabled={!isMigrationWorkflow || isSourceQueryMode}
|
||||
disabled={
|
||||
!isMigrationWorkflow ||
|
||||
isSourceQueryMode ||
|
||||
migrationCapabilityStatus !== "ready" ||
|
||||
capabilityPresentation?.forceExistingTarget === true
|
||||
}
|
||||
>
|
||||
<Option value="existing_only">
|
||||
{tr("data_sync.option.target_strategy.existing_only")}
|
||||
</Option>
|
||||
<Option value="auto_create_if_missing">
|
||||
<Option
|
||||
value="auto_create_if_missing"
|
||||
disabled={migrationCapability?.supportsAutoCreate !== true}
|
||||
>
|
||||
{tr(
|
||||
"data_sync.option.target_strategy.auto_create_if_missing",
|
||||
)}
|
||||
</Option>
|
||||
<Option value="smart">
|
||||
<Option
|
||||
value="smart"
|
||||
disabled={migrationCapability?.supportsAutoCreate !== true}
|
||||
>
|
||||
{tr("data_sync.option.target_strategy.smart")}
|
||||
</Option>
|
||||
</Select>
|
||||
@@ -1988,7 +2113,11 @@ const DataSyncModal: React.FC<{
|
||||
<Checkbox
|
||||
checked={autoAddColumns}
|
||||
onChange={(e) => setAutoAddColumns(e.target.checked)}
|
||||
disabled={isSourceQueryMode || syncContent === "data"}
|
||||
disabled={
|
||||
isSourceQueryMode ||
|
||||
syncContent === "data" ||
|
||||
migrationCapability?.supportsAutoAddColumns !== true
|
||||
}
|
||||
>
|
||||
{isSchemaCompareEntry
|
||||
? tr("data_sync.compare_entry.option.auto_add_columns")
|
||||
@@ -2004,14 +2133,35 @@ const DataSyncModal: React.FC<{
|
||||
disabled={
|
||||
!isMigrationWorkflow ||
|
||||
targetTableStrategy === "existing_only" ||
|
||||
isSourceQueryMode
|
||||
isSourceQueryMode ||
|
||||
migrationCapability?.supportsAutoCreate !== true
|
||||
}
|
||||
>
|
||||
{tr("data_sync.option.create_indexes")}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!isSourceQueryMode && capabilityStatusPresentation && (
|
||||
<Alert
|
||||
type={capabilityStatusPresentation.alertType}
|
||||
showIcon
|
||||
message={capabilityStatusPresentation.message}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isSourceQueryMode &&
|
||||
capabilityPresentation &&
|
||||
(isMigrationWorkflow ||
|
||||
capabilityPresentation.blocksExecution) && (
|
||||
<Alert
|
||||
type={capabilityPresentation.alertType}
|
||||
showIcon
|
||||
message={capabilityPresentation.message}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{isMigrationWorkflow &&
|
||||
!capabilityPresentation &&
|
||||
targetTableStrategy !== "existing_only" && (
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -2020,14 +2170,16 @@ const DataSyncModal: React.FC<{
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isCompareEntry && !isMigrationWorkflow && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={tr("data_sync.alert.existing_target_only")}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{!isCompareEntry &&
|
||||
!isMigrationWorkflow &&
|
||||
!capabilityPresentation?.blocksExecution && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={tr("data_sync.alert.existing_target_only")}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
)}
|
||||
{syncContent !== "schema" && syncMode === "full_overwrite" && (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -2493,7 +2645,17 @@ const DataSyncModal: React.FC<{
|
||||
|
||||
<div style={modalFooterBarStyle}>
|
||||
{currentStep === 0 && (
|
||||
<Button type="primary" onClick={nextToTables} loading={loading}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={nextToTables}
|
||||
loading={loading}
|
||||
disabled={
|
||||
!isSourceQueryMode &&
|
||||
(migrationCapabilityStatus === "loading" ||
|
||||
migrationCapabilityStatus === "error" ||
|
||||
capabilityPresentation?.blocksExecution === true)
|
||||
}
|
||||
>
|
||||
{tr("data_sync.action.next")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -2512,6 +2674,8 @@ const DataSyncModal: React.FC<{
|
||||
(isCompareEntry ? false : syncContent === "schema") ||
|
||||
selectedTables.length === 0 ||
|
||||
analyzing ||
|
||||
(!isSourceQueryMode && migrationCapabilityStatus !== "ready") ||
|
||||
(!isSourceQueryMode && capabilityPresentation?.blocksExecution === true) ||
|
||||
(isSourceQueryMode && !sourceQuery.trim())
|
||||
}
|
||||
style={{ marginRight: 8 }}
|
||||
@@ -2533,6 +2697,8 @@ const DataSyncModal: React.FC<{
|
||||
disabled={
|
||||
selectedTables.length === 0 ||
|
||||
(isSourceQueryMode && !sourceQuery.trim()) ||
|
||||
(!isSourceQueryMode && migrationCapabilityStatus !== "ready") ||
|
||||
(!isSourceQueryMode && capabilityPresentation?.blocksExecution === true) ||
|
||||
!executionReadiness.ready
|
||||
}
|
||||
>
|
||||
|
||||
36
frontend/src/components/dataSyncCapability.i18n.test.ts
Normal file
36
frontend/src/components/dataSyncCapability.i18n.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { t } from '../i18n';
|
||||
|
||||
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
|
||||
|
||||
describe('data sync capability catalog', () => {
|
||||
it.each(locales)('does not expose the obsolete MySQL-to-Kingbase-only scope in %s', (locale) => {
|
||||
const plannerScope = t(
|
||||
'data_sync.alert.auto_create_planner_scope',
|
||||
undefined,
|
||||
locale,
|
||||
).toLowerCase();
|
||||
const autoAddColumns = t(
|
||||
'data_sync.option.auto_add_columns',
|
||||
undefined,
|
||||
locale,
|
||||
).toLowerCase();
|
||||
|
||||
expect(plannerScope.includes('mysql') && plannerScope.includes('kingbase')).toBe(false);
|
||||
expect(autoAddColumns.includes('mysql') && autoAddColumns.includes('kingbase')).toBe(false);
|
||||
});
|
||||
|
||||
it.each(locales)('renders the selected source and target pair in %s', (locale) => {
|
||||
const message = t(
|
||||
'data_sync.capability.full',
|
||||
{ sourceType: 'mysql', targetType: 'postgres' },
|
||||
locale,
|
||||
);
|
||||
|
||||
expect(message).toContain('mysql');
|
||||
expect(message).toContain('postgres');
|
||||
expect(message).not.toContain('{{sourceType}}');
|
||||
expect(message).not.toContain('{{targetType}}');
|
||||
});
|
||||
});
|
||||
80
frontend/src/components/dataSyncCapability.test.ts
Normal file
80
frontend/src/components/dataSyncCapability.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveDataSyncCapabilityPresentation,
|
||||
type DataSyncCapabilitySnapshot,
|
||||
} from './dataSyncCapability';
|
||||
|
||||
const tr = (key: string, vars?: Record<string, unknown>) =>
|
||||
`${key}:${String(vars?.sourceType || '')}->${String(vars?.targetType || '')}`;
|
||||
|
||||
const capability = (
|
||||
overrides: Partial<DataSyncCapabilitySnapshot> = {},
|
||||
): DataSyncCapabilitySnapshot => ({
|
||||
sourceType: 'mysql',
|
||||
targetType: 'postgres',
|
||||
planner: 'mysql-pglike-planner',
|
||||
supportLevel: 'full',
|
||||
canExecute: true,
|
||||
supportsAutoCreate: true,
|
||||
supportsAutoAddColumns: true,
|
||||
requiresExistingTarget: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('resolveDataSyncCapabilityPresentation', () => {
|
||||
it('presents a full planner without the obsolete MySQL-to-Kingbase-only claim', () => {
|
||||
const result = resolveDataSyncCapabilityPresentation(capability(), tr);
|
||||
|
||||
expect(result.alertType).toBe('info');
|
||||
expect(result.message).toBe(
|
||||
'data_sync.capability.full:mysql->postgres',
|
||||
);
|
||||
expect(result.blocksExecution).toBe(false);
|
||||
expect(result.forceExistingTarget).toBe(false);
|
||||
});
|
||||
|
||||
it('makes legacy compatibility mode require an existing target', () => {
|
||||
const result = resolveDataSyncCapabilityPresentation(
|
||||
capability({
|
||||
sourceType: 'oracle',
|
||||
targetType: 'sqlserver',
|
||||
planner: 'generic-legacy-planner',
|
||||
supportLevel: 'partial',
|
||||
supportsAutoCreate: false,
|
||||
supportsAutoAddColumns: false,
|
||||
requiresExistingTarget: true,
|
||||
}),
|
||||
tr,
|
||||
);
|
||||
|
||||
expect(result.alertType).toBe('warning');
|
||||
expect(result.message).toBe(
|
||||
'data_sync.capability.partial:oracle->sqlserver',
|
||||
);
|
||||
expect(result.blocksExecution).toBe(false);
|
||||
expect(result.forceExistingTarget).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['planned', 'unsupported'] as const)(
|
||||
'blocks %s migration pairs before execution',
|
||||
(supportLevel) => {
|
||||
const result = resolveDataSyncCapabilityPresentation(
|
||||
capability({
|
||||
supportLevel,
|
||||
canExecute: false,
|
||||
supportsAutoCreate: false,
|
||||
requiresExistingTarget: true,
|
||||
}),
|
||||
tr,
|
||||
);
|
||||
|
||||
expect(result.alertType).toBe('error');
|
||||
expect(result.message).toBe(
|
||||
`data_sync.capability.${supportLevel}:mysql->postgres`,
|
||||
);
|
||||
expect(result.blocksExecution).toBe(true);
|
||||
expect(result.forceExistingTarget).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
69
frontend/src/components/dataSyncCapability.ts
Normal file
69
frontend/src/components/dataSyncCapability.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { sync } from '../../wailsjs/go/models';
|
||||
|
||||
export type DataSyncCapabilitySupportLevel =
|
||||
| 'full'
|
||||
| 'partial'
|
||||
| 'planned'
|
||||
| 'unsupported';
|
||||
|
||||
export type DataSyncCapabilitySnapshot = Pick<
|
||||
sync.MigrationCapability,
|
||||
| 'sourceType'
|
||||
| 'targetType'
|
||||
| 'planner'
|
||||
| 'canExecute'
|
||||
| 'supportsAutoCreate'
|
||||
| 'supportsAutoAddColumns'
|
||||
| 'requiresExistingTarget'
|
||||
> & {
|
||||
supportLevel: DataSyncCapabilitySupportLevel;
|
||||
};
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
variables?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => string;
|
||||
|
||||
export type DataSyncCapabilityPresentation = {
|
||||
alertType: 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
blocksExecution: boolean;
|
||||
forceExistingTarget: boolean;
|
||||
};
|
||||
|
||||
export const resolveDataSyncCapabilityPresentation = (
|
||||
capability: DataSyncCapabilitySnapshot,
|
||||
tr: Translate,
|
||||
): DataSyncCapabilityPresentation => {
|
||||
const sourceType = String(capability.sourceType || '').trim() || 'unknown';
|
||||
const targetType = String(capability.targetType || '').trim() || 'unknown';
|
||||
const variables = { sourceType, targetType };
|
||||
|
||||
if (capability.supportLevel === 'full' && capability.canExecute) {
|
||||
return {
|
||||
alertType: 'info',
|
||||
message: tr('data_sync.capability.full', variables),
|
||||
blocksExecution: false,
|
||||
forceExistingTarget: !capability.supportsAutoCreate,
|
||||
};
|
||||
}
|
||||
|
||||
if (capability.supportLevel === 'partial' && capability.canExecute) {
|
||||
return {
|
||||
alertType: 'warning',
|
||||
message: tr('data_sync.capability.partial', variables),
|
||||
blocksExecution: false,
|
||||
forceExistingTarget: true,
|
||||
};
|
||||
}
|
||||
|
||||
const supportLevel = capability.supportLevel === 'planned'
|
||||
? 'planned'
|
||||
: 'unsupported';
|
||||
return {
|
||||
alertType: 'error',
|
||||
message: tr(`data_sync.capability.${supportLevel}`, variables),
|
||||
blocksExecution: true,
|
||||
forceExistingTarget: true,
|
||||
};
|
||||
};
|
||||
58
frontend/src/components/dataSyncDatabaseSelection.test.ts
Normal file
58
frontend/src/components/dataSyncDatabaseSelection.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveDataSyncDatabaseSelection } from './dataSyncDatabaseSelection';
|
||||
|
||||
describe('resolveDataSyncDatabaseSelection', () => {
|
||||
it('deduplicates metadata rows and prefers the configured database', () => {
|
||||
expect(resolveDataSyncDatabaseSelection(
|
||||
{ type: 'mysql', database: 'app' },
|
||||
[{ Database: 'mysql' }, { database: 'APP' }, 'app'],
|
||||
)).toEqual({
|
||||
options: ['mysql', 'APP'],
|
||||
preferred: 'APP',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-selects the only database returned by the driver', () => {
|
||||
expect(resolveDataSyncDatabaseSelection(
|
||||
{ type: 'trino' },
|
||||
[{ catalog: 'hive' }],
|
||||
)).toEqual({ options: ['hive'], preferred: 'hive' });
|
||||
});
|
||||
|
||||
it('falls back to the configured database when enumeration is empty', () => {
|
||||
expect(resolveDataSyncDatabaseSelection(
|
||||
{ type: 'sqlite', database: 'D:/data/app.db' },
|
||||
[],
|
||||
)).toEqual({
|
||||
options: ['D:/data/app.db'],
|
||||
preferred: 'D:/data/app.db',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the owner for Oracle service-name connections', () => {
|
||||
expect(resolveDataSyncDatabaseSelection(
|
||||
{ type: 'oracle', database: 'ORCL', user: 'APP_OWNER' },
|
||||
[],
|
||||
)).toEqual({ options: ['APP_OWNER'], preferred: 'APP_OWNER' });
|
||||
});
|
||||
|
||||
it('uses the owner for OceanBase Oracle tenants', () => {
|
||||
expect(resolveDataSyncDatabaseSelection(
|
||||
{
|
||||
type: 'oceanbase',
|
||||
database: 'service',
|
||||
user: 'tenant_owner',
|
||||
oceanBaseProtocol: 'oracle',
|
||||
},
|
||||
[],
|
||||
)).toEqual({ options: ['tenant_owner'], preferred: 'tenant_owner' });
|
||||
});
|
||||
|
||||
it('leaves a manual-entry fallback when no database can be inferred', () => {
|
||||
expect(resolveDataSyncDatabaseSelection(
|
||||
{ type: 'custom', driver: 'acme-sql' },
|
||||
[],
|
||||
)).toEqual({ options: [], preferred: '' });
|
||||
});
|
||||
});
|
||||
97
frontend/src/components/dataSyncDatabaseSelection.ts
Normal file
97
frontend/src/components/dataSyncDatabaseSelection.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { resolveOceanBaseProtocolFromConfig } from '../utils/oceanBaseProtocol';
|
||||
|
||||
type DataSyncDatabaseConfig = {
|
||||
type?: unknown;
|
||||
driver?: unknown;
|
||||
database?: unknown;
|
||||
user?: unknown;
|
||||
redisDB?: unknown;
|
||||
oceanBaseProtocol?: unknown;
|
||||
connectionParams?: unknown;
|
||||
uri?: unknown;
|
||||
};
|
||||
|
||||
export type DataSyncDatabaseSelection = {
|
||||
options: string[];
|
||||
preferred: string;
|
||||
};
|
||||
|
||||
const METADATA_NAME_KEYS = ['database', 'username', 'name', 'catalog', 'schema'];
|
||||
|
||||
const normalizeText = (value: unknown): string => String(value ?? '').trim();
|
||||
|
||||
const readMetadataName = (row: unknown): string => {
|
||||
if (typeof row === 'string') {
|
||||
return row.trim();
|
||||
}
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const values = new Map<string, unknown>();
|
||||
Object.entries(row as Record<string, unknown>).forEach(([key, value]) => {
|
||||
values.set(key.toLowerCase(), value);
|
||||
});
|
||||
for (const key of METADATA_NAME_KEYS) {
|
||||
const value = normalizeText(values.get(key));
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveConfiguredDatabase = (config: DataSyncDatabaseConfig): string => {
|
||||
const type = normalizeText(config.type || config.driver).toLowerCase();
|
||||
const isOracle = type === 'oracle';
|
||||
let isOceanBaseOracle = false;
|
||||
if (type === 'oceanbase') {
|
||||
try {
|
||||
isOceanBaseOracle = resolveOceanBaseProtocolFromConfig(
|
||||
config as Record<string, unknown>,
|
||||
) === 'oracle';
|
||||
} catch {
|
||||
isOceanBaseOracle = false;
|
||||
}
|
||||
}
|
||||
if (isOracle || isOceanBaseOracle) {
|
||||
return normalizeText(config.user);
|
||||
}
|
||||
if (type === 'redis') {
|
||||
const configuredDatabase = normalizeText(config.database);
|
||||
return configuredDatabase || normalizeText(config.redisDB);
|
||||
}
|
||||
return normalizeText(config.database);
|
||||
};
|
||||
|
||||
export const resolveDataSyncDatabaseSelection = (
|
||||
config: DataSyncDatabaseConfig,
|
||||
rows: unknown[],
|
||||
): DataSyncDatabaseSelection => {
|
||||
const seen = new Set<string>();
|
||||
const options: string[] = [];
|
||||
rows.forEach((row) => {
|
||||
const name = readMetadataName(row);
|
||||
const key = name.toLowerCase();
|
||||
if (!name || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
options.push(name);
|
||||
});
|
||||
|
||||
const configured = resolveConfiguredDatabase(config);
|
||||
if (options.length === 0) {
|
||||
return configured
|
||||
? { options: [configured], preferred: configured }
|
||||
: { options: [], preferred: '' };
|
||||
}
|
||||
|
||||
const configuredOption = configured
|
||||
? options.find((option) => option.toLowerCase() === configured.toLowerCase())
|
||||
: undefined;
|
||||
return {
|
||||
options,
|
||||
preferred: configuredOption || (options.length === 1 ? options[0] : ''),
|
||||
};
|
||||
};
|
||||
@@ -433,6 +433,23 @@ if (
|
||||
GetTableColumns: async () => [],
|
||||
DBGetDatabases: async () => ({ success: true, data: ['missav_bot'] }),
|
||||
DBGetTables: async () => ({ success: true, data: cloneBrowserMockValue(mockQueryTables) }),
|
||||
DataSyncCapability: async (sourceConfig: any, targetConfig: any) => {
|
||||
const sourceType = String(sourceConfig?.type || sourceConfig?.driver || '').trim().toLowerCase();
|
||||
const targetType = String(targetConfig?.type || targetConfig?.driver || '').trim().toLowerCase();
|
||||
const canExecute = sourceType !== '' && targetType !== '';
|
||||
return {
|
||||
sourceType,
|
||||
targetType,
|
||||
sourceModel: 'custom',
|
||||
targetModel: 'custom',
|
||||
planner: canExecute ? 'browser-mock-existing-target' : '',
|
||||
supportLevel: canExecute ? 'partial' : 'unsupported',
|
||||
canExecute,
|
||||
supportsAutoCreate: false,
|
||||
supportsAutoAddColumns: false,
|
||||
requiresExistingTarget: true,
|
||||
};
|
||||
},
|
||||
DBGetAllColumns: async () => ({ success: true, data: cloneBrowserMockValue(mockQueryColumns) }),
|
||||
DBGetDatabaseForeignKeys: async () => ({ success: true, data: {} }),
|
||||
DBGetColumns: async (_config: any, _dbName: string, tableName: string) => ({
|
||||
|
||||
Reference in New Issue
Block a user