From b5fcb3a8320f50d5bbf59bc9cdbdc451a3178274 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sat, 5 Sep 2026 09:52:52 +0800 Subject: [PATCH] feat(diff): manual table mapping for schema compare --- .../components/diff/SchemaDiffConfigStep.vue | 141 +++++++-- .../src/components/diff/SchemaDiffDialog.vue | 36 ++- .../diff/__tests__/SchemaDiffDialog.spec.ts | 8 + apps/desktop/src/i18n/locales/en.ts | 11 + apps/desktop/src/i18n/locales/es.ts | 11 + apps/desktop/src/i18n/locales/it.ts | 11 + apps/desktop/src/i18n/locales/ja.ts | 11 + apps/desktop/src/i18n/locales/ko.ts | 20 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 11 + apps/desktop/src/i18n/locales/tr.ts | 11 + apps/desktop/src/i18n/locales/zh-CN.ts | 11 + apps/desktop/src/i18n/locales/zh-TW.ts | 11 + .../__tests__/diff/sameNameTableMatch.spec.ts | 24 -- .../schema/schemaDiffTableMapping.spec.ts | 125 ++++++++ .../src/lib/diff/sameNameTableMatch.ts | 23 -- apps/desktop/src/lib/schema/schemaDiff.ts | 7 +- .../src/lib/schema/schemaDiffTableFilter.ts | 35 ++- .../src/lib/schema/schemaDiffTableList.ts | 2 + .../src/lib/schema/schemaDiffTableMapping.ts | 116 ++++++++ apps/desktop/src/types/schemaDiff.ts | 14 +- crates/dbx-core/src/correction.rs | 1 + crates/dbx-core/src/schema_diff.rs | 279 +++++++++++++++--- crates/dbx-core/src/script_generator.rs | 7 + .../tests/api_contract_verification.rs | 2 + .../dbx-core/tests/bidirectional_diff_e2e.rs | 1 + .../tests/cross_dialect_integration.rs | 3 + .../tests/regression_compatibility.rs | 1 + .../tests/schema_diff_table_mapping.rs | 74 +++++ 28 files changed, 885 insertions(+), 122 deletions(-) delete mode 100644 apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts delete mode 100644 apps/desktop/src/lib/diff/sameNameTableMatch.ts create mode 100644 apps/desktop/src/lib/schema/schemaDiffTableMapping.ts create mode 100644 crates/dbx-core/tests/schema_diff_table_mapping.rs diff --git a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue index 99e43830ca..c175f89bf5 100644 --- a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue +++ b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue @@ -7,7 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue"; import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue"; import TableMultiSelect from "@/components/diff/TableMultiSelect.vue"; -import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch"; +import { buildSchemaDiffTableMatches, availableSchemaDiffTargetTables, areSchemaDiffTableMappingsEqual, pruneSchemaDiffTableMappings, reconcileSchemaDiffTableMappings, updateSchemaDiffTableMapping, type SchemaDiffTableMatch } from "@/lib/schema/schemaDiffTableMapping"; import { createSchemaDiffTableListCoordinator, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableListLoader, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList"; import { useConnectionStore } from "@/stores/connectionStore"; import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; @@ -15,7 +15,7 @@ import * as api from "@/lib/backend/api"; import { isSchemaAware } from "@/lib/database/databaseCapabilities"; import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions"; import { ArrowLeftRight, GitCompareArrows, Save, FolderOpen, Settings, X } from "@lucide/vue"; -import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry } from "@/types/schemaDiff"; +import type { SchemaDiffConfig, SchemaDiffCompareOptions, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff"; const { t } = useI18n(); const store = useConnectionStore(); @@ -46,6 +46,7 @@ const emit = defineEmits<{ (e: "update:targetSchema", value: string): void; (e: "update:ignoreComments", value: boolean): void; (e: "update:fieldMappings", value: FieldMappingEntry[]): void; + (e: "update:tableMappings", value: SchemaDiffTableMapping[]): void; (e: "open-field-mapping"): void; (e: "compare"): void; (e: "saveConfig"): void; @@ -77,11 +78,38 @@ const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []); // ---- Visual (explicit) table selection ---- const sourceTableList = ref([]); const targetTableList = ref([]); +const targetTableListLoaded = ref(false); const restrictTables = ref(false); const localSelectedTables = ref([]); -const matchResult = computed(() => buildSameNameTableMatches(localSelectedTables.value, targetTableList.value)); -const missingTargetTables = computed(() => matchResult.value.missing); +const activeTableMappings = computed(() => props.options?.tableMappings ?? []); +const tableMatches = computed(() => buildSchemaDiffTableMatches(localSelectedTables.value, targetTableList.value, activeTableMappings.value)); +const matchedTableCount = computed(() => tableMatches.value.filter((match) => match.targetTable).length); +const missingTargetTables = computed(() => tableMatches.value.filter((match) => !match.targetTable).map((match) => match.sourceTable)); +const tableMappingConflictSource = ref(null); + +function targetTableOptions(sourceTable: string): string[] { + return availableSchemaDiffTargetTables(sourceTable, targetTableList.value, activeTableMappings.value); +} + +function handleTableMappingUpdate(sourceTable: string, targetTable: string) { + const update = updateSchemaDiffTableMapping(activeTableMappings.value, sourceTable, targetTable); + if (update.accepted) { + tableMappingConflictSource.value = null; + emitTableMappings(update.mappings); + } else { + tableMappingConflictSource.value = update.conflictSource ?? sourceTable; + } +} + +function emitTableMappings(nextMappings: SchemaDiffTableMapping[]) { + if (!areSchemaDiffTableMappingsEqual(nextMappings, activeTableMappings.value)) emit("update:tableMappings", nextMappings); +} + +function reconcileTableMappings(selectedTables: string[], targetTables = targetTableList.value) { + const nextMappings = targetTableListLoaded.value ? reconcileSchemaDiffTableMappings(selectedTables, targetTables, activeTableMappings.value) : pruneSchemaDiffTableMappings(selectedTables, activeTableMappings.value); + emitTableMappings(nextMappings); +} // Keep the visual restriction in sync with the (persisted) config options. // `undefined` = not restricted (compare all tables, then regex filter); @@ -91,27 +119,46 @@ watch( (value) => { restrictTables.value = Array.isArray(value); localSelectedTables.value = value && Array.isArray(value) ? [...value] : []; + if (!restrictTables.value) emitTableMappings([]); + else if (targetTableListLoaded.value) reconcileTableMappings(localSelectedTables.value); }, { immediate: true }, ); +watch( + () => props.options?.tableMappings, + () => { + if (restrictTables.value && targetTableListLoaded.value) reconcileTableMappings(localSelectedTables.value); + }, + { deep: true }, +); + function handleToggleRestrict(enabled: boolean) { restrictTables.value = enabled; emit("update:selectedTables", enabled ? [...localSelectedTables.value] : undefined); + if (!enabled) emitTableMappings([]); + else reconcileTableMappings(localSelectedTables.value); } function handleUpdateSelectedTables(value: string[]) { localSelectedTables.value = value; - if (restrictTables.value) emit("update:selectedTables", [...value]); + if (!restrictTables.value) return; + emit("update:selectedTables", [...value]); + reconcileTableMappings(value); } function clearUnavailableTableSelection() { sourceTableList.value = []; targetTableList.value = []; - if (props.selectedTables === undefined && !restrictTables.value && localSelectedTables.value.length === 0) return; + targetTableListLoaded.value = false; + if (props.selectedTables === undefined && !restrictTables.value && localSelectedTables.value.length === 0) { + emitTableMappings([]); + return; + } restrictTables.value = false; localSelectedTables.value = []; emit("update:selectedTables", undefined); + emitTableMappings([]); } function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity { @@ -127,18 +174,28 @@ function isTableIdentityReady(side: SchemaDiffTableSide): boolean { function setTableList(side: SchemaDiffTableSide, tables: Array<{ name?: string }>) { const names = tables.map((entry) => entry.name ?? "").filter(Boolean); if (side === "source") sourceTableList.value = names; - else targetTableList.value = names; + else { + targetTableList.value = names; + targetTableListLoaded.value = false; + } } function reconcileSelectedTablesAfterSuccessfulLoad(tables: Array<{ name?: string }>) { - if (!restrictTables.value || !Array.isArray(props.selectedTables)) return; - const availableTables = tables.map((entry) => entry.name ?? "").filter(Boolean); - const nextSelectedTables = reconcileSchemaDiffSelectedTables(props.selectedTables, availableTables); - if (nextSelectedTables.length === props.selectedTables.length && nextSelectedTables.every((table, index) => table === props.selectedTables?.[index])) return; + let selectedTables = localSelectedTables.value; + if (restrictTables.value && Array.isArray(props.selectedTables)) { + const nextSelectedTables = reconcileSchemaDiffSelectedTables(props.selectedTables, availableTables); + const changed = nextSelectedTables.length !== props.selectedTables.length || nextSelectedTables.some((table, index) => table !== props.selectedTables?.[index]); + selectedTables = nextSelectedTables; + localSelectedTables.value = nextSelectedTables; + if (changed) emit("update:selectedTables", nextSelectedTables); + } + if (restrictTables.value) reconcileTableMappings(selectedTables); +} - localSelectedTables.value = nextSelectedTables; - emit("update:selectedTables", nextSelectedTables); +function reconcileTargetTablesAfterSuccessfulLoad(tables: Array<{ name?: string }>) { + targetTableListLoaded.value = true; + reconcileTableMappings(localSelectedTables.value, tables.map((entry) => entry.name ?? "").filter(Boolean)); } const tableListCoordinator = createSchemaDiffTableListCoordinator({ @@ -146,6 +203,7 @@ const tableListCoordinator = createSchemaDiffTableListCoordinator({ getIdentity: getTableIdentity, setTables: setTableList, onSourceTablesLoaded: reconcileSelectedTablesAfterSuccessfulLoad, + onTargetTablesLoaded: reconcileTargetTablesAfterSuccessfulLoad, }); watch( @@ -170,7 +228,10 @@ const canConfigureTableSelection = computed(() => isTableIdentityReady("source") const canCompare = computed(() => { const hasSelectedTables = props.selectedTables === undefined || props.selectedTables.length > 0; - return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema); + // Unmapped source tables are not a blocker: they flow through the comparison as + // source-only tables (CREATE TABLE in the target), matching the pre-mapping behavior. + const hasValidTableMappings = !restrictTables.value || localSelectedTables.value.length === 0 || isTableIdentityReady("target"); + return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && hasSelectedTables && hasValidTableMappings && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema); }); async function loadDatabases(connectionId: string, side: "source" | "target") { @@ -558,13 +619,53 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st -
-
{{ t("diff.autoMatchHint") }}
-
- {{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }} + +
+
+
{{ t("diff.targetTableMatching") }}
+
+ {{ t("diff.matchedTables", { matched: matchedTableCount, total: localSelectedTables.length }) }} +
-
- {{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }} +
+ + + + + + + + + + + + + + + +
{{ t("diff.sourceTables") }}{{ t("diff.targetTable") }}{{ t("diff.status") }}
{{ match.sourceTable }} + + + {{ t(`diff.tableMatchStatus.${match.kind}`) }} +
+
+
+ {{ t("diff.unmatchedTableWarning", { count: missingTargetTables.length }) }} +
+
+ {{ t("diff.targetTableInUse") }}
diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index 74b0c6d76e..0b09c24950 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -22,7 +22,7 @@ import { loadSchemaDetails } from "@/lib/schema/schemaDiffMetadataLoad"; import { getSchemaDiffNextProgressStep, isSchemaDiffPostgresLike, shouldLoadSchemaDiffExtraObjects, type SchemaDiffProgressPhase } from "@/lib/schema/schemaDiffProgress"; import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList"; import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff"; -import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff"; +import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry, SchemaDiffTableMapping } from "@/types/schemaDiff"; import type { DatabaseType, ObjectSourceKind } from "@/types/database"; import { convertToSchemaDiffObjects, @@ -54,6 +54,7 @@ import { normalizeSchemaDiffDependencyGraph, } from "@/lib/schema/schemaDiff"; import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter"; +import { swapSchemaDiffTableMappings } from "@/lib/schema/schemaDiffTableMapping"; import { Splitpanes, Pane } from "splitpanes"; import "splitpanes/dist/splitpanes.css"; @@ -409,6 +410,22 @@ function getDbType(): DatabaseType { } function handleSwap() { + const currentOptions = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, getDbType()); + const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []); + const swappedSelectedTables = Array.isArray(currentOptions.selectedTables) ? swappedMappings.map((mapping) => mapping.sourceTable) : undefined; + if (activeConfig.value) { + updateActiveConfigOptions( + normalizeSchemaDiffCompareOptions( + { + ...currentOptions, + selectedTables: swappedSelectedTables, + tableMappings: swappedMappings, + }, + getDbType(), + ), + ); + } + const tempConn = sourceConnectionId.value; const tempDb = sourceDatabase.value; const tempSchema = sourceSchema.value; @@ -432,6 +449,12 @@ function handleSelectedTablesUpdate(value?: string[]) { } } +function handleTableMappingsUpdate(value: SchemaDiffTableMapping[]) { + if (activeConfig.value) { + updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, tableMappings: value }, getDbType())); + } +} + function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) { if (activeConfig.value) { const updated = { ...activeConfig.value.options, fieldMappings: mappings }; @@ -559,6 +582,10 @@ async function handleCompare() { targetRules: tgtRules, sourceOwners: srcOwners, targetOwners: tgtOwners, + // Mappings only apply to an explicit table selection; the all-tables path + // (selectedTables === undefined) must stay mapping-free so the Rust side + // cannot reclassify added/removed tables from stale persisted mappings. + tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings, databaseType: dbType, targetSchema: schemaDiffDeployTargetSchema(dbType, targetDatabase.value, targetSchema.value), ignoreComments: ignoreComments.value, @@ -915,7 +942,7 @@ function handleLoadHistoryConfig(config: SchemaDiffConfig) { targetDatabase.value = config.targetDatabase; targetSchema.value = config.targetSchema; if (config.options) { - updateActiveConfigOptions(config.options); + updateActiveConfigOptions(normalizeSchemaDiffCompareOptions(config.options, getDbType())); } } @@ -1057,8 +1084,8 @@ const targetConnectionInfo = computed(() => { v-model:ignore-comments="ignoreComments" :configs="configs" :active-config-id="activeConfigId" - :options="activeConfig?.options" - :selected-tables="activeConfig?.options?.selectedTables" + :options="schemaDiffPanelOptions" + :selected-tables="schemaDiffPanelOptions.selectedTables" :table-list-loader="schemaDiffTableListLoader" :loading="loading" :recent-configs="recentConfigs" @@ -1069,6 +1096,7 @@ const targetConnectionInfo = computed(() => { @load-history-config="handleLoadHistoryConfig" @delete-history-config="handleDeleteHistoryConfig" @update:field-mappings="handleFieldMappingsUpdate" + @update:table-mappings="handleTableMappingsUpdate" @update:selected-tables="handleSelectedTablesUpdate" @open-field-mapping="showFieldMappingDialog = true" /> diff --git a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts index 95920f4a03..dd9ef8d02c 100644 --- a/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts +++ b/apps/desktop/src/components/diff/__tests__/SchemaDiffDialog.spec.ts @@ -107,6 +107,14 @@ describe("SchemaDiffDialog fullscreen layout", () => { expect(targetMatch).toBeLessThan(options); }); + it("wires source-to-target table mappings through the selector and compare request", () => { + expect(configStepSource).toContain('@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"'); + expect(configStepSource).toContain("tableMatchStatus.${match.kind}"); + expect(dialogSource).toContain('@update:table-mappings="handleTableMappingsUpdate"'); + expect(dialogSource).toContain("tableMappings: opts.selectedTables === undefined ? undefined : opts.tableMappings"); + expect(dialogSource).toContain("const swappedMappings = swapSchemaDiffTableMappings(currentOptions.tableMappings ?? []);"); + }); + it("keeps focused and selected deployment SQL projections separate", () => { expect(dialogSource).toContain('const focusedDeploySql = ref("");'); expect(dialogSource).toContain('const selectedDeploySql = ref("");'); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c520992e72..9a6c55fbd1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6050,6 +6050,17 @@ export default { autoMatchHint: "Target tables are matched by the same name.", matchedTables: "{matched}/{total} tables matched", missingTargetTables: "Missing same-name target tables: {tables}", + targetTableMatching: "Target table matching", + selectTargetTable: "Select target table", + searchTargetTable: "Search target tables...", + noTargetTables: "No target tables available", + tableMatchStatus: { + automatic: "Automatic", + manual: "Manual", + unmatched: "Unmatched", + }, + unmatchedTableWarning: "{count} selected source table(s) have no target mapping", + targetTableInUse: "This target table is already used by another source table", sourceObject: "Source object", targetObject: "Target object", operation: "Operation", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index c05fe3651a..af043533c0 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5727,6 +5727,17 @@ export default withEnglishFallback({ autoMatchHint: "Las tablas de destino se emparejan por el mismo nombre.", matchedTables: "{matched}/{total} tablas coinciden", missingTargetTables: "Faltan tablas de destino con el mismo nombre: {tables}", + targetTableMatching: "Emparejamiento de tablas de destino", + selectTargetTable: "Selecciona una tabla de destino", + searchTargetTable: "Buscar tablas de destino...", + noTargetTables: "No hay tablas de destino disponibles", + tableMatchStatus: { + automatic: "Automático", + manual: "Manual", + unmatched: "Sin coincidencia", + }, + unmatchedTableWarning: "{count} tabla(s) de origen seleccionada(s) no tienen una tabla de destino", + targetTableInUse: "Esta tabla de destino ya está usada por otra tabla de origen", sourceObject: "Objeto de origen", targetObject: "Objeto de destino", operation: "Operación", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4ebf93d7a3..d87004df10 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5727,6 +5727,17 @@ export default withEnglishFallback({ autoMatchHint: "Le tabelle di destinazione vengono abbinate per nome.", matchedTables: "{matched}/{total} tabelle abbinate", missingTargetTables: "Tabelle di destinazione omonime mancanti: {tables}", + targetTableMatching: "Abbinamento delle tabelle di destinazione", + selectTargetTable: "Seleziona la tabella di destinazione", + searchTargetTable: "Cerca tabelle di destinazione...", + noTargetTables: "Nessuna tabella di destinazione disponibile", + tableMatchStatus: { + automatic: "Automatico", + manual: "Manuale", + unmatched: "Non abbinata", + }, + unmatchedTableWarning: "{count} tabella/e sorgente selezionata/e senza abbinamento di destinazione", + targetTableInUse: "Questa tabella di destinazione è già usata da un'altra tabella sorgente", sourceObject: "Oggetto sorgente", targetObject: "Oggetto destinazione", operation: "Operazione", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b3488b3337..d289e27b4f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5821,6 +5821,17 @@ export default withEnglishFallback({ autoMatchHint: "ターゲットテーブルは同じ名前でマッチングされます。", matchedTables: "{matched}/{total}テーブルがマッチしました", missingTargetTables: "同名のターゲットテーブルがありません: {tables}", + targetTableMatching: "ターゲットテーブルのマッピング", + selectTargetTable: "ターゲットテーブルを選択", + searchTargetTable: "ターゲットテーブルを検索...", + noTargetTables: "利用可能なターゲットテーブルがありません", + tableMatchStatus: { + automatic: "自動マッチ", + manual: "手動マッチ", + unmatched: "未マッチ", + }, + unmatchedTableWarning: "選択したソーステーブルのうち {count} 件にターゲットマッピングがありません", + targetTableInUse: "このターゲットテーブルは別のソーステーブルで使用されています", sourceObject: "ソースオブジェクト", targetObject: "ターゲットオブジェクト", operation: "操作", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index f3d0c3ae68..3cc423783a 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5459,6 +5459,26 @@ export default withEnglishFallback({ syncSummary: "성공 {success}, 실패 {failed}", configSaved: "설정을 저장했습니다", configDeleted: "설정을 삭제했습니다", + tableSelection: "테이블", + chooseTables: "테이블 선택...", + compareAllTables: "모든 테이블 비교", + tableSelectionUnrestricted: "정규식 필터 후 모든 테이블을 비교합니다. 특정 테이블을 선택하면 비교 범위를 제한할 수 있습니다.", + sourceTables: "소스 테이블", + targetTable: "대상 테이블", + autoMatchHint: "대상 테이블은 같은 이름으로 자동 매칭됩니다.", + matchedTables: "{matched}/{total}개 테이블 매칭됨", + missingTargetTables: "같은 이름의 대상 테이블 누락: {tables}", + targetTableMatching: "대상 테이블 매핑", + selectTargetTable: "대상 테이블 선택", + searchTargetTable: "대상 테이블 검색...", + noTargetTables: "사용 가능한 대상 테이블이 없습니다", + tableMatchStatus: { + automatic: "자동 매칭", + manual: "수동 매칭", + unmatched: "매칭되지 않음", + }, + unmatchedTableWarning: "선택한 소스 테이블 중 {count}개에 대상 매핑이 없습니다", + targetTableInUse: "이 대상 테이블은 다른 소스 테이블에서 이미 사용 중입니다", sourceObject: "소스 객체", targetObject: "대상 객체", operation: "작업", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e2d54beec1..493d7a59d8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5729,6 +5729,17 @@ export default withEnglishFallback({ autoMatchHint: "As tabelas de destino são correspondidas pelo mesmo nome.", matchedTables: "{matched}/{total} tabelas correspondidas", missingTargetTables: "Tabelas de destino com o mesmo nome ausentes: {tables}", + targetTableMatching: "Mapeamento das tabelas de destino", + selectTargetTable: "Selecione a tabela de destino", + searchTargetTable: "Pesquisar tabelas de destino...", + noTargetTables: "Nenhuma tabela de destino disponível", + tableMatchStatus: { + automatic: "Automático", + manual: "Manual", + unmatched: "Sem correspondência", + }, + unmatchedTableWarning: "{count} tabela(s) de origem selecionada(s) sem mapeamento de destino", + targetTableInUse: "Esta tabela de destino já está sendo usada por outra tabela de origem", sourceObject: "Objeto de origem", targetObject: "Objeto de destino", operation: "Operação", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 4d2976d511..c02a268dd7 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -6040,6 +6040,17 @@ export default withEnglishFallback({ autoMatchHint: "Hedef tablolar aynı ada göre eşleştirilir.", matchedTables: "{matched}/{total} tablo eşleşti", missingTargetTables: "Aynı adlı hedef tablolar eksik: {tables}", + targetTableMatching: "Hedef tablo eşleştirme", + selectTargetTable: "Hedef tabloyu seçin", + searchTargetTable: "Hedef tabloları arayın...", + noTargetTables: "Kullanılabilir hedef tablo yok", + tableMatchStatus: { + automatic: "Otomatik", + manual: "Manuel", + unmatched: "Eşleşmedi", + }, + unmatchedTableWarning: "Seçilen {count} kaynak tablonun hedef eşlemesi yok", + targetTableInUse: "Bu hedef tablo başka bir kaynak tablo tarafından zaten kullanılıyor", sourceObject: "Kaynak nesne", targetObject: "Hedef nesne", operation: "İşlem", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4d16092a36..3054483e7d 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6117,6 +6117,17 @@ export default withEnglishFallback({ autoMatchHint: "目标表按同名自动匹配。", matchedTables: "已匹配 {matched}/{total} 张表", missingTargetTables: "缺少同名的目标表:{tables}", + targetTableMatching: "目标表匹配", + selectTargetTable: "请选择目标表", + searchTargetTable: "搜索目标表...", + noTargetTables: "暂无目标表", + tableMatchStatus: { + automatic: "自动匹配", + manual: "手动匹配", + unmatched: "未匹配", + }, + unmatchedTableWarning: "还有 {count} 张源表未匹配目标表", + targetTableInUse: "该目标表已被其他源表使用", noDeployScriptAll: "暂无所选对象的部署脚本", saveConfigPrompt: "请输入配置名称:", defaultConfigName: "默认", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 73224ecac0..e4ff520925 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5063,6 +5063,17 @@ export default withEnglishFallback({ autoMatchHint: "目標資料表會依相同名稱進行比對。", matchedTables: "已比對 {matched}/{total} 張資料表", missingTargetTables: "缺少同名的目標資料表:{tables}", + targetTableMatching: "目標資料表比對", + selectTargetTable: "請選擇目標資料表", + searchTargetTable: "搜尋目標資料表...", + noTargetTables: "沒有可用的目標資料表", + tableMatchStatus: { + automatic: "自動比對", + manual: "手動比對", + unmatched: "未比對", + }, + unmatchedTableWarning: "還有 {count} 張來源資料表未比對目標資料表", + targetTableInUse: "此目標資料表已被其他來源資料表使用", sourceObject: "來源物件", targetObject: "目標物件", saveConfig: "儲存配置", diff --git a/apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts b/apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts deleted file mode 100644 index e183d1cbef..0000000000 --- a/apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch"; - -describe("buildSameNameTableMatches", () => { - it("matches same-name target tables and reports missing ones", () => { - const result = buildSameNameTableMatches(["a", "b", "c"], ["a", "c", "d"]); - expect(result).toEqual({ matched: ["a", "c"], missing: ["b"] }); - }); - - it("reports all missing when no target tables exist", () => { - const result = buildSameNameTableMatches(["x", "y"], []); - expect(result).toEqual({ matched: [], missing: ["x", "y"] }); - }); - - it("has no missing when every source matches", () => { - const result = buildSameNameTableMatches(["a", "b"], ["b", "a"]); - expect(result).toEqual({ matched: ["a", "b"], missing: [] }); - }); - - it("handles empty source set", () => { - const result = buildSameNameTableMatches([], ["a"]); - expect(result).toEqual({ matched: [], missing: [] }); - }); -}); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts new file mode 100644 index 0000000000..8afb115185 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableMapping.spec.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import { loadSchemaDetails, type SchemaDiffMetadataApi } from "@/lib/schema/schemaDiffMetadataLoad"; +import { compileSchemaDiffTableFilter, filterSchemaDiffTables } from "@/lib/schema/schemaDiffTableFilter"; +import { availableSchemaDiffTargetTables, buildSchemaDiffTableMatches, pruneSchemaDiffTableMappings, reconcileSchemaDiffTableMappings, swapSchemaDiffTableMappings, updateSchemaDiffTableMapping } from "@/lib/schema/schemaDiffTableMapping"; +import { DEFAULT_MYSQL_OPTIONS, normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff"; +import type { TableInfo } from "@/types/database"; + +const table = (name: string): TableInfo => ({ name, table_type: "BASE TABLE" }); + +describe("schema diff table mappings", () => { + it("automatically matches same-name tables", () => { + expect(buildSchemaDiffTableMatches(["a", "b"], ["a", "b"], [])).toEqual([ + { sourceTable: "a", targetTable: "a", kind: "automatic" }, + { sourceTable: "b", targetTable: "b", kind: "automatic" }, + ]); + }); + + it("leaves a source unmatched when only some names exist on target", () => { + expect(buildSchemaDiffTableMatches(["a", "b", "c"], ["a", "x", "c"], [])).toEqual([ + { sourceTable: "a", targetTable: "a", kind: "automatic" }, + { sourceTable: "b", kind: "unmatched" }, + { sourceTable: "c", targetTable: "c", kind: "automatic" }, + ]); + }); + + it("keeps manual mappings alongside automatic matches", () => { + expect(buildSchemaDiffTableMatches(["a", "b", "c"], ["a", "x", "c"], [{ sourceTable: "b", targetTable: "x" }])).toEqual([ + { sourceTable: "a", targetTable: "a", kind: "automatic" }, + { sourceTable: "b", targetTable: "x", kind: "manual" }, + { sourceTable: "c", targetTable: "c", kind: "automatic" }, + ]); + }); + + it("allows a manual mapping to override an automatic match", () => { + expect(buildSchemaDiffTableMatches(["a"], ["a", "a_new"], [{ sourceTable: "a", targetTable: "a_new" }])).toEqual([{ sourceTable: "a", targetTable: "a_new", kind: "manual" }]); + }); + + it("removes mappings for deselected sources and automatically matches new sources", () => { + const mappings = [ + { sourceTable: "a", targetTable: "a" }, + { sourceTable: "b", targetTable: "b_new" }, + ]; + expect(reconcileSchemaDiffTableMappings(["a", "c"], ["a", "b_new", "c"], mappings)).toEqual([ + { sourceTable: "a", targetTable: "a" }, + { sourceTable: "c", targetTable: "c" }, + ]); + expect(pruneSchemaDiffTableMappings(["a"], mappings)).toEqual([{ sourceTable: "a", targetTable: "a" }]); + }); + + it("reconciles a vanished target mapping back to same-name or unmatched", () => { + expect(reconcileSchemaDiffTableMappings(["a", "b"], ["a", "b"], [{ sourceTable: "b", targetTable: "b_old" }])).toEqual([ + { sourceTable: "a", targetTable: "a" }, + { sourceTable: "b", targetTable: "b" }, + ]); + expect(reconcileSchemaDiffTableMappings(["b"], ["b_other"], [{ sourceTable: "b", targetTable: "b_old" }])).toEqual([]); + }); + + it("rejects duplicate target mappings and hides occupied targets", () => { + const mappings = [{ sourceTable: "a", targetTable: "x" }]; + expect(updateSchemaDiffTableMapping(mappings, "b", "x")).toEqual({ mappings, accepted: false, conflictSource: "a" }); + expect(availableSchemaDiffTargetTables("b", ["x", "y"], mappings)).toEqual(["y"]); + expect( + reconcileSchemaDiffTableMappings( + ["a", "b"], + ["x", "b"], + [ + { sourceTable: "a", targetTable: "x" }, + { sourceTable: "b", targetTable: "x" }, + ], + ), + ).toEqual([ + { sourceTable: "a", targetTable: "x" }, + { sourceTable: "b", targetTable: "b" }, + ]); + }); + + it("round-trips table mappings through normalized config JSON", () => { + const options = normalizeSchemaDiffCompareOptions({ tableMappings: [{ sourceTable: "a", targetTable: "a_new" }] }); + const restored = normalizeSchemaDiffCompareOptions(JSON.parse(JSON.stringify(options))); + expect(restored.tableMappings).toEqual([{ sourceTable: "a", targetTable: "a_new" }]); + expect(normalizeSchemaDiffCompareOptions({}).tableMappings).toEqual([]); + }); + + it("swaps each mapping direction", () => { + expect( + swapSchemaDiffTableMappings([ + { sourceTable: "a", targetTable: "b" }, + { sourceTable: "c", targetTable: "d" }, + ]), + ).toEqual([ + { sourceTable: "b", targetTable: "a" }, + { sourceTable: "d", targetTable: "c" }, + ]); + }); + + it("selects mapped target tables for metadata loading instead of same-name tables", async () => { + const options = { ...DEFAULT_MYSQL_OPTIONS, tableMappings: [{ sourceTable: "charge_records", targetTable: "charge_record" }] }; + const filter = compileSchemaDiffTableFilter(options); + const filtered = filterSchemaDiffTables([table("charge_records")], [table("charge_records"), table("charge_record")], filter, options, ["charge_records"]); + expect(filtered.sourceTables.map((entry) => entry.name)).toEqual(["charge_records"]); + expect(filtered.targetTables.map((entry) => entry.name)).toEqual(["charge_record"]); + + const getColumns = vi.fn().mockResolvedValue([]); + const api: SchemaDiffMetadataApi = { + getTableDdl: vi.fn().mockResolvedValue(""), + getColumns, + listIndexes: vi.fn().mockResolvedValue([]), + listForeignKeys: vi.fn().mockResolvedValue([]), + listTriggers: vi.fn().mockResolvedValue([]), + }; + await loadSchemaDetails( + filtered.targetTables, + { + connectionId: "target", + database: "app", + schema: "public", + dbType: "mysql", + options, + }, + api, + ); + expect(getColumns).toHaveBeenCalledWith("target", "app", "public", "charge_record"); + expect(getColumns).not.toHaveBeenCalledWith("target", "app", "public", "charge_records"); + }); +}); diff --git a/apps/desktop/src/lib/diff/sameNameTableMatch.ts b/apps/desktop/src/lib/diff/sameNameTableMatch.ts deleted file mode 100644 index 237215b29d..0000000000 --- a/apps/desktop/src/lib/diff/sameNameTableMatch.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Same-name target table matching, shared by Data Compare and Schema Diff. - * Given the selected source table set and the available target tables, produces - * which source tables matched a same-name target and which are missing. - */ -export interface SameNameTableMatchResult { - matched: string[]; - missing: string[]; -} - -export function buildSameNameTableMatches(sourceTables: string[], targetTables: string[]): SameNameTableMatchResult { - const targetSet = new Set(targetTables); - const matched: string[] = []; - const missing: string[] = []; - for (const source of sourceTables) { - if (targetSet.has(source)) { - matched.push(source); - } else { - missing.push(source); - } - } - return { matched, missing }; -} diff --git a/apps/desktop/src/lib/schema/schemaDiff.ts b/apps/desktop/src/lib/schema/schemaDiff.ts index 6b2e3253a1..9aa387cbcb 100644 --- a/apps/desktop/src/lib/schema/schemaDiff.ts +++ b/apps/desktop/src/lib/schema/schemaDiff.ts @@ -1,4 +1,5 @@ import type { ColumnInfo, IndexInfo, ForeignKeyInfo, TriggerInfo, FunctionInfo, SequenceInfo, RuleInfo, OwnerInfo, DatabaseType, TableInfo } from "@/types/database"; +import type { SchemaDiffTableMapping } from "@/types/schemaDiff"; import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; const DIALECT_KIND_MAP: Record = { @@ -176,6 +177,7 @@ export interface TableDiff { type: "added" | "removed" | "modified" | "renamed"; objectType?: "table" | "view"; name: string; + targetName?: string; columns?: ColumnDiff[]; indexes?: IndexDiff[]; foreignKeys?: ForeignKeyDiff[]; @@ -230,6 +232,7 @@ export interface SchemaDiffPreparationOptions { targetDialect?: string; compatibilityThreshold?: number; fieldMappings?: FieldMappingEntry[]; + tableMappings?: SchemaDiffTableMapping[]; } export interface RenameCandidate { @@ -699,7 +702,7 @@ export function convertToSchemaDiffObjects(tableDiffs: TableDiff[], functionDiff objectKind: diff.objectType === "view" ? "view" : "table", name: diff.name, sourceName: diff.type === "added" ? undefined : diff.name, - targetName: diff.type === "removed" ? undefined : isRenamed ? newName : diff.name, + targetName: diff.type === "removed" ? undefined : isRenamed ? newName : (diff.targetName ?? diff.name), selected: opType !== "none", sourceDdl: diff.ddl, targetDdl: diff.targetDdl, @@ -974,7 +977,7 @@ export function injectColumnRenameSql(sql: string, diffs: TableDiff[], threshold // rc = removed column (exists in source, NOT in target → needs to be ADDED to target) // best = added column (exists in target, NOT in source → needs to be DROPPED from target) // To sync target → source: rename target's "best.name" to source's "rc.name" - replacements.push({ table: diff.name, oldName: best.name, newName: rc.name }); + replacements.push({ table: diff.targetName ?? diff.name, oldName: best.name, newName: rc.name }); used.add(best.name); } } diff --git a/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts b/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts index bd25d89c02..2a5e4893b0 100644 --- a/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts +++ b/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts @@ -1,4 +1,5 @@ import type { TableInfo } from "@/types/database"; +import { reconcileSchemaDiffTableMappings } from "@/lib/schema/schemaDiffTableMapping"; import type { SchemaDiffCompareOptions } from "@/types/schemaDiff"; export interface CompiledSchemaDiffTableFilter { @@ -49,19 +50,33 @@ function isSchemaDiffObjectEnabled(table: TableInfo, options: Pick = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables { +type SchemaDiffTableFilterOptions = Pick & { + tableMappings?: SchemaDiffCompareOptions["tableMappings"]; +}; + +export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: SchemaDiffTableFilterOptions = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables { // Visual (explicit) table selection is applied first, then the existing include/exclude - // regex filter. Source and target use the SAME selected table set, so targets are matched - // by the same name and any target missing a same-name table simply drops out (surfaced to - // the user in the UI as "missing"). Semantics: - // - `undefined`: no visual restriction — legacy all-tables behavior + regex filter. - // - `[]`: restriction explicitly enabled with an empty selection — compare nothing. - // - `["a", "b"]`: restrict to exactly those table names. + // regex filter. With an explicit selection, targets are selected by the effective + // source→target mapping; without one, the legacy all-tables path is unchanged. const selectedSet = selectedTables === undefined ? null : new Set(selectedTables); - const includeTable = (table: TableInfo) => isSchemaDiffObjectEnabled(table, options) && (!selectedSet || selectedSet.has(table.name)) && matchesSchemaDiffTableFilter(table.name, filter); + const includeTable = (table: TableInfo) => isSchemaDiffObjectEnabled(table, options) && matchesSchemaDiffTableFilter(table.name, filter); + const filteredSourceTables = sourceTables.filter((table) => includeTable(table) && (!selectedSet || selectedSet.has(table.name))); + const filteredTargetTables = targetTables.filter(includeTable); + + if (!selectedSet) { + return { sourceTables: filteredSourceTables, targetTables: filteredTargetTables }; + } + + const selectedTargetNames = new Set( + reconcileSchemaDiffTableMappings( + filteredSourceTables.map((table) => table.name), + filteredTargetTables.map((table) => table.name), + options.tableMappings ?? [], + ).map((mapping) => mapping.targetTable), + ); return { - sourceTables: sourceTables.filter(includeTable), - targetTables: targetTables.filter(includeTable), + sourceTables: filteredSourceTables, + targetTables: filteredTargetTables.filter((table) => selectedTargetNames.has(table.name)), }; } diff --git a/apps/desktop/src/lib/schema/schemaDiffTableList.ts b/apps/desktop/src/lib/schema/schemaDiffTableList.ts index 1ab1dcb0e1..9744afc80d 100644 --- a/apps/desktop/src/lib/schema/schemaDiffTableList.ts +++ b/apps/desktop/src/lib/schema/schemaDiffTableList.ts @@ -22,6 +22,7 @@ interface SchemaDiffTableListCoordinatorOptions { getIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity; setTables(side: SchemaDiffTableSide, tables: TableInfo[]): void; onSourceTablesLoaded?(tables: TableInfo[]): void; + onTargetTablesLoaded?(tables: TableInfo[]): void; } function identityKey(identity: SchemaDiffTableIdentity): string { @@ -89,6 +90,7 @@ export function createSchemaDiffTableListCoordinator(options: SchemaDiffTableLis options.setTables(side, tables); if (side === "source") options.onSourceTablesLoaded?.(tables); + else options.onTargetTablesLoaded?.(tables); return true; } catch { if (generation === generations[side] && sameSchemaDiffTableIdentity(identity, options.getIdentity(side))) { diff --git a/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts b/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts new file mode 100644 index 0000000000..3654ac06d1 --- /dev/null +++ b/apps/desktop/src/lib/schema/schemaDiffTableMapping.ts @@ -0,0 +1,116 @@ +import type { SchemaDiffTableMapping } from "@/types/schemaDiff"; + +export type SchemaDiffTableMatchKind = "automatic" | "manual" | "unmatched"; + +export interface SchemaDiffTableMatch { + sourceTable: string; + targetTable?: string; + kind: SchemaDiffTableMatchKind; +} + +export interface SchemaDiffTableMappingUpdate { + mappings: SchemaDiffTableMapping[]; + accepted: boolean; + conflictSource?: string; +} + +function firstMappingBySource(mappings: readonly SchemaDiffTableMapping[]): Map { + const bySource = new Map(); + for (const mapping of mappings) { + if (!mapping.sourceTable || !mapping.targetTable || bySource.has(mapping.sourceTable)) continue; + bySource.set(mapping.sourceTable, mapping.targetTable); + } + return bySource; +} + +/** Keep mappings for selected source tables while preserving their current target, even if the target list is loading. */ +export function pruneSchemaDiffTableMappings(selectedTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] { + const selected = new Set(selectedTables); + const seenSources = new Set(); + return mappings.filter((mapping) => { + if (!selected.has(mapping.sourceTable) || !mapping.targetTable || seenSources.has(mapping.sourceTable)) return false; + seenSources.add(mapping.sourceTable); + return true; + }); +} + +/** + * Reconcile explicit mappings with the current source and target lists. + * Explicit mappings win when valid; otherwise a same-name target is tried. + * A target can only be consumed once, so stale duplicate mappings cannot leak + * into the comparison request. + */ +export function reconcileSchemaDiffTableMappings(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] { + const targets = new Set(targetTables); + const explicitBySource = firstMappingBySource(mappings); + const usedTargets = new Set(); + const next: SchemaDiffTableMapping[] = []; + + for (const sourceTable of selectedTables) { + if (!sourceTable) continue; + + let targetTable = explicitBySource.get(sourceTable); + if (!targetTable || !targets.has(targetTable) || usedTargets.has(targetTable)) { + targetTable = targets.has(sourceTable) && !usedTargets.has(sourceTable) ? sourceTable : undefined; + } + + if (!targetTable) continue; + usedTargets.add(targetTable); + next.push({ sourceTable, targetTable }); + } + + return next; +} + +export function buildSchemaDiffTableMatches(selectedTables: readonly string[], targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMatch[] { + const reconciled = reconcileSchemaDiffTableMappings(selectedTables, targetTables, mappings); + const targetBySource = new Map(reconciled.map((mapping) => [mapping.sourceTable, mapping.targetTable])); + + return selectedTables.map((sourceTable) => { + const targetTable = targetBySource.get(sourceTable); + let kind: SchemaDiffTableMatchKind; + if (!targetTable) kind = "unmatched"; + else if (targetTable === sourceTable) kind = "automatic"; + else kind = "manual"; + return { sourceTable, targetTable, kind }; + }); +} + +/** Update one mapping and reject a target already assigned to another source. */ +export function updateSchemaDiffTableMapping(mappings: readonly SchemaDiffTableMapping[], sourceTable: string, targetTable: string): SchemaDiffTableMappingUpdate { + const conflict = mappings.find((mapping) => mapping.sourceTable !== sourceTable && mapping.targetTable === targetTable); + if (targetTable && conflict) { + return { mappings: [...mappings], accepted: false, conflictSource: conflict.sourceTable }; + } + + return { + mappings: [...mappings.filter((mapping) => mapping.sourceTable !== sourceTable), ...(targetTable ? [{ sourceTable, targetTable }] : [])], + accepted: true, + }; +} + +/** Hide targets occupied by another source while retaining the current value for the edited row. */ +export function availableSchemaDiffTargetTables(sourceTable: string, targetTables: readonly string[], mappings: readonly SchemaDiffTableMapping[]): string[] { + const currentTarget = mappings.find((mapping) => mapping.sourceTable === sourceTable)?.targetTable; + const occupiedTargets = new Set(); + for (const mapping of mappings) { + if (mapping.sourceTable !== sourceTable) occupiedTargets.add(mapping.targetTable); + } + return targetTables.filter((targetTable) => targetTable === currentTarget || !occupiedTargets.has(targetTable)); +} + +/** Reverse source/target for swap, dropping malformed duplicate targets defensively. */ +export function swapSchemaDiffTableMappings(mappings: readonly SchemaDiffTableMapping[]): SchemaDiffTableMapping[] { + const usedTargets = new Set(); + const swapped: SchemaDiffTableMapping[] = []; + for (const mapping of mappings) { + if (!mapping.sourceTable || !mapping.targetTable || usedTargets.has(mapping.sourceTable)) continue; + usedTargets.add(mapping.sourceTable); + swapped.push({ sourceTable: mapping.targetTable, targetTable: mapping.sourceTable }); + } + return swapped; +} + +export function areSchemaDiffTableMappingsEqual(left: readonly SchemaDiffTableMapping[], right: readonly SchemaDiffTableMapping[]): boolean { + return left.length === right.length && left.every((mapping, index) => mapping.sourceTable === right[index]?.sourceTable && mapping.targetTable === right[index]?.targetTable); +} diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts index 7afc5d8980..40bc7a2f24 100644 --- a/apps/desktop/src/types/schemaDiff.ts +++ b/apps/desktop/src/types/schemaDiff.ts @@ -1,5 +1,10 @@ export type SchemaDiffTableFilterPriority = "include" | "exclude"; +export interface SchemaDiffTableMapping { + sourceTable: string; + targetTable: string; +} + export interface SchemaDiffCompareOptions { tables: boolean; primaryKeys: boolean; @@ -30,6 +35,7 @@ export interface SchemaDiffCompareOptions { * list so newly added tables keep entering unrestricted comparisons and configs stay small. */ selectedTables: string[] | undefined; + tableMappings?: SchemaDiffTableMapping[]; detectRenames: boolean; renameThreshold: number; detectTableRenames: boolean; @@ -72,7 +78,7 @@ export interface SchemaDiffOptionItem { } export type BooleanSchemaDiffCompareOptionKey = { - [K in keyof SchemaDiffCompareOptions]: SchemaDiffCompareOptions[K] extends boolean ? K : never; + [K in keyof SchemaDiffCompareOptions]-?: NonNullable extends boolean ? K : never; }[keyof SchemaDiffCompareOptions]; export type SchemaDiffOptionsMap = Partial>; @@ -98,6 +104,7 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = { tableExcludePattern: "", tableFilterPriority: "exclude", selectedTables: undefined, + tableMappings: [], detectRenames: false, renameThreshold: 0.5, detectTableRenames: false, @@ -130,6 +137,7 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = { tableExcludePattern: "", tableFilterPriority: "exclude", selectedTables: undefined, + tableMappings: [], detectRenames: false, renameThreshold: 0.5, detectTableRenames: false, @@ -149,9 +157,11 @@ export function getDefaultOptionsForDbType(dbType: string): SchemaDiffCompareOpt } export function normalizeSchemaDiffCompareOptions(options: Partial | null | undefined, dbType = "postgres"): SchemaDiffCompareOptions { + const defaults = getDefaultOptionsForDbType(dbType); return { - ...getDefaultOptionsForDbType(dbType), + ...defaults, ...options, + tableMappings: Array.isArray(options?.tableMappings) ? options.tableMappings.map((mapping) => ({ ...mapping })) : defaults.tableMappings, }; } diff --git a/crates/dbx-core/src/correction.rs b/crates/dbx-core/src/correction.rs index 17497ace01..66e9dcfc07 100644 --- a/crates/dbx-core/src/correction.rs +++ b/crates/dbx-core/src/correction.rs @@ -446,6 +446,7 @@ mod tests { diff_type: diff_type.to_string(), object_type: Some("table".to_string()), name: name.to_string(), + target_name: None, columns: None, indexes: None, foreign_keys: None, diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index 20bbeba03f..f609251c8b 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -154,6 +154,10 @@ pub struct TableDiff { #[serde(skip_serializing_if = "Option::is_none")] pub object_type: Option, pub name: String, + /// Source-side display/key name. `target_name` is set for an explicit + /// source→target table mapping and is the physical target used by DDL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub columns: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -279,6 +283,13 @@ fn type_supports_params(kind: DialectKind, type_name: &str) -> bool { }) } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SchemaDiffTableMapping { + pub source_table: String, + pub target_table: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SchemaDiffPreparationOptions { @@ -341,6 +352,8 @@ pub struct SchemaDiffPreparationOptions { pub resource_constraint: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub field_mappings: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub table_mappings: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1257,6 +1270,7 @@ impl RollbackGraph { TableDiff { diff_type: inverted_type, object_type: diff.object_type.clone(), + target_name: diff.target_name.clone(), name: diff.name.clone(), columns: inverted_columns, indexes: inverted_indexes, @@ -1404,7 +1418,12 @@ pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &Shard target_tables: options .target_tables .iter() - .filter(|t| shard_set.contains(t.name.as_str())) + .filter(|t| { + shard_set.contains(t.name.as_str()) + || options.table_mappings.iter().any(|mapping| { + shard_set.contains(mapping.source_table.as_str()) && mapping.target_table == t.name + }) + }) .cloned() .collect(), source_details: options @@ -1416,7 +1435,12 @@ pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &Shard target_details: options .target_details .iter() - .filter(|d| shard_set.contains(d.name.as_str())) + .filter(|d| { + shard_set.contains(d.name.as_str()) + || options.table_mappings.iter().any(|mapping| { + shard_set.contains(mapping.source_table.as_str()) && mapping.target_table == d.name + }) + }) .cloned() .collect(), source_functions: options.source_functions.clone(), @@ -1434,6 +1458,7 @@ pub fn shard_diff(options: &SchemaDiffPreparationOptions, shard_strategy: &Shard compare_column_order: options.compare_column_order, source_dialect: options.source_dialect, target_dialect: options.target_dialect, + table_mappings: options.table_mappings.clone(), ..Default::default() }; diff_schema(&shard_options) @@ -1715,6 +1740,7 @@ impl Default for SchemaDiffPreparationOptions { shard_strategy: None, resource_constraint: None, field_mappings: Vec::new(), + table_mappings: Vec::new(), } } } @@ -1769,6 +1795,11 @@ impl SchemaDiffPreparationOptions { self.field_mappings = mappings; self } + + pub fn with_table_mappings(mut self, mappings: Vec) -> Self { + self.table_mappings = mappings; + self + } } pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffPreparation { @@ -1822,6 +1853,7 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP diffs.push(TableDiff { diff_type: "renamed".to_string(), object_type: Some("table".to_string()), + target_name: Some(c.target_name.clone()), name: c.source_name.clone(), columns: None, indexes: None, @@ -1847,7 +1879,8 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP for diff in &diffs { if diff.diff_type == "modified" { if let Some(source_detail) = options.source_details.iter().find(|d| d.name == diff.name) { - if let Some(target_detail) = options.target_details.iter().find(|d| d.name == diff.name) { + let target_name = diff.target_name.as_deref().unwrap_or(&diff.name); + if let Some(target_detail) = options.target_details.iter().find(|d| d.name == target_name) { let (_, warnings) = diff_columns_with_compatibility( &source_detail.columns, &target_detail.columns, @@ -1961,6 +1994,53 @@ pub fn prepare_schema_diff(options: SchemaDiffPreparationOptions) -> SchemaDiffP } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SchemaDiffTableNameResolution { + pairs: Vec<(String, String)>, + source_only: Vec, + target_only: Vec, +} + +fn resolve_schema_diff_table_names( + source_names: &[String], + target_names: &[String], + mappings: &[SchemaDiffTableMapping], +) -> SchemaDiffTableNameResolution { + let target_set: HashSet<&str> = target_names.iter().map(String::as_str).collect(); + let mut explicit_by_source: HashMap<&str, &str> = HashMap::new(); + for mapping in mappings { + if !mapping.source_table.is_empty() && !mapping.target_table.is_empty() { + explicit_by_source.entry(mapping.source_table.as_str()).or_insert(mapping.target_table.as_str()); + } + } + + let mut used_targets = HashSet::new(); + let mut pairs = Vec::new(); + let mut source_only = Vec::new(); + for source_name in source_names { + let explicit_target = explicit_by_source + .get(source_name.as_str()) + .copied() + .filter(|target_name| target_set.contains(target_name) && !used_targets.contains(target_name)); + let target_name = explicit_target.or_else(|| { + target_set + .contains(source_name.as_str()) + .then_some(source_name.as_str()) + .filter(|target_name| !used_targets.contains(target_name)) + }); + + if let Some(target_name) = target_name { + used_targets.insert(target_name); + pairs.push((source_name.clone(), target_name.to_string())); + } else { + source_only.push(source_name.clone()); + } + } + + let target_only = target_names.iter().filter(|name| !used_targets.contains(name.as_str())).cloned().collect(); + SchemaDiffTableNameResolution { pairs, source_only, target_only } +} + fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { let source_details: HashMap<&str, &TableSchemaDetail> = options.source_details.iter().map(|detail| (detail.name.as_str(), detail)).collect(); @@ -1996,8 +2076,10 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { .map(|table| table.name.clone()) .collect(); - let (added, removed, common) = diff_names(&source_table_names, &target_table_names); - let (added_views, removed_views, common_views) = diff_names(&source_view_names, &target_view_names); + let table_resolution = + resolve_schema_diff_table_names(&source_table_names, &target_table_names, &options.table_mappings); + let view_resolution = + resolve_schema_diff_table_names(&source_view_names, &target_view_names, &options.table_mappings); let mut result = Vec::new(); // A foreign key whose `ref_table` is itself one of the tables being compared is a @@ -2011,11 +2093,12 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { let source_table_name_set: HashSet<&str> = source_table_names.iter().map(String::as_str).collect(); let target_table_name_set: HashSet<&str> = target_table_names.iter().map(String::as_str).collect(); - for name in added { + for name in table_resolution.source_only { let source_detail = source_details.get(name.as_str()); result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("table".to_string()), + target_name: None, name, ddl: source_detail.and_then(|detail| detail.ddl.clone()), target_ddl: None, @@ -2052,7 +2135,12 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { .foreign_keys .iter() .map(|fk| { - let fk = normalize_self_referencing_fk(fk, &source_table_name_set); + let fk = normalize_mapped_foreign_key( + fk, + &source_table_name_set, + &target_table_name_set, + &options.table_mappings, + ); ForeignKeyDiff { diff_type: "added".to_string(), name: fk.name.clone(), @@ -2088,12 +2176,13 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { }); } - for name in removed { + for name in table_resolution.target_only { let name_clone = name.clone(); let target_detail = target_details.get(name_clone.as_str()).copied(); result.push(TableDiff { diff_type: "removed".to_string(), object_type: Some("table".to_string()), + target_name: None, name, columns: target_detail.map(|detail| { detail @@ -2157,11 +2246,12 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { }); } - for name in added_views { + for name in view_resolution.source_only { let name_clone = name.clone(); result.push(TableDiff { diff_type: "added".to_string(), object_type: Some("view".to_string()), + target_name: None, name, columns: None, indexes: None, @@ -2175,11 +2265,12 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { }); } - for name in removed_views { + for name in view_resolution.target_only { let name_clone = name.clone(); result.push(TableDiff { diff_type: "removed".to_string(), object_type: Some("view".to_string()), + target_name: None, name, columns: None, indexes: None, @@ -2193,11 +2284,11 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { }); } - for name in common_views { + for (name, target_name) in view_resolution.pairs { let Some(source_ddl) = source_details.get(name.as_str()).and_then(|detail| detail.ddl.as_ref()) else { continue; }; - let Some(target_ddl) = target_details.get(name.as_str()).and_then(|detail| detail.ddl.as_ref()) else { + let Some(target_ddl) = target_details.get(target_name.as_str()).and_then(|detail| detail.ddl.as_ref()) else { continue; }; if !mysql_view_definitions_differ(source_ddl, target_ddl, options.source_dialect, options.target_dialect) { @@ -2207,6 +2298,7 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { result.push(TableDiff { diff_type: "modified".to_string(), object_type: Some("view".to_string()), + target_name: (name != target_name).then_some(target_name), name, columns: None, indexes: None, @@ -2220,9 +2312,9 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { }); } - for name in common { + for (name, target_name) in table_resolution.pairs { let Some(source) = source_details.get(name.as_str()) else { continue }; - let Some(target) = target_details.get(name.as_str()) else { continue }; + let Some(target) = target_details.get(target_name.as_str()) else { continue }; let column_diffs = diff_columns_with_dialect_options( &source.columns, &target.columns, @@ -2234,14 +2326,24 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { options.target_dialect, ); let index_diffs = diff_indexes(&source.indexes, &target.indexes); - let normalized_source_fks: Vec = - source.foreign_keys.iter().map(|fk| normalize_self_referencing_fk(fk, &source_table_name_set)).collect(); + let normalized_source_fks: Vec = source + .foreign_keys + .iter() + .map(|fk| { + normalize_mapped_foreign_key( + fk, + &source_table_name_set, + &target_table_name_set, + &options.table_mappings, + ) + }) + .collect(); let normalized_target_fks: Vec = target.foreign_keys.iter().map(|fk| normalize_self_referencing_fk(fk, &target_table_name_set)).collect(); let foreign_key_diffs = diff_foreign_keys(&normalized_source_fks, &normalized_target_fks); let trigger_diffs = diff_triggers(&source.triggers, &target.triggers); let source_comment = source_table_comments.get(name.as_str()).cloned().unwrap_or(None); - let target_comment = target_table_comments.get(name.as_str()).cloned().unwrap_or(None); + let target_comment = target_table_comments.get(target_name.as_str()).cloned().unwrap_or(None); let comment_changed = !options.ignore_comments && source_comment.clone().unwrap_or_default() != target_comment.clone().unwrap_or_default(); @@ -2251,17 +2353,17 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { || !trigger_diffs.is_empty() || comment_changed; - let name_clone = name.clone(); result.push(TableDiff { diff_type: if has_diff { "modified".to_string() } else { "none".to_string() }, object_type: Some("table".to_string()), + target_name: (name != target_name).then_some(target_name.clone()), name, columns: if has_diff { (!column_diffs.is_empty()).then_some(column_diffs) } else { None }, indexes: if has_diff { (!index_diffs.is_empty()).then_some(index_diffs) } else { None }, foreign_keys: if has_diff { (!foreign_key_diffs.is_empty()).then_some(foreign_key_diffs) } else { None }, triggers: if has_diff { (!trigger_diffs.is_empty()).then_some(trigger_diffs) } else { None }, - ddl: source_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), - target_ddl: target_details.get(name_clone.as_str()).and_then(|detail| detail.ddl.clone()), + ddl: source.ddl.clone(), + target_ddl: target.ddl.clone(), source_table_comment: if has_diff { comment_changed.then_some(source_comment) } else { None }, target_table_comment: if has_diff { comment_changed.then_some(target_comment) } else { None }, sync_sql: None, @@ -2917,6 +3019,22 @@ fn normalize_self_referencing_fk(fk: &ForeignKeyInfo, own_table_names: &HashSet< normalized } +fn normalize_mapped_foreign_key( + fk: &ForeignKeyInfo, + source_table_names: &HashSet<&str>, + target_table_names: &HashSet<&str>, + mappings: &[SchemaDiffTableMapping], +) -> ForeignKeyInfo { + let mut normalized = normalize_self_referencing_fk(fk, source_table_names); + if let Some(mapping) = mappings.iter().find(|mapping| { + mapping.source_table == normalized.ref_table && target_table_names.contains(mapping.target_table.as_str()) + }) { + normalized.ref_table = mapping.target_table.clone(); + normalized.ref_schema = None; + } + normalized +} + pub fn diff_foreign_keys(source: &[ForeignKeyInfo], target: &[ForeignKeyInfo]) -> Vec { let mut diffs = Vec::new(); let target_map: HashMap<&str, &ForeignKeyInfo> = target.iter().map(|fk| (fk.name.as_str(), fk)).collect(); @@ -3696,9 +3814,13 @@ fn add_foreign_key_sql_with_reference_separator( ) } +fn target_table_name(diff: &TableDiff) -> &str { + diff.target_name.as_deref().unwrap_or(&diff.name) +} + fn drop_object_sql(diff: &TableDiff, db_type: DatabaseType, schema: Option<&str>, cascade: &str) -> String { let object_type = if diff.object_type.as_deref() == Some("view") { "VIEW" } else { "TABLE" }; - let name = qualified_name(&diff.name, db_type, schema); + let name = qualified_name(target_table_name(diff), db_type, schema); if db_type == DatabaseType::SqlServer { let object_id_type = if object_type == "VIEW" { "V" } else { "U" }; return format!( @@ -5047,7 +5169,8 @@ fn generate_schema_sync_sql_inner( }); for diff in diffs { - let table = qualified_name(&diff.name, db_type, schema); + let target_name = target_table_name(diff); + let table = qualified_name(target_name, db_type, schema); if diff.diff_type == "added" && diff.object_type.as_deref() == Some("view") { if let Some(ddl) = &diff.ddl { @@ -5080,7 +5203,7 @@ fn generate_schema_sync_sql_inner( .as_ref() .map_or_else(Vec::new, |triggers| triggers.iter().filter_map(|t| t.source.clone()).collect()); let (generated, missing) = generate_create_table_sql( - &diff.name, + target_name, diff.columns.as_ref().map_or(&[] as &[ColumnDiff], |columns| columns.as_slice()), diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |indexes| indexes.as_slice()), diff.foreign_keys @@ -5127,7 +5250,7 @@ fn generate_schema_sync_sql_inner( .as_ref() .map_or_else(Vec::new, |triggers| triggers.iter().filter_map(|t| t.source.clone()).collect()); let (gen, missing) = generate_create_table_sql( - &diff.name, + target_name, cols, diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |v| v.as_slice()), diff.foreign_keys.as_ref().map_or(&[] as &[ForeignKeyDiff], |v| v.as_slice()), @@ -5154,7 +5277,7 @@ fn generate_schema_sync_sql_inner( .as_ref() .map_or_else(Vec::new, |triggers| triggers.iter().filter_map(|t| t.source.clone()).collect()); let (gen, missing) = generate_create_table_sql( - &diff.name, + target_name, diff.columns.as_ref().map_or(&[] as &[ColumnDiff], |v| v.as_slice()), diff.indexes.as_ref().map_or(&[] as &[IndexDiff], |v| v.as_slice()), diff.foreign_keys.as_ref().map_or(&[] as &[ForeignKeyDiff], |v| v.as_slice()), @@ -5189,7 +5312,7 @@ fn generate_schema_sync_sql_inner( if let Some(foreign_keys) = &diff.foreign_keys { for fk in foreign_keys { if fk.diff_type == "removed" || fk.diff_type == "modified" { - lines.push(drop_foreign_key_sql(&diff.name, &fk.name, db_type, schema)); + lines.push(drop_foreign_key_sql(target_name, &fk.name, db_type, schema)); } } } @@ -5200,7 +5323,7 @@ fn generate_schema_sync_sql_inner( // SQL Server will reject DROP/ALTER COLUMN while a changed // index still depends on it. Drop changed indexes before // applying column DDL, then recreate modified ones below. - lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); + lines.push(drop_index_sql(target_name, &index.name, db_type, schema)); } } } @@ -5341,7 +5464,7 @@ fn generate_schema_sync_sql_inner( } } RenameColumnSyntax::SqlServerSpRename => { - let target_table = qualified_name(&diff.name, db_type, schema); + let target_table = qualified_name(target_name, db_type, schema); let full_obj_path = format!("{target_table}.{}", quote_id(&target_col.name, db_type)); standalone_statements.push(format!( @@ -5389,7 +5512,7 @@ fn generate_schema_sync_sql_inner( if let Some(source) = &column.source { if column.changes.iter().any(|change| change.starts_with("comment:")) { lines.extend(column_comment_sql( - &diff.name, + target_name, &column.name, source.comment.as_deref().unwrap_or_default(), db_type, @@ -5398,12 +5521,12 @@ fn generate_schema_sync_sql_inner( } if column.diff_type == "added" { if let Some(comment) = &source.comment { - lines.extend(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); + lines.extend(column_comment_sql(target_name, &column.name, comment, db_type, schema)); } } if column.diff_type == "renamed" { if let Some(comment) = &source.comment { - lines.extend(column_comment_sql(&diff.name, &column.name, comment, db_type, schema)); + lines.extend(column_comment_sql(target_name, &column.name, comment, db_type, schema)); } } } @@ -5413,7 +5536,7 @@ fn generate_schema_sync_sql_inner( if diff.source_table_comment.is_some() && diff.source_table_comment != diff.target_table_comment { let comment = diff.source_table_comment.as_ref().and_then(|comment| comment.as_deref()).unwrap_or_default(); - lines.extend(table_comment_sql(&diff.name, comment, db_type, schema)); + lines.extend(table_comment_sql(target_name, comment, db_type, schema)); } if let Some(indexes) = &diff.indexes { @@ -5421,20 +5544,20 @@ fn generate_schema_sync_sql_inner( match index.diff_type.as_str() { "added" => { if let Some(source) = &index.source { - lines.push(create_index_sql(&diff.name, source, db_type, schema)); + lines.push(create_index_sql(target_name, source, db_type, schema)); } } "removed" => { if db_type != DatabaseType::SqlServer { - lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); + lines.push(drop_index_sql(target_name, &index.name, db_type, schema)); } } "modified" => { if let Some(source) = &index.source { if db_type != DatabaseType::SqlServer { - lines.push(drop_index_sql(&diff.name, &index.name, db_type, schema)); + lines.push(drop_index_sql(target_name, &index.name, db_type, schema)); } - lines.push(create_index_sql(&diff.name, source, db_type, schema)); + lines.push(create_index_sql(target_name, source, db_type, schema)); } } _ => {} @@ -5446,7 +5569,7 @@ fn generate_schema_sync_sql_inner( for fk in foreign_keys { if fk.diff_type == "added" || fk.diff_type == "modified" { if let Some(source) = &fk.source { - lines.push(add_foreign_key_sql(&diff.name, source, db_type, schema)); + lines.push(add_foreign_key_sql(target_name, source, db_type, schema)); } } } @@ -5723,6 +5846,48 @@ mod tests { } } + #[test] + fn manual_table_mapping_compares_source_with_target_metadata_and_uses_target_ddl_name() { + let result = prepare_schema_diff(SchemaDiffPreparationOptions { + source_tables: vec![table_info("charge_records", "TABLE")], + target_tables: vec![table_info("charge_record", "TABLE")], + source_details: vec![TableSchemaDetail { + name: "charge_records".to_string(), + columns: vec![column("id", "int", None), column("amount", "decimal(12,2)", None)], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + ddl: Some("CREATE TABLE charge_records (id int, amount decimal(12,2));".to_string()), + }], + target_details: vec![TableSchemaDetail { + name: "charge_record".to_string(), + columns: vec![column("id", "int", None), column("amount", "decimal(10,2)", None)], + indexes: Vec::new(), + foreign_keys: Vec::new(), + triggers: Vec::new(), + ddl: Some("CREATE TABLE charge_record (id int, amount decimal(10,2));".to_string()), + }], + database_type: DatabaseType::Mysql, + table_mappings: vec![SchemaDiffTableMapping { + source_table: "charge_records".to_string(), + target_table: "charge_record".to_string(), + }], + ..Default::default() + }); + + assert_eq!(result.diffs.len(), 1); + let table_diff = &result.diffs[0]; + assert_eq!(table_diff.diff_type, "modified"); + assert_eq!(table_diff.name, "charge_records"); + assert_eq!(table_diff.target_name.as_deref(), Some("charge_record")); + assert!(table_diff + .columns + .as_ref() + .is_some_and(|columns| columns.iter().any(|column| column.name == "amount"))); + assert!(result.sync_sql.contains("ALTER TABLE `charge_record`"), "sync SQL: {}", result.sync_sql); + assert!(!result.sync_sql.contains("ALTER TABLE `charge_records`"), "sync SQL: {}", result.sync_sql); + } + #[test] fn ignores_column_order_when_option_is_disabled() { let diffs = diff_columns_with_options( @@ -5834,6 +5999,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: name.to_string(), + target_name: None, columns: Some(columns), indexes: None, foreign_keys: None, @@ -6398,6 +6564,7 @@ mod tests { diff_type: "modified".into(), object_type: Some("table".into()), name: "events".into(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".into(), name: "payload".into(), @@ -6444,6 +6611,7 @@ mod tests { diff_type: "removed".into(), object_type: Some("table".into()), name: "events".into(), + target_name: None, ..Default::default() }; let drop_sql = generate_schema_sync_sql( @@ -6474,6 +6642,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "accounts".into(), + target_name: None, columns: Some(vec![ ColumnDiff { diff_type: "added".into(), @@ -6606,6 +6775,7 @@ mod tests { diff_type: "modified".into(), object_type: Some("table".into()), name: "events".into(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "removed".into(), name: "legacy_status".into(), @@ -7812,6 +7982,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "test".to_string(), + target_name: None, columns: None, indexes: Some(diffs), foreign_keys: None, @@ -7869,6 +8040,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "tankong_data".to_string(), + target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), @@ -7935,6 +8107,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "tankong_data".to_string(), + target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), @@ -8002,6 +8175,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "tankong_data".to_string(), + target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), @@ -8160,6 +8334,7 @@ mod tests { diff_type: "modified".to_string(), object_type: None, name: "orders".to_string(), + target_name: None, columns: None, indexes: Some(vec![IndexDiff { diff_type: "modified".to_string(), @@ -8220,6 +8395,7 @@ mod tests { diff_type: "modified".to_string(), object_type: None, name: "users".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "name".to_string(), @@ -8257,6 +8433,7 @@ mod tests { diff_type: "modified".to_string(), object_type: None, name: "notify_channel_config".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "config_json".to_string(), @@ -8303,6 +8480,7 @@ mod tests { diff_type: "modified".to_string(), object_type: None, name: "notify_channel_config".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "config_json".to_string(), @@ -8444,6 +8622,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "nickname".to_string(), @@ -8481,6 +8660,7 @@ mod tests { diff_type: "modified".to_string(), object_type: None, name: "orders".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "status".to_string(), @@ -9146,6 +9326,7 @@ mod tests { diff_type: diff_type.to_string(), object_type: Some("table".to_string()), name: name.to_string(), + target_name: None, columns: None, indexes: None, foreign_keys: None, @@ -9489,6 +9670,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".to_string(), name: "name".to_string(), @@ -10418,6 +10600,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: Some(col_diffs), indexes: Some(vec![IndexDiff { diff_type: "added".to_string(), @@ -10459,6 +10642,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: Some(col_diffs), indexes: Some(vec![IndexDiff { diff_type: "removed".to_string(), @@ -10566,6 +10750,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "renamed".to_string(), name: "new_name".to_string(), @@ -10598,6 +10783,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "id".to_string(), @@ -10666,6 +10852,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: None, indexes: None, foreign_keys: None, @@ -10802,6 +10989,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: None, indexes: None, foreign_keys: None, @@ -11227,6 +11415,7 @@ mod tests { diff_type: "modified".into(), object_type: Some("table".into()), name: "orders".into(), + target_name: None, columns: None, indexes: None, foreign_keys: Some(vec![ForeignKeyDiff { @@ -11460,6 +11649,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "t".to_string(), + target_name: None, columns: Some(col_diffs), indexes: Some(vec![ IndexDiff { @@ -11791,6 +11981,7 @@ mod tests { shard_strategy: None, resource_constraint: None, field_mappings: vec![], + table_mappings: vec![], }; let result = prepare_schema_diff(options); result.sync_sql @@ -12022,6 +12213,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(s1_diffs()), indexes: None, foreign_keys: None, @@ -12163,6 +12355,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(cols), indexes: Some(idxs), foreign_keys: None, @@ -12246,6 +12439,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(cols), indexes: None, foreign_keys: Some(fks), @@ -12303,6 +12497,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(s1_diffs()), indexes: None, foreign_keys: None, @@ -12356,6 +12551,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(cols), indexes: None, foreign_keys: None, @@ -12406,6 +12602,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(cols), indexes: None, foreign_keys: None, @@ -12455,6 +12652,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(cols), indexes: None, foreign_keys: None, @@ -12502,6 +12700,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "t".into(), + target_name: None, columns: Some(diffs), indexes: None, foreign_keys: None, @@ -12663,6 +12862,7 @@ mod tests { diff_type: "added".into(), object_type: Some("table".into()), name: "events".into(), + target_name: None, ddl: Some( "CREATE TABLE public.events (id bigint NOT NULL DEFAULT nextval('public.events_id_seq'::regclass))" .into(), @@ -12710,6 +12910,7 @@ mod tests { diff_type: "removed".into(), object_type: Some("table".into()), name: "events".into(), + target_name: None, ..TableDiff::default() }; let sequence_diff = SequenceDiff { @@ -12744,6 +12945,7 @@ mod tests { diff_type: "modified".into(), object_type: Some("table".into()), name: "events".into(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "modified".into(), name: "created_at".into(), @@ -12917,6 +13119,7 @@ mod tests { diff_type: "added".into(), object_type: Some("view".into()), name: "active_users".into(), + target_name: None, ddl: Some("CREATE VIEW `active_users` AS SELECT `id` FROM `users` WHERE `active` = 1;".into()), ..TableDiff::default() }; @@ -12945,6 +13148,7 @@ mod tests { diff_type: "added".into(), object_type: Some("view".into()), name: "active_users".into(), + target_name: None, ddl: Some("CREATE VIEW `active_users` AS SELECT `id` FROM `users`".into()), ..TableDiff::default() }; @@ -12972,6 +13176,7 @@ mod tests { diff_type: "added".into(), object_type: Some("view".into()), name: "active_users".into(), + target_name: None, ..TableDiff::default() }; diff --git a/crates/dbx-core/src/script_generator.rs b/crates/dbx-core/src/script_generator.rs index 5154f173ec..d1ecd80d6c 100644 --- a/crates/dbx-core/src/script_generator.rs +++ b/crates/dbx-core/src/script_generator.rs @@ -1826,6 +1826,7 @@ mod tests { diff_type: "added".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "added".to_string(), name: "id".to_string(), @@ -1858,6 +1859,7 @@ mod tests { diff_type: "removed".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![ColumnDiff { diff_type: "removed".to_string(), name: "id".to_string(), @@ -1987,6 +1989,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: Some(vec![]), foreign_keys: Some(vec![]), @@ -2034,6 +2037,7 @@ mod tests { diff_type: "added".to_string(), object_type: Some("table".to_string()), name: "test_table".to_string(), + target_name: None, columns: Some(vec![]), indexes: Some(vec![]), foreign_keys: Some(vec![]), @@ -2218,6 +2222,7 @@ mod tests { diff_type: "added".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: Some(vec![]), foreign_keys: Some(vec![]), @@ -2243,6 +2248,7 @@ mod tests { diff_type: "removed".to_string(), object_type: Some("table".to_string()), name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: Some(vec![]), foreign_keys: Some(vec![]), @@ -2399,6 +2405,7 @@ mod tests { diff_type: "modified".to_string(), object_type: Some("TABLE".to_string()), name: "test_table".to_string(), + target_name: None, columns: Some(vec![col_diff]), ..Default::default() }; diff --git a/crates/dbx-core/tests/api_contract_verification.rs b/crates/dbx-core/tests/api_contract_verification.rs index 4a26d68173..7a635120c2 100644 --- a/crates/dbx-core/tests/api_contract_verification.rs +++ b/crates/dbx-core/tests/api_contract_verification.rs @@ -62,6 +62,7 @@ fn prepare_schema_diff_function_signature() { shard_strategy: None, resource_constraint: None, field_mappings: vec![], + table_mappings: vec![], }; let _result: SchemaDiffPreparation = prepare_schema_diff(options); } @@ -139,6 +140,7 @@ fn schema_diff_preparation_field_names() { shard_strategy: None, resource_constraint: None, field_mappings: vec![], + table_mappings: vec![], }); let json = serde_json::to_value(&result).unwrap(); diff --git a/crates/dbx-core/tests/bidirectional_diff_e2e.rs b/crates/dbx-core/tests/bidirectional_diff_e2e.rs index cc96be0c79..28bccc92fc 100644 --- a/crates/dbx-core/tests/bidirectional_diff_e2e.rs +++ b/crates/dbx-core/tests/bidirectional_diff_e2e.rs @@ -254,6 +254,7 @@ fn rollback_graph_direct_consistency_check() { diff_type: "added".to_string(), object_type: None, name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: None, foreign_keys: None, diff --git a/crates/dbx-core/tests/cross_dialect_integration.rs b/crates/dbx-core/tests/cross_dialect_integration.rs index 7781ad0557..4120667a58 100644 --- a/crates/dbx-core/tests/cross_dialect_integration.rs +++ b/crates/dbx-core/tests/cross_dialect_integration.rs @@ -189,6 +189,7 @@ fn generate_schema_sync_sql_mysql_output_format() { diff_type: "added".to_string(), object_type: None, name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: None, foreign_keys: None, @@ -212,6 +213,7 @@ fn generate_schema_sync_sql_postgres_output_format() { diff_type: "added".to_string(), object_type: None, name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: None, foreign_keys: None, @@ -234,6 +236,7 @@ fn generate_schema_sync_sql_sqlite_output_format() { diff_type: "added".to_string(), object_type: None, name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: None, foreign_keys: None, diff --git a/crates/dbx-core/tests/regression_compatibility.rs b/crates/dbx-core/tests/regression_compatibility.rs index 7efd262416..796d43ef9c 100644 --- a/crates/dbx-core/tests/regression_compatibility.rs +++ b/crates/dbx-core/tests/regression_compatibility.rs @@ -144,6 +144,7 @@ fn generate_schema_sync_sql_output_invariant() { diff_type: "added".to_string(), object_type: None, name: "users".to_string(), + target_name: None, columns: Some(vec![]), indexes: None, foreign_keys: None, diff --git a/crates/dbx-core/tests/schema_diff_table_mapping.rs b/crates/dbx-core/tests/schema_diff_table_mapping.rs new file mode 100644 index 0000000000..1558d6df10 --- /dev/null +++ b/crates/dbx-core/tests/schema_diff_table_mapping.rs @@ -0,0 +1,74 @@ +use dbx_core::models::connection::DatabaseType; +use dbx_core::schema_diff::{ + prepare_schema_diff, SchemaDiffPreparationOptions, SchemaDiffTableMapping, TableSchemaDetail, +}; +use dbx_core::types::{ColumnInfo, TableInfo}; + +fn table(name: &str) -> TableInfo { + TableInfo { + name: name.to_string(), + table_type: "TABLE".to_string(), + comment: None, + parent_schema: None, + parent_name: None, + } +} + +fn column(name: &str, data_type: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_string(), + data_type: data_type.to_string(), + resolved_schema: None, + is_nullable: false, + column_default: None, + is_primary_key: false, + is_unique: false, + extra: None, + comment: None, + numeric_precision: None, + numeric_scale: None, + character_maximum_length: None, + enum_values: None, + character_set: None, + collation: None, + } +} + +#[test] +fn manual_table_mapping_compares_source_and_target_details_and_targets_physical_table() { + let prepared = prepare_schema_diff(SchemaDiffPreparationOptions { + source_tables: vec![table("charge_records")], + target_tables: vec![table("charge_record")], + source_details: vec![TableSchemaDetail { + name: "charge_records".to_string(), + columns: vec![column("amount", "decimal(12,2)")], + indexes: vec![], + foreign_keys: vec![], + triggers: vec![], + ddl: Some("CREATE TABLE charge_records (amount decimal(12,2));".to_string()), + }], + target_details: vec![TableSchemaDetail { + name: "charge_record".to_string(), + columns: vec![column("amount", "decimal(10,2)")], + indexes: vec![], + foreign_keys: vec![], + triggers: vec![], + ddl: Some("CREATE TABLE charge_record (amount decimal(10,2));".to_string()), + }], + database_type: DatabaseType::Mysql, + table_mappings: vec![SchemaDiffTableMapping { + source_table: "charge_records".to_string(), + target_table: "charge_record".to_string(), + }], + ..Default::default() + }); + + assert_eq!(prepared.diffs.len(), 1); + let diff = &prepared.diffs[0]; + assert_eq!(diff.diff_type, "modified"); + assert_eq!(diff.name, "charge_records"); + assert_eq!(diff.target_name.as_deref(), Some("charge_record")); + assert!(diff.columns.as_ref().is_some_and(|columns| columns.iter().any(|column| column.name == "amount"))); + assert!(prepared.sync_sql.contains("ALTER TABLE `charge_record`"), "{}", prepared.sync_sql); + assert!(!prepared.sync_sql.contains("ALTER TABLE `charge_records`"), "{}", prepared.sync_sql); +}