feat(schema-diff): support selecting tables to compare

This commit is contained in:
jearhe authored and GitHub committed 2026-08-18 15:31:00 +08:00
1 parent 0facea2740
commit b3b6d29099
21 files changed
+933 -69

No files matched your search

@@ -19,6 +19,7 @@ import type { ColumnInfo, DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
type CompareColumn = ColumnInfo;
@@ -89,7 +90,6 @@ const sourceTable = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
const sourceTables = ref<string[]>([]);
const sourceTableSearch = ref("");
const selectedSourceTables = ref<Set<string>>(new Set());
const targetConnectionId = ref("");
@@ -122,12 +122,11 @@ let syncPlanRequestId = 0;
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
const filteredSourceTables = computed(() => {
const query = sourceTableSearch.value.trim().toLowerCase();
if (!query) return sourceTables.value;
return sourceTables.value.filter((table) => table.toLowerCase().includes(query));
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
const sourceTableSelection = computed<string[]>({
get: () => [...selectedSourceTables.value],
set: (value: string[]) => resetSelectedSourceTables(value),
});
const allFilteredTablesSelected = computed(() => filteredSourceTables.value.length > 0 && filteredSourceTables.value.every((table) => selectedSourceTables.value.has(table)));
const compareTasksPreview = computed(() =>
selectedSourceTableNames.value.map((table) => {
const target = isBatchCompare.value ? table : targetTable.value || table;
@@ -221,23 +220,6 @@ function resetSelectedSourceTables(nextTables: Iterable<string>) {
selectedSourceTables.value = new Set(nextTables);
}
function toggleSourceTable(table: string) {
const next = new Set(selectedSourceTables.value);
if (next.has(table)) next.delete(table);
else next.add(table);
resetSelectedSourceTables(next);
}
function toggleSelectAllSourceTables() {
const next = new Set(selectedSourceTables.value);
if (allFilteredTablesSelected.value) {
filteredSourceTables.value.forEach((table) => next.delete(table));
} else {
filteredSourceTables.value.forEach((table) => next.add(table));
}
resetSelectedSourceTables(next);
}
function buildCompareTasks(): DataCompareTableTask[] {
if (!selectedSourceTableNames.value.length) return [];
if (!isBatchCompare.value) {
@@ -804,7 +786,6 @@ watch(sourceConnectionId, (id) => {
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
loadDatabases(id, "source").catch((e) => toast(String(e), 5000));
});
@@ -823,7 +804,6 @@ watch(sourceDatabase, () => {
sourceSchemas.value = [];
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
loadSchemas("source", props.prefillSchema).catch((e) => toast(String(e), 5000));
});
@@ -839,7 +819,6 @@ watch(sourceSchema, () => {
clearResult();
sourceTables.value = [];
sourceTable.value = "";
sourceTableSearch.value = "";
resetSelectedSourceTables([]);
if (sourceSchema.value) loadTables("source").catch((e) => toast(String(e), 5000));
});
@@ -947,41 +926,13 @@ watch(
content-class="w-[var(--reka-popover-trigger-width)]"
/>
<div class="space-y-2 rounded-lg border p-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ t("dataCompare.sourceTables") }}</Label>
<div v-if="sourceTables.length" class="text-[11px] text-muted-foreground">
{{
t("dataCompare.selectedTables", {
selected: selectedSourceTableNames.length,
total: sourceTables.length,
})
}}
</div>
</div>
<Input v-if="sourceTables.length > 5" v-model="sourceTableSearch" class="h-7 text-xs" :placeholder="t('dataCompare.searchTables')" />
<div class="flex items-center gap-2">
<Button v-if="sourceTables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="toggleSelectAllSourceTables">
{{ allFilteredTablesSelected ? t("dataCompare.deselectAllTables") : t("dataCompare.selectAllTables") }}
</Button>
</div>
<div v-if="!sourceConnectionId || !sourceDatabase" class="text-xs text-muted-foreground py-3 text-center">
{{ t("dataCompare.selectSourceTables") }}
</div>
<div v-else-if="sourceTables.length === 0" class="text-xs text-muted-foreground py-3 text-center">
{{ t("dataCompare.noTables") }}
</div>
<div v-else class="max-h-40 overflow-auto rounded border">
<button v-for="table in filteredSourceTables" :key="table" type="button" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50" @click="toggleSourceTable(table)">
<CheckSquare v-if="selectedSourceTables.has(table)" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
<TableMultiSelect
:key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`"
v-model="sourceTableSelection"
:tables="sourceTables"
:title="t('dataCompare.sourceTables')"
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
/>
</div>
<div class="flex items-center pt-6">
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import ConnectionGroupBadge from "@/components/connection/ConnectionGroupBadge.vue";
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
import { buildSameNameTableMatches } from "@/lib/diff/sameNameTableMatch";
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";
import * as api from "@/lib/backend/api";
@@ -28,6 +31,8 @@ const props = defineProps<{
targetSchema: string;
ignoreComments: boolean;
options: SchemaDiffCompareOptions;
selectedTables?: string[];
tableListLoader: SchemaDiffTableListLoader;
loading: boolean;
recentConfigs: SchemaDiffConfig[];
}>();
@@ -49,6 +54,7 @@ const emit = defineEmits<{
(e: "swap"): void;
(e: "loadHistoryConfig", config: SchemaDiffConfig): void;
(e: "deleteHistoryConfig", configId: string): void;
(e: "update:selectedTables", value?: string[]): void;
}>();
const sourceDatabases = ref<string[]>([]);
@@ -68,8 +74,94 @@ const targetDbType = computed(() => targetConfig.value?.db_type || "");
const showFieldMapping = computed(() => sourceDbType.value && targetDbType.value && sourceDbType.value !== targetDbType.value);
const activeFieldMappings = computed(() => props.options?.fieldMappings ?? []);
// ---- Visual (explicit) table selection ----
const sourceTableList = ref<string[]>([]);
const targetTableList = ref<string[]>([]);
const restrictTables = ref(false);
const localSelectedTables = ref<string[]>([]);
const matchResult = computed(() => buildSameNameTableMatches(localSelectedTables.value, targetTableList.value));
const missingTargetTables = computed(() => matchResult.value.missing);
// Keep the visual restriction in sync with the (persisted) config options.
// `undefined` = not restricted (compare all tables, then regex filter);
// an array = explicitly restricted to exactly those tables.
watch(
() => props.selectedTables,
(value) => {
restrictTables.value = Array.isArray(value);
localSelectedTables.value = value && Array.isArray(value) ? [...value] : [];
},
{ immediate: true },
);
function handleToggleRestrict(enabled: boolean) {
restrictTables.value = enabled;
emit("update:selectedTables", enabled ? [...localSelectedTables.value] : undefined);
}
function handleUpdateSelectedTables(value: string[]) {
localSelectedTables.value = value;
if (restrictTables.value) emit("update:selectedTables", [...value]);
}
function getTableIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity {
return side === "source" ? { connectionId: props.sourceConnectionId, database: props.sourceDatabase, schema: props.sourceSchema } : { connectionId: props.targetConnectionId, database: props.targetDatabase, schema: props.targetSchema };
}
function isTableIdentityReady(side: SchemaDiffTableSide): boolean {
const identity = getTableIdentity(side);
const config = side === "source" ? sourceConfig.value : targetConfig.value;
return !!identity.connectionId && !!identity.database && (!isSchemaAware(config?.db_type) || !!identity.schema);
}
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;
}
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;
localSelectedTables.value = nextSelectedTables;
emit("update:selectedTables", nextSelectedTables);
}
const tableListCoordinator = createSchemaDiffTableListCoordinator({
loader: props.tableListLoader,
getIdentity: getTableIdentity,
setTables: setTableList,
onSourceTablesLoaded: reconcileSelectedTablesAfterSuccessfulLoad,
});
watch(
() => [restrictTables.value, props.sourceConnectionId, props.sourceDatabase, props.sourceSchema, sourceDbType.value],
() => {
const shouldLoad = shouldLoadSchemaDiffTableList("source", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("source"));
tableListCoordinator.refresh("source", shouldLoad).catch(() => {});
},
{ immediate: true },
);
watch(
() => [restrictTables.value, localSelectedTables.value.length, props.targetConnectionId, props.targetDatabase, props.targetSchema, targetDbType.value],
() => {
const shouldLoad = shouldLoadSchemaDiffTableList("target", restrictTables.value, localSelectedTables.value.length, isTableIdentityReady("target"));
tableListCoordinator.refresh("target", shouldLoad).catch(() => {});
},
{ immediate: true },
);
const canConfigureTableSelection = computed(() => isTableIdentityReady("source"));
const canCompare = computed(() => {
return props.sourceConnectionId && props.targetConnectionId && props.sourceDatabase && props.targetDatabase && (!isSchemaAware(sourceConfig.value?.db_type) || props.sourceSchema) && (!isSchemaAware(targetConfig.value?.db_type) || props.targetSchema);
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);
});
async function loadDatabases(connectionId: string, side: "source" | "target") {
@@ -175,6 +267,25 @@ watch(
{ immediate: true },
);
// A visual selection belongs to the current source connection/database/schema. Clear it
// when that identity changes so a saved table name cannot silently restrict a different
// source. If the source and selected tables change together, it is a saved-config load;
// let the selectedTables watcher restore that config instead of clearing it again.
watch(
() => ({
source: [props.sourceConnectionId, props.sourceDatabase, props.sourceSchema] as const,
selectedTables: props.selectedTables,
}),
(current, previous) => {
if (current.source.every((value, index) => value === previous.source[index])) return;
if (current.selectedTables !== previous.selectedTables) return;
if (current.selectedTables === undefined && !restrictTables.value) return;
restrictTables.value = false;
localSelectedTables.value = [];
emit("update:selectedTables", undefined);
},
);
watch(
() => props.targetDatabase,
async (db) => {
@@ -324,6 +435,20 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
/>
</div>
<!-- Source Table Selection -->
<div class="mt-3 space-y-1.5">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ t("diff.tableSelection") }}</Label>
<Button v-if="restrictTables || canConfigureTableSelection" variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="handleToggleRestrict(!restrictTables)">
{{ restrictTables ? t("diff.compareAllTables") : t("diff.chooseTables") }}
</Button>
</div>
<div v-if="!restrictTables" class="text-[11px] text-muted-foreground">
{{ t("diff.tableSelectionUnrestricted") }}
</div>
<TableMultiSelect v-if="restrictTables" :key="`${sourceConnectionId}.${sourceDatabase}.${sourceSchema}`" :model-value="localSelectedTables" @update:model-value="handleUpdateSelectedTables" :tables="sourceTableList" :title="t('diff.sourceTables')" :empty-text="t('dataCompare.noTables')" />
</div>
<!-- Source Info -->
<div v-if="getConnectionInfo(sourceConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
<div class="text-xs font-medium text-blue-500">{{ t("diff.info") }}</div>
@@ -409,6 +534,17 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st
/>
</div>
<!-- Target Same-Name Match -->
<div v-if="restrictTables && localSelectedTables.length" class="mt-3 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 }) }}
</div>
<div v-if="missingTargetTables.length" class="text-destructive">
{{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }}
</div>
</div>
<!-- Target Info -->
<div v-if="getConnectionInfo(targetConnectionId)" class="mt-4 p-3 rounded-lg bg-muted/30 border space-y-1.5">
<div class="text-xs font-medium text-green-500">{{ t("diff.info") }}</div>
@@ -19,6 +19,7 @@ import SchemaDiffOptionsPanel from "@/components/diff/SchemaDiffOptionsPanel.vue
import { getSchemaDiffOptionsForDbType } from "@/lib/schema/schemaDiffOptions";
import { buildDeployTxResult } from "@/lib/schema/deployTxResult";
import { createConcurrencyLimiter, mapWithConcurrency, schemaDiffMetadataConcurrency, schemaDiffMetadataLoadPlan } from "@/lib/schema/schemaDiffMetadataLoad";
import { createSchemaDiffTableListLoader, type SchemaDiffTableIdentity } from "@/lib/schema/schemaDiffTableList";
import { normalizeSchemaDiffCompareOptions } from "@/types/schemaDiff";
import type { SchemaDiffCompareOptions, SchemaDiffConfig, FieldMappingEntry } from "@/types/schemaDiff";
import type { ObjectSourceKind, TableInfo } from "@/types/database";
@@ -56,6 +57,10 @@ const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const store = useConnectionStore();
const schemaDiffTableListLoader = createSchemaDiffTableListLoader({
ensureConnected: (connectionId) => store.ensureConnected(connectionId),
listTables: (connectionId, database, schema) => api.listTables(connectionId, database, schema),
});
const props = defineProps<{
prefillConnectionId?: string;
@@ -326,6 +331,12 @@ function handleOptionsUpdate(options: SchemaDiffCompareOptions) {
}
}
function handleSelectedTablesUpdate(value?: string[]) {
if (activeConfig.value) {
updateActiveConfigOptions(normalizeSchemaDiffCompareOptions({ ...activeConfig.value.options, selectedTables: value }, getDbType()));
}
}
function handleFieldMappingsUpdate(mappings: FieldMappingEntry[]) {
if (activeConfig.value) {
const updated = { ...activeConfig.value.options, fieldMappings: mappings };
@@ -396,11 +407,13 @@ async function handleCompare() {
const opts = normalizeSchemaDiffCompareOptions(activeConfig.value?.options, dbType);
const tableFilter = compileSchemaDiffTableFilter(opts);
await store.ensureConnected(sourceConnectionId.value);
await store.ensureConnected(targetConnectionId.value);
const [srcTables, tgtTables] = await Promise.all([api.listTables(sourceConnectionId.value, sourceDatabase.value, sourceSchema.value), api.listTables(targetConnectionId.value, targetDatabase.value, targetSchema.value)]);
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts);
const sourceTableIdentity: SchemaDiffTableIdentity = { connectionId: sourceConnectionId.value, database: sourceDatabase.value, schema: sourceSchema.value };
const targetTableIdentity: SchemaDiffTableIdentity = { connectionId: targetConnectionId.value, database: targetDatabase.value, schema: targetSchema.value };
const [srcTables, tgtTables] = await Promise.all([schemaDiffTableListLoader.load(sourceTableIdentity), schemaDiffTableListLoader.load(targetTableIdentity)]);
// Explicit (visual) table selection is applied here, BEFORE any per-table
// metadata details are loaded, so metadata requests only happen for the
// final table set. `undefined`/empty means no restriction (legacy path).
const { sourceTables, targetTables } = filterSchemaDiffTables(srcTables, tgtTables, tableFilter, opts, opts.selectedTables);
const sourceDetails = await loadSchemaDetails(sourceTables, {
connectionId: sourceConnectionId.value,
@@ -871,6 +884,8 @@ const targetConnectionInfo = computed(() => {
:configs="configs"
:active-config-id="activeConfigId"
:options="activeConfig?.options"
:selected-tables="activeConfig?.options?.selectedTables"
:table-list-loader="schemaDiffTableListLoader"
:loading="loading"
:recent-configs="recentConfigs"
@compare="handleCompare"
@@ -880,6 +895,7 @@ const targetConnectionInfo = computed(() => {
@load-history-config="handleLoadHistoryConfig"
@delete-history-config="handleDeleteHistoryConfig"
@update:field-mappings="handleFieldMappingsUpdate"
@update:selected-tables="handleSelectedTablesUpdate"
@open-field-mapping="showFieldMappingDialog = true"
/>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { CheckSquare, Square } from "@lucide/vue";
import { filterTableNames, isEveryFilteredSelected, toggleSelectFiltered, toggleTableName } from "@/lib/diff/tableMultiSelect";
/**
* Shared, searchable multi-select for a set of table names. Used by both Data
* Compare and Schema Diff. It only knows about "a list of table names to
* multi-select" — no connection/database/diff-engine concepts.
*/
const props = withDefaults(
defineProps<{
/** All available table names. */
tables: string[];
/** Currently selected table names (v-model). */
modelValue: string[];
/** Optional header label; falls back to `tableMultiSelect.tables`. */
title?: string;
/** Show the search box (only rendered when there are more than 5 tables). */
searchable?: boolean;
/** Text shown when `tables` is empty. */
emptyText?: string;
disabled?: boolean;
}>(),
{
title: "",
searchable: true,
emptyText: "",
disabled: false,
},
);
const emit = defineEmits<{ (e: "update:modelValue", value: string[]): void }>();
const { t } = useI18n();
const search = ref("");
const filteredTables = computed(() => filterTableNames(props.tables, search.value));
const selectedSet = computed(() => new Set(props.modelValue));
const allFilteredSelected = computed(() => isEveryFilteredSelected(props.modelValue, filteredTables.value));
const showSearch = computed(() => props.searchable && props.tables.length > 5);
function toggle(table: string) {
if (props.disabled) return;
emit("update:modelValue", toggleTableName(props.modelValue, table));
}
function toggleSelectAll() {
if (props.disabled) return;
emit("update:modelValue", toggleSelectFiltered(props.modelValue, filteredTables.value));
}
</script>
<template>
<div class="space-y-2 rounded-lg border p-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs font-medium">{{ title || t("tableMultiSelect.tables") }}</Label>
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
{{ t("tableMultiSelect.selected", { selected: modelValue.length, total: tables.length }) }}
</div>
</div>
<Input v-if="showSearch" v-model="search" class="h-7 text-xs" :placeholder="t('tableMultiSelect.search')" :disabled="disabled" />
<div class="flex items-center gap-2">
<Button v-if="tables.length" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="disabled" @click="toggleSelectAll">
{{ allFilteredSelected ? t("tableMultiSelect.deselectAll") : t("tableMultiSelect.selectAll") }}
</Button>
</div>
<div v-if="tables.length === 0" class="py-3 text-center text-xs text-muted-foreground">
{{ emptyText || t("tableMultiSelect.noTables") }}
</div>
<div v-else class="max-h-40 overflow-auto rounded border">
<button v-for="table in filteredTables" :key="table" type="button" :disabled="disabled" class="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs hover:bg-muted/50 disabled:opacity-50" @click="toggle(table)">
<CheckSquare v-if="selectedSet.has(table)" class="h-3.5 w-3.5 shrink-0 text-primary" />
<Square v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
</template>
+16
View File
@@ -5369,6 +5369,14 @@ export default {
syncSummary: "Succeeded {success}, failed {failed}",
configSaved: "Config saved",
configDeleted: "Config deleted",
tableSelection: "Tables",
chooseTables: "Choose tables...",
compareAllTables: "Compare all tables",
tableSelectionUnrestricted: "Comparing all tables (after regex filters). Choose specific tables to limit the comparison.",
sourceTables: "Source tables",
autoMatchHint: "Target tables are matched by the same name.",
matchedTables: "{matched}/{total} tables matched",
missingTargetTables: "Missing same-name target tables: {tables}",
sourceObject: "Source object",
targetObject: "Target object",
operation: "Operation",
@@ -5562,6 +5570,14 @@ export default {
dialectAuto: "auto",
},
},
tableMultiSelect: {
tables: "Tables",
selected: "{selected}/{total} selected",
search: "Search tables...",
selectAll: "Select all",
deselectAll: "Deselect all",
noTables: "No tables available",
},
dataCompare: {
title: "Compare Data",
selectTable: "Select table",
+16
View File
@@ -5120,6 +5120,14 @@ export default withEnglishFallback({
syncSummary: "Exitosos {success}, fallidos {failed}",
configSaved: "Config guardada",
configDeleted: "Config eliminada",
tableSelection: "Tablas",
chooseTables: "Elegir tablas...",
compareAllTables: "Comparar todas las tablas",
tableSelectionUnrestricted: "Comparando todas las tablas (después de los filtros regex). Elige tablas específicas para limitar la comparación.",
sourceTables: "Tablas de origen",
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}",
sourceObject: "Objeto de origen",
targetObject: "Objeto de destino",
operation: "Operación",
@@ -5312,6 +5320,14 @@ export default withEnglishFallback({
dialectAuto: "automático",
},
},
tableMultiSelect: {
tables: "Tablas",
selected: "{selected}/{total} seleccionadas",
search: "Buscar tablas...",
selectAll: "Seleccionar todo",
deselectAll: "Deseleccionar todo",
noTables: "No hay tablas disponibles",
},
dataCompare: {
title: "Comparar datos",
selectTable: "Seleccionar tabla",
+16
View File
@@ -5120,6 +5120,14 @@ export default withEnglishFallback({
syncSummary: "Riuscite {success}, non riuscite {failed}",
configSaved: "Configurazione salvata",
configDeleted: "Configurazione eliminata",
tableSelection: "Tabelle",
chooseTables: "Scegli tabelle...",
compareAllTables: "Confronta tutte le tabelle",
tableSelectionUnrestricted: "Confronto di tutte le tabelle (dopo i filtri regex). Scegli tabelle specifiche per limitare il confronto.",
sourceTables: "Tabelle sorgente",
autoMatchHint: "Le tabelle di destinazione vengono abbinate per nome.",
matchedTables: "{matched}/{total} tabelle abbinate",
missingTargetTables: "Tabelle di destinazione omonime mancanti: {tables}",
sourceObject: "Oggetto sorgente",
targetObject: "Oggetto destinazione",
operation: "Operazione",
@@ -5312,6 +5320,14 @@ export default withEnglishFallback({
dialectAuto: "auto",
},
},
tableMultiSelect: {
tables: "Tabelle",
selected: "{selected}/{total} selezionate",
search: "Cerca tabelle...",
selectAll: "Seleziona tutte",
deselectAll: "Deseleziona tutte",
noTables: "Nessuna tabella disponibile",
},
dataCompare: {
title: "Confronta Dati",
selectTable: "Seleziona tabella",
+16
View File
@@ -5214,6 +5214,14 @@ export default withEnglishFallback({
serverVersion: "サーバーバージョン",
close: "閉じる",
configDeleted: "設定を削除しました",
tableSelection: "テーブル",
chooseTables: "テーブルを選択...",
compareAllTables: "すべてのテーブルを比較",
tableSelectionUnrestricted: "正規表現フィルター後のすべてのテーブルを比較しています。比較対象を限定するにはテーブルを選択してください。",
sourceTables: "ソーステーブル",
autoMatchHint: "ターゲットテーブルは同じ名前でマッチングされます。",
matchedTables: "{matched}/{total}テーブルがマッチしました",
missingTargetTables: "同名のターゲットテーブルがありません: {tables}",
sourceObject: "ソースオブジェクト",
targetObject: "ターゲットオブジェクト",
operation: "操作",
@@ -5351,6 +5359,14 @@ export default withEnglishFallback({
dialectAuto: "自動",
},
},
tableMultiSelect: {
tables: "テーブル",
selected: "{selected}/{total}件選択中",
search: "テーブルを検索...",
selectAll: "すべて選択",
deselectAll: "すべて解除",
noTables: "利用可能なテーブルがありません",
},
dataCompare: {
title: "データを比較",
selectTable: "テーブルを選択",
+16
View File
@@ -5122,6 +5122,14 @@ export default withEnglishFallback({
selectRecentConfig: "Selecionar configuração recente",
configSaved: "Configuração salva",
configDeleted: "Configuração excluída",
tableSelection: "Tabelas",
chooseTables: "Escolher tabelas...",
compareAllTables: "Comparar todas as tabelas",
tableSelectionUnrestricted: "Comparando todas as tabelas (após os filtros regex). Escolha tabelas específicas para limitar a comparação.",
sourceTables: "Tabelas de origem",
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}",
sourceObject: "Objeto de origem",
targetObject: "Objeto de destino",
operation: "Operação",
@@ -5314,6 +5322,14 @@ export default withEnglishFallback({
dialectAuto: "automático",
},
},
tableMultiSelect: {
tables: "Tabelas",
selected: "{selected}/{total} selecionadas",
search: "Pesquisar tabelas...",
selectAll: "Selecionar tudo",
deselectAll: "Desmarcar tudo",
noTables: "Nenhuma tabela disponível",
},
dataCompare: {
title: "Comparar Dados",
selectTable: "Selecionar tabela",
+16
View File
@@ -5438,6 +5438,14 @@ export default withEnglishFallback({
copied: "已复制到剪贴板",
configSaved: "配置已保存",
configDeleted: "配置已删除",
tableSelection: "表",
chooseTables: "指定表...",
compareAllTables: "比较所有表",
tableSelectionUnrestricted: "当前比较所有表(经过正则过滤后)。可指定表来限定比较范围。",
sourceTables: "源表",
autoMatchHint: "目标表按同名自动匹配。",
matchedTables: "已匹配 {matched}/{total} 张表",
missingTargetTables: "缺少同名的目标表:{tables}",
noDeployScriptAll: "暂无所选对象的部署脚本",
saveConfigPrompt: "请输入配置名称:",
defaultConfigName: "默认",
@@ -5548,6 +5556,14 @@ export default withEnglishFallback({
dialectAuto: "自动",
},
},
tableMultiSelect: {
tables: "表",
selected: "已选 {selected}/{total}",
search: "搜索表名...",
selectAll: "全选",
deselectAll: "取消全选",
noTables: "没有可用表",
},
dataCompare: {
title: "比较数据",
selectTable: "选择表",
+16
View File
@@ -4462,6 +4462,14 @@ export default withEnglishFallback({
selectRecentConfig: "選擇最近配置",
configSaved: "配置已儲存",
configDeleted: "配置已刪除",
tableSelection: "資料表",
chooseTables: "選擇資料表...",
compareAllTables: "比較所有資料表",
tableSelectionUnrestricted: "目前比較所有資料表(套用正規表示式篩選後)。選擇特定資料表以限制比較範圍。",
sourceTables: "來源資料表",
autoMatchHint: "目標資料表會依相同名稱進行比對。",
matchedTables: "已比對 {matched}/{total} 張資料表",
missingTargetTables: "缺少同名的目標資料表:{tables}",
sourceObject: "來源物件",
targetObject: "目標物件",
saveConfig: "儲存配置",
@@ -4639,6 +4647,14 @@ export default withEnglishFallback({
dialectAuto: "自動",
},
},
tableMultiSelect: {
tables: "資料表",
selected: "已選擇 {selected}/{total}",
search: "搜尋資料表名稱……",
selectAll: "全選",
deselectAll: "取消全選",
noTables: "沒有可用的資料表",
},
dataCompare: {
title: "比較資料",
selectTable: "選擇資料表",
@@ -0,0 +1,24 @@
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,96 @@
import { describe, expect, it } from "vitest";
import { filterTableNames, isEveryFilteredSelected, toggleSelectFiltered, toggleTableName } from "@/lib/diff/tableMultiSelect";
describe("tableMultiSelect helpers", () => {
const tables = ["users", "active_users", "orders", "audit_log", "payments"];
describe("filterTableNames", () => {
it("returns all tables for an empty/whitespace query", () => {
expect(filterTableNames(tables, "")).toEqual(tables);
expect(filterTableNames(tables, " ")).toEqual(tables);
});
it("filters by case-insensitive substring", () => {
expect(filterTableNames(tables, "USER")).toEqual(["users", "active_users"]);
expect(filterTableNames(tables, "pay")).toEqual(["payments"]);
});
it("returns an empty list when nothing matches", () => {
expect(filterTableNames(tables, "zzz")).toEqual([]);
});
});
describe("toggleTableName", () => {
it("adds a table to the selection", () => {
expect(toggleTableName([], "users")).toEqual(["users"]);
expect(toggleTableName(["orders"], "users")).toEqual(["orders", "users"]);
});
it("removes a table from the selection", () => {
expect(toggleTableName(["users", "orders"], "users")).toEqual(["orders"]);
});
it("returns a new array and never duplicates", () => {
const input = ["users"];
const next = toggleTableName(input, "users");
expect(next).toEqual([]);
expect(next).not.toBe(input);
});
});
describe("isEveryFilteredSelected", () => {
it("is false for an empty filtered set", () => {
expect(isEveryFilteredSelected(["users"], [])).toBe(false);
});
it("is true only when every filtered table is selected", () => {
expect(isEveryFilteredSelected(["users", "orders"], ["users", "orders"])).toBe(true);
expect(isEveryFilteredSelected(["users"], ["users", "orders"])).toBe(false);
expect(isEveryFilteredSelected([], ["users"])).toBe(false);
});
});
describe("toggleSelectFiltered", () => {
it("selects all filtered tables when not all are selected (select-all)", () => {
const result = toggleSelectFiltered(["users"], [...tables]);
expect(result).toEqual([...tables]);
});
it("deselects all filtered tables when all are already selected (deselect-all)", () => {
const result = toggleSelectFiltered([...tables], [...tables]);
expect(result).toEqual([]);
});
it("only affects the filtered subset and preserves other selections", () => {
const filtered = ["users", "orders"];
const result = toggleSelectFiltered(["payments"], filtered);
expect(result).toEqual(["payments", "users", "orders"]);
});
it("after a search, select-all only toggles the current filtered subset", () => {
const filtered = filterTableNames(tables, "user"); // users, active_users
const selected = ["orders", "payments"];
const afterSelect = toggleSelectFiltered(selected, filtered);
expect([...afterSelect].sort()).toEqual(["active_users", "orders", "payments", "users"]);
});
it("keeps already-selected tables untouched by the filtered subset change", () => {
// simulating: selected [users, orders], search narrowed to [users], deselect-all removes only users
const filtered = ["users"];
const result = toggleSelectFiltered(["users", "orders"], filtered);
expect(result).toEqual(["orders"]);
});
});
it("selection persists across a changing search query (search is independent of selection)", () => {
// selection state is external; filtering only selects which rows are visible
const selected = ["users", "audit_log"];
const visibleA = filterTableNames(tables, "user");
const visibleB = filterTableNames(tables, "audit");
expect(visibleA).toEqual(["users", "active_users"]);
expect(visibleB).toEqual(["audit_log"]);
// both still reflect the same underlying selection
expect(isEveryFilteredSelected(selected, ["users"])).toBe(true);
expect(isEveryFilteredSelected(selected, ["audit_log"])).toBe(true);
});
});
@@ -44,4 +44,67 @@ describe("schemaDiffTableFilter", () => {
expect(result.sourceTables.map((table) => table.name)).toEqual(["users", "monthly_users"]);
});
// ---- Visual (explicit) table selection (#6533) ----
const all: TableInfo[] = [
{ name: "users", table_type: "BASE TABLE" },
{ name: "active_users", table_type: "BASE TABLE" },
{ name: "orders", table_type: "BASE TABLE" },
{ name: "orders_bak", table_type: "BASE TABLE" },
];
const baseOptions = { ...DEFAULT_MYSQL_OPTIONS, tables: true, views: true };
it("keeps legacy behavior when no visual selection is set (undefined selection = no restriction)", () => {
const options = { ...baseOptions, tableIncludePattern: "^orders$", tableExcludePattern: "_bak$" };
const result = filterSchemaDiffTables(all, all, compileSchemaDiffTableFilter(options), options, undefined);
expect(result.sourceTables.map((t) => t.name)).toEqual(["orders"]);
});
it("restricts to the explicitly selected tables only", () => {
const result = filterSchemaDiffTables(all, all, compileSchemaDiffTableFilter(baseOptions), baseOptions, ["users", "orders"]);
expect(result.sourceTables.map((t) => t.name)).toEqual(["users", "orders"]);
});
it("intersects visual selection with the include regex", () => {
const options = { ...baseOptions, tableIncludePattern: "^users$" };
const result = filterSchemaDiffTables(all, all, compileSchemaDiffTableFilter(options), options, ["users", "orders"]);
expect(result.sourceTables.map((t) => t.name)).toEqual(["users"]);
});
it("intersects visual selection with the exclude regex", () => {
const options = { ...baseOptions, tableExcludePattern: "_bak$" };
const result = filterSchemaDiffTables(all, all, compileSchemaDiffTableFilter(options), options, ["orders", "orders_bak"]);
expect(result.sourceTables.map((t) => t.name)).toEqual(["orders"]);
});
it("treats an explicitly empty selection ([]) as selecting nothing, distinct from undefined", () => {
const result = filterSchemaDiffTables(all, all, compileSchemaDiffTableFilter(baseOptions), baseOptions, []);
expect(result.sourceTables).toEqual([]);
});
it("keeps selected source tables when same-name targets are missing", () => {
const sourceOnly: TableInfo[] = [
{ name: "a", table_type: "BASE TABLE" },
{ name: "b", table_type: "BASE TABLE" },
{ name: "c", table_type: "BASE TABLE" },
];
const targetPartly: TableInfo[] = [
{ name: "a", table_type: "BASE TABLE" },
{ name: "c", table_type: "BASE TABLE" },
];
const result = filterSchemaDiffTables(sourceOnly, targetPartly, compileSchemaDiffTableFilter(baseOptions), baseOptions, ["a", "b", "c"]);
expect(result.sourceTables.map((t) => t.name)).toEqual(["a", "b", "c"]);
expect(result.targetTables.map((t) => t.name)).toEqual(["a", "c"]);
});
it("does not include unselected target tables that would otherwise look like drops", () => {
const source: TableInfo[] = [{ name: "a", table_type: "BASE TABLE" }];
const target: TableInfo[] = [
{ name: "a", table_type: "BASE TABLE" },
{ name: "b", table_type: "BASE TABLE" },
];
const result = filterSchemaDiffTables(source, target, compileSchemaDiffTableFilter(baseOptions), baseOptions, ["a"]);
expect(result.sourceTables.map((t) => t.name)).toEqual(["a"]);
expect(result.targetTables.map((t) => t.name)).toEqual(["a"]);
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { createSchemaDiffTableListCoordinator, createSchemaDiffTableListLoader, reconcileSchemaDiffSelectedTables, shouldLoadSchemaDiffTableList, type SchemaDiffTableIdentity, type SchemaDiffTableSide } from "@/lib/schema/schemaDiffTableList";
import type { TableInfo } from "@/types/database";
function table(name: string): TableInfo {
return { name, table_type: "BASE TABLE" };
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe("schemaDiffTableList", () => {
it("keeps the unrestricted config path free of table-list requests", async () => {
const listTables = vi.fn().mockResolvedValue([table("users")]);
const loader = createSchemaDiffTableListLoader({ ensureConnected: vi.fn().mockResolvedValue(undefined), listTables });
const identities: Record<SchemaDiffTableSide, SchemaDiffTableIdentity> = {
source: { connectionId: "source", database: "app", schema: "public" },
target: { connectionId: "target", database: "app", schema: "public" },
};
const coordinator = createSchemaDiffTableListCoordinator({
loader,
getIdentity: (side) => identities[side],
setTables: vi.fn(),
});
await coordinator.refresh("source", shouldLoadSchemaDiffTableList("source", false, 0, true));
await coordinator.refresh("target", shouldLoadSchemaDiffTableList("target", false, 0, true));
expect(listTables).not.toHaveBeenCalled();
expect(shouldLoadSchemaDiffTableList("target", true, 0, true)).toBe(false);
});
it("loads each side at most once and reuses successful results for Compare", async () => {
const listTables = vi.fn(async (connectionId: string) => [table(`${connectionId}_table`)]);
const loader = createSchemaDiffTableListLoader({ ensureConnected: vi.fn().mockResolvedValue(undefined), listTables });
const identities: Record<SchemaDiffTableSide, SchemaDiffTableIdentity> = {
source: { connectionId: "source", database: "app", schema: "public" },
target: { connectionId: "target", database: "app", schema: "public" },
};
const coordinator = createSchemaDiffTableListCoordinator({
loader,
getIdentity: (side) => identities[side],
setTables: vi.fn(),
});
await coordinator.refresh("source", shouldLoadSchemaDiffTableList("source", true, 0, true));
await coordinator.refresh("target", shouldLoadSchemaDiffTableList("target", true, 1, true));
await Promise.all([loader.load(identities.source), loader.load(identities.target)]);
expect(listTables).toHaveBeenCalledTimes(2);
expect(listTables).toHaveBeenCalledWith("source", "app", "public");
expect(listTables).toHaveBeenCalledWith("target", "app", "public");
});
it("prunes deleted selections only after a successful source load", async () => {
const listTables = vi.fn(async (_connectionId: string, database: string) => {
if (database === "unavailable") throw new Error("offline");
return [table("users")];
});
const loader = createSchemaDiffTableListLoader({ ensureConnected: vi.fn().mockResolvedValue(undefined), listTables });
let sourceIdentity: SchemaDiffTableIdentity = { connectionId: "source", database: "app", schema: "public" };
let selectedTables = ["deleted_table"];
const coordinator = createSchemaDiffTableListCoordinator({
loader,
getIdentity: () => sourceIdentity,
setTables: vi.fn(),
onSourceTablesLoaded: (tables) => {
selectedTables = reconcileSchemaDiffSelectedTables(
selectedTables,
tables.map((entry) => entry.name),
);
},
});
await coordinator.refresh("source", true);
expect(selectedTables).toEqual([]);
selectedTables = ["keep_on_error"];
sourceIdentity = { ...sourceIdentity, database: "unavailable" };
await coordinator.refresh("source", true);
expect(selectedTables).toEqual(["keep_on_error"]);
});
it("ignores an older response after the source identity changes", async () => {
const sourceA = deferred<TableInfo[]>();
const sourceB = deferred<TableInfo[]>();
const listTables = vi.fn((_connectionId: string, database: string) => (database === "a" ? sourceA.promise : sourceB.promise));
const loader = createSchemaDiffTableListLoader({ ensureConnected: vi.fn().mockResolvedValue(undefined), listTables });
let sourceIdentity: SchemaDiffTableIdentity = { connectionId: "source", database: "a", schema: "public" };
let visibleTables: TableInfo[] = [];
const coordinator = createSchemaDiffTableListCoordinator({
loader,
getIdentity: () => sourceIdentity,
setTables: (_side, tables) => {
visibleTables = tables;
},
});
const loadA = coordinator.refresh("source", true);
sourceIdentity = { ...sourceIdentity, database: "b" };
const loadB = coordinator.refresh("source", true);
sourceB.resolve([table("b_table")]);
await loadB;
sourceA.resolve([table("a_table")]);
await loadA;
expect(visibleTables.map((entry) => entry.name)).toEqual(["b_table"]);
});
});
@@ -0,0 +1,23 @@
/**
* 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 };
}
@@ -0,0 +1,43 @@
/**
* Pure selection/filtering helpers for the shared TableMultiSelect UI.
*
* These are framework-agnostic (no Vue state) so the behavior of the shared
* multi-table selector can be unit-tested independently of any component
* mount infrastructure, and so the same logic backs both Data Compare and
* Schema Diff table selection.
*/
/** Case-insensitive substring filter over table names. An empty/whitespace query returns all tables. */
export function filterTableNames(tables: string[], query: string): string[] {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return tables;
return tables.filter((table) => table.toLowerCase().includes(trimmed));
}
/** Toggle a single table name within a selection list (preserves order, no duplicates). */
export function toggleTableName(selected: string[], table: string): string[] {
if (selected.includes(table)) {
return selected.filter((name) => name !== table);
}
return [...selected, table];
}
/** True when every entry of `filtered` is already present in `selected`. */
export function isEveryFilteredSelected(selected: string[], filtered: string[]): boolean {
return filtered.length > 0 && filtered.every((table) => selected.includes(table));
}
/**
* Select every table in `filtered` if not all are selected; otherwise deselect them all.
* Only tables within `filtered` are touched — the rest of `selected` is preserved, so
* selecting-all after a search only affects the current filtered subset.
*/
export function toggleSelectFiltered(selected: string[], filtered: string[]): string[] {
const next = new Set(selected);
if (isEveryFilteredSelected(selected, filtered)) {
filtered.forEach((table) => next.delete(table));
} else {
filtered.forEach((table) => next.add(table));
}
return [...next];
}
@@ -49,8 +49,16 @@ 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 }): FilteredSchemaDiffTables {
const includeTable = (table: TableInfo) => isSchemaDiffObjectEnabled(table, options) && matchesSchemaDiffTableFilter(table.name, filter);
export function filterSchemaDiffTables(sourceTables: TableInfo[], targetTables: TableInfo[], filter: CompiledSchemaDiffTableFilter, options: Pick<SchemaDiffCompareOptions, "tables" | "views"> = { 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.
const selectedSet = selectedTables === undefined ? null : new Set(selectedTables);
const includeTable = (table: TableInfo) => isSchemaDiffObjectEnabled(table, options) && (!selectedSet || selectedSet.has(table.name)) && matchesSchemaDiffTableFilter(table.name, filter);
return {
sourceTables: sourceTables.filter(includeTable),
@@ -0,0 +1,101 @@
import type { TableInfo } from "@/types/database";
export type SchemaDiffTableSide = "source" | "target";
export interface SchemaDiffTableIdentity {
connectionId: string;
database: string;
schema: string;
}
export interface SchemaDiffTableListLoader {
load(identity: SchemaDiffTableIdentity): Promise<TableInfo[]>;
}
interface SchemaDiffTableListLoaderDependencies {
ensureConnected(connectionId: string): Promise<unknown>;
listTables(connectionId: string, database: string, schema: string): Promise<TableInfo[]>;
}
interface SchemaDiffTableListCoordinatorOptions {
loader: SchemaDiffTableListLoader;
getIdentity(side: SchemaDiffTableSide): SchemaDiffTableIdentity;
setTables(side: SchemaDiffTableSide, tables: TableInfo[]): void;
onSourceTablesLoaded?(tables: TableInfo[]): void;
}
function identityKey(identity: SchemaDiffTableIdentity): string {
return JSON.stringify([identity.connectionId, identity.database, identity.schema]);
}
export function sameSchemaDiffTableIdentity(left: SchemaDiffTableIdentity, right: SchemaDiffTableIdentity): boolean {
return left.connectionId === right.connectionId && left.database === right.database && left.schema === right.schema;
}
export function shouldLoadSchemaDiffTableList(side: SchemaDiffTableSide, restricted: boolean, selectedTableCount: number, identityReady: boolean): boolean {
return identityReady && restricted && (side === "source" || selectedTableCount > 0);
}
export function reconcileSchemaDiffSelectedTables(selectedTables: string[], availableTables: string[]): string[] {
const available = new Set(availableTables);
return selectedTables.filter((table) => available.has(table));
}
export function createSchemaDiffTableListLoader(dependencies: SchemaDiffTableListLoaderDependencies): SchemaDiffTableListLoader {
const successful = new Map<string, TableInfo[]>();
const pending = new Map<string, Promise<TableInfo[]>>();
return {
async load(identity) {
const key = identityKey(identity);
const inFlight = pending.get(key);
if (inFlight) return inFlight;
const cached = successful.get(key);
if (cached) {
await dependencies.ensureConnected(identity.connectionId);
return cached;
}
const request = dependencies
.ensureConnected(identity.connectionId)
.then(() => dependencies.listTables(identity.connectionId, identity.database, identity.schema))
.then((tables) => {
const normalized = Array.isArray(tables) ? tables : [];
successful.set(key, normalized);
return normalized;
})
.finally(() => pending.delete(key));
pending.set(key, request);
return request;
},
};
}
export function createSchemaDiffTableListCoordinator(options: SchemaDiffTableListCoordinatorOptions) {
const generations: Record<SchemaDiffTableSide, number> = { source: 0, target: 0 };
return {
async refresh(side: SchemaDiffTableSide, enabled: boolean): Promise<boolean> {
const generation = ++generations[side];
const identity = options.getIdentity(side);
options.setTables(side, []);
if (!enabled) return false;
try {
const tables = await options.loader.load(identity);
if (generation !== generations[side] || !sameSchemaDiffTableIdentity(identity, options.getIdentity(side))) return false;
options.setTables(side, tables);
if (side === "source") options.onSourceTablesLoaded?.(tables);
return true;
} catch {
if (generation === generations[side] && sameSchemaDiffTableIdentity(identity, options.getIdentity(side))) {
options.setTables(side, []);
}
return false;
}
},
};
}
+12
View File
@@ -20,6 +20,16 @@ export interface SchemaDiffCompareOptions {
tableIncludePattern: string;
tableExcludePattern: string;
tableFilterPriority: SchemaDiffTableFilterPriority;
/**
* Explicitly selected table names to compare (the source table set, applied BEFORE the
* existing include/exclude regex filters). `undefined` means no visual restriction, so all
* tables still flow through the regex filters and legacy configs keep their
* behavior. `[]` means an explicitly enabled restriction with no selected tables and
* therefore compares nothing. A non-empty array restricts the comparison to exactly those
* names. Never initialize this to the full table
* list so newly added tables keep entering unrestricted comparisons and configs stay small.
*/
selectedTables: string[] | undefined;
detectRenames: boolean;
renameThreshold: number;
detectTableRenames: boolean;
@@ -87,6 +97,7 @@ export const DEFAULT_POSTGRES_OPTIONS: SchemaDiffCompareOptions = {
tableIncludePattern: "",
tableExcludePattern: "",
tableFilterPriority: "exclude",
selectedTables: undefined,
detectRenames: false,
renameThreshold: 0.5,
detectTableRenames: false,
@@ -118,6 +129,7 @@ export const DEFAULT_MYSQL_OPTIONS: SchemaDiffCompareOptions = {
tableIncludePattern: "",
tableExcludePattern: "",
tableFilterPriority: "exclude",
selectedTables: undefined,
detectRenames: false,
renameThreshold: 0.5,
detectTableRenames: false,