diff --git a/apps/desktop/src/components/diff/__tests__/DataCompareDialog.keyColumns.spec.ts b/apps/desktop/src/components/diff/__tests__/DataCompareDialog.keyColumns.spec.ts
index 6f7ab5ee13..144ed4c824 100644
--- a/apps/desktop/src/components/diff/__tests__/DataCompareDialog.keyColumns.spec.ts
+++ b/apps/desktop/src/components/diff/__tests__/DataCompareDialog.keyColumns.spec.ts
@@ -206,6 +206,7 @@ function preparationFor(table: string) {
sourceTruncated: false,
targetTruncated: false,
rowBudget: 100_000,
+ syncEligible: true,
};
}
diff --git a/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts b/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts
index 8e0ec5b345..0fd2b2d8a8 100644
--- a/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts
+++ b/apps/desktop/src/components/diff/__tests__/DataCompareDialog.prefill.spec.ts
@@ -130,6 +130,7 @@ function completedSession(): DataCompareSession {
sourceTruncated: false,
targetTruncated: false,
rowBudget: 100_000,
+ syncEligible: true,
databaseType: "oracle",
diff: {
added: [
diff --git a/apps/desktop/src/composables/__tests__/useDataCompareSession.spec.ts b/apps/desktop/src/composables/__tests__/useDataCompareSession.spec.ts
index 604123a11d..845fdfe7ad 100644
--- a/apps/desktop/src/composables/__tests__/useDataCompareSession.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useDataCompareSession.spec.ts
@@ -20,7 +20,7 @@ vi.mock("@/lib/backend/api", () => apiMock);
vi.mock("@/composables/useDialogSources", () => ({ openDataCompareSession: openMock }));
vi.mock("@/composables/useExportTracker", () => ({ useExportTracker: () => trackerMock }));
-const { normalizeKeyColumnOverrides, startDataCompareSession } = await import("../useDataCompareSession.ts");
+const { buildDataCompareSyncPlanTables, normalizeKeyColumnOverrides, startDataCompareSession } = await import("../useDataCompareSession.ts");
function column(name: string, isPrimaryKey = false): ColumnInfo {
return { name, data_type: "TEXT", is_nullable: true, column_default: null, is_primary_key: isPrimaryKey, extra: null };
@@ -45,6 +45,7 @@ function preparationFor(table: string) {
sourceTruncated: false,
targetTruncated: false,
rowBudget: 100_000,
+ syncEligible: true,
};
}
@@ -228,13 +229,16 @@ test("keeps the configured per-table match columns after cloning the session con
});
test("carries the row budget and truncation flags from the backend into the table result", async () => {
- apiMock.prepareDataCompareFromTables.mockImplementation((options: { targetTable: string }) => Promise.resolve({ ...preparationFor(options.targetTable), sourceRowCount: 4_000_000, sourceTruncated: true, targetTruncated: true, rowBudget: 100_000 }));
+ apiMock.prepareDataCompareFromTables.mockImplementation((options: { targetTable: string }) => Promise.resolve({ ...preparationFor(options.targetTable), sourceRowCount: 4_000_000, sourceTruncated: true, targetTruncated: true, rowBudget: 100_000, syncEligible: false }));
const session = await runSession(baseConfig({ selectedSourceTables: ["users"], targetTable: "users" }), [{ sourceTable: "users", targetTable: "users" }]);
assert.equal(session.batchResults[0]?.rowBudget, 100_000);
assert.equal(session.batchResults[0]?.sourceTruncated, true);
assert.equal(session.batchResults[0]?.targetTruncated, true);
+ assert.equal(session.batchResults[0]?.syncEligible, false);
+ assert.equal(buildDataCompareSyncPlanTables(session.batchResults, "public").length, 0);
+ assert.equal(session.batchResults[0]?.diff.added[0]?.selected, false);
});
test("keeps a truncated missing-target table on the same row budget", async () => {
@@ -243,12 +247,14 @@ test("keeps a truncated missing-target table on the same row budget", async () =
sourceRowCount: 8_000_000,
sourceTruncated: true,
rowBudget: 100_000,
+ syncEligible: false,
});
const session = await runSession(baseConfig({ selectedSourceTables: ["users"], targetTable: "users", targetTables: ["orders"] }), [{ sourceTable: "users", targetTable: "users" }]);
assert.equal(session.batchResults[0]?.rowBudget, 100_000);
assert.equal(session.batchResults[0]?.sourceTruncated, true);
+ assert.equal(buildDataCompareSyncPlanTables(session.batchResults, "public").length, 0);
});
test("normalizes a per-table match-column map", () => {
diff --git a/apps/desktop/src/composables/useDataCompareSession.ts b/apps/desktop/src/composables/useDataCompareSession.ts
index 848cd00019..29247fc84f 100644
--- a/apps/desktop/src/composables/useDataCompareSession.ts
+++ b/apps/desktop/src/composables/useDataCompareSession.ts
@@ -59,8 +59,9 @@ export interface DataCompareTableResult {
targetRowCount: number;
sourceTruncated: boolean;
targetTruncated: boolean;
- /** Rows the backend was allowed to read per side; the diff only covers this many rows when a side is truncated. */
+ /** Rows the backend was allowed to inspect per side; incomplete results are never synchronization-eligible. */
rowBudget: number;
+ syncEligible: boolean;
databaseType?: DatabaseType;
preSyncStatements?: string[];
diff: SelectableDataCompareResult;
@@ -179,17 +180,17 @@ function compareErrorMessage(dependencies: DataCompareSessionDependencies, kind:
return dependencies.formatError?.(kind, columns) ?? fallbackCompareErrorMessage(kind, columns);
}
-function toSelectableDiff(diff: DataCompareResult): SelectableDataCompareResult {
+function toSelectableDiff(diff: DataCompareResult, selected: boolean): SelectableDataCompareResult {
return {
- added: diff.added.map((row) => ({ ...row, selected: true })),
- removed: diff.removed.map((row) => ({ ...row, selected: true })),
- modified: diff.modified.map((row) => ({ ...row, selected: true })),
+ added: diff.added.map((row) => ({ ...row, selected })),
+ removed: diff.removed.map((row) => ({ ...row, selected })),
+ modified: diff.modified.map((row) => ({ ...row, selected })),
};
}
export function buildDataCompareSyncPlanTables(results: DataCompareTableResult[], targetSchema: string): DataCompareSyncPlanTableOptions[] {
return results
- .filter((table) => table.status === "different")
+ .filter((table) => table.status === "different" && table.syncEligible)
.map((table) => ({
tableName: table.targetTable,
schema: targetSchema,
@@ -203,6 +204,7 @@ export function buildDataCompareSyncPlanTables(results: DataCompareTableResult[]
},
databaseType: table.databaseType,
preSyncStatements: table.preSyncStatements ?? [],
+ syncEligible: true,
}))
.filter((table) => table.preSyncStatements.length > 0 || table.diff.added.length > 0 || table.diff.removed.length > 0 || table.diff.modified.length > 0);
}
@@ -267,9 +269,10 @@ function buildTableResult(task: DataCompareTableTask, preparation: DataCompareFr
sourceTruncated: preparation.sourceTruncated,
targetTruncated: preparation.targetTruncated,
rowBudget: preparation.rowBudget,
+ syncEligible: preparation.syncEligible,
databaseType,
preSyncStatements: preparation.preSyncStatements,
- diff: toSelectableDiff(preparation.result),
+ diff: toSelectableDiff(preparation.result, preparation.syncEligible),
expanded: status === "different",
showAll: { added: false, removed: false, modified: false },
};
@@ -291,9 +294,10 @@ function buildMissingTargetResult(task: DataCompareTableTask, preparation: DataC
sourceTruncated: preparation.sourceTruncated,
targetTruncated: false,
rowBudget: preparation.rowBudget,
+ syncEligible: preparation.syncEligible,
databaseType,
preSyncStatements: preparation.preSyncStatements,
- diff: toSelectableDiff(preparation.result),
+ diff: toSelectableDiff(preparation.result, preparation.syncEligible),
expanded: preparation.result.added.length > 0,
showAll: { added: false, removed: false, modified: false },
};
@@ -315,6 +319,7 @@ function buildErrorResult(task: DataCompareTableTask, keyColumns: string[], data
sourceTruncated: false,
targetTruncated: false,
rowBudget: 0,
+ syncEligible: false,
databaseType,
preSyncStatements: [],
diff: { added: [], removed: [], modified: [] },
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 3c536e2bf0..ff96b9ee97 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -8223,9 +8223,9 @@ export default {
noDifferences: "Data is identical, no sync SQL needed",
syncSuccess: "Data sync executed successfully",
truncatedTitle: "Some tables were not compared in full",
- truncatedHint: "Tables with more rows than a single compare can read were compared only over their first rows, so added, modified, or deleted rows beyond that point are not reported. Narrow the range with a filter and compare in batches.",
+ truncatedHint: "Tables above the single-compare limit were inspected only over a subset, so changes outside it are not reported. Synchronization SQL is disabled for these results; narrow the range and run complete comparisons in batches.",
truncatedTable: "{table}: {source} source rows, {target} target rows, compared the first {budget} rows only",
- truncatedBadge: "Compared the first {budget} rows only",
+ truncatedBadge: "Inspected a subset (up to {budget} rows per side); synchronization disabled",
},
settings: {
historyRetentionLimit: "Query history retention",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index e69fed7574..3f84402379 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -8206,9 +8206,9 @@ export default withEnglishFallback({
noDifferences: "数据完全一致,无需生成同步 SQL",
syncSuccess: "数据同步执行成功",
truncatedTitle: "部分表未完整比较",
- truncatedHint: "行数超过单次比较上限的表只比较了前若干行,超出部分的新增、修改和删除不会被发现。建议加上过滤条件缩小范围后分批比较。",
+ truncatedHint: "行数超过单次比较上限的表只检查了部分数据,未检查范围内的新增、修改和删除不会被发现,因此不会生成或执行同步 SQL。建议缩小范围后分批完整比较。",
truncatedTable: "{table}:源 {source} 行、目标 {target} 行,仅比较前 {budget} 行",
- truncatedBadge: "仅比较前 {budget} 行",
+ truncatedBadge: "仅检查部分数据(单侧上限 {budget} 行),同步已禁用",
},
settings: {
historyRetentionLimit: "查询历史保留条数",
diff --git a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts
index d43ed4a3c3..c98c971afd 100644
--- a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts
+++ b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
-import { databaseConnectionFormKind, databaseDefaultPort, databaseManifestEntry, databaseProductCapabilities, databaseRuntimeMode, supportsDatabaseFeature, usesAgentCursorForQuery, usesAgentCursorForTableData } from "@/lib/database/databaseDriverManifest";
+import {
+ databaseConnectionFormKind,
+ databaseDefaultPort,
+ databaseManifestEntry,
+ databaseProductCapabilities,
+ databaseRuntimeMode,
+ databaseTransferSupportLevel,
+ supportsDatabaseCompareMode,
+ supportsDatabaseFeature,
+ usesAgentCursorForQuery,
+ usesAgentCursorForTableData,
+} from "@/lib/database/databaseDriverManifest";
describe("databaseDriverManifest", () => {
it("advertises implemented Xugu capabilities without enabling unsupported features", () => {
@@ -61,6 +72,20 @@ describe("databaseDriverManifest", () => {
});
});
+ it("exposes transfer compatibility evidence from the shared manifest", () => {
+ expect(databaseTransferSupportLevel("mysql")).toBe("verified");
+ expect(databaseTransferSupportLevel("kingbase")).toBe("family-compatible");
+ expect(databaseTransferSupportLevel("db2")).toBe("best-effort");
+ expect(databaseTransferSupportLevel("gbase")).toBeUndefined();
+ });
+
+ it("exposes schema and data comparison support from the shared manifest", () => {
+ expect(supportsDatabaseCompareMode("mysql", "schema")).toBe(true);
+ expect(supportsDatabaseCompareMode("kingbase", "data")).toBe(true);
+ expect(supportsDatabaseCompareMode("mongodb", "data")).toBe(false);
+ expect(supportsDatabaseCompareMode("redis", "schema")).toBe(false);
+ });
+
it("offers only the supported NebulaGraph 3.x profile", () => {
expect(databaseManifestEntry("nebula")?.driverProfiles).toEqual([{ profile: "nebula-v3", agentKey: "nebula", label: "NebulaGraph 3.x", storeVisible: false }]);
});
diff --git a/apps/desktop/src/lib/dataGrid/dataCompare.ts b/apps/desktop/src/lib/dataGrid/dataCompare.ts
index 08fb0c017c..e8f54dc5b4 100644
--- a/apps/desktop/src/lib/dataGrid/dataCompare.ts
+++ b/apps/desktop/src/lib/dataGrid/dataCompare.ts
@@ -95,8 +95,9 @@ export interface DataCompareFromTablesPreparation extends DataComparePreparation
targetRowCount: number;
sourceTruncated: boolean;
targetTruncated: boolean;
- /** Rows per side this compare was allowed to read; fewer than `sourceRowCount`/`targetRowCount` when truncated. */
+ /** Rows per side this compare was allowed to inspect; fewer than the full table when truncated or sampled. */
rowBudget: number;
+ syncEligible: boolean;
}
export interface DataCompareSyncPlanTableOptions {
@@ -108,6 +109,7 @@ export interface DataCompareSyncPlanTableOptions {
diff: DataCompareResult;
databaseType?: DatabaseType;
preSyncStatements?: string[];
+ syncEligible: boolean;
}
export interface DataCompareSyncPlanOptions {
diff --git a/apps/desktop/src/lib/database/__tests__/databaseCompareCapabilities.spec.ts b/apps/desktop/src/lib/database/__tests__/databaseCompareCapabilities.spec.ts
new file mode 100644
index 0000000000..89d059ba9a
--- /dev/null
+++ b/apps/desktop/src/lib/database/__tests__/databaseCompareCapabilities.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { supportsDatabaseCompare } from "@/lib/database/databaseCompareCapabilities";
+
+describe("databaseCompareCapabilities", () => {
+ it("reads comparison support from the shared driver manifest", () => {
+ expect(supportsDatabaseCompare({ db_type: "mysql" }, "schema")).toBe(true);
+ expect(supportsDatabaseCompare({ db_type: "kingbase" }, "data")).toBe(true);
+ expect(supportsDatabaseCompare({ db_type: "mongodb" }, "data")).toBe(false);
+ expect(supportsDatabaseCompare({ db_type: "redis" }, "schema")).toBe(false);
+ });
+
+ it("applies manifest support after resolving a JDBC product dialect", () => {
+ expect(supportsDatabaseCompare({ db_type: "jdbc", connection_string: "jdbc:postgresql://localhost:5432/app" }, "data")).toBe(true);
+ expect(supportsDatabaseCompare({ db_type: "jdbc", driver_profile: "unknown-product" }, "data")).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/lib/database/databaseCompareCapabilities.ts b/apps/desktop/src/lib/database/databaseCompareCapabilities.ts
index 8e5ed12a40..4a0b5efff1 100644
--- a/apps/desktop/src/lib/database/databaseCompareCapabilities.ts
+++ b/apps/desktop/src/lib/database/databaseCompareCapabilities.ts
@@ -1,84 +1,14 @@
-import type { ConnectionConfig, DatabaseType } from "@/types/database";
+import type { ConnectionConfig } from "@/types/database";
import { CONNECTION_PROFILES, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles";
-import { databaseManifestEntry } from "@/lib/database/databaseDriverManifest";
+import { databaseManifestEntry, supportsDatabaseCompareMode, type DatabaseCompareMode } from "@/lib/database/databaseDriverManifest";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
-export type DatabaseCompareMode = "schema" | "data";
-
-// Comparison loads relational metadata and, for data, generates COUNT/SELECT
-// statements. Unknown providers must opt in here rather than pass a blacklist.
-const SQL_COMPARE_TYPES = new Set
([
- "mysql",
- "postgres",
- "sqlite",
- "rqlite",
- "turso",
- "cloudflare-d1",
- "duckdb",
- "clickhouse",
- "sqlserver",
- "oracle",
- "doris",
- "starrocks",
- "manticoresearch",
- "redshift",
- "dameng",
- "kingbase",
- "highgo",
- "uxdb",
- "vastbase",
- "goldendb",
- "databend",
- "gaussdb",
- "kwdb",
- "yashandb",
- "databricks",
- "saphana",
- "teradata",
- "vertica",
- "firebird",
- "exasol",
- "opengauss",
- "questdb",
- "oceanbase-oracle",
- "gbase",
- "access",
- "h2",
- "snowflake",
- "trino",
- "prestosql",
- "hive",
- "argo",
- "transwarp",
- "kyuubi",
- "impala",
- "db2",
- "informix",
- "bigquery",
- "spanner",
- "kylin",
- "ignite",
- "ignite3",
- "sundb",
- "oscar",
- "tdengine",
- "xugu",
- "iotdb",
- "iris",
- "influxdb3",
- "spark",
-]);
+export type { DatabaseCompareMode } from "@/lib/database/databaseDriverManifest";
export function supportsDatabaseCompare(connection: ConnectionConfig | undefined, mode: DatabaseCompareMode): boolean {
if (!connection) return false;
const type = effectiveDatabaseTypeForConnection(connection);
- // Keep the two paths explicit: a future driver may support metadata compare
- // without supporting the generated data comparison SQL (or vice versa).
- switch (mode) {
- case "schema":
- case "data":
- return !!type && SQL_COMPARE_TYPES.has(type);
- }
+ return supportsDatabaseCompareMode(type, mode);
}
const profiles: Readonly> = CONNECTION_PROFILES;
diff --git a/apps/desktop/src/lib/database/databaseDriverManifest.ts b/apps/desktop/src/lib/database/databaseDriverManifest.ts
index a9f6a12c1f..d70ff57bc7 100644
--- a/apps/desktop/src/lib/database/databaseDriverManifest.ts
+++ b/apps/desktop/src/lib/database/databaseDriverManifest.ts
@@ -3,6 +3,9 @@ import driverManifest from "../../../../../crates/dbx-core/assets/database-drive
export type DatabaseSupportLevel = "connect" | "browse" | "understand" | "operate";
export type DatabaseRuntimeMode = "native" | "file" | "agent" | "external";
+export type DatabaseTransferObjectFamily = "mysql" | "postgres" | "oracle" | "sqlserver";
+export type DatabaseTransferSupportLevel = "verified" | "family-compatible" | "best-effort";
+export type DatabaseCompareMode = "schema" | "data";
export type ConnectionFormKind = "standard" | "jdbc" | "mq" | "mqtt" | "nacos";
export const DATABASE_PRODUCT_CAPABILITY_KEYS = [
@@ -50,6 +53,9 @@ export interface ManagedDatabaseDriver {
export interface DatabaseDriverManifestEntry {
dbType: DatabaseType;
+ compareModes?: DatabaseCompareMode[];
+ transferObjectFamily?: DatabaseTransferObjectFamily;
+ transferSupportLevel?: DatabaseTransferSupportLevel;
label: string;
dialect?: string;
runtimeMode: DatabaseRuntimeMode;
@@ -140,6 +146,18 @@ export function databaseRuntimeMode(dbType?: DatabaseType): DatabaseRuntimeMode
return dbType ? DATABASE_DRIVER_BY_TYPE.get(dbType)?.runtimeMode : undefined;
}
+export function databaseTransferObjectFamily(dbType?: DatabaseType): DatabaseTransferObjectFamily | undefined {
+ return dbType ? DATABASE_DRIVER_BY_TYPE.get(dbType)?.transferObjectFamily : undefined;
+}
+
+export function databaseTransferSupportLevel(dbType?: DatabaseType): DatabaseTransferSupportLevel | undefined {
+ return dbType ? DATABASE_DRIVER_BY_TYPE.get(dbType)?.transferSupportLevel : undefined;
+}
+
+export function supportsDatabaseCompareMode(dbType: DatabaseType | undefined, mode: DatabaseCompareMode): boolean {
+ return dbType ? DATABASE_DRIVER_BY_TYPE.get(dbType)?.compareModes?.includes(mode) === true : false;
+}
+
export function databaseProductCapabilities(dbType?: DatabaseType): DatabaseProductCapabilities {
if (!dbType) return DEFAULT_CAPABILITIES;
return (DATABASE_DRIVER_BY_TYPE.get(dbType)?.capabilities ?? DEFAULT_CAPABILITIES) as DatabaseProductCapabilities;
diff --git a/apps/desktop/src/lib/database/transferObjectKinds.ts b/apps/desktop/src/lib/database/transferObjectKinds.ts
index fc3c71b25c..cd7d3cddd7 100644
--- a/apps/desktop/src/lib/database/transferObjectKinds.ts
+++ b/apps/desktop/src/lib/database/transferObjectKinds.ts
@@ -1,5 +1,6 @@
import type { DatabaseType } from "@/types/database";
import { supportsTransfer } from "@/lib/database/databaseFeatureSupport";
+import { databaseTransferObjectFamily } from "@/lib/database/databaseDriverManifest";
export enum TransferObjectFamily {
Mysql = "mysql",
@@ -15,22 +16,8 @@ const POSTGRES_KINDS: TransferObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIE
const ORACLE_KINDS: TransferObjectKind[] = ["TABLE", "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE"];
const SQLSERVER_KINDS: TransferObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE"];
-const FAMILY_BY_DB = new Map([
- ["mysql", TransferObjectFamily.Mysql],
- ["gbase", TransferObjectFamily.Mysql],
- ["postgres", TransferObjectFamily.Postgres],
- ["kingbase", TransferObjectFamily.Postgres],
- ["gaussdb", TransferObjectFamily.Postgres],
- ["kwdb", TransferObjectFamily.Postgres],
- ["opengauss", TransferObjectFamily.Postgres],
- ["oracle", TransferObjectFamily.Oracle],
- ["dameng", TransferObjectFamily.Oracle],
- ["oceanbase-oracle", TransferObjectFamily.Oracle],
- ["sqlserver", TransferObjectFamily.SqlServer],
-]);
-
export function transferObjectFamily(dbType?: DatabaseType): TransferObjectFamily | undefined {
- return dbType ? FAMILY_BY_DB.get(dbType) : undefined;
+ return databaseTransferObjectFamily(dbType) as TransferObjectFamily | undefined;
}
export function isSameTransferFamily(a?: DatabaseType, b?: DatabaseType): boolean {
diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json
index e516d12a17..34e7373491 100644
--- a/crates/dbx-core/assets/database-drivers.manifest.json
+++ b/crates/dbx-core/assets/database-drivers.manifest.json
@@ -3,6 +3,12 @@
"drivers": [
{
"dbType": "mysql",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "verified",
+ "transferObjectFamily": "mysql",
"label": "MySQL",
"dialect": "MySQL",
"runtimeMode": "native",
@@ -36,6 +42,12 @@
},
{
"dbType": "postgres",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "verified",
+ "transferObjectFamily": "postgres",
"label": "PostgreSQL",
"dialect": "PostgreSQL",
"runtimeMode": "native",
@@ -73,6 +85,11 @@
},
{
"dbType": "sqlite",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "verified",
"label": "SQLite",
"dialect": "SQLite",
"runtimeMode": "file",
@@ -114,6 +131,11 @@
},
{
"dbType": "rqlite",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "RQLite",
"dialect": "Rqlite",
"runtimeMode": "native",
@@ -147,6 +169,11 @@
},
{
"dbType": "turso",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "Turso",
"dialect": "Turso",
"runtimeMode": "native",
@@ -180,6 +207,11 @@
},
{
"dbType": "cloudflare-d1",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "Cloudflare D1",
"dialect": "SQLite",
"runtimeMode": "native",
@@ -242,6 +274,11 @@
},
{
"dbType": "duckdb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "DuckDB",
"dialect": "DuckDB",
"runtimeMode": "file",
@@ -284,6 +321,11 @@
},
{
"dbType": "clickhouse",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "ClickHouse",
"dialect": "ClickHouse",
"runtimeMode": "native",
@@ -314,6 +356,12 @@
},
{
"dbType": "sqlserver",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "verified",
+ "transferObjectFamily": "sqlserver",
"label": "SQL Server",
"dialect": "SQL Server",
"runtimeMode": "native",
@@ -359,6 +407,7 @@
},
{
"dbType": "mongodb",
+ "transferSupportLevel": "best-effort",
"label": "MongoDB (Legacy)",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -428,6 +477,12 @@
},
{
"dbType": "oracle",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "verified",
+ "transferObjectFamily": "oracle",
"label": "Oracle",
"dialect": "Oracle",
"runtimeMode": "agent",
@@ -779,6 +834,10 @@
},
{
"dbType": "doris",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Doris",
"dialect": "Doris",
"runtimeMode": "native",
@@ -809,6 +868,10 @@
},
{
"dbType": "starrocks",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "StarRocks",
"dialect": "StarRocks",
"runtimeMode": "native",
@@ -839,6 +902,10 @@
},
{
"dbType": "manticoresearch",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Manticore Search",
"dialect": "ManticoreSearch",
"runtimeMode": "native",
@@ -869,6 +936,10 @@
},
{
"dbType": "redshift",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Redshift",
"dialect": "Redshift",
"runtimeMode": "native",
@@ -905,6 +976,12 @@
},
{
"dbType": "dameng",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "family-compatible",
+ "transferObjectFamily": "oracle",
"label": "达梦 DM8",
"dialect": "Dameng",
"runtimeMode": "agent",
@@ -945,6 +1022,12 @@
},
{
"dbType": "kingbase",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "family-compatible",
+ "transferObjectFamily": "postgres",
"label": "金仓KingbaseES",
"dialect": "Kingbase",
"runtimeMode": "agent",
@@ -983,6 +1066,11 @@
},
{
"dbType": "highgo",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "瀚高 HighGo",
"dialect": "HighGo",
"runtimeMode": "agent",
@@ -1021,6 +1109,10 @@
},
{
"dbType": "uxdb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "优炫 UXDB",
"dialect": "PostgreSQL",
"runtimeMode": "agent",
@@ -1059,6 +1151,11 @@
},
{
"dbType": "vastbase",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "海量 Vastbase",
"dialect": "Vastbase",
"runtimeMode": "agent",
@@ -1097,6 +1194,11 @@
},
{
"dbType": "goldendb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "金篆 GoldenDB",
"dialect": "GoldenDB",
"runtimeMode": "agent",
@@ -1130,6 +1232,10 @@
},
{
"dbType": "databend",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Databend",
"dialect": "Databend",
"runtimeMode": "agent",
@@ -1166,6 +1272,12 @@
},
{
"dbType": "gaussdb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "family-compatible",
+ "transferObjectFamily": "postgres",
"label": "GaussDB",
"dialect": "GaussDB",
"runtimeMode": "native",
@@ -1204,6 +1316,12 @@
},
{
"dbType": "kwdb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "family-compatible",
+ "transferObjectFamily": "postgres",
"label": "KWDB",
"dialect": "KwDB",
"runtimeMode": "native",
@@ -1241,6 +1359,11 @@
},
{
"dbType": "yashandb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "崖山 YashanDB",
"dialect": "YashanDB",
"runtimeMode": "agent",
@@ -1278,6 +1401,10 @@
},
{
"dbType": "databricks",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Databricks SQL",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1314,6 +1441,10 @@
},
{
"dbType": "saphana",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "SAP HANA",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1350,6 +1481,10 @@
},
{
"dbType": "teradata",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Teradata",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1386,6 +1521,10 @@
},
{
"dbType": "vertica",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Vertica",
"dialect": "Vertica",
"runtimeMode": "agent",
@@ -1423,6 +1562,10 @@
},
{
"dbType": "firebird",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Firebird",
"dialect": "Firebird",
"runtimeMode": "agent",
@@ -1459,6 +1602,10 @@
},
{
"dbType": "exasol",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Exasol",
"dialect": "Exasol",
"runtimeMode": "agent",
@@ -1496,6 +1643,12 @@
},
{
"dbType": "opengauss",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "family-compatible",
+ "transferObjectFamily": "postgres",
"label": "openGauss",
"dialect": "openGauss",
"runtimeMode": "native",
@@ -1533,6 +1686,11 @@
},
{
"dbType": "questdb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "QuestDB",
"dialect": "QuestDB",
"runtimeMode": "native",
@@ -1568,6 +1726,12 @@
},
{
"dbType": "oceanbase-oracle",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "family-compatible",
+ "transferObjectFamily": "oracle",
"label": "OceanBase Oracle Mode",
"dialect": "OceanBase",
"runtimeMode": "agent",
@@ -1608,6 +1772,11 @@
},
{
"dbType": "gbase",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferObjectFamily": "mysql",
"label": "南大通用 GBase 8a",
"dialect": "GBase",
"runtimeMode": "agent",
@@ -1661,6 +1830,10 @@
},
{
"dbType": "access",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Microsoft Access",
"dialect": "Access",
"runtimeMode": "agent",
@@ -1697,6 +1870,11 @@
},
{
"dbType": "h2",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "H2",
"dialect": "H2",
"runtimeMode": "agent",
@@ -1768,6 +1946,10 @@
},
{
"dbType": "snowflake",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Snowflake",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1805,6 +1987,10 @@
},
{
"dbType": "trino",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Trino",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1842,6 +2028,10 @@
},
{
"dbType": "prestosql",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "PrestoSQL",
"runtimeMode": "external",
"mcpMode": "bridge",
@@ -1876,6 +2066,11 @@
},
{
"dbType": "hive",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "Apache Hive",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1911,6 +2106,11 @@
},
{
"dbType": "argo",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "星环Argo",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -1946,6 +2146,11 @@
},
{
"dbType": "transwarp",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "星环Inceptor",
"dialect": "Transwarp",
"runtimeMode": "agent",
@@ -1990,6 +2195,11 @@
},
{
"dbType": "kyuubi",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "Apache Kyuubi",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2024,6 +2234,11 @@
},
{
"dbType": "impala",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "Apache Impala",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2058,6 +2273,11 @@
},
{
"dbType": "db2",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "IBM DB2",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2094,6 +2314,10 @@
},
{
"dbType": "informix",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "IBM Informix",
"dialect": "Informix",
"runtimeMode": "agent",
@@ -2235,6 +2459,10 @@
},
{
"dbType": "bigquery",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Google BigQuery",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2266,6 +2494,10 @@
},
{
"dbType": "spanner",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Google Cloud Spanner",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2302,6 +2534,10 @@
},
{
"dbType": "kylin",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Apache Kylin",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2334,6 +2570,10 @@
},
{
"dbType": "ignite",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Apache Ignite",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2370,6 +2610,10 @@
},
{
"dbType": "ignite3",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Apache Ignite 3",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2406,6 +2650,10 @@
},
{
"dbType": "sundb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "科蓝 SUNDB",
"dialect": "SunDB",
"runtimeMode": "agent",
@@ -2443,6 +2691,10 @@
},
{
"dbType": "oscar",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "神通 OSCAR",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2479,6 +2731,10 @@
},
{
"dbType": "tdengine",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "TDengine",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2511,6 +2767,11 @@
},
{
"dbType": "xugu",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "虚谷 XuguDB",
"dialect": "Xugu",
"runtimeMode": "agent",
@@ -2766,6 +3027,10 @@
},
{
"dbType": "iotdb",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "Apache IoTDB",
"runtimeMode": "agent",
"mcpMode": "bridge",
@@ -2801,6 +3066,11 @@
},
{
"dbType": "iris",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "InterSystems IRIS",
"dialect": "Iris",
"runtimeMode": "agent",
@@ -2878,6 +3148,10 @@
},
{
"dbType": "influxdb3",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
"label": "InfluxDB 3.x",
"runtimeMode": "native",
"mcpMode": "bridge",
@@ -2973,6 +3247,11 @@
},
{
"dbType": "spark",
+ "compareModes": [
+ "schema",
+ "data"
+ ],
+ "transferSupportLevel": "best-effort",
"label": "Apache Spark",
"runtimeMode": "agent",
"mcpMode": "bridge",
diff --git a/crates/dbx-core/src/data/correction.rs b/crates/dbx-core/src/data/correction.rs
index c03b7f313c..036dccc7db 100644
--- a/crates/dbx-core/src/data/correction.rs
+++ b/crates/dbx-core/src/data/correction.rs
@@ -513,6 +513,7 @@ mod tests {
source_truncated: false,
target_truncated: false,
row_budget: 100_000,
+ sync_eligible: true,
degradation_level: Some("full".to_string()),
sampling_rate: Some(1.0),
confidence_score: Some(1.0),
diff --git a/crates/dbx-core/src/data/data_compare.rs b/crates/dbx-core/src/data/data_compare.rs
index a7705927a8..d7e34e68c2 100644
--- a/crates/dbx-core/src/data/data_compare.rs
+++ b/crates/dbx-core/src/data/data_compare.rs
@@ -11,11 +11,13 @@ use crate::models::connection::DatabaseType;
use crate::query::{execute_sql_statement_with_options, QueryExecutionOptions};
use crate::schema::get_columns_core;
use crate::sql_dialect::{
- build_count_table_sql, firebird_rows_clause, pagination_strategy, qualified_table_name, quote_table_identifier,
- PaginationContext, TablePaginationStrategy,
+ build_count_table_sql, build_iris_table_select_sql, firebird_rows_clause, pagination_strategy,
+ qualified_table_name, quote_table_identifier, PaginationContext, TablePaginationStrategy,
};
use crate::transfer::{generate_comment_ddl, generate_create_table_ddl};
+use super::sample_pairing::retain_shared_sample_rows_by_key;
+
const DATA_SYNC_INSERT_BATCH_SIZE: usize = 500;
const DATA_SYNC_CONDITION_BATCH_SIZE: usize = 200;
@@ -149,6 +151,8 @@ pub struct DataCompareSyncPlanTableOptions {
pub database_type: Option,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pre_sync_statements: Vec,
+ #[serde(default)]
+ pub sync_eligible: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -182,11 +186,13 @@ pub struct DataCompareFromTablesPreparation {
pub source_truncated: bool,
pub target_truncated: bool,
/// Rows per side this compare was allowed to read. When a side holds more rows than
- /// this, the diff only covers the first `row_budget` rows and the matching
- /// `*_truncated` flag is set, so the UI can warn instead of showing a partial plan
- /// as if it were complete.
+ /// this, the diff covers only a bounded subset and the matching `*_truncated`
+ /// flag is set. `sync_eligible` remains false so a partial result cannot become
+ /// executable synchronization SQL.
#[serde(default)]
pub row_budget: usize,
+ #[serde(default)]
+ pub sync_eligible: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub degradation_level: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -331,6 +337,8 @@ pub async fn prepare_data_compare_from_tables(
) -> Result {
let source_database_type = connection_database_type(state, &options.source_connection_id).await?;
let target_database_type = connection_database_type(state, &options.target_connection_id).await?;
+ ensure_data_compare_supported(source_database_type, "source")?;
+ ensure_data_compare_supported(target_database_type, "target")?;
let fetch_batch_size = options.fetch_batch_size.unwrap_or(1000).max(1);
let source_count_sql =
@@ -435,14 +443,24 @@ pub async fn prepare_data_compare_from_tables(
threshold.sample_size,
)
)?;
+ let column_indexes = column_index_map(&options.columns);
+ let (src, tgt) = retain_shared_sample_rows_by_key(src, tgt, |row| {
+ key_for_row(row, &options.key_columns, &column_indexes)
+ });
let max_count = source_row_count.max(target_row_count);
let sample_rate = if max_count > 0 { threshold.sample_size as f64 / max_count as f64 } else { 1.0 };
- let method = "sampled".to_string();
- (src, tgt, sample_rate.min(1.0), method, false, false)
+ let method = "paired_key_sample".to_string();
+ let source_truncated = source_row_count > src.len() as u64;
+ let target_truncated = target_row_count > tgt.len() as u64;
+ (src, tgt, sample_rate.min(1.0), method, source_truncated, target_truncated)
+ }
+ DegradationLevel::SkipWithRisk => {
+ (Vec::new(), Vec::new(), 0.0, "skipped".to_string(), source_row_count > 0, target_row_count > 0)
}
- DegradationLevel::SkipWithRisk => (Vec::new(), Vec::new(), 0.0, "skipped".to_string(), false, false),
};
+ let sync_eligible = matches!(degradation_level, DegradationLevel::Full) && !source_truncated && !target_truncated;
+
let target_columns = get_columns_core(
state,
&options.target_connection_id,
@@ -478,16 +496,20 @@ pub async fn prepare_data_compare_from_tables(
database_type: Some(target_database_type),
})?;
+ let (sync_statements, sync_sql) =
+ if sync_eligible { (preparation.sync_statements, preparation.sync_sql) } else { (Vec::new(), String::new()) };
+
Ok(DataCompareFromTablesPreparation {
result: preparation.result,
- sync_statements: preparation.sync_statements,
- sync_sql: preparation.sync_sql,
+ sync_statements,
+ sync_sql,
pre_sync_statements: Vec::new(),
source_row_count,
target_row_count,
source_truncated,
target_truncated,
row_budget,
+ sync_eligible,
degradation_level: Some(degradation_level.to_string()),
sampling_rate: Some(sampling_rate),
confidence_score: Some(confidence_score),
@@ -503,6 +525,8 @@ pub async fn prepare_data_compare_missing_target(
) -> Result {
let source_database_type = connection_database_type(state, &options.source_connection_id).await?;
let target_database_type = connection_database_type(state, &options.target_connection_id).await?;
+ ensure_data_compare_supported(source_database_type, "source")?;
+ ensure_data_compare_supported(target_database_type, "target")?;
let fetch_batch_size = options.fetch_batch_size.unwrap_or(1000).max(1);
let source_columns = get_columns_core(
state,
@@ -546,6 +570,7 @@ pub async fn prepare_data_compare_missing_target(
)
.await?;
let source_truncated = source_row_count > row_budget as u64;
+ let sync_eligible = !source_truncated;
let result = missing_target_diff(&column_names, &options.key_columns, source_rows);
let mut pre_sync_statements = Vec::new();
pre_sync_statements.push(format!(
@@ -587,14 +612,15 @@ pub async fn prepare_data_compare_missing_target(
Ok(DataCompareFromTablesPreparation {
result,
- sync_statements: sync_plan.sync_statements,
- sync_sql: sync_plan.sync_sql,
+ sync_statements: if sync_eligible { sync_plan.sync_statements } else { Vec::new() },
+ sync_sql: if sync_eligible { sync_plan.sync_sql } else { String::new() },
pre_sync_statements,
source_row_count,
target_row_count: 0,
source_truncated,
target_truncated: false,
row_budget,
+ sync_eligible,
degradation_level: Some(should_degrade(source_row_count, 0, &threshold).to_string()),
sampling_rate: Some(if source_truncated { row_budget as f64 / source_row_count.max(1) as f64 } else { 1.0 }),
confidence_score: Some(if source_truncated { 0.5 } else { 1.0 }),
@@ -610,6 +636,7 @@ pub fn build_data_compare_sync_plan(options: DataCompareSyncPlanOptions) -> Data
let tables = options
.tables
.iter()
+ .filter(|table| table.sync_eligible)
.map(|table| DataCompareSyncPlanTableRef {
table_name: &table.table_name,
schema: table.schema.as_deref(),
@@ -1069,6 +1096,16 @@ async fn connection_database_type(state: &AppState, connection_id: &str) -> Resu
.ok_or_else(|| format!("Connection config not found: {connection_id}"))
}
+fn ensure_data_compare_supported(database_type: DatabaseType, endpoint: &str) -> Result<(), String> {
+ if dbx_types::database_manifest::supports_compare_mode(
+ &database_type,
+ dbx_types::database_manifest::DatabaseCompareMode::Data,
+ ) {
+ return Ok(());
+ }
+ Err(format!("{endpoint} database {} does not support data comparison", database_type.as_str()))
+}
+
fn first_count(rows: &[Vec]) -> Result {
let value = rows.first().and_then(|row| row.first()).ok_or_else(|| "COUNT query returned no rows".to_string())?;
match value {
@@ -1164,7 +1201,9 @@ fn build_data_compare_select_sql(
"WITH {page_alias} AS (SELECT {select_columns}, ROW_NUMBER() OVER (ORDER BY {order_expression}) AS {row_number_alias} FROM {table}) SELECT {select_columns} FROM {page_alias} WHERE {row_number_alias} > {offset} AND {row_number_alias} <= {end} ORDER BY {row_number_alias}"
)
}
- TablePaginationStrategy::IrisTop => format!("SELECT TOP {row_limit} {select_columns} FROM {table}{order_by}"),
+ TablePaginationStrategy::IrisTop => {
+ build_iris_table_select_sql(&select_columns, &table, "", &order_by, row_limit, offset as u64)
+ }
TablePaginationStrategy::InformixFirst => {
let row_limit_clause =
if offset > 0 { format!("SKIP {offset} FIRST {row_limit}") } else { format!("FIRST {row_limit}") };
@@ -1991,6 +2030,7 @@ mod tests {
},
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2038,6 +2078,7 @@ mod tests {
},
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2090,6 +2131,7 @@ mod tests {
diff: DataCompareResult { added: Vec::new(), removed: Vec::new(), modified },
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2174,6 +2216,7 @@ mod tests {
},
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2183,6 +2226,35 @@ mod tests {
assert_eq!(plan.statement_count, 2);
}
+ #[test]
+ fn incomplete_comparisons_cannot_build_sync_statements() {
+ let plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions {
+ tables: vec![DataCompareSyncPlanTableOptions {
+ table_name: "users".to_string(),
+ schema: Some("public".to_string()),
+ columns: vec!["id".to_string()],
+ key_columns: vec!["id".to_string()],
+ column_info: Vec::new(),
+ diff: DataCompareResult {
+ added: vec![DataCompareRow {
+ key: "1".to_string(),
+ key_values: HashMap::from([(String::from("id"), json!(1))]),
+ values: HashMap::from([(String::from("id"), json!(1))]),
+ }],
+ removed: Vec::new(),
+ modified: Vec::new(),
+ },
+ database_type: Some(DatabaseType::Postgres),
+ pre_sync_statements: Vec::new(),
+ sync_eligible: false,
+ }],
+ });
+
+ assert_eq!(plan.statement_count, 0);
+ assert!(plan.sync_statements.is_empty());
+ assert!(plan.sync_sql.is_empty());
+ }
+
#[test]
fn batches_added_rows_into_multi_value_insert_statements() {
let plan = build_data_compare_sync_plan(DataCompareSyncPlanOptions {
@@ -2216,6 +2288,7 @@ mod tests {
},
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2270,6 +2343,7 @@ mod tests {
},
database_type: Some(DatabaseType::Mysql),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2308,6 +2382,7 @@ mod tests {
},
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: Vec::new(),
+ sync_eligible: true,
}],
});
@@ -2356,6 +2431,7 @@ mod tests {
diff: diff.clone(),
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: pre_sync_statements.clone(),
+ sync_eligible: true,
}],
});
let borrowed_plan = build_data_compare_sync_plan_from_refs(&[DataCompareSyncPlanTableRef {
@@ -2397,6 +2473,7 @@ mod tests {
},
database_type: Some(DatabaseType::Postgres),
pre_sync_statements: vec!["CREATE TABLE \"public\".\"users\" (\"id\" integer);".to_string()],
+ sync_eligible: true,
}],
});
@@ -2440,6 +2517,7 @@ mod tests {
pre_sync_statements: vec![
"CREATE TABLE \"public\".\"generated_column_sync_test\" (\"id\" bigint);".to_string()
],
+ sync_eligible: true,
}],
});
@@ -2874,6 +2952,22 @@ mod tests {
);
}
+ #[test]
+ fn builds_backend_table_select_sql_for_iris_offset_pages() {
+ assert_eq!(
+ build_data_compare_select_sql(
+ DatabaseType::Iris,
+ "APP",
+ "USERS",
+ &["ID".to_string(), "NAME".to_string()],
+ &["ID".to_string()],
+ 25,
+ 50,
+ ),
+ "SELECT * FROM (SELECT TOP 75 ID, NAME FROM APP.USERS ORDER BY ID ASC) WHERE %VID > 50"
+ );
+ }
+
#[test]
fn builds_backend_table_select_sql_for_oceanbase_oracle_rownum_pages() {
assert_eq!(
diff --git a/crates/dbx-core/src/data/mod.rs b/crates/dbx-core/src/data/mod.rs
index f1dc15e6c2..cf9ca1fd8b 100644
--- a/crates/dbx-core/src/data/mod.rs
+++ b/crates/dbx-core/src/data/mod.rs
@@ -10,6 +10,7 @@ pub mod grid_clipboard_guard;
pub mod mongodb_dump;
pub mod mongodb_import_export;
pub mod query_result_export;
+mod sample_pairing;
pub mod script_generator;
pub mod sql_file_import;
pub mod sqlite_backup;
@@ -17,3 +18,4 @@ pub mod table_export;
pub mod table_import;
pub mod transfer;
pub mod transfer_rebuild;
+pub mod transfer_runner;
diff --git a/crates/dbx-core/src/data/sample_pairing.rs b/crates/dbx-core/src/data/sample_pairing.rs
new file mode 100644
index 0000000000..2a538fe8da
--- /dev/null
+++ b/crates/dbx-core/src/data/sample_pairing.rs
@@ -0,0 +1,18 @@
+use std::collections::HashSet;
+use std::hash::Hash;
+
+pub(super) fn retain_shared_sample_rows_by_key(
+ source_rows: Vec,
+ target_rows: Vec,
+ key_for_row: KeyFn,
+) -> (Vec, Vec)
+where
+ Key: Eq + Hash,
+ KeyFn: Fn(&Row) -> Key,
+{
+ let source_keys = source_rows.iter().map(&key_for_row).collect::>();
+ let target_keys = target_rows.iter().map(&key_for_row).collect::>();
+ let source_rows = source_rows.into_iter().filter(|row| target_keys.contains(&key_for_row(row))).collect();
+ let target_rows = target_rows.into_iter().filter(|row| source_keys.contains(&key_for_row(row))).collect();
+ (source_rows, target_rows)
+}
diff --git a/crates/dbx-core/src/data/transfer.rs b/crates/dbx-core/src/data/transfer.rs
index 720aed59ef..978d2ef305 100644
--- a/crates/dbx-core/src/data/transfer.rs
+++ b/crates/dbx-core/src/data/transfer.rs
@@ -224,28 +224,15 @@ pub struct TransferObjectSelection {
pub names: Vec,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum TransferObjectFamily {
- Mysql,
- Postgres,
- Oracle,
- SqlServer,
-}
+pub use dbx_types::database_manifest::DatabaseTransferObjectFamily as TransferObjectFamily;
+pub use dbx_types::database_manifest::DatabaseTransferSupportLevel as TransferSupportLevel;
pub fn transfer_object_family(db_type: &DatabaseType) -> Option {
- match db_type {
- DatabaseType::Mysql | DatabaseType::Gbase => Some(TransferObjectFamily::Mysql),
- DatabaseType::Postgres
- | DatabaseType::Kingbase
- | DatabaseType::Gaussdb
- | DatabaseType::Kwdb
- | DatabaseType::OpenGauss => Some(TransferObjectFamily::Postgres),
- DatabaseType::Oracle | DatabaseType::Dameng | DatabaseType::OceanbaseOracle => {
- Some(TransferObjectFamily::Oracle)
- }
- DatabaseType::SqlServer => Some(TransferObjectFamily::SqlServer),
- _ => None,
- }
+ dbx_types::database_manifest::transfer_object_family(db_type)
+}
+
+pub fn transfer_support_level(db_type: &DatabaseType) -> Option {
+ dbx_types::database_manifest::transfer_support_level(db_type)
}
pub fn is_same_transfer_family(a: &DatabaseType, b: &DatabaseType) -> bool {
@@ -1045,6 +1032,12 @@ pub fn validate_transfer_database_pair(
source_db_type: &DatabaseType,
target_db_type: &DatabaseType,
) -> Result<(), String> {
+ if transfer_support_level(source_db_type).is_none() {
+ return Err(format!("{} 暂未声明数据传输兼容级别", source_db_type.as_str()));
+ }
+ if transfer_support_level(target_db_type).is_none() {
+ return Err(format!("{} 暂未声明数据传输兼容级别", target_db_type.as_str()));
+ }
if !matches!(source_db_type, DatabaseType::Xugu) && !matches!(target_db_type, DatabaseType::Xugu) {
return Ok(());
}
@@ -14368,6 +14361,14 @@ CREATE TABLE "Other"."prefix""Source"."NAME" ("ID" INT);"#;
assert!(validate_transfer_database_pair(&rebuild, &DatabaseType::Xugu, &DatabaseType::Xugu).is_err());
}
+ #[test]
+ fn transfer_pair_requires_declared_endpoint_compatibility() {
+ let request = base_request();
+ let error = validate_transfer_database_pair(&request, &DatabaseType::Gbase, &DatabaseType::Mysql)
+ .expect_err("GBase data transfer is disabled in the shared manifest");
+ assert!(error.contains("gbase"));
+ }
+
#[test]
fn xugu_structure_transfer_rejects_renamed_tables_and_non_table_objects() {
let request = TransferRequest { target_table_name_case: TransferTableNameCase::Upper, ..base_request() };
diff --git a/crates/dbx-core/src/data/transfer_runner.rs b/crates/dbx-core/src/data/transfer_runner.rs
new file mode 100644
index 0000000000..e412648840
--- /dev/null
+++ b/crates/dbx-core/src/data/transfer_runner.rs
@@ -0,0 +1,694 @@
+use std::sync::{Arc, Mutex};
+
+use crate::{
+ connection::AppState,
+ models::connection::DatabaseType,
+ persistence::task_history::{TaskItemStatus, TransferTaskJournal},
+};
+
+use super::transfer::{TransferProgress, TransferRequest, TransferStatus};
+
+struct ProgressEmitter {
+ callback: Mutex,
+ terminal: Mutex