feat(schema-diff): support selecting tables to compare
This commit is contained in:
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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "テーブルを選択",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "选择表",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user