|
-
+
|
{{ d.name }} |
diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts
index 2843fa9eb8..72d033a308 100644
--- a/apps/desktop/src/lib/api.ts
+++ b/apps/desktop/src/lib/api.ts
@@ -77,6 +77,8 @@ export const listIndexes = forward("listIndexes");
export const listForeignKeys = forward("listForeignKeys");
export const listTriggers = forward("listTriggers");
export const getTableDdl = forward("getTableDdl");
+export const prepareSchemaDiff = forward("prepareSchemaDiff");
+export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
// Query
export const executeQuery = forward("executeQuery");
@@ -89,6 +91,8 @@ export const closeQuerySession = forward("closeQuerySession");
export const analyzeSqlReferences = forward("analyzeSqlReferences");
export const analyzeEditableQueryEditability = forward("analyzeEditableQueryEditability");
export const prepareDataGridSave = forward("prepareDataGridSave");
+export const prepareDataCompare = forward("prepareDataCompare");
+export const prepareDataCompareFromTables = forward("prepareDataCompareFromTables");
// AI
export const aiComplete = forward("aiComplete");
diff --git a/apps/desktop/src/lib/dataCompare.ts b/apps/desktop/src/lib/dataCompare.ts
index e5be7ac37e..0c943f19cb 100644
--- a/apps/desktop/src/lib/dataCompare.ts
+++ b/apps/desktop/src/lib/dataCompare.ts
@@ -1,6 +1,4 @@
import type { DatabaseType, QueryResult } from "@/types/database";
-import { quoteTableIdentifier } from "./tableSelectSql";
-import { formatGridSqlLiteral } from "./dataGridSql";
export type DataCompareCellValue = QueryResult["rows"][number][number];
@@ -30,152 +28,39 @@ export interface DataCompareResult {
modified: DataCompareModifiedRow[];
}
-export interface CompareDataRowsOptions {
- columns: readonly string[];
- keyColumns: readonly string[];
- sourceRows: readonly (readonly DataCompareCellValue[])[];
- targetRows: readonly (readonly DataCompareCellValue[])[];
-}
-
-export interface GenerateDataSyncSqlOptions {
+export interface DataComparePreparationOptions {
tableName: string;
schema?: string;
- columns: readonly string[];
- keyColumns: readonly string[];
- diff: DataCompareResult;
+ columns: string[];
+ keyColumns: string[];
+ sourceRows: DataCompareCellValue[][];
+ targetRows: DataCompareCellValue[][];
databaseType?: DatabaseType;
}
-function rowObject(
- columns: readonly string[],
- row: readonly DataCompareCellValue[],
-): Record {
- const item: Record = {};
- columns.forEach((column, index) => {
- item[column] = row[index] ?? null;
- });
- return item;
+export interface DataComparePreparation {
+ result: DataCompareResult;
+ syncStatements: string[];
+ syncSql: string;
}
-function keyFor(row: Record, keyColumns: readonly string[]): string {
- return keyColumns.map((column) => JSON.stringify(row[column] ?? null)).join("\u001f");
+export interface DataCompareFromTablesOptions {
+ sourceConnectionId: string;
+ sourceDatabase: string;
+ sourceSchema: string;
+ sourceTable: string;
+ targetConnectionId: string;
+ targetDatabase: string;
+ targetSchema: string;
+ targetTable: string;
+ columns: string[];
+ keyColumns: string[];
+ rowLimit: number;
}
-function keyValues(row: Record, keyColumns: readonly string[]) {
- const values: Record = {};
- keyColumns.forEach((column) => {
- values[column] = row[column] ?? null;
- });
- return values;
-}
-
-export function compareDataRows(options: CompareDataRowsOptions): DataCompareResult {
- if (options.keyColumns.length === 0) {
- throw new Error("At least one key column is required for data comparison");
- }
-
- const source = new Map>();
- const target = new Map>();
- options.sourceRows.forEach((row) => {
- const item = rowObject(options.columns, row);
- const key = keyFor(item, options.keyColumns);
- if (source.has(key)) throw new Error(`Duplicate source key: ${key}`);
- source.set(key, item);
- });
- options.targetRows.forEach((row) => {
- const item = rowObject(options.columns, row);
- const key = keyFor(item, options.keyColumns);
- if (target.has(key)) throw new Error(`Duplicate target key: ${key}`);
- target.set(key, item);
- });
-
- const added: DataCompareRow[] = [];
- const removed: DataCompareRow[] = [];
- const modified: DataCompareModifiedRow[] = [];
-
- for (const [key, sourceValues] of source) {
- const targetValues = target.get(key);
- if (!targetValues) {
- added.push({ key, keyValues: keyValues(sourceValues, options.keyColumns), values: sourceValues });
- continue;
- }
-
- const changes = options.columns
- .filter((column) => !options.keyColumns.includes(column))
- .filter((column) => sourceValues[column] !== targetValues[column])
- .map((column) => ({ column, source: sourceValues[column] ?? null, target: targetValues[column] ?? null }));
-
- if (changes.length > 0) {
- modified.push({
- key,
- keyValues: keyValues(sourceValues, options.keyColumns),
- sourceValues,
- targetValues,
- changes,
- });
- }
- }
-
- for (const [key, targetValues] of target) {
- if (!source.has(key)) {
- removed.push({ key, keyValues: keyValues(targetValues, options.keyColumns), values: targetValues });
- }
- }
-
- return { added, removed, modified };
-}
-
-function qualifiedTableName(schema: string | undefined, tableName: string, databaseType?: DatabaseType): string {
- const table = quoteTableIdentifier(databaseType, tableName);
- return schema ? `${quoteTableIdentifier(databaseType, schema)}.${table}` : table;
-}
-
-function whereByKey(
- keyValues: Record,
- keyColumns: readonly string[],
- databaseType?: DatabaseType,
-): string {
- return keyColumns
- .map(
- (column) =>
- `${quoteTableIdentifier(databaseType, column)} = ${formatGridSqlLiteral(keyValues[column], databaseType)}`,
- )
- .join(" AND ");
-}
-
-export function generateDataSyncStatements(options: GenerateDataSyncSqlOptions): string[] {
- const table = qualifiedTableName(options.schema, options.tableName, options.databaseType);
- const statements: string[] = [];
-
- for (const row of options.diff.added) {
- const columns = options.columns.map((column) => quoteTableIdentifier(options.databaseType, column)).join(", ");
- const values = options.columns
- .map((column) => formatGridSqlLiteral(row.values[column], options.databaseType))
- .join(", ");
- statements.push(`INSERT INTO ${table} (${columns}) VALUES (${values});`);
- }
-
- for (const row of options.diff.modified) {
- const assignments = row.changes
- .map(
- (change) =>
- `${quoteTableIdentifier(options.databaseType, change.column)} = ${formatGridSqlLiteral(change.source, options.databaseType)}`,
- )
- .join(", ");
- statements.push(
- `UPDATE ${table} SET ${assignments} WHERE ${whereByKey(row.keyValues, options.keyColumns, options.databaseType)};`,
- );
- }
-
- for (const row of options.diff.removed) {
- statements.push(
- `DELETE FROM ${table} WHERE ${whereByKey(row.keyValues, options.keyColumns, options.databaseType)};`,
- );
- }
-
- return statements;
-}
-
-export function generateDataSyncSql(options: GenerateDataSyncSqlOptions): string {
- return generateDataSyncStatements(options).join("\n");
+export interface DataCompareFromTablesPreparation extends DataComparePreparation {
+ sourceRowCount: number;
+ targetRowCount: number;
+ sourceTruncated: boolean;
+ targetTruncated: boolean;
}
diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts
index 9217ccb085..f514a7c757 100644
--- a/apps/desktop/src/lib/http.ts
+++ b/apps/desktop/src/lib/http.ts
@@ -11,6 +11,7 @@ import type {
TriggerInfo,
QueryResult,
SqlReferenceAnalysis,
+ DatabaseType,
InstalledPlugin,
JdbcDriverInfo,
JdbcPluginStatus,
@@ -51,6 +52,13 @@ import type {
} from "./tauri";
import type { QueryEditability } from "@/lib/sqlAnalysis";
import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql";
+import type {
+ DataCompareFromTablesOptions,
+ DataCompareFromTablesPreparation,
+ DataComparePreparation,
+ DataComparePreparationOptions,
+} from "@/lib/dataCompare";
+import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff } from "@/lib/schemaDiff";
import type { DataGridSavePreparation } from "./tauri";
// ---------------------------------------------------------------------------
@@ -342,6 +350,18 @@ export async function getTableDdl(
return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table })}`);
}
+export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise {
+ return post("/api/schema-diff/prepare", options);
+}
+
+export async function generateSchemaSyncSql(
+ diffs: TableDiff[],
+ databaseType: DatabaseType,
+ targetSchema?: string,
+): Promise {
+ return post("/api/schema-diff/generate-sync-sql", { diffs, databaseType, targetSchema });
+}
+
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
@@ -415,6 +435,16 @@ export async function prepareDataGridSave(options: DataGridSaveStatementOptions)
return post("/api/query/prepare-data-grid-save", { options });
}
+export async function prepareDataCompare(options: DataComparePreparationOptions): Promise {
+ return post("/api/data-compare/prepare", options);
+}
+
+export async function prepareDataCompareFromTables(
+ options: DataCompareFromTablesOptions,
+): Promise {
+ return post("/api/data-compare/prepare-from-tables", options);
+}
+
// ---------------------------------------------------------------------------
// AI
// ---------------------------------------------------------------------------
diff --git a/apps/desktop/src/lib/schemaDiff.ts b/apps/desktop/src/lib/schemaDiff.ts
index bcd8528cea..a2ad576dd5 100644
--- a/apps/desktop/src/lib/schemaDiff.ts
+++ b/apps/desktop/src/lib/schemaDiff.ts
@@ -1,4 +1,4 @@
-import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, DatabaseType } from "@/types/database";
+import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, DatabaseType, TableInfo } from "@/types/database";
export interface ColumnDiff {
type: "added" | "removed" | "modified";
@@ -45,407 +45,25 @@ export interface TableDiff {
targetTableComment?: string | null;
}
-export function diffColumns(source: ColumnInfo[], target: ColumnInfo[]): ColumnDiff[] {
- const diffs: ColumnDiff[] = [];
- const targetMap = new Map(target.map((c) => [c.name, c]));
- const sourceMap = new Map(source.map((c) => [c.name, c]));
-
- for (const sc of source) {
- const tc = targetMap.get(sc.name);
- if (!tc) {
- diffs.push({ type: "added", name: sc.name, source: sc });
- } else {
- const changes: string[] = [];
- if (sc.data_type.toLowerCase() !== tc.data_type.toLowerCase()) {
- changes.push(`type: ${tc.data_type} → ${sc.data_type}`);
- }
- if (sc.is_nullable !== tc.is_nullable) {
- changes.push(`nullable: ${tc.is_nullable ? "YES" : "NO"} → ${sc.is_nullable ? "YES" : "NO"}`);
- }
- if ((sc.column_default ?? "") !== (tc.column_default ?? "")) {
- changes.push(`default: ${tc.column_default ?? "NULL"} → ${sc.column_default ?? "NULL"}`);
- }
- if ((sc.comment ?? "") !== (tc.comment ?? "")) {
- changes.push(`comment: ${tc.comment ?? ""} → ${sc.comment ?? ""}`);
- }
- if (changes.length > 0) {
- diffs.push({ type: "modified", name: sc.name, source: sc, target: tc, changes });
- }
- }
- }
-
- for (const tc of target) {
- if (!sourceMap.has(tc.name)) {
- diffs.push({ type: "removed", name: tc.name, target: tc });
- }
- }
-
- return diffs;
+export interface TableSchemaDetail {
+ name: string;
+ columns?: ColumnInfo[];
+ indexes?: IndexInfo[];
+ foreignKeys?: ForeignKeyInfo[];
+ triggers?: TriggerInfo[];
+ ddl?: string;
}
-export function diffIndexes(source: IndexInfo[], target: IndexInfo[]): IndexDiff[] {
- const diffs: IndexDiff[] = [];
- const targetMap = new Map(target.map((i) => [i.name, i]));
- const sourceMap = new Map(source.map((i) => [i.name, i]));
-
- for (const si of source) {
- if (si.is_primary) continue;
- const ti = targetMap.get(si.name);
- if (!ti) {
- diffs.push({ type: "added", name: si.name, source: si });
- continue;
- }
-
- const changes: string[] = [];
- if (si.is_unique !== ti.is_unique) {
- changes.push(`unique: ${ti.is_unique ? "YES" : "NO"} → ${si.is_unique ? "YES" : "NO"}`);
- }
- if (si.columns.join(",") !== ti.columns.join(",")) {
- changes.push(`columns: ${ti.columns.join(", ")} → ${si.columns.join(", ")}`);
- }
- if ((si.index_type ?? "") !== (ti.index_type ?? "")) {
- changes.push(`type: ${ti.index_type ?? "default"} → ${si.index_type ?? "default"}`);
- }
- if ((si.filter ?? "") !== (ti.filter ?? "")) {
- changes.push(`filter: ${ti.filter ?? "none"} → ${si.filter ?? "none"}`);
- }
- if ((si.included_columns ?? []).join(",") !== (ti.included_columns ?? []).join(",")) {
- changes.push(
- `include: ${(ti.included_columns ?? []).join(", ") || "none"} → ${(si.included_columns ?? []).join(", ") || "none"}`,
- );
- }
-
- if (changes.length > 0) {
- diffs.push({ type: "modified", name: si.name, source: si, target: ti, changes });
- }
- }
-
- for (const ti of target) {
- if (ti.is_primary) continue;
- if (!sourceMap.has(ti.name)) {
- diffs.push({ type: "removed", name: ti.name, target: ti });
- }
- }
-
- return diffs;
+export interface SchemaDiffPreparationOptions {
+ sourceTables: TableInfo[];
+ targetTables: TableInfo[];
+ sourceDetails: TableSchemaDetail[];
+ targetDetails: TableSchemaDetail[];
+ databaseType: DatabaseType;
+ targetSchema?: string;
}
-export function diffForeignKeys(source: ForeignKeyInfo[], target: ForeignKeyInfo[]): ForeignKeyDiff[] {
- const diffs: ForeignKeyDiff[] = [];
- const targetMap = new Map(target.map((fk) => [fk.name, fk]));
- const sourceMap = new Map(source.map((fk) => [fk.name, fk]));
-
- for (const sfk of source) {
- const tfk = targetMap.get(sfk.name);
- if (!tfk) {
- diffs.push({ type: "added", name: sfk.name, source: sfk });
- continue;
- }
-
- const changes: string[] = [];
- if (sfk.column !== tfk.column) changes.push(`column: ${tfk.column} → ${sfk.column}`);
- if (sfk.ref_table !== tfk.ref_table) changes.push(`ref table: ${tfk.ref_table} → ${sfk.ref_table}`);
- if (sfk.ref_column !== tfk.ref_column) changes.push(`ref column: ${tfk.ref_column} → ${sfk.ref_column}`);
-
- if (changes.length > 0) {
- diffs.push({ type: "modified", name: sfk.name, source: sfk, target: tfk, changes });
- }
- }
-
- for (const tfk of target) {
- if (!sourceMap.has(tfk.name)) {
- diffs.push({ type: "removed", name: tfk.name, target: tfk });
- }
- }
-
- return diffs;
-}
-
-export function diffTriggers(source: TriggerInfo[], target: TriggerInfo[]): TriggerDiff[] {
- const diffs: TriggerDiff[] = [];
- const targetMap = new Map(target.map((trigger) => [trigger.name, trigger]));
- const sourceMap = new Map(source.map((trigger) => [trigger.name, trigger]));
-
- for (const sourceTrigger of source) {
- const targetTrigger = targetMap.get(sourceTrigger.name);
- if (!targetTrigger) {
- diffs.push({ type: "added", name: sourceTrigger.name, source: sourceTrigger });
- continue;
- }
-
- const changes: string[] = [];
- if (sourceTrigger.event !== targetTrigger.event) {
- changes.push(`event: ${targetTrigger.event} → ${sourceTrigger.event}`);
- }
- if (sourceTrigger.timing !== targetTrigger.timing) {
- changes.push(`timing: ${targetTrigger.timing} → ${sourceTrigger.timing}`);
- }
-
- if (changes.length > 0) {
- diffs.push({ type: "modified", name: sourceTrigger.name, source: sourceTrigger, target: targetTrigger, changes });
- }
- }
-
- for (const targetTrigger of target) {
- if (!sourceMap.has(targetTrigger.name)) {
- diffs.push({ type: "removed", name: targetTrigger.name, target: targetTrigger });
- }
- }
-
- return diffs;
-}
-
-export function diffTables(
- sourceTables: string[],
- targetTables: string[],
-): { added: string[]; removed: string[]; common: string[] } {
- const targetSet = new Set(targetTables);
- const sourceSet = new Set(sourceTables);
- return {
- added: sourceTables.filter((t) => !targetSet.has(t)),
- removed: targetTables.filter((t) => !sourceSet.has(t)),
- common: sourceTables.filter((t) => targetSet.has(t)),
- };
-}
-
-function quoteId(name: string, dbType: DatabaseType): string {
- if (dbType === "mysql" || dbType === "doris" || dbType === "starrocks") {
- return `\`${name.replace(/`/g, "``")}\``;
- }
- return `"${name.replace(/"/g, '""')}"`;
-}
-
-function columnDef(col: ColumnInfo, dbType: DatabaseType): string {
- let def = `${quoteId(col.name, dbType)} ${col.data_type}`;
- if (!col.is_nullable) def += " NOT NULL";
- if (col.column_default !== null && col.column_default !== undefined) {
- def += ` DEFAULT ${col.column_default}`;
- }
- return def;
-}
-
-function qualifiedName(name: string, dbType: DatabaseType, schema?: string): string {
- return schema ? `${quoteId(schema, dbType)}.${quoteId(name, dbType)}` : quoteId(name, dbType);
-}
-
-function dropIndexSql(tableName: string, indexName: string, dbType: DatabaseType, schema?: string): string {
- const qt = qualifiedName(tableName, dbType, schema);
- const qi = qualifiedName(indexName, dbType, schema);
- if (dbType === "mysql" || dbType === "doris" || dbType === "starrocks") {
- return `DROP INDEX ${quoteId(indexName, dbType)} ON ${qt};`;
- }
- return `DROP INDEX IF EXISTS ${qi};`;
-}
-
-function createIndexSql(tableName: string, idx: IndexInfo, dbType: DatabaseType, schema?: string): string {
- const qt = qualifiedName(tableName, dbType, schema);
- const cols = idx.columns.map((c) => quoteId(c, dbType)).join(", ");
- const unique = idx.is_unique ? "UNIQUE " : "";
- const idxType = idx.index_type ?? "";
- const usingClause = idxType && dbType === "postgres" ? ` USING ${idxType}` : "";
- const typePrefix = idxType && dbType === "sqlserver" ? `${idxType} ` : "";
- const incCols = idx.included_columns ?? [];
- const includeClause =
- incCols.length > 0 && (dbType === "postgres" || dbType === "sqlserver")
- ? ` INCLUDE (${incCols.map((c) => quoteId(c, dbType)).join(", ")})`
- : "";
- const supportsWhere = dbType === "postgres" || dbType === "sqlserver" || dbType === "sqlite";
- const filter = idx.filter && supportsWhere ? ` WHERE ${idx.filter}` : "";
- return `CREATE ${unique}${typePrefix}INDEX ${quoteId(idx.name, dbType)} ON ${qt}${usingClause} (${cols})${includeClause}${filter};`;
-}
-
-function dropForeignKeySql(tableName: string, fkName: string, dbType: DatabaseType, schema?: string): string {
- const qt = qualifiedName(tableName, dbType, schema);
- const qf = quoteId(fkName, dbType);
- if (dbType === "mysql" || dbType === "doris" || dbType === "starrocks") {
- return `ALTER TABLE ${qt} DROP FOREIGN KEY ${qf};`;
- }
- return `ALTER TABLE ${qt} DROP CONSTRAINT ${qf};`;
-}
-
-function addForeignKeySql(tableName: string, fk: ForeignKeyInfo, dbType: DatabaseType, schema?: string): string {
- const qt = qualifiedName(tableName, dbType, schema);
- return `ALTER TABLE ${qt} ADD CONSTRAINT ${quoteId(fk.name, dbType)} FOREIGN KEY (${quoteId(fk.column, dbType)}) REFERENCES ${quoteId(fk.ref_table, dbType)} (${quoteId(fk.ref_column, dbType)});`;
-}
-
-function dropObjectSql(diff: TableDiff, dbType: DatabaseType, schema?: string): string {
- const objectType = diff.objectType === "view" ? "VIEW" : "TABLE";
- return `DROP ${objectType} IF EXISTS ${qualifiedName(diff.name, dbType, schema)};`;
-}
-
-function commentLiteral(comment: string): string {
- return `'${comment.replace(/'/g, "''")}'`;
-}
-
-function columnCommentSql(
- tableName: string,
- colName: string,
- comment: string,
- dbType: DatabaseType,
- schema?: string,
-): string {
- const isMySQL = dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
- if (isMySQL) {
- return `-- Column comment for ${colName}: use ALTER TABLE ... MODIFY COLUMN to set comment in MySQL`;
- }
- const qt = qualifiedName(tableName, dbType, schema);
- return `COMMENT ON COLUMN ${qt}.${quoteId(colName, dbType)} IS ${commentLiteral(comment)};`;
-}
-
-function tableCommentSql(tableName: string, comment: string, dbType: DatabaseType, schema?: string): string {
- const isMySQL = dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
- const qt = qualifiedName(tableName, dbType, schema);
- if (isMySQL) {
- return `ALTER TABLE ${qt} COMMENT = ${commentLiteral(comment)};`;
- }
- return `COMMENT ON TABLE ${qt} IS ${commentLiteral(comment)};`;
-}
-
-export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType, schema?: string): string {
- const lines: string[] = [];
- const isMySQL = dbType === "mysql" || dbType === "doris" || dbType === "starrocks";
-
- for (const diff of diffs) {
- const qt = qualifiedName(diff.name, dbType, schema);
-
- if (diff.type === "added" && diff.ddl) {
- lines.push(`-- Create ${diff.objectType ?? "table"}: ${diff.name}`);
- lines.push(diff.ddl + ";");
- lines.push("");
- continue;
- }
-
- if (diff.type === "added" && diff.objectType === "view") {
- lines.push(`-- View exists only in source: ${diff.name}`);
- lines.push("-- Source view definition is not available from this driver yet.");
- lines.push("");
- continue;
- }
-
- if (diff.type === "removed") {
- lines.push(`-- Drop ${diff.objectType ?? "table"}: ${diff.name}`);
- lines.push(dropObjectSql(diff, dbType, schema));
- lines.push("");
- continue;
- }
-
- if (diff.type === "modified") {
- const parts: string[] = [];
-
- if (diff.foreignKeys) {
- for (const fk of diff.foreignKeys) {
- if (fk.type === "removed" || fk.type === "modified") {
- lines.push(dropForeignKeySql(diff.name, fk.name, dbType, schema));
- }
- }
- }
-
- if (diff.columns) {
- for (const col of diff.columns) {
- if (col.type === "added" && col.source) {
- parts.push(` ADD COLUMN ${columnDef(col.source, dbType)}`);
- } else if (col.type === "removed") {
- parts.push(` DROP COLUMN ${quoteId(col.name, dbType)}`);
- } else if (col.type === "modified" && col.source) {
- if (isMySQL) {
- parts.push(` MODIFY COLUMN ${columnDef(col.source, dbType)}`);
- } else {
- const qn = quoteId(col.name, dbType);
- if (col.changes?.some((c) => c.startsWith("type:"))) {
- parts.push(` ALTER COLUMN ${qn} TYPE ${col.source.data_type}`);
- }
- if (col.changes?.some((c) => c.startsWith("nullable:"))) {
- parts.push(
- col.source.is_nullable ? ` ALTER COLUMN ${qn} DROP NOT NULL` : ` ALTER COLUMN ${qn} SET NOT NULL`,
- );
- }
- if (col.changes?.some((c) => c.startsWith("default:"))) {
- if (col.source.column_default !== null && col.source.column_default !== undefined) {
- parts.push(` ALTER COLUMN ${qn} SET DEFAULT ${col.source.column_default}`);
- } else {
- parts.push(` ALTER COLUMN ${qn} DROP DEFAULT`);
- }
- }
- }
- }
- }
- }
-
- if (parts.length > 0) {
- lines.push(`-- Alter table: ${diff.name}`);
- if (isMySQL) {
- lines.push(`ALTER TABLE ${qt}`);
- lines.push(parts.join(",\n") + ";");
- } else {
- for (const part of parts) {
- lines.push(`ALTER TABLE ${qt}${part};`);
- }
- }
- lines.push("");
- }
-
- if (diff.columns) {
- for (const col of diff.columns) {
- if (col.source && col.changes?.some((c) => c.startsWith("comment:"))) {
- lines.push(columnCommentSql(diff.name, col.name, col.source.comment ?? "", dbType, schema));
- }
- if (col.type === "added" && col.source?.comment) {
- lines.push(columnCommentSql(diff.name, col.name, col.source.comment, dbType, schema));
- }
- }
- }
-
- if (diff.sourceTableComment !== undefined && diff.sourceTableComment !== diff.targetTableComment) {
- lines.push(tableCommentSql(diff.name, diff.sourceTableComment ?? "", dbType, schema));
- }
-
- if (diff.indexes) {
- for (const idx of diff.indexes) {
- if (idx.type === "added" && idx.source) {
- lines.push(createIndexSql(diff.name, idx.source, dbType, schema));
- } else if (idx.type === "removed") {
- lines.push(dropIndexSql(diff.name, idx.name, dbType, schema));
- } else if (idx.type === "modified" && idx.source) {
- lines.push(dropIndexSql(diff.name, idx.name, dbType, schema));
- lines.push(createIndexSql(diff.name, idx.source, dbType, schema));
- }
- }
- }
-
- if (diff.foreignKeys) {
- for (const fk of diff.foreignKeys) {
- if (fk.type === "added" && fk.source) {
- lines.push(addForeignKeySql(diff.name, fk.source, dbType, schema));
- } else if (fk.type === "modified" && fk.source) {
- lines.push(addForeignKeySql(diff.name, fk.source, dbType, schema));
- }
- }
- }
-
- if (diff.triggers) {
- for (const trigger of diff.triggers) {
- lines.push(
- `-- Trigger ${trigger.type}: ${trigger.name} on ${diff.name}; review trigger definition manually.`,
- );
- }
- }
-
- if (diff.indexes || diff.foreignKeys || diff.triggers) {
- if (
- (diff.indexes?.length ?? 0) > 0 ||
- (diff.foreignKeys?.length ?? 0) > 0 ||
- (diff.triggers?.length ?? 0) > 0
- ) {
- lines.push("");
- }
- }
-
- if (dbType === "sqlite" && diff.foreignKeys?.length) {
- lines.push(`-- SQLite foreign key synchronization may require table rebuild for: ${diff.name}`);
- lines.push("");
- }
- }
- }
-
- return lines.join("\n").trim();
+export interface SchemaDiffPreparation {
+ diffs: TableDiff[];
+ syncSql: string;
}
diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts
index 0a8f7d26f1..28c02a02ff 100644
--- a/apps/desktop/src/lib/tauri.ts
+++ b/apps/desktop/src/lib/tauri.ts
@@ -13,6 +13,7 @@ import type {
TriggerInfo,
QueryResult,
SqlReferenceAnalysis,
+ DatabaseType,
InstalledPlugin,
JdbcDriverInfo,
JdbcPluginStatus,
@@ -23,6 +24,13 @@ import type {
import type { AiConfig } from "@/stores/settingsStore";
import type { QueryEditability } from "@/lib/sqlAnalysis";
import type { DataGridSaveStatementOptions } from "@/lib/dataGridSql";
+import type {
+ DataCompareFromTablesOptions,
+ DataCompareFromTablesPreparation,
+ DataComparePreparation,
+ DataComparePreparationOptions,
+} from "@/lib/dataCompare";
+import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff } from "@/lib/schemaDiff";
export interface AgentDriverInfo {
db_type: string;
@@ -329,6 +337,16 @@ export async function prepareDataGridSave(options: DataGridSaveStatementOptions)
return invoke("prepare_data_grid_save", { options });
}
+export async function prepareDataCompare(options: DataComparePreparationOptions): Promise {
+ return invoke("prepare_data_compare", { options });
+}
+
+export async function prepareDataCompareFromTables(
+ options: DataCompareFromTablesOptions,
+): Promise {
+ return invoke("prepare_data_compare_from_tables", { options });
+}
+
export async function listIndexes(
connectionId: string,
database: string,
@@ -365,6 +383,18 @@ export async function getTableDdl(
return invoke("get_table_ddl", { connectionId, database, schema, table });
}
+export async function prepareSchemaDiff(options: SchemaDiffPreparationOptions): Promise {
+ return invoke("prepare_schema_diff", { options });
+}
+
+export async function generateSchemaSyncSql(
+ diffs: TableDiff[],
+ databaseType: DatabaseType,
+ targetSchema?: string,
+): Promise {
+ return invoke("generate_schema_sync_sql", { diffs, databaseType, targetSchema });
+}
+
export async function saveConnections(configs: ConnectionConfig[]): Promise {
return invoke("save_connections", { configs });
}
diff --git a/crates/dbx-core/src/data_compare.rs b/crates/dbx-core/src/data_compare.rs
new file mode 100644
index 0000000000..4b118ba2cd
--- /dev/null
+++ b/crates/dbx-core/src/data_compare.rs
@@ -0,0 +1,639 @@
+use std::collections::HashMap;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::connection::AppState;
+use crate::models::connection::DatabaseType;
+use crate::query::{execute_sql_statement_with_options, QueryExecutionOptions};
+use crate::sql_dialect::{
+ build_count_table_sql, build_table_select_sql, qualified_table_name, quote_table_identifier, TableSelectSqlOptions,
+};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CompareDataRowsOptions {
+ pub columns: Vec,
+ pub key_columns: Vec,
+ #[serde(default)]
+ pub source_rows: Vec>,
+ #[serde(default)]
+ pub target_rows: Vec>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataComparePreparationOptions {
+ pub table_name: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub schema: Option,
+ pub columns: Vec,
+ pub key_columns: Vec,
+ #[serde(default)]
+ pub source_rows: Vec>,
+ #[serde(default)]
+ pub target_rows: Vec>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub database_type: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataCompareFromTablesOptions {
+ pub source_connection_id: String,
+ pub source_database: String,
+ pub source_schema: String,
+ pub source_table: String,
+ pub target_connection_id: String,
+ pub target_database: String,
+ pub target_schema: String,
+ pub target_table: String,
+ pub columns: Vec,
+ pub key_columns: Vec,
+ pub row_limit: usize,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataCompareChangedCell {
+ pub column: String,
+ pub source: Value,
+ pub target: Value,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataCompareRow {
+ pub key: String,
+ pub key_values: HashMap,
+ pub values: HashMap,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataCompareModifiedRow {
+ pub key: String,
+ pub key_values: HashMap,
+ pub source_values: HashMap,
+ pub target_values: HashMap,
+ pub changes: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataCompareResult {
+ pub added: Vec,
+ pub removed: Vec,
+ pub modified: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataComparePreparation {
+ pub result: DataCompareResult,
+ pub sync_statements: Vec,
+ pub sync_sql: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DataCompareFromTablesPreparation {
+ pub result: DataCompareResult,
+ pub sync_statements: Vec,
+ pub sync_sql: String,
+ pub source_row_count: u64,
+ pub target_row_count: u64,
+ pub source_truncated: bool,
+ pub target_truncated: bool,
+}
+
+pub fn prepare_data_compare(options: DataComparePreparationOptions) -> Result {
+ let result = compare_data_rows(CompareDataRowsOptions {
+ columns: options.columns.clone(),
+ key_columns: options.key_columns.clone(),
+ source_rows: options.source_rows,
+ target_rows: options.target_rows,
+ })?;
+ let sync_statements = generate_data_sync_statements(&GenerateDataSyncSqlOptions {
+ table_name: options.table_name,
+ schema: options.schema,
+ columns: options.columns,
+ key_columns: options.key_columns,
+ diff: result.clone(),
+ database_type: options.database_type,
+ });
+ let sync_sql = sync_statements.join("\n");
+ Ok(DataComparePreparation { result, sync_statements, sync_sql })
+}
+
+pub async fn prepare_data_compare_from_tables(
+ state: &AppState,
+ options: DataCompareFromTablesOptions,
+) -> 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?;
+ let row_limit = options.row_limit.max(1);
+
+ let source_count_sql =
+ build_count_table_sql(Some(source_database_type), Some(&options.source_schema), &options.source_table);
+ let target_count_sql =
+ build_count_table_sql(Some(target_database_type), Some(&options.target_schema), &options.target_table);
+
+ let source_count_result = execute_sql_statement_with_options(
+ state,
+ &options.source_connection_id,
+ &options.source_database,
+ &source_count_sql,
+ Some(&options.source_schema),
+ None,
+ QueryExecutionOptions { max_rows: Some(1), ..Default::default() },
+ )
+ .await?;
+ let target_count_result = execute_sql_statement_with_options(
+ state,
+ &options.target_connection_id,
+ &options.target_database,
+ &target_count_sql,
+ Some(&options.target_schema),
+ None,
+ QueryExecutionOptions { max_rows: Some(1), ..Default::default() },
+ )
+ .await?;
+ let source_row_count = first_count(&source_count_result.rows)?;
+ let target_row_count = first_count(&target_count_result.rows)?;
+
+ let source_sql = build_data_compare_select_sql(
+ source_database_type,
+ &options.source_schema,
+ &options.source_table,
+ &options.columns,
+ &options.key_columns,
+ row_limit,
+ );
+ let target_sql = build_data_compare_select_sql(
+ target_database_type,
+ &options.target_schema,
+ &options.target_table,
+ &options.columns,
+ &options.key_columns,
+ row_limit,
+ );
+
+ let source_result = execute_sql_statement_with_options(
+ state,
+ &options.source_connection_id,
+ &options.source_database,
+ &source_sql,
+ Some(&options.source_schema),
+ None,
+ QueryExecutionOptions { max_rows: Some(row_limit), ..Default::default() },
+ )
+ .await?;
+ let target_result = execute_sql_statement_with_options(
+ state,
+ &options.target_connection_id,
+ &options.target_database,
+ &target_sql,
+ Some(&options.target_schema),
+ None,
+ QueryExecutionOptions { max_rows: Some(row_limit), ..Default::default() },
+ )
+ .await?;
+
+ let preparation = prepare_data_compare(DataComparePreparationOptions {
+ table_name: options.target_table,
+ schema: Some(options.target_schema),
+ columns: options.columns,
+ key_columns: options.key_columns,
+ source_rows: source_result.rows,
+ target_rows: target_result.rows,
+ database_type: Some(target_database_type),
+ })?;
+
+ Ok(DataCompareFromTablesPreparation {
+ result: preparation.result,
+ sync_statements: preparation.sync_statements,
+ sync_sql: preparation.sync_sql,
+ source_row_count,
+ target_row_count,
+ source_truncated: source_row_count > row_limit as u64,
+ target_truncated: target_row_count > row_limit as u64,
+ })
+}
+
+pub fn compare_data_rows(options: CompareDataRowsOptions) -> Result {
+ if options.key_columns.is_empty() {
+ return Err("At least one key column is required for data comparison".to_string());
+ }
+
+ let mut source: HashMap> = HashMap::new();
+ let mut target: HashMap> = HashMap::new();
+ let mut source_order = Vec::new();
+ let mut target_order = Vec::new();
+
+ for row in &options.source_rows {
+ let item = row_object(&options.columns, row);
+ let key = key_for(&item, &options.key_columns);
+ if source.contains_key(&key) {
+ return Err(format!("Duplicate source key: {key}"));
+ }
+ source_order.push(key.clone());
+ source.insert(key, item);
+ }
+
+ for row in &options.target_rows {
+ let item = row_object(&options.columns, row);
+ let key = key_for(&item, &options.key_columns);
+ if target.contains_key(&key) {
+ return Err(format!("Duplicate target key: {key}"));
+ }
+ target_order.push(key.clone());
+ target.insert(key, item);
+ }
+
+ let mut added = Vec::new();
+ let mut removed = Vec::new();
+ let mut modified = Vec::new();
+
+ for key in &source_order {
+ let source_values = source.get(key).expect("source key should exist");
+ let Some(target_values) = target.get(key) else {
+ added.push(DataCompareRow {
+ key: key.clone(),
+ key_values: key_values(source_values, &options.key_columns),
+ values: source_values.clone(),
+ });
+ continue;
+ };
+
+ let changes: Vec = options
+ .columns
+ .iter()
+ .filter(|column| !options.key_columns.contains(column))
+ .filter(|column| value_for(source_values, column) != value_for(target_values, column))
+ .map(|column| DataCompareChangedCell {
+ column: column.clone(),
+ source: value_for(source_values, column),
+ target: value_for(target_values, column),
+ })
+ .collect();
+
+ if !changes.is_empty() {
+ modified.push(DataCompareModifiedRow {
+ key: key.clone(),
+ key_values: key_values(source_values, &options.key_columns),
+ source_values: source_values.clone(),
+ target_values: target_values.clone(),
+ changes,
+ });
+ }
+ }
+
+ for key in &target_order {
+ if let Some(target_values) = target.get(key).filter(|_| !source.contains_key(key)) {
+ removed.push(DataCompareRow {
+ key: key.clone(),
+ key_values: key_values(target_values, &options.key_columns),
+ values: target_values.clone(),
+ });
+ }
+ }
+
+ Ok(DataCompareResult { added, removed, modified })
+}
+
+#[derive(Debug, Clone)]
+struct GenerateDataSyncSqlOptions {
+ table_name: String,
+ schema: Option,
+ columns: Vec,
+ key_columns: Vec,
+ diff: DataCompareResult,
+ database_type: Option,
+}
+
+fn row_object(columns: &[String], row: &[Value]) -> HashMap {
+ columns
+ .iter()
+ .enumerate()
+ .map(|(index, column)| (column.clone(), row.get(index).cloned().unwrap_or(Value::Null)))
+ .collect()
+}
+
+fn key_for(row: &HashMap, key_columns: &[String]) -> String {
+ key_columns.iter().map(|column| json_stringify(&value_for(row, column))).collect::>().join("\u{001f}")
+}
+
+fn key_values(row: &HashMap, key_columns: &[String]) -> HashMap {
+ key_columns.iter().map(|column| (column.clone(), value_for(row, column))).collect()
+}
+
+fn value_for(row: &HashMap, column: &str) -> Value {
+ row.get(column).cloned().unwrap_or(Value::Null)
+}
+
+fn json_stringify(value: &Value) -> String {
+ serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
+}
+
+fn generate_data_sync_statements(options: &GenerateDataSyncSqlOptions) -> Vec {
+ let table = qualified_table_name(options.database_type, options.schema.as_deref(), &options.table_name);
+ let mut statements = Vec::new();
+
+ for row in &options.diff.added {
+ let columns = options
+ .columns
+ .iter()
+ .map(|column| quote_table_identifier(options.database_type, column))
+ .collect::>()
+ .join(", ");
+ let values = options
+ .columns
+ .iter()
+ .map(|column| {
+ format_grid_sql_literal(row.values.get(column).unwrap_or(&Value::Null), options.database_type)
+ })
+ .collect::>()
+ .join(", ");
+ statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});"));
+ }
+
+ for row in &options.diff.modified {
+ let assignments = row
+ .changes
+ .iter()
+ .map(|change| {
+ format!(
+ "{} = {}",
+ quote_table_identifier(options.database_type, &change.column),
+ format_grid_sql_literal(&change.source, options.database_type)
+ )
+ })
+ .collect::>()
+ .join(", ");
+ statements.push(format!(
+ "UPDATE {table} SET {assignments} WHERE {};",
+ where_by_key(&row.key_values, &options.key_columns, options.database_type)
+ ));
+ }
+
+ for row in &options.diff.removed {
+ statements.push(format!(
+ "DELETE FROM {table} WHERE {};",
+ where_by_key(&row.key_values, &options.key_columns, options.database_type)
+ ));
+ }
+
+ statements
+}
+
+async fn connection_database_type(state: &AppState, connection_id: &str) -> Result {
+ state
+ .configs
+ .read()
+ .await
+ .get(connection_id)
+ .map(|config| config.db_type)
+ .ok_or_else(|| format!("Connection config not found: {connection_id}"))
+}
+
+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 {
+ Value::Number(number) => {
+ number.as_u64().or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok()))
+ }
+ Value::String(text) => text.parse::().ok(),
+ _ => None,
+ }
+ .ok_or_else(|| format!("COUNT query returned non-numeric value: {value}"))
+}
+
+fn build_data_compare_select_sql(
+ database_type: DatabaseType,
+ schema: &str,
+ table_name: &str,
+ columns: &[String],
+ key_columns: &[String],
+ row_limit: usize,
+) -> String {
+ build_table_select_sql(TableSelectSqlOptions {
+ database_type: Some(database_type),
+ schema: Some(schema),
+ table_name,
+ columns,
+ order_columns: key_columns,
+ limit: row_limit,
+ })
+}
+
+fn where_by_key(
+ key_values: &HashMap,
+ key_columns: &[String],
+ database_type: Option,
+) -> String {
+ key_columns
+ .iter()
+ .map(|column| {
+ format!(
+ "{} = {}",
+ quote_table_identifier(database_type, column),
+ format_grid_sql_literal(key_values.get(column).unwrap_or(&Value::Null), database_type)
+ )
+ })
+ .collect::>()
+ .join(" AND ")
+}
+
+fn format_grid_sql_literal(value: &Value, database_type: Option) -> String {
+ match value {
+ Value::Null => "NULL".to_string(),
+ Value::Bool(value) => {
+ if *value {
+ "TRUE".to_string()
+ } else {
+ "FALSE".to_string()
+ }
+ }
+ Value::Number(number) => number.to_string(),
+ Value::String(text) if text.is_empty() && database_type == Some(DatabaseType::SqlServer) => "N''".to_string(),
+ Value::String(text) if text.is_empty() => "''".to_string(),
+ Value::String(text) => {
+ let escaped = format!("'{}'", literal_text(text, database_type).replace('\\', "\\\\").replace('\'', "''"));
+ if database_type == Some(DatabaseType::SqlServer) {
+ format!("N{escaped}")
+ } else {
+ escaped
+ }
+ }
+ other => {
+ let text = other.to_string();
+ let escaped = format!("'{}'", text.replace('\\', "\\\\").replace('\'', "''"));
+ if database_type == Some(DatabaseType::SqlServer) {
+ format!("N{escaped}")
+ } else {
+ escaped
+ }
+ }
+ }
+}
+
+fn literal_text(text: &str, database_type: Option) -> String {
+ if database_type == Some(DatabaseType::Tdengine) {
+ return format_tdengine_timestamp_literal_text(text);
+ }
+ text.to_string()
+}
+
+fn format_tdengine_timestamp_literal_text(text: &str) -> String {
+ // Keep non-timestamp text unchanged; TDengine timestamp normalization is UI-parity best effort in Rust.
+ if text.len() < 19 || !text.as_bytes().get(10).is_some_and(|ch| *ch == b' ') {
+ return text.to_string();
+ }
+ text.replacen(' ', "T", 1)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::models::connection::DatabaseType;
+ use serde_json::json;
+
+ #[test]
+ fn compares_rows_by_primary_key_and_reports_added_removed_and_modified_rows() {
+ let diff = compare_data_rows(CompareDataRowsOptions {
+ columns: vec!["id".to_string(), "name".to_string(), "active".to_string()],
+ key_columns: vec!["id".to_string()],
+ source_rows: vec![
+ vec![json!(1), json!("Ada"), json!(true)],
+ vec![json!(2), json!("Bob"), json!(false)],
+ vec![json!(4), json!("Dora"), json!(true)],
+ ],
+ target_rows: vec![
+ vec![json!(1), json!("Ada"), json!(true)],
+ vec![json!(2), json!("Bobby"), json!(false)],
+ vec![json!(3), json!("Cara"), json!(true)],
+ ],
+ })
+ .expect("data comparison should succeed");
+
+ assert_eq!(
+ diff.added.iter().map(|row| row.key_values.get("id").cloned()).collect::>(),
+ vec![Some(json!(4))]
+ );
+ assert_eq!(
+ diff.removed.iter().map(|row| row.key_values.get("id").cloned()).collect::>(),
+ vec![Some(json!(3))]
+ );
+ assert_eq!(diff.modified[0].changes[0].column, "name");
+ assert_eq!(diff.modified[0].changes[0].source, json!("Bob"));
+ assert_eq!(diff.modified[0].changes[0].target, json!("Bobby"));
+ }
+
+ #[test]
+ fn generates_data_synchronization_sql() {
+ let preparation = prepare_data_compare(DataComparePreparationOptions {
+ table_name: "users".to_string(),
+ schema: Some("public".to_string()),
+ columns: vec!["id".to_string(), "name".to_string(), "active".to_string()],
+ key_columns: vec!["id".to_string()],
+ source_rows: vec![vec![json!(1), json!("Ada"), json!(true)], vec![json!(2), json!("Bob"), json!(false)]],
+ target_rows: vec![
+ vec![json!(1), json!("Ada Lovelace"), json!(true)],
+ vec![json!(3), json!("Cara"), json!(true)],
+ ],
+ database_type: Some(DatabaseType::Postgres),
+ })
+ .expect("data compare preparation should succeed");
+
+ assert_eq!(
+ preparation.sync_sql,
+ [
+ "INSERT INTO \"public\".\"users\" (\"id\", \"name\", \"active\") VALUES (2, 'Bob', FALSE);",
+ "UPDATE \"public\".\"users\" SET \"name\" = 'Ada' WHERE \"id\" = 1;",
+ "DELETE FROM \"public\".\"users\" WHERE \"id\" = 3;",
+ ]
+ .join("\n")
+ );
+ assert_eq!(preparation.sync_statements.len(), 3);
+ }
+
+ #[test]
+ fn requires_at_least_one_key_column() {
+ let err = compare_data_rows(CompareDataRowsOptions {
+ columns: vec!["id".to_string()],
+ key_columns: Vec::new(),
+ source_rows: vec![vec![json!(1)]],
+ target_rows: vec![vec![json!(1)]],
+ })
+ .expect_err("missing key columns should fail");
+
+ assert!(err.contains("At least one key column"));
+ }
+
+ #[test]
+ fn rejects_duplicate_row_keys() {
+ let err = compare_data_rows(CompareDataRowsOptions {
+ columns: vec!["id".to_string(), "name".to_string()],
+ key_columns: vec!["id".to_string()],
+ source_rows: vec![vec![json!(1), json!("Ada")], vec![json!(1), json!("Ada Clone")]],
+ target_rows: vec![vec![json!(1), json!("Ada")]],
+ })
+ .expect_err("duplicate source keys should fail");
+
+ assert!(err.contains("Duplicate source key"));
+ }
+
+ #[test]
+ fn builds_backend_table_select_sql_with_explicit_columns_and_key_order() {
+ assert_eq!(
+ build_data_compare_select_sql(
+ DatabaseType::Postgres,
+ "public",
+ "users",
+ &["id".to_string(), "name".to_string()],
+ &["id".to_string()],
+ 1000,
+ ),
+ "SELECT \"id\", \"name\" FROM \"public\".\"users\" ORDER BY \"id\" ASC LIMIT 1000;"
+ );
+ }
+
+ #[test]
+ fn builds_backend_table_select_sql_for_sqlserver_limit_syntax() {
+ assert_eq!(
+ build_data_compare_select_sql(
+ DatabaseType::SqlServer,
+ "dbo",
+ "users",
+ &["id".to_string(), "name".to_string()],
+ &["id".to_string()],
+ 50,
+ ),
+ "SELECT TOP (50) [id], [name] FROM [dbo].[users] ORDER BY [id] ASC"
+ );
+ }
+
+ #[test]
+ fn shared_sql_dialect_helpers_build_data_compare_table_sql() {
+ use crate::sql_dialect::{
+ build_count_table_sql as build_shared_count_table_sql, build_table_select_sql, TableSelectSqlOptions,
+ };
+
+ assert_eq!(
+ build_shared_count_table_sql(Some(DatabaseType::Postgres), Some("public"), "users"),
+ "SELECT COUNT(*) AS row_count FROM \"public\".\"users\""
+ );
+ assert_eq!(
+ build_table_select_sql(TableSelectSqlOptions {
+ database_type: Some(DatabaseType::Oracle),
+ schema: Some("APP"),
+ table_name: "EVENTS",
+ columns: &["ID".to_string(), "NAME".to_string()],
+ order_columns: &["ID".to_string()],
+ limit: 25,
+ }),
+ "SELECT \"ID\", \"NAME\" FROM \"APP\".\"EVENTS\" ORDER BY \"ID\" ASC FETCH FIRST 25 ROWS ONLY"
+ );
+ }
+}
diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs
index a2237a8e11..d29625b050 100644
--- a/crates/dbx-core/src/data_grid_sql.rs
+++ b/crates/dbx-core/src/data_grid_sql.rs
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::models::connection::DatabaseType;
+use crate::sql_dialect::quote_table_identifier;
const DBX_ROWID_COLUMN: &str = "__DBX_ROWID";
const DBX_NEO4J_ELEMENT_ID_COLUMN: &str = "__DBX_ELEMENT_ID";
@@ -976,55 +977,11 @@ fn predicate_ident(database_type: Option, name: &str) -> String {
}
fn quote_ident(database_type: Option, name: &str) -> String {
- match database_type {
- Some(DatabaseType::Mysql | DatabaseType::Hive | DatabaseType::Tdengine | DatabaseType::Access) => {
- format!("`{}`", name.replace('`', "``"))
- }
- Some(DatabaseType::Informix) if is_plain_identifier(name) => name.to_string(),
- Some(DatabaseType::Neo4j) => format!("`{}`", name.replace('`', "``")),
- Some(DatabaseType::SqlServer) => format!("[{}]", name.replace(']', "]]")),
- _ => format!("\"{}\"", name.replace('"', "\"\"")),
- }
+ quote_table_identifier(database_type, name)
}
fn qualified_table_name(database_type: Option, schema: Option<&str>, table_name: &str) -> String {
- if is_schema_aware(database_type) && schema.is_some_and(|schema| !schema.is_empty()) {
- format!("{}.{}", quote_ident(database_type, schema.unwrap()), quote_ident(database_type, table_name))
- } else {
- quote_ident(database_type, table_name)
- }
-}
-
-fn is_schema_aware(database_type: Option) -> bool {
- matches!(
- database_type,
- Some(
- DatabaseType::Postgres
- | DatabaseType::SqlServer
- | DatabaseType::Oracle
- | DatabaseType::Redshift
- | DatabaseType::Dameng
- | DatabaseType::Gaussdb
- | DatabaseType::Kingbase
- | DatabaseType::Highgo
- | DatabaseType::Vastbase
- | DatabaseType::Yashandb
- | DatabaseType::Databricks
- | DatabaseType::SapHana
- | DatabaseType::Teradata
- | DatabaseType::Vertica
- | DatabaseType::Exasol
- | DatabaseType::OpenGauss
- | DatabaseType::OceanbaseOracle
- | DatabaseType::Gbase
- | DatabaseType::Jdbc
- | DatabaseType::H2
- | DatabaseType::Snowflake
- | DatabaseType::Trino
- | DatabaseType::Db2
- | DatabaseType::Tdengine
- )
- )
+ crate::sql_dialect::qualified_table_name(database_type, schema, table_name)
}
fn uses_keyless_row_predicate(database_type: Option) -> bool {
@@ -1068,15 +1025,6 @@ fn uses_keyless_row_predicate(database_type: Option) -> bool {
)
}
-fn is_plain_identifier(name: &str) -> bool {
- let mut chars = name.chars();
- let Some(first) = chars.next() else {
- return false;
- };
- (first.is_ascii_alphabetic() || first == '_')
- && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
-}
-
fn column_info_for<'a>(columns: &'a [DataGridColumnInfo], name: &str) -> Option<&'a DataGridColumnInfo> {
let normalized = normalize_column_name(name);
columns.iter().find(|column| normalize_column_name(&column.name) == normalized)
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index fd3c9a5b85..932705a93d 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -4,6 +4,7 @@ pub mod ai;
pub mod connection;
pub mod connection_secrets;
pub mod csv_export;
+pub mod data_compare;
pub mod data_grid_sql;
pub mod database_capabilities;
pub mod database_export;
@@ -18,8 +19,10 @@ pub mod query_cancel;
pub mod redis_ops;
pub mod saved_sql;
pub mod schema;
+pub mod schema_diff;
pub mod sql;
pub mod sql_analysis;
+pub mod sql_dialect;
pub mod sql_editability;
pub mod storage;
pub mod table_import;
diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs
new file mode 100644
index 0000000000..4f9e4686c3
--- /dev/null
+++ b/crates/dbx-core/src/schema_diff.rs
@@ -0,0 +1,1050 @@
+use std::collections::{HashMap, HashSet};
+
+use serde::{Deserialize, Serialize};
+
+use crate::models::connection::DatabaseType;
+use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo, TableInfo, TriggerInfo};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ColumnDiff {
+ #[serde(rename = "type")]
+ pub diff_type: String,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub changes: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct IndexDiff {
+ #[serde(rename = "type")]
+ pub diff_type: String,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub changes: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ForeignKeyDiff {
+ #[serde(rename = "type")]
+ pub diff_type: String,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub changes: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TriggerDiff {
+ #[serde(rename = "type")]
+ pub diff_type: String,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub target: Option,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub changes: Vec,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TableDiff {
+ #[serde(rename = "type")]
+ pub diff_type: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub object_type: Option,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub columns: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub indexes: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub foreign_keys: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub triggers: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub ddl: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source_table_comment: Option |