From b3b6d2909918c7583fdf5d800824b0199661b9aa Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Tue, 18 Aug 2026 15:31:00 +0800 Subject: [PATCH] feat(schema-diff): support selecting tables to compare --- .../src/components/diff/DataCompareDialog.vue | 73 ++------- .../components/diff/SchemaDiffConfigStep.vue | 138 +++++++++++++++++- .../src/components/diff/SchemaDiffDialog.vue | 26 +++- .../src/components/diff/TableMultiSelect.vue | 86 +++++++++++ apps/desktop/src/i18n/locales/en.ts | 16 ++ apps/desktop/src/i18n/locales/es.ts | 16 ++ apps/desktop/src/i18n/locales/it.ts | 16 ++ apps/desktop/src/i18n/locales/ja.ts | 16 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 16 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 16 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 16 ++ .../__tests__/diff/sameNameTableMatch.spec.ts | 24 +++ .../__tests__/diff/tableMultiSelect.spec.ts | 96 ++++++++++++ .../schema/schemaDiffTableFilter.spec.ts | 63 ++++++++ .../schema/schemaDiffTableList.spec.ts | 117 +++++++++++++++ .../src/lib/diff/sameNameTableMatch.ts | 23 +++ apps/desktop/src/lib/diff/tableMultiSelect.ts | 43 ++++++ .../src/lib/schema/schemaDiffTableFilter.ts | 12 +- .../src/lib/schema/schemaDiffTableList.ts | 101 +++++++++++++ apps/desktop/src/types/schemaDiff.ts | 12 ++ .../app-tests/schemaDiffTableFilter.test.ts | 76 ++++++++++ 21 files changed, 933 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/components/diff/TableMultiSelect.vue create mode 100644 apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/diff/tableMultiSelect.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/schema/schemaDiffTableList.spec.ts create mode 100644 apps/desktop/src/lib/diff/sameNameTableMatch.ts create mode 100644 apps/desktop/src/lib/diff/tableMultiSelect.ts create mode 100644 apps/desktop/src/lib/schema/schemaDiffTableList.ts diff --git a/apps/desktop/src/components/diff/DataCompareDialog.vue b/apps/desktop/src/components/diff/DataCompareDialog.vue index 5e96993d4f..de62aebb3e 100644 --- a/apps/desktop/src/components/diff/DataCompareDialog.vue +++ b/apps/desktop/src/components/diff/DataCompareDialog.vue @@ -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([]); const sourceSchemas = ref([]); const sourceTables = ref([]); -const sourceTableSearch = ref(""); const selectedSourceTables = ref>(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({ + 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) { 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)]" /> -
-
- -
- {{ - t("dataCompare.selectedTables", { - selected: selectedSourceTableNames.length, - total: sourceTables.length, - }) - }} -
-
- - - -
- -
- -
- {{ t("dataCompare.selectSourceTables") }} -
-
- {{ t("dataCompare.noTables") }} -
-
- -
-
+
diff --git a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue index bc03b22eec..23bb3dae99 100644 --- a/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue +++ b/apps/desktop/src/components/diff/SchemaDiffConfigStep.vue @@ -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([]); @@ -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([]); +const targetTableList = ref([]); +const restrictTables = ref(false); +const localSelectedTables = ref([]); + +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 />
+ +
+
+ + +
+
+ {{ t("diff.tableSelectionUnrestricted") }} +
+ +
+
{{ t("diff.info") }}
@@ -409,6 +534,17 @@ async function fetchDbVersion(connectionId: string, database: string, schema: st />
+ +
+
{{ t("diff.autoMatchHint") }}
+
+ {{ t("diff.matchedTables", { matched: matchResult.matched.length, total: localSelectedTables.length }) }} +
+
+ {{ t("diff.missingTargetTables", { tables: missingTargetTables.join(", ") }) }} +
+
+
{{ t("diff.info") }}
diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index cf7e3507d7..c95ae7e4d1 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -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("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" /> diff --git a/apps/desktop/src/components/diff/TableMultiSelect.vue b/apps/desktop/src/components/diff/TableMultiSelect.vue new file mode 100644 index 0000000000..d56c3a5c2a --- /dev/null +++ b/apps/desktop/src/components/diff/TableMultiSelect.vue @@ -0,0 +1,86 @@ + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 78c57353c9..bad29f288f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 95e1199adf..dcce2ec6cb 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index ed068317e7..a2801e6b8c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 38ad325e01..ae30558985 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "テーブルを選択", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 0af98d750f..05ac294343 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b514f76201..0350b125cb 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "选择表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 638abda9c0..4d2f818002 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "選擇資料表", diff --git a/apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts b/apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts new file mode 100644 index 0000000000..e183d1cbef --- /dev/null +++ b/apps/desktop/src/lib/__tests__/diff/sameNameTableMatch.spec.ts @@ -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: [] }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/diff/tableMultiSelect.spec.ts b/apps/desktop/src/lib/__tests__/diff/tableMultiSelect.spec.ts new file mode 100644 index 0000000000..53c72c1fe5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/diff/tableMultiSelect.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts index e717963cde..19151f9b87 100644 --- a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableFilter.spec.ts @@ -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"]); + }); }); diff --git a/apps/desktop/src/lib/__tests__/schema/schemaDiffTableList.spec.ts b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableList.spec.ts new file mode 100644 index 0000000000..ab361d74ab --- /dev/null +++ b/apps/desktop/src/lib/__tests__/schema/schemaDiffTableList.spec.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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 = { + 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 = { + 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(); + const sourceB = deferred(); + 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"]); + }); +}); diff --git a/apps/desktop/src/lib/diff/sameNameTableMatch.ts b/apps/desktop/src/lib/diff/sameNameTableMatch.ts new file mode 100644 index 0000000000..237215b29d --- /dev/null +++ b/apps/desktop/src/lib/diff/sameNameTableMatch.ts @@ -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 }; +} diff --git a/apps/desktop/src/lib/diff/tableMultiSelect.ts b/apps/desktop/src/lib/diff/tableMultiSelect.ts new file mode 100644 index 0000000000..c81da0433a --- /dev/null +++ b/apps/desktop/src/lib/diff/tableMultiSelect.ts @@ -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]; +} diff --git a/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts b/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts index 2d1c64797c..bd25d89c02 100644 --- a/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts +++ b/apps/desktop/src/lib/schema/schemaDiffTableFilter.ts @@ -49,8 +49,16 @@ function isSchemaDiffObjectEnabled(table: TableInfo, options: Pick = { 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 = { 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), diff --git a/apps/desktop/src/lib/schema/schemaDiffTableList.ts b/apps/desktop/src/lib/schema/schemaDiffTableList.ts new file mode 100644 index 0000000000..04e0256995 --- /dev/null +++ b/apps/desktop/src/lib/schema/schemaDiffTableList.ts @@ -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; +} + +interface SchemaDiffTableListLoaderDependencies { + ensureConnected(connectionId: string): Promise; + listTables(connectionId: string, database: string, schema: string): Promise; +} + +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(); + const pending = new Map>(); + + 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 = { source: 0, target: 0 }; + + return { + async refresh(side: SchemaDiffTableSide, enabled: boolean): Promise { + 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; + } + }, + }; +} diff --git a/apps/desktop/src/types/schemaDiff.ts b/apps/desktop/src/types/schemaDiff.ts index 4de47387d1..7afc5d8980 100644 --- a/apps/desktop/src/types/schemaDiff.ts +++ b/apps/desktop/src/types/schemaDiff.ts @@ -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, diff --git a/packages/app-tests/schemaDiffTableFilter.test.ts b/packages/app-tests/schemaDiffTableFilter.test.ts index cdb5540e5a..f2e97d0093 100644 --- a/packages/app-tests/schemaDiffTableFilter.test.ts +++ b/packages/app-tests/schemaDiffTableFilter.test.ts @@ -63,3 +63,79 @@ test("rejects invalid schema diff table regex", () => { /Invalid include table name regex/, ); }); + +test("respects explicit visual table selection before loading details", () => { + const filter = compileSchemaDiffTableFilter(normalizeSchemaDiffCompareOptions({})); + + const tables = [table("a"), table("b"), table("c"), table("d")]; + const result = filterSchemaDiffTables(tables, tables, filter, undefined, ["a", "c"]); + + assert.deepEqual( + result.sourceTables.map((item) => item.name), + ["a", "c"], + ); +}); + +test("intersects visual selection with the include regex", () => { + const filter = compileSchemaDiffTableFilter( + normalizeSchemaDiffCompareOptions({ + tableIncludePattern: "^user_", + }), + ); + + const tables = [table("user_a"), table("user_b"), table("order")]; + const result = filterSchemaDiffTables(tables, tables, filter, undefined, ["user_a", "user_b", "order"]); + + assert.deepEqual( + result.sourceTables.map((item) => item.name), + ["user_a", "user_b"], + ); +}); + +test("intersects visual selection with the exclude regex", () => { + const filter = compileSchemaDiffTableFilter( + normalizeSchemaDiffCompareOptions({ + tableExcludePattern: "_backup$", + }), + ); + + const tables = [table("user"), table("user_backup")]; + const result = filterSchemaDiffTables(tables, tables, filter, undefined, ["user", "user_backup"]); + + assert.deepEqual( + result.sourceTables.map((item) => item.name), + ["user"], + ); +}); + +test("undefined visual selection keeps legacy all-tables behavior", () => { + const filter = compileSchemaDiffTableFilter( + normalizeSchemaDiffCompareOptions({ + tableIncludePattern: "^user_", + }), + ); + const tables = [table("user_a"), table("user_b"), table("order")]; + const result = filterSchemaDiffTables(tables, tables, filter, undefined, undefined); + + assert.deepEqual( + result.sourceTables.map((item) => item.name), + ["user_a", "user_b"], + ); +}); + +test("explicit empty selection remains restricted after option normalization", () => { + const options = normalizeSchemaDiffCompareOptions({ selectedTables: [] }); + assert.deepEqual(options.selectedTables, []); + const filter = compileSchemaDiffTableFilter(options); + const result = filterSchemaDiffTables([table("a")], [table("a")], filter, undefined, options.selectedTables); + assert.deepEqual(result.sourceTables, []); + assert.deepEqual(result.targetTables, []); +}); + +test("selected source tables remain available for create diffs when the target is missing", () => { + const options = normalizeSchemaDiffCompareOptions({}); + const filter = compileSchemaDiffTableFilter(options); + const result = filterSchemaDiffTables([table("a"), table("b")], [table("a"), table("extra")], filter, undefined, ["a", "b"]); + assert.deepEqual(result.sourceTables.map((item) => item.name), ["a", "b"]); + assert.deepEqual(result.targetTables.map((item) => item.name), ["a"]); +});