feat(diff): manual table mapping for schema compare

This commit is contained in:
jearhe authored and GitHub committed 2026-09-05 09:52:52 +08:00
1 parent d5a511b719
commit b5fcb3a832
28 files changed
+885 -122

No files matched your search

@@ -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<string[]>([]);
const targetTableList = ref<string[]>([]);
const targetTableListLoaded = ref(false);
const restrictTables = ref(false);
const localSelectedTables = ref<string[]>([]);
const matchResult = computed(() => buildSameNameTableMatches(localSelectedTables.value, targetTableList.value));
const missingTargetTables = computed(() => matchResult.value.missing);
const activeTableMappings = computed(() => props.options?.tableMappings ?? []);
const tableMatches = computed<SchemaDiffTableMatch[]>(() => 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<string | null>(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
</div>
<!-- Target Same-Name Match -->
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="space-y-1.5 rounded-lg border p-3 text-xs">
<div class="font-medium">{{ t("diff.autoMatchHint") }}</div>
<div class="text-muted-foreground">
{{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }}
<!-- Source → target table mapping -->
<div v-if="restrictTables && localSelectedTables.length && canConfigureTableSelection && isTableIdentityReady('target')" class="space-y-2 rounded-lg border p-3 text-xs">
<div class="flex items-center justify-between gap-2">
<div class="font-medium">{{ t("diff.targetTableMatching") }}</div>
<div class="text-muted-foreground">
{{ t("diff.matchedTables", { matched: matchedTableCount, total: localSelectedTables.length }) }}
</div>
</div>
<div v-if="missingTargetTables.length" class="text-destructive">
{{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
<div class="overflow-x-auto rounded border">
<table class="w-full min-w-[520px]">
<thead class="border-b bg-muted/30 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.sourceTables") }}</th>
<th class="px-2 py-1.5 text-left font-medium">{{ t("diff.targetTable") }}</th>
<th class="w-28 px-2 py-1.5 text-left font-medium">{{ t("diff.status") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="match in tableMatches" :key="match.sourceTable" class="border-b last:border-b-0">
<td class="max-w-[220px] truncate px-2 py-1.5 font-mono" :title="match.sourceTable">{{ match.sourceTable }}</td>
<td class="px-2 py-1.5">
<SearchableSelect
:model-value="match.targetTable ?? ''"
@update:model-value="(value: string) => handleTableMappingUpdate(match.sourceTable, value)"
:options="targetTableOptions(match.sourceTable)"
:placeholder="t('diff.selectTargetTable')"
:search-placeholder="t('diff.searchTargetTable')"
:empty-text="t('diff.noTargetTables')"
:disabled="!targetTableListLoaded"
:clearable="true"
:clear-selected-option="true"
trigger-class="h-7 w-full text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
</td>
<td class="px-2 py-1.5" :class="match.kind === 'unmatched' ? 'text-destructive' : match.kind === 'manual' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'">
{{ t(`diff.tableMatchStatus.${match.kind}`) }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="targetTableListLoaded && missingTargetTables.length" class="text-muted-foreground">
{{ t("diff.unmatchedTableWarning", { count: missingTargetTables.length }) }}
</div>
<div v-if="tableMappingConflictSource" class="text-destructive">
{{ t("diff.targetTableInUse") }}
</div>
</div>
</div>
@@ -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"
/>
@@ -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("");');
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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: "操作",
+20
View File
@@ -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: "작업",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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: "默认",
+11
View File
@@ -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: "儲存配置",
@@ -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: [] });
});
});
@@ -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");
});
});
@@ -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 };
}
+5 -2
View File
@@ -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<string, string> = {
@@ -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);
}
}
@@ -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<SchemaDiffCom
return isSchemaDiffView(table) ? options.views : options.tables;
}
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: Pick<SchemaDiffCompareOptions, "tables" | "views"> = { tables: true, views: true }, selectedTables?: string[]): FilteredSchemaDiffTables {
type SchemaDiffTableFilterOptions = Pick<SchemaDiffCompareOptions, "tables" | "views"> & {
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)),
};
}
@@ -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))) {
@@ -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<string, string> {
const bySource = new Map<string, string>();
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<string>();
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<string>();
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<string>();
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<string>();
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);
}
+12 -2
View File
@@ -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<SchemaDiffCompareOptions[K]> extends boolean ? K : never;
}[keyof SchemaDiffCompareOptions];
export type SchemaDiffOptionsMap = Partial<Record<string, SchemaDiffOptionItem[]>>;
@@ -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<SchemaDiffCompareOptions> | 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,
};
}