feat(database): support Doris and StarRocks catalogs

This commit is contained in:
如梦技术 authored and GitHub committed 2026-07-30 16:03:32 +08:00
1 parent b39a0f18a4
commit adb6d86b53
44 files changed
+1330 -242

No files matched your search

@@ -15,8 +15,11 @@ import * as api from "@/lib/backend/api";
import type { TransferMode, TransferTableNameCase } from "@/lib/backend/api";
import type { DatabaseType } from "@/types/database";
import { isSchemaAware, supportsTransfer } from "@/lib/database/databaseCapabilities";
import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions";
import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
import { isSameTransferDatabase, normalizeTransferCatalog } from "@/lib/database/dataTransferSelection";
import { databaseOptionsForConnection, fetchCatalogNamespaceOptions, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions";
import { useExportTracker } from "@/composables/useExportTracker";
import type { CatalogInfo } from "@/types/database";
import { ArrowRightLeft, ArrowLeftRight, Loader2, Square, CheckSquare } from "@lucide/vue";
const { t } = useI18n();
@@ -26,6 +29,7 @@ const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillCatalog?: string;
prefillSchema?: string;
prefillTables?: string[];
prefillTargetConnectionId?: string;
@@ -39,6 +43,8 @@ const sqlConnections = computed(() => store.connections.filter((c) => supportsTr
// Source state
const sourceConnectionId = ref("");
const sourceCatalog = ref("");
const sourceCatalogs = ref<CatalogInfo[]>([]);
const sourceDatabase = ref("");
const sourceDatabases = ref<string[]>([]);
const sourceSchemas = ref<string[]>([]);
@@ -52,6 +58,8 @@ const pendingSelectedTablesPrefill = ref<string[] | null>(null);
// Target state
const targetConnectionId = ref("");
const targetCatalog = ref("");
const targetCatalogs = ref<CatalogInfo[]>([]);
const targetDatabase = ref("");
const targetDatabases = ref<string[]>([]);
const targetSchemas = ref<string[]>([]);
@@ -68,7 +76,7 @@ const ownershipDialogOpen = ref(false);
const ownershipMissingOwners = ref<string[]>([]);
const ownershipTargetOwner = ref("");
const pendingOwnershipRequest = ref<api.TransferRequest | null>(null);
const pendingOwnershipRefresh = ref<{ targetConnection: string; targetDatabase: string; targetSchema: string; shouldRefreshTargetTree: boolean } | null>(null);
const pendingOwnershipRefresh = ref<{ shouldRefreshTargetTree: boolean } | null>(null);
const filteredTables = computed(() => {
const q = tableSearch.value.toLowerCase();
@@ -85,11 +93,22 @@ function isMongoConnection(id: string): boolean {
return connectionType(id) === "mongodb";
}
function isCatalogCapable(id: string): boolean {
const config = store.getConfig(id);
return isDorisFamilyCatalogCapable(config?.db_type, config?.driver_profile);
}
const canStart = computed(() => {
const effectiveSourceSchema = sourceSchema.value || sourceDatabase.value;
const effectiveTargetSchema = targetSchema.value || targetDatabase.value;
const sameSourceAndTarget = sourceConnectionId.value === targetConnectionId.value && sourceDatabase.value === targetDatabase.value && effectiveSourceSchema === effectiveTargetSchema;
return sourceConnectionId.value && sourceDatabase.value && targetConnectionId.value && targetDatabase.value && selectedTables.value.size > 0 && !sameSourceAndTarget;
const sameCatalogAndDatabase = isSameTransferDatabase(
{ connectionId: sourceConnectionId.value, catalog: sourceCatalog.value, catalogs: sourceCatalogs.value, database: sourceDatabase.value },
{ connectionId: targetConnectionId.value, catalog: targetCatalog.value, catalogs: targetCatalogs.value, database: targetDatabase.value },
);
const sameSourceAndTarget = sameCatalogAndDatabase && effectiveSourceSchema === effectiveTargetSchema;
return (
!!sourceConnectionId.value && !!sourceDatabase.value && !!targetConnectionId.value && !!targetDatabase.value && (sourceCatalogs.value.length <= 1 || !!sourceCatalog.value) && (targetCatalogs.value.length <= 1 || !!targetCatalog.value) && selectedTables.value.size > 0 && !sameSourceAndTarget
);
});
function toggleSelectAll() {
@@ -108,6 +127,37 @@ function toggleTable(table: string) {
}
}
async function loadCatalogs(connectionId: string, side: "source" | "target") {
if (!connectionId || !isCatalogCapable(connectionId)) {
if (side === "source") {
sourceCatalogs.value = [];
sourceCatalog.value = "";
} else {
targetCatalogs.value = [];
targetCatalog.value = "";
}
return;
}
try {
const catalogs = await api.listDorisCatalogs(connectionId);
if (side === "source") {
sourceCatalogs.value = catalogs;
sourceCatalog.value = catalogs.length === 1 ? catalogs[0].name : "";
} else {
targetCatalogs.value = catalogs;
targetCatalog.value = catalogs.length === 1 ? catalogs[0].name : "";
}
} catch {
if (side === "source") {
sourceCatalogs.value = [];
sourceCatalog.value = "";
} else {
targetCatalogs.value = [];
targetCatalog.value = "";
}
}
}
async function loadDatabases(connectionId: string, target: "source" | "target") {
if (!connectionId) return;
try {
@@ -128,6 +178,26 @@ async function loadDatabases(connectionId: string, target: "source" | "target")
}
}
async function loadDatabasesForCatalog(connectionId: string, catalog: string, target: "source" | "target") {
if (!connectionId || !catalog) return;
try {
await store.ensureConnected(connectionId);
const config = store.getConfig(connectionId);
if (!config) return;
const names = await fetchCatalogNamespaceOptions(connectionId, catalog, config);
if (target === "source") {
sourceDatabases.value = names;
sourceDatabase.value = names.length === 1 ? names[0] : "";
} else {
targetDatabases.value = names;
targetDatabase.value = names.length === 1 ? names[0] : "";
}
} catch {
if (target === "source") sourceDatabases.value = [];
else targetDatabases.value = [];
}
}
async function loadSchemas(connectionId: string, database: string, side: "source" | "target", preferredSchema = "") {
if (!connectionId || !database) return;
if (isMongoConnection(connectionId)) {
@@ -182,7 +252,8 @@ async function loadTables() {
const config = store.getConfig(sourceConnectionId.value);
const needsSchema = isSchemaAware(config?.db_type);
const schema = needsSchema && sourceSchema.value ? sourceSchema.value : sourceDatabase.value;
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema);
const catalog = sourceCatalog.value || undefined;
const tables = await api.listTables(sourceConnectionId.value, sourceDatabase.value, schema, undefined, undefined, undefined, undefined, catalog);
sourceTables.value = tables.filter((t) => t.table_type === "TABLE" || t.table_type === "BASE TABLE").map((t) => t.name);
applyPendingTableSelection();
} catch {
@@ -195,17 +266,36 @@ async function loadTables() {
const skipSourceWatch = ref(false);
const skipTargetWatch = ref(false);
watch(sourceConnectionId, (id) => {
watch(sourceConnectionId, async (id) => {
if (skipSourceWatch.value) {
skipSourceWatch.value = false;
return;
}
sourceCatalog.value = "";
sourceCatalogs.value = [];
sourceDatabase.value = "";
sourceTables.value = [];
selectedTables.value.clear();
pendingSourceSchemaPrefill.value = "";
pendingSelectedTablesPrefill.value = null;
loadDatabases(id, "source");
if (isCatalogCapable(id)) {
await loadCatalogs(id, "source");
if (sourceCatalog.value) {
await loadDatabasesForCatalog(id, sourceCatalog.value, "source");
}
} else {
await loadDatabases(id, "source");
}
});
watch(sourceCatalog, async (catalog) => {
if (!sourceConnectionId.value) return;
sourceDatabase.value = "";
sourceTables.value = [];
selectedTables.value.clear();
if (catalog) {
await loadDatabasesForCatalog(sourceConnectionId.value, catalog, "source");
}
});
watch(sourceDatabase, async (db) => {
@@ -227,15 +317,35 @@ watch(sourceDatabase, async (db) => {
watch(sourceSchema, () => loadTables());
watch(targetConnectionId, (id) => {
watch(targetConnectionId, async (id) => {
if (skipTargetWatch.value) {
skipTargetWatch.value = false;
return;
}
targetCatalog.value = "";
targetCatalogs.value = [];
targetDatabase.value = "";
targetSchemas.value = [];
targetSchema.value = "";
loadDatabases(id, "target");
pendingTargetSchemaPrefill.value = "";
if (isCatalogCapable(id)) {
await loadCatalogs(id, "target");
if (targetCatalog.value) {
await loadDatabasesForCatalog(id, targetCatalog.value, "target");
}
} else {
await loadDatabases(id, "target");
}
});
watch(targetCatalog, async (catalog) => {
if (!targetConnectionId.value) return;
targetDatabase.value = "";
targetSchemas.value = [];
targetSchema.value = "";
if (catalog) {
await loadDatabasesForCatalog(targetConnectionId.value, catalog, "target");
}
});
watch(targetDatabase, async (db) => {
@@ -264,13 +374,30 @@ watch(
if (props.prefillConnectionId) {
skipSourceWatch.value = true;
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
if (isCatalogCapable(props.prefillConnectionId)) {
await loadCatalogs(props.prefillConnectionId, "source");
if (props.prefillCatalog) {
sourceCatalog.value = props.prefillCatalog;
}
if (sourceCatalog.value) {
await loadDatabasesForCatalog(props.prefillConnectionId, sourceCatalog.value, "source");
}
} else {
await loadDatabases(props.prefillConnectionId, "source");
}
if (props.prefillDatabase) sourceDatabase.value = props.prefillDatabase;
}
if (props.prefillTargetConnectionId) {
skipTargetWatch.value = true;
targetConnectionId.value = props.prefillTargetConnectionId;
await loadDatabases(props.prefillTargetConnectionId, "target");
if (isCatalogCapable(props.prefillTargetConnectionId)) {
await loadCatalogs(props.prefillTargetConnectionId, "target");
if (targetCatalog.value) {
await loadDatabasesForCatalog(props.prefillTargetConnectionId, targetCatalog.value, "target");
}
} else {
await loadDatabases(props.prefillTargetConnectionId, "target");
}
if (props.prefillTargetDatabase) targetDatabase.value = props.prefillTargetDatabase;
}
}
@@ -280,6 +407,8 @@ watch(
function resetState() {
sourceConnectionId.value = "";
sourceCatalog.value = "";
sourceCatalogs.value = [];
sourceDatabase.value = "";
sourceDatabases.value = [];
sourceSchemas.value = [];
@@ -290,6 +419,8 @@ function resetState() {
pendingSelectedTablesPrefill.value = null;
tableSearch.value = "";
targetConnectionId.value = "";
targetCatalog.value = "";
targetCatalogs.value = [];
targetDatabase.value = "";
targetDatabases.value = [];
targetSchemas.value = [];
@@ -323,9 +454,11 @@ async function startTransfer() {
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabaseName,
sourceSchema: effectiveSourceSchema,
sourceCatalog: normalizeTransferCatalog(sourceCatalog.value, sourceCatalogs.value) || undefined,
targetConnectionId: targetConnection,
targetDatabase: targetDatabaseName,
targetSchema: effectiveTargetSchema,
targetCatalog: normalizeTransferCatalog(targetCatalog.value, targetCatalogs.value) || undefined,
tables: [...selectedTables.value],
createTable: createTable.value,
mode: transferMode.value,
@@ -342,9 +475,6 @@ async function startTransfer() {
ownershipTargetOwner.value = preview.targetOwner;
pendingOwnershipRequest.value = request;
pendingOwnershipRefresh.value = {
targetConnection,
targetDatabase: targetDatabaseName,
targetSchema: effectiveTargetSchema,
shouldRefreshTargetTree,
};
ownershipDialogOpen.value = true;
@@ -357,16 +487,16 @@ async function startTransfer() {
}
}
runTransfer(request, targetConnection, targetDatabaseName, effectiveTargetSchema, shouldRefreshTargetTree);
runTransfer(request, shouldRefreshTargetTree);
}
function runTransfer(request: api.TransferRequest, targetConnection: string, targetDatabaseName: string, effectiveTargetSchema: string, shouldRefreshTargetTree: boolean) {
function runTransfer(request: api.TransferRequest, shouldRefreshTargetTree: boolean) {
isSubmitting.value = true;
startDataTransferTask(request, `${request.sourceDatabase} → ${targetDatabaseName}`, {
startDataTransferTask(request, `${request.sourceDatabase} → ${request.targetDatabase}`, {
formatOverlapError: (tables) => t("transfer.targetTableBusy", { tables: tables.join(", ") }),
onDone: async () => {
if (shouldRefreshTargetTree) {
await store.refreshObjectListTreeNode(targetConnection, targetDatabaseName, effectiveTargetSchema);
await store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog);
}
},
});
@@ -386,7 +516,7 @@ function resolveOwnershipDecision(policy: api.TransferOwnershipPolicy | null) {
isSubmitting.value = false;
return;
}
runTransfer({ ...request, ownershipPolicy: policy }, refresh.targetConnection, refresh.targetDatabase, refresh.targetSchema, refresh.shouldRefreshTargetTree);
runTransfer({ ...request, ownershipPolicy: policy }, refresh.shouldRefreshTargetTree);
}
function getConnectionName(id: string) {
@@ -437,6 +567,21 @@ function getConnectionName(id: string) {
</SearchableSelect>
</div>
<!-- Source Catalog (Doris/StarRocks multi-catalog) -->
<div v-if="sourceCatalogs.length > 1" class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceCatalog") }}</Label>
<SearchableSelect
v-model="sourceCatalog"
:options="sourceCatalogs.map((c) => c.name)"
:placeholder="t('transfer.selectCatalog')"
:search-placeholder="t('transfer.searchCatalog')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.sourceDatabase") }}</Label>
<SearchableSelect
@@ -501,6 +646,21 @@ function getConnectionName(id: string) {
</SearchableSelect>
</div>
<!-- Target Catalog (Doris/StarRocks multi-catalog) -->
<div v-if="targetCatalogs.length > 1" class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.targetCatalog") }}</Label>
<SearchableSelect
v-model="targetCatalog"
:options="targetCatalogs.map((c) => c.name)"
:placeholder="t('transfer.selectCatalog')"
:search-placeholder="t('transfer.searchCatalog')"
:empty-text="t('common.noResults')"
trigger-variant="outline"
trigger-class="h-8 w-full justify-between text-xs"
content-class="w-[var(--reka-popover-trigger-width)]"
/>
</div>
<div class="space-y-1.5">
<Label class="text-xs">{{ t("transfer.targetDatabase") }}</Label>
<SearchableSelect
@@ -36,7 +36,12 @@ describe("DataTransferDialog transfer prefill", () => {
});
it("allows a transfer between different schemas in the same database", () => {
expect(dialogSource).toContain("const sameSourceAndTarget = sourceConnectionId.value === targetConnectionId.value");
expect(dialogSource).toContain("effectiveSourceSchema === effectiveTargetSchema");
expect(dialogSource).toContain("isSameTransferDatabase");
expect(dialogSource).toContain("const sameSourceAndTarget = sameCatalogAndDatabase && effectiveSourceSchema === effectiveTargetSchema");
});
it("keeps catalog filtering and completion refresh catalog-aware", () => {
expect(dialogSource).toContain("fetchCatalogNamespaceOptions(connectionId, catalog, config)");
expect(dialogSource).toContain("store.refreshObjectListTreeNode(request.targetConnectionId, request.targetDatabase, request.targetSchema, request.targetCatalog)");
});
});