From 97b77ec46d239468c1c52aefd176044bc04f0bf5 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 9 May 2026 23:44:49 +0800 Subject: [PATCH] feat(diff): add data compare and schema sync coverage --- package.json | 1 + pnpm-lock.yaml | 6 +- src/components/diff/DataCompareDialog.vue | 575 ++++++++++++++++++++++ src/components/diff/SchemaDiffDialog.vue | 205 ++++++-- src/components/layout/AppDialogs.vue | 9 + src/components/sidebar/TreeItem.vue | 18 + src/composables/useDialogSources.ts | 27 + src/i18n/locales/en.ts | 22 + src/i18n/locales/zh-CN.ts | 21 + src/lib/dataCompare.ts | 181 +++++++ src/lib/schemaDiff.ts | 264 ++++++++-- src/lib/sqlCompletion.ts | 2 +- src/lib/xlsxExport.ts | 15 +- src/stores/connectionStore.ts | 9 +- tests/dataCompare.test.ts | 87 ++++ tests/schemaDiff.test.ts | 126 +++++ 16 files changed, 1493 insertions(+), 75 deletions(-) create mode 100644 src/components/diff/DataCompareDialog.vue create mode 100644 src/lib/dataCompare.ts create mode 100644 tests/dataCompare.test.ts create mode 100644 tests/schemaDiff.test.ts diff --git a/package.json b/package.json index 3d6db8ca1..a7ac7a60b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@oxfmt/binding-darwin-arm64": "0.47.0", + "@oxlint/binding-darwin-arm64": "1.62.0", "@tailwindcss/vite": "^4.2.4", "@tauri-apps/cli": "^2.11.0", "@types/node": "^25.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b3b39e34..9a79a1bfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: '@oxfmt/binding-darwin-arm64': specifier: 0.47.0 version: 0.47.0 + '@oxlint/binding-darwin-arm64': + specifier: 1.62.0 + version: 1.62.0 '@tailwindcss/vite': specifier: ^4.2.4 version: 4.2.4(vite@6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(stylus@0.57.0)(yaml@2.8.4)) @@ -3684,8 +3687,7 @@ snapshots: '@oxlint/binding-android-arm64@1.62.0': optional: true - '@oxlint/binding-darwin-arm64@1.62.0': - optional: true + '@oxlint/binding-darwin-arm64@1.62.0': {} '@oxlint/binding-darwin-x64@1.62.0': optional: true diff --git a/src/components/diff/DataCompareDialog.vue b/src/components/diff/DataCompareDialog.vue new file mode 100644 index 000000000..192a928c6 --- /dev/null +++ b/src/components/diff/DataCompareDialog.vue @@ -0,0 +1,575 @@ + + + + + + + + + {{ t("dataCompare.title") }} + + + + + + + {{ t("diff.source") }} + (sourceConnectionId = String(v))" + > + + + + + + + + + {{ connection.name }} + + + + (sourceDatabase = String(v))"> + + + {{ + database + }} + + + (sourceSchema = String(v))" + > + + + {{ schema }} + + + (sourceTable = String(v))"> + + + {{ table }} + + + + + + {{ t("diff.target") }} + (targetConnectionId = String(v))" + > + + + + + + + + + {{ connection.name }} + + + + (targetDatabase = String(v))"> + + + {{ + database + }} + + + (targetSchema = String(v))" + > + + + {{ schema }} + + + (targetTable = String(v))"> + + + {{ table }} + + + + + + + {{ t("dataCompare.keyColumns") }} + + + + + {{ t("dataCompare.rowLimit") }} + + + + + + + {{ t("dataCompare.rowLimitOption", { count: limit }) }} + + + + + + + + + {{ t("dataCompare.compare") }} + + + + {{ summary }} + + {{ + t("dataCompare.rowCounts", { + source: sourceRowCount, + target: targetRowCount, + limit: rowLimitNumber, + }) + }} + + + {{ t("dataCompare.truncatedWarning") }} + + + + + {{ t("diff.generatedSql") }} + + + + {{ t("dataCompare.noDifferences") }} + + + + + + {{ t("diff.copySql") }} + + + + + {{ t("diff.executeSync") }} + + + + + diff --git a/src/components/diff/SchemaDiffDialog.vue b/src/components/diff/SchemaDiffDialog.vue index 7f8cdf811..9e94d4354 100644 --- a/src/components/diff/SchemaDiffDialog.vue +++ b/src/components/diff/SchemaDiffDialog.vue @@ -10,7 +10,15 @@ import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import { isSchemaAware } from "@/lib/databaseCapabilities"; -import { diffColumns, diffIndexes, diffTables, generateSyncSql, type TableDiff } from "@/lib/schemaDiff"; +import { + diffColumns, + diffForeignKeys, + diffIndexes, + diffTables, + diffTriggers, + generateSyncSql, + type TableDiff, +} from "@/lib/schemaDiff"; import { useToast } from "@/composables/useToast"; import { Loader2, Copy, Play, GitCompareArrows } from "lucide-vue-next"; @@ -22,17 +30,20 @@ const store = useConnectionStore(); const props = defineProps<{ prefillConnectionId?: string; prefillDatabase?: string; + prefillSchema?: string; }>(); const sourceConnectionId = ref(""); const sourceDatabase = ref(""); const sourceDatabases = ref([]); const sourceSchema = ref(""); +const sourceSchemas = ref([]); const targetConnectionId = ref(""); const targetDatabase = ref(""); const targetDatabases = ref([]); const targetSchema = ref(""); +const targetSchemas = ref([]); const step = ref<"select" | "comparing" | "result">("select"); const diffs = ref([]); @@ -44,7 +55,13 @@ const sqlConnections = computed(() => ); const canCompare = computed( - () => sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value, + () => + sourceConnectionId.value && + sourceDatabase.value && + sourceSchema.value && + targetConnectionId.value && + targetDatabase.value && + targetSchema.value, ); function connectionIconType(connectionId: string) { @@ -61,9 +78,13 @@ async function loadDatabases(connectionId: string, side: "source" | "target") { if (side === "source") { sourceDatabases.value = names; sourceDatabase.value = names.length === 1 ? names[0] : ""; + sourceSchemas.value = []; + sourceSchema.value = ""; } else { targetDatabases.value = names; targetDatabase.value = names.length === 1 ? names[0] : ""; + targetSchemas.value = []; + targetSchema.value = ""; } } catch { if (side === "source") sourceDatabases.value = []; @@ -71,14 +92,36 @@ async function loadDatabases(connectionId: string, side: "source" | "target") { } } -async function resolveSchema(connectionId: string, database: string): Promise { +async function loadSchemas(side: "source" | "target", preferredSchema = "") { + const connectionId = side === "source" ? sourceConnectionId.value : targetConnectionId.value; + const database = side === "source" ? sourceDatabase.value : targetDatabase.value; + if (!connectionId || !database) return; const config = store.getConfig(connectionId); - const needsSchema = isSchemaAware(config?.db_type); - if (needsSchema) { - const schemas = await api.listSchemas(connectionId, database); - return schemas.includes("public") ? "public" : (schemas[0] ?? ""); + if (!isSchemaAware(config?.db_type)) { + if (side === "source") { + sourceSchemas.value = []; + sourceSchema.value = database; + } else { + targetSchemas.value = []; + targetSchema.value = database; + } + return; + } + + const schemas = await api.listSchemas(connectionId, database); + const selected = + preferredSchema && schemas.includes(preferredSchema) + ? preferredSchema + : schemas.includes("public") + ? "public" + : (schemas[0] ?? ""); + if (side === "source") { + sourceSchemas.value = schemas; + sourceSchema.value = selected; + } else { + targetSchemas.value = schemas; + targetSchema.value = selected; } - return database; } async function startCompare() { @@ -91,55 +134,70 @@ async function startCompare() { await store.ensureConnected(sourceConnectionId.value); await store.ensureConnected(targetConnectionId.value); - const srcSchema = await resolveSchema(sourceConnectionId.value, sourceDatabase.value); - const tgtSchema = await resolveSchema(targetConnectionId.value, targetDatabase.value); - sourceSchema.value = srcSchema; - targetSchema.value = tgtSchema; - const [srcTables, tgtTables] = await Promise.all([ - api.listTables(sourceConnectionId.value, sourceDatabase.value, srcSchema), - api.listTables(targetConnectionId.value, targetDatabase.value, tgtSchema), + api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), + api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value), ]); - const srcNames = srcTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name); - const tgtNames = tgtTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name); - const { added, removed, common } = diffTables(srcNames, tgtNames); + const srcTableNames = srcTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name); + const tgtTableNames = tgtTables.filter((t) => t.table_type !== "VIEW").map((t) => t.name); + const srcViewNames = srcTables.filter((t) => t.table_type === "VIEW").map((t) => t.name); + const tgtViewNames = tgtTables.filter((t) => t.table_type === "VIEW").map((t) => t.name); + const { added, removed, common } = diffTables(srcTableNames, tgtTableNames); + const { added: addedViews, removed: removedViews } = diffTables(srcViewNames, tgtViewNames); const result: TableDiff[] = []; for (const name of added) { - const ddl = await api.getTableDdl(sourceConnectionId.value, sourceDatabase.value, srcSchema, name); - result.push({ type: "added", name, ddl }); + const ddl = await api.getTableDdl(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name); + result.push({ type: "added", objectType: "table", name, ddl }); } for (const name of removed) { - result.push({ type: "removed", name }); + result.push({ type: "removed", objectType: "table", name }); + } + + for (const name of addedViews) { + result.push({ type: "added", objectType: "view", name }); + } + + for (const name of removedViews) { + result.push({ type: "removed", objectType: "view", name }); } for (const name of common) { - const [srcCols, tgtCols, srcIdx, tgtIdx] = await Promise.all([ - api.getColumns(sourceConnectionId.value, sourceDatabase.value, srcSchema, name), - api.getColumns(targetConnectionId.value, targetDatabase.value, tgtSchema, name), - api.listIndexes(sourceConnectionId.value, sourceDatabase.value, srcSchema, name), - api.listIndexes(targetConnectionId.value, targetDatabase.value, tgtSchema, name), + const [srcCols, tgtCols, srcIdx, tgtIdx, srcFks, tgtFks, srcTriggers, tgtTriggers] = await Promise.all([ + api.getColumns(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name), + api.getColumns(targetConnectionId.value, targetDatabase.value, targetSchema.value, name), + api.listIndexes(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name), + api.listIndexes(targetConnectionId.value, targetDatabase.value, targetSchema.value, name), + api.listForeignKeys(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name), + api.listForeignKeys(targetConnectionId.value, targetDatabase.value, targetSchema.value, name), + api.listTriggers(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, name), + api.listTriggers(targetConnectionId.value, targetDatabase.value, targetSchema.value, name), ]); const colDiffs = diffColumns(srcCols, tgtCols); const idxDiffs = diffIndexes(srcIdx, tgtIdx); + const fkDiffs = diffForeignKeys(srcFks, tgtFks); + const triggerDiffs = diffTriggers(srcTriggers, tgtTriggers); - if (colDiffs.length > 0 || idxDiffs.length > 0) { + if (colDiffs.length > 0 || idxDiffs.length > 0 || fkDiffs.length > 0 || triggerDiffs.length > 0) { result.push({ type: "modified", + objectType: "table", name, columns: colDiffs.length > 0 ? colDiffs : undefined, indexes: idxDiffs.length > 0 ? idxDiffs : undefined, + foreignKeys: fkDiffs.length > 0 ? fkDiffs : undefined, + triggers: triggerDiffs.length > 0 ? triggerDiffs : undefined, }); } } diffs.value = result; const srcConfig = store.getConfig(targetConnectionId.value); - syncSql.value = generateSyncSql(result, srcConfig?.db_type || "mysql"); + syncSql.value = generateSyncSql(result, srcConfig?.db_type || "mysql", targetSchema.value); step.value = "result"; } catch (e: any) { toast(e?.message || String(e), 5000); @@ -152,7 +210,7 @@ async function executeSql() { executing.value = true; try { await store.ensureConnected(targetConnectionId.value); - await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value); + await api.executeScript(targetConnectionId.value, targetDatabase.value, syncSql.value, targetSchema.value); toast(t("diff.syncSuccess"), 2000); open.value = false; } catch (e: any) { @@ -197,8 +255,20 @@ watch(targetConnectionId, (id) => { resetResult(); }); -watch(sourceDatabase, () => resetResult()); -watch(targetDatabase, () => resetResult()); +watch(sourceDatabase, (database) => { + sourceSchema.value = ""; + sourceSchemas.value = []; + resetResult(); + if (database) loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000)); +}); +watch(targetDatabase, (database) => { + targetSchema.value = ""; + targetSchemas.value = []; + resetResult(); + if (database) loadSchemas("target").catch((e) => toast(String(e), 5000)); +}); +watch(sourceSchema, () => resetResult()); +watch(targetSchema, () => resetResult()); watch(open, async (val) => { if (val) { @@ -210,6 +280,7 @@ watch(open, async (val) => { await loadDatabases(props.prefillConnectionId, "source"); if (props.prefillDatabase) { sourceDatabase.value = props.prefillDatabase; + await loadSchemas("source", props.prefillSchema); } } } @@ -266,6 +337,18 @@ watch(open, async (val) => { {{ db }} + (sourceSchema = String(v))" + > + + + + + {{ schema }} + + @@ -305,6 +388,18 @@ watch(open, async (val) => { {{ db }} + (targetSchema = String(v))" + > + + + + + {{ schema }} + + @@ -347,7 +442,7 @@ watch(open, async (val) => { - + , + + ; + {{ t("diff.indexes") }}: + + {{ idx.type === "added" ? "+" : idx.type === "removed" ? "-" : "~" }}{{ idx.name }} + , + + + + ; + {{ t("diff.foreignKeys") }}: + + {{ fk.type === "added" ? "+" : fk.type === "removed" ? "-" : "~" }}{{ fk.name }} + , + + + + ; + {{ t("diff.triggers") }}: + + {{ trigger.type === "added" ? "+" : trigger.type === "removed" ? "-" : "~" + }}{{ trigger.name }} + , + + {{ t("diff.newTable") }} {{ t("diff.dropTable") }} diff --git a/src/components/layout/AppDialogs.vue b/src/components/layout/AppDialogs.vue index b912a4aa2..bf1d34d7c 100644 --- a/src/components/layout/AppDialogs.vue +++ b/src/components/layout/AppDialogs.vue @@ -8,6 +8,7 @@ import EditorSettingsDialog from "@/components/editor/EditorSettingsDialog.vue"; import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; const DataTransferDialog = defineAsyncComponent(() => import("@/components/transfer/DataTransferDialog.vue")); const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/SchemaDiffDialog.vue")); +const DataCompareDialog = defineAsyncComponent(() => import("@/components/diff/DataCompareDialog.vue")); const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue")); const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue")); const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue")); @@ -113,6 +114,14 @@ watch( v-model:open="dialogs.showSchemaDiffDialog.value" :prefill-connection-id="dialogs.schemaDiffPrefillConnectionId.value" :prefill-database="dialogs.schemaDiffPrefillDatabase.value" + :prefill-schema="dialogs.schemaDiffPrefillSchema.value" + /> + dragState.active && dragState.draggedId === pr {{ t("diff.title") }} + + {{ t("dataCompare.title") }} + @@ -1437,6 +1452,9 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr {{ t("contextMenu.importData") }} + + {{ t("dataCompare.title") }} + diff --git a/src/composables/useDialogSources.ts b/src/composables/useDialogSources.ts index 04f01f43d..df98d4150 100644 --- a/src/composables/useDialogSources.ts +++ b/src/composables/useDialogSources.ts @@ -6,6 +6,7 @@ import type { SidebarLayout } from "@/types/database"; const showTransferDialog = ref(false); const showSchemaDiffDialog = ref(false); +const showDataCompareDialog = ref(false); const showSqlFileDialog = ref(false); const showDiagramDialog = ref(false); const showTableImportDialog = ref(false); @@ -23,6 +24,11 @@ const transferPrefillConnectionId = ref(""); const transferPrefillDatabase = ref(""); const schemaDiffPrefillConnectionId = ref(""); const schemaDiffPrefillDatabase = ref(""); +const schemaDiffPrefillSchema = ref(""); +const dataComparePrefillConnectionId = ref(""); +const dataComparePrefillDatabase = ref(""); +const dataComparePrefillSchema = ref(""); +const dataComparePrefillTable = ref(""); const sqlFilePrefillConnectionId = ref(""); const sqlFilePrefillDatabase = ref(""); const diagramPrefillConnectionId = ref(""); @@ -75,12 +81,27 @@ export function useDialogSources() { if (v) { schemaDiffPrefillConnectionId.value = v.connectionId; schemaDiffPrefillDatabase.value = v.database; + schemaDiffPrefillSchema.value = v.schema ?? ""; showSchemaDiffDialog.value = true; connectionStore.schemaDiffSource = null; } }, ); + watch( + () => connectionStore.dataCompareSource, + (v) => { + if (v) { + dataComparePrefillConnectionId.value = v.connectionId; + dataComparePrefillDatabase.value = v.database; + dataComparePrefillSchema.value = v.schema ?? ""; + dataComparePrefillTable.value = v.tableName ?? ""; + showDataCompareDialog.value = true; + connectionStore.dataCompareSource = null; + } + }, + ); + watch( () => connectionStore.sqlFileSource, (v) => { @@ -221,6 +242,7 @@ export function useDialogSources() { return { showTransferDialog, showSchemaDiffDialog, + showDataCompareDialog, showSqlFileDialog, showDiagramDialog, showTableImportDialog, @@ -237,6 +259,11 @@ export function useDialogSources() { transferPrefillDatabase, schemaDiffPrefillConnectionId, schemaDiffPrefillDatabase, + schemaDiffPrefillSchema, + dataComparePrefillConnectionId, + dataComparePrefillDatabase, + dataComparePrefillSchema, + dataComparePrefillTable, sqlFilePrefillConnectionId, sqlFilePrefillDatabase, diagramPrefillConnectionId, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a4c583ca0..a1f9f1f9f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -714,6 +714,7 @@ export default { target: "Target", selectConnection: "Select connection", selectDatabase: "Select database", + selectSchema: "Select schema", compare: "Compare", comparing: "Comparing schemas...", noDifferences: "No differences found", @@ -723,6 +724,9 @@ export default { added: "Added", removed: "Removed", modified: "Modified", + indexes: "Indexes", + foreignKeys: "Foreign keys", + triggers: "Triggers", newTable: "New table", dropTable: "Drop table", generatedSql: "Sync SQL", @@ -730,6 +734,24 @@ export default { executeSync: "Execute Sync", syncSuccess: "Sync executed successfully", }, + dataCompare: { + title: "Compare Data", + selectTable: "Select table", + keyColumns: "Key Columns", + keyColumnsPlaceholder: "Comma-separated primary or unique columns", + rowLimit: "Compare row limit", + rowLimitOption: "{count} rows", + rowCounts: "Source {source} rows, target {target} rows; comparing up to the first {limit} rows", + truncatedWarning: + "The table exceeds this compare limit, so results only cover loaded rows. Increase the limit or use chunked compare later.", + compare: "Compare Data", + summary: "Added {added}, removed {removed}, modified {modified}", + noKeyColumns: "Select at least one key column", + missingKeyColumns: "Key columns must exist in both source and target tables: {columns}", + noCommonColumns: "Source and target tables do not have common columns to compare", + noDifferences: "Data is identical, no sync SQL needed", + syncSuccess: "Data sync executed successfully", + }, settings: { title: "Settings", editorTab: "Editor", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index d01bb09c1..b00cca796 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -702,6 +702,7 @@ export default { target: "目标数据库", selectConnection: "选择连接", selectDatabase: "选择数据库", + selectSchema: "选择 Schema", compare: "开始比较", comparing: "正在比较结构...", noDifferences: "两个数据库结构完全一致", @@ -711,6 +712,9 @@ export default { added: "新增", removed: "删除", modified: "修改", + indexes: "索引", + foreignKeys: "外键", + triggers: "触发器", newTable: "新增表", dropTable: "删除表", generatedSql: "同步 SQL", @@ -718,6 +722,23 @@ export default { executeSync: "执行同步", syncSuccess: "同步执行成功", }, + dataCompare: { + title: "比较数据", + selectTable: "选择表", + keyColumns: "匹配字段", + keyColumnsPlaceholder: "用逗号分隔主键或唯一键字段", + rowLimit: "比较行数上限", + rowLimitOption: "{count} 行", + rowCounts: "源表 {source} 行,目标表 {target} 行;本次最多比较前 {limit} 行", + truncatedWarning: "表数据超过本次比较上限,结果只代表已加载范围。建议提高上限或后续使用分块比较。", + compare: "开始比较数据", + summary: "新增 {added} 行,删除 {removed} 行,修改 {modified} 行", + noKeyColumns: "请至少选择一个匹配字段", + missingKeyColumns: "匹配字段在源表和目标表中都必须存在:{columns}", + noCommonColumns: "源表和目标表没有可比较的同名字段", + noDifferences: "数据完全一致,无需生成同步 SQL", + syncSuccess: "数据同步执行成功", + }, settings: { title: "设置", editorTab: "编辑器", diff --git a/src/lib/dataCompare.ts b/src/lib/dataCompare.ts new file mode 100644 index 000000000..e5be7ac37 --- /dev/null +++ b/src/lib/dataCompare.ts @@ -0,0 +1,181 @@ +import type { DatabaseType, QueryResult } from "@/types/database"; +import { quoteTableIdentifier } from "./tableSelectSql"; +import { formatGridSqlLiteral } from "./dataGridSql"; + +export type DataCompareCellValue = QueryResult["rows"][number][number]; + +export interface DataCompareChangedCell { + column: string; + source: DataCompareCellValue; + target: DataCompareCellValue; +} + +export interface DataCompareRow { + key: string; + keyValues: Record; + values: Record; +} + +export interface DataCompareModifiedRow { + key: string; + keyValues: Record; + sourceValues: Record; + targetValues: Record; + changes: DataCompareChangedCell[]; +} + +export interface DataCompareResult { + added: DataCompareRow[]; + removed: DataCompareRow[]; + modified: DataCompareModifiedRow[]; +} + +export interface CompareDataRowsOptions { + columns: readonly string[]; + keyColumns: readonly string[]; + sourceRows: readonly (readonly DataCompareCellValue[])[]; + targetRows: readonly (readonly DataCompareCellValue[])[]; +} + +export interface GenerateDataSyncSqlOptions { + tableName: string; + schema?: string; + columns: readonly string[]; + keyColumns: readonly string[]; + diff: DataCompareResult; + 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; +} + +function keyFor(row: Record, keyColumns: readonly string[]): string { + return keyColumns.map((column) => JSON.stringify(row[column] ?? null)).join("\u001f"); +} + +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"); +} diff --git a/src/lib/schemaDiff.ts b/src/lib/schemaDiff.ts index ac809012b..6351c210d 100644 --- a/src/lib/schemaDiff.ts +++ b/src/lib/schemaDiff.ts @@ -1,4 +1,4 @@ -import type { ColumnInfo, IndexInfo, DatabaseType } from "@/types/database"; +import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, DatabaseType } from "@/types/database"; export interface ColumnDiff { type: "added" | "removed" | "modified"; @@ -9,17 +9,37 @@ export interface ColumnDiff { } export interface IndexDiff { - type: "added" | "removed"; + type: "added" | "removed" | "modified"; name: string; source?: IndexInfo; target?: IndexInfo; + changes?: string[]; +} + +export interface ForeignKeyDiff { + type: "added" | "removed" | "modified"; + name: string; + source?: ForeignKeyInfo; + target?: ForeignKeyInfo; + changes?: string[]; +} + +export interface TriggerDiff { + type: "added" | "removed" | "modified"; + name: string; + source?: TriggerInfo; + target?: TriggerInfo; + changes?: string[]; } export interface TableDiff { type: "added" | "removed" | "modified"; + objectType?: "table" | "view"; name: string; columns?: ColumnDiff[]; indexes?: IndexDiff[]; + foreignKeys?: ForeignKeyDiff[]; + triggers?: TriggerDiff[]; ddl?: string; } @@ -65,8 +85,33 @@ export function diffIndexes(source: IndexInfo[], target: IndexInfo[]): IndexDiff for (const si of source) { if (si.is_primary) continue; - if (!targetMap.has(si.name)) { + 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 }); } } @@ -80,6 +125,71 @@ export function diffIndexes(source: IndexInfo[], target: IndexInfo[]): IndexDiff return diffs; } +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[], @@ -109,23 +219,79 @@ function columnDef(col: ColumnInfo, dbType: DatabaseType): string { return def; } -export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): string { +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)};`; +} + +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 = quoteId(diff.name, dbType); + const qt = qualifiedName(diff.name, dbType, schema); if (diff.type === "added" && diff.ddl) { - lines.push(`-- Create table: ${diff.name}`); + 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 table: ${diff.name}`); - lines.push(`DROP TABLE IF EXISTS ${qt};`); + lines.push(`-- Drop ${diff.objectType ?? "table"}: ${diff.name}`); + lines.push(dropObjectSql(diff, dbType, schema)); lines.push(""); continue; } @@ -133,6 +299,14 @@ export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): strin 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) { @@ -164,34 +338,6 @@ export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): strin } } - if (diff.indexes) { - for (const idx of diff.indexes) { - if (idx.type === "added" && idx.source) { - const cols = idx.source.columns.map((c) => quoteId(c, dbType)).join(", "); - const unique = idx.source.is_unique ? "UNIQUE " : ""; - const idxType = idx.source.index_type ?? ""; - const usingClause = idxType && dbType === "postgres" ? ` USING ${idxType}` : ""; - const typePrefix = idxType && dbType === "sqlserver" ? `${idxType} ` : ""; - const incCols = idx.source.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.source.filter && supportsWhere ? ` WHERE ${idx.source.filter}` : ""; - lines.push( - `CREATE ${unique}${typePrefix}INDEX ${quoteId(idx.name, dbType)} ON ${qt}${usingClause} (${cols})${includeClause}${filter};`, - ); - } else if (idx.type === "removed") { - if (isMySQL) { - lines.push(`DROP INDEX ${quoteId(idx.name, dbType)} ON ${qt};`); - } else { - lines.push(`DROP INDEX IF EXISTS ${quoteId(idx.name, dbType)};`); - } - } - } - } - if (parts.length > 0) { lines.push(`-- Alter table: ${diff.name}`); if (isMySQL) { @@ -204,6 +350,52 @@ export function generateSyncSql(diffs: TableDiff[], dbType: DatabaseType): strin } lines.push(""); } + + 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(""); + } } } diff --git a/src/lib/sqlCompletion.ts b/src/lib/sqlCompletion.ts index 0cdda30c7..421bd9413 100644 --- a/src/lib/sqlCompletion.ts +++ b/src/lib/sqlCompletion.ts @@ -308,7 +308,7 @@ function isInColumnContext(beforeCursor: string): boolean { for (let i = lastWords.length - 1; i >= Math.max(0, lastWords.length - 3); i--) { const word = lastWords[i]?.toLowerCase().replace(/[^a-z0-9.]/g, "") ?? ""; // Operators that indicate column context - if (/^[=<>!\+\-\*\/(,]$/.test(word)) return true; + if (/^[=<>!+\-*/(,]$/.test(word)) return true; // Keywords that directly precede column expressions if (["where", "on", "having", "set", "and", "or", "not", "is", "like", "in", "between", "select"].includes(word)) { return true; diff --git a/src/lib/xlsxExport.ts b/src/lib/xlsxExport.ts index e022c5184..654c941fc 100644 --- a/src/lib/xlsxExport.ts +++ b/src/lib/xlsxExport.ts @@ -37,8 +37,12 @@ function crc32(data: Uint8Array): number { } function escapeXml(value: string): string { - return value - .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "") + return [...value] + .filter((char) => { + const code = char.charCodeAt(0); + return code === 9 || code === 10 || code === 13 || code >= 32; + }) + .join("") .replace(/&/g, "&") .replace(//g, ">") @@ -66,7 +70,12 @@ function sheetRange(columnCount: number, rowCount: number): string { } function normalizeSheetName(value?: string): string { - const name = (value || "Sheet1").replace(/[\[\]:*?/\\]/g, " ").trim() || "Sheet1"; + const invalidChars = new Set(["[", "]", ":", "*", "?", "/", "\\"]); + const name = + [...(value || "Sheet1")] + .map((char) => (invalidChars.has(char) ? " " : char)) + .join("") + .trim() || "Sheet1"; return name.slice(0, 31); } diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 91aecb5a2..b610132ec 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -50,7 +50,13 @@ export const useConnectionStore = defineStore("connection", () => { const completionTablesCache = ref>({}); const completionColumnsCache = ref>({}); const transferSource = ref<{ connectionId: string; database: string } | null>(null); - const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null); + const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null); + const dataCompareSource = ref<{ + connectionId: string; + database: string; + schema?: string; + tableName?: string; + } | null>(null); const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null); const diagramSource = ref<{ connectionId: string; @@ -1154,6 +1160,7 @@ export const useConnectionStore = defineStore("connection", () => { applySidebarLayout, transferSource, schemaDiffSource, + dataCompareSource, sqlFileSource, diagramSource, tableImportSource, diff --git a/tests/dataCompare.test.ts b/tests/dataCompare.test.ts new file mode 100644 index 000000000..70fcfab74 --- /dev/null +++ b/tests/dataCompare.test.ts @@ -0,0 +1,87 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { compareDataRows, generateDataSyncSql } from "../src/lib/dataCompare.ts"; + +test("compares rows by primary key and reports added, removed, and modified rows", () => { + const diff = compareDataRows({ + columns: ["id", "name", "active"], + keyColumns: ["id"], + sourceRows: [ + [1, "Ada", true], + [2, "Bob", false], + [4, "Dora", true], + ], + targetRows: [ + [1, "Ada", true], + [2, "Bobby", false], + [3, "Cara", true], + ], + }); + + assert.deepEqual( + diff.added.map((row) => row.keyValues), + [{ id: 4 }], + ); + assert.deepEqual( + diff.removed.map((row) => row.keyValues), + [{ id: 3 }], + ); + assert.deepEqual( + diff.modified.map((row) => row.changes), + [[{ column: "name", source: "Bob", target: "Bobby" }]], + ); +}); + +test("generates data synchronization SQL", () => { + const diff = compareDataRows({ + columns: ["id", "name", "active"], + keyColumns: ["id"], + sourceRows: [ + [1, "Ada", true], + [2, "Bob", false], + ], + targetRows: [ + [1, "Ada Lovelace", true], + [3, "Cara", true], + ], + }); + + assert.equal( + generateDataSyncSql({ + tableName: "users", + schema: "public", + columns: ["id", "name", "active"], + keyColumns: ["id"], + diff, + databaseType: "postgres", + }), + [ + `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"), + ); +}); + +test("requires at least one key column", () => { + assert.throws( + () => compareDataRows({ columns: ["id"], keyColumns: [], sourceRows: [[1]], targetRows: [[1]] }), + /At least one key column/, + ); +}); + +test("rejects duplicate row keys", () => { + assert.throws( + () => + compareDataRows({ + columns: ["id", "name"], + keyColumns: ["id"], + sourceRows: [ + [1, "Ada"], + [1, "Ada Clone"], + ], + targetRows: [[1, "Ada"]], + }), + /Duplicate source key/, + ); +}); diff --git a/tests/schemaDiff.test.ts b/tests/schemaDiff.test.ts new file mode 100644 index 000000000..889ccfc7d --- /dev/null +++ b/tests/schemaDiff.test.ts @@ -0,0 +1,126 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { diffForeignKeys, diffIndexes, generateSyncSql, type TableDiff } from "../src/lib/schemaDiff.ts"; +import type { ForeignKeyInfo, IndexInfo } from "../src/types/database.ts"; + +function index(overrides: Partial): IndexInfo { + return { + name: "idx_users_email", + columns: ["email"], + is_unique: false, + is_primary: false, + ...overrides, + }; +} + +function foreignKey(overrides: Partial): ForeignKeyInfo { + return { + name: "orders_user_id_fk", + column: "user_id", + ref_table: "users", + ref_column: "id", + ...overrides, + }; +} + +test("detects modified indexes, not only added or removed indexes", () => { + const diffs = diffIndexes( + [index({ name: "idx_orders_status", columns: ["status", "created_at"], is_unique: false })], + [index({ name: "idx_orders_status", columns: ["status"], is_unique: true })], + ); + + assert.equal(diffs.length, 1); + assert.equal(diffs[0].type, "modified"); + assert.deepEqual(diffs[0].changes, ["unique: YES → NO", "columns: status → status, created_at"]); +}); + +test("detects foreign key additions, removals, and target changes", () => { + const diffs = diffForeignKeys( + [ + foreignKey({ name: "orders_user_id_fk" }), + foreignKey({ name: "orders_account_id_fk", column: "account_id", ref_table: "accounts" }), + ], + [ + foreignKey({ name: "orders_user_id_fk", ref_table: "members" }), + foreignKey({ name: "orders_region_id_fk", column: "region_id", ref_table: "regions" }), + ], + ); + + assert.deepEqual( + diffs.map((diff) => [diff.type, diff.name]), + [ + ["modified", "orders_user_id_fk"], + ["added", "orders_account_id_fk"], + ["removed", "orders_region_id_fk"], + ], + ); +}); + +test("generates sync SQL for index and foreign key changes", () => { + const diffs: TableDiff[] = [ + { + type: "modified", + name: "orders", + indexes: [ + { + type: "modified", + name: "idx_orders_status", + source: index({ name: "idx_orders_status", columns: ["status", "created_at"], is_unique: true }), + }, + ], + foreignKeys: [ + { + type: "modified", + name: "orders_user_id_fk", + source: foreignKey({ name: "orders_user_id_fk", ref_table: "users" }), + }, + ], + }, + ]; + + assert.equal( + generateSyncSql(diffs, "postgres"), + [ + 'ALTER TABLE "orders" DROP CONSTRAINT "orders_user_id_fk";', + 'DROP INDEX IF EXISTS "idx_orders_status";', + 'CREATE UNIQUE INDEX "idx_orders_status" ON "orders" ("status", "created_at");', + 'ALTER TABLE "orders" ADD CONSTRAINT "orders_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "users" ("id");', + ].join("\n"), + ); +}); + +test("qualifies generated schema sync SQL with target schema", () => { + const diffs: TableDiff[] = [ + { + type: "modified", + name: "orders", + columns: [ + { + type: "added", + name: "status", + source: { + name: "status", + data_type: "text", + is_nullable: true, + column_default: null, + is_primary_key: false, + extra: null, + }, + }, + ], + indexes: [ + { type: "added", name: "idx_orders_status", source: index({ name: "idx_orders_status", columns: ["status"] }) }, + ], + }, + ]; + + assert.equal( + generateSyncSql(diffs, "postgres", "sales"), + [ + "-- Alter table: orders", + 'ALTER TABLE "sales"."orders" ADD COLUMN "status" text;', + "", + 'CREATE INDEX "idx_orders_status" ON "sales"."orders" ("status");', + ].join("\n"), + ); +});