feat(database): support Doris and StarRocks catalogs

This commit is contained in:
如梦技术
2026-07-30 16:03:32 +08:00
committed by GitHub
parent b39a0f18a4
commit adb6d86b53
44 changed files with 1330 additions and 242 deletions
+1
View File
@@ -1553,6 +1553,7 @@ function changeActiveSchema(schema: string | undefined) {
const tab = activeTab.value;
if (tab) queryStore.updateSchema(tab.id, schema);
}
function openGitHub() {
openUrl("https://github.com/t8y2/dbx");
}
@@ -178,6 +178,7 @@ watch(
v-model:open="dialogs.showTransferDialog.value"
:prefill-connection-id="dialogs.transferPrefillConnectionId.value"
:prefill-database="dialogs.transferPrefillDatabase.value"
:prefill-catalog="dialogs.transferPrefillCatalog.value"
:prefill-schema="dialogs.transferPrefillSchema.value"
:prefill-tables="dialogs.transferPrefillTables.value"
:prefill-target-connection-id="dialogs.transferPrefillTargetConnectionId.value"
@@ -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)");
});
});
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
databaseAfterCatalogChange,
databaseOptionsForConnection,
fetchCatalogNamespaceOptions,
fetchNamespaceOptionsForConnection,
fetchSqlFileTargetOptions,
namespaceOptionsAreSchemas,
@@ -130,6 +131,19 @@ describe("namespace options", () => {
).toEqual(["analytics"]);
});
it("preserves visible database filtering for catalog-scoped transfer options", async () => {
mocks.listDorisCatalogDatabases.mockResolvedValue([{ name: "app" }, { name: "analytics" }]);
await expect(
fetchCatalogNamespaceOptions("connection-1", "hive", {
db_type: "starrocks",
visible_databases: ["analytics"],
}),
).resolves.toEqual(["analytics"]);
expect(mocks.listDorisCatalogDatabases).toHaveBeenCalledWith("connection-1", "hive");
});
it("propagates metadata loading errors", async () => {
const error = new Error("schema metadata failed");
mocks.listSchemas.mockRejectedValue(error);
@@ -139,6 +139,26 @@ describe("data transfer task duration", () => {
await Promise.resolve();
});
it("allows the same target table in different catalogs", async () => {
const resolvers: Array<() => void> = [];
vi.mocked(api.startTransfer).mockImplementation(
() =>
new Promise<void>((resolve) => {
resolvers.push(resolve);
}),
);
const tracker = useExportTracker();
tracker.startDataTransferTask({ ...transferRequest("hive"), targetCatalog: "hive" }, "hive");
const iceberg = tracker.startDataTransferTask({ ...transferRequest("iceberg"), targetCatalog: "iceberg" }, "iceberg");
expect(iceberg.status).toBe("Running");
expect(api.startTransfer).toHaveBeenCalledTimes(2);
for (const resolve of resolvers) resolve();
await Promise.resolve();
});
it("keeps SQL-file backend elapsed time unchanged", () => {
const tracker = useExportTracker();
const task = tracker.addSqlFileTask("sql", "script.sql", "/tmp/script.sql");
@@ -1,6 +1,7 @@
import { ref } from "vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { filterDatabaseNamesForConnection, filterSchemaNamesForConnection } from "@/lib/database/visibleDatabases";
import { isDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
import { usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
import { isInternalDorisCatalog } from "@/lib/database/databaseFeatureSupport";
import type { CatalogInfo, ConnectionConfig } from "@/types/database";
@@ -56,6 +57,14 @@ export async function fetchNamespaceOptionsForConnection(connectionId: string, c
);
}
export async function fetchCatalogNamespaceOptions(connectionId: string, catalog: string, connection: NamespaceOptionsConnection): Promise<string[]> {
const dbs = await api.listDorisCatalogDatabases(connectionId, catalog);
return databaseOptionsForConnection(
dbs.map((db) => db.name),
connection,
);
}
export async function fetchSqlFileTargetOptions(connectionId: string, connection: NamespaceOptionsConnection): Promise<string[]> {
return fetchNamespaceOptionsForConnection(connectionId, connection);
}
@@ -72,7 +81,7 @@ export function useDatabaseOptions() {
const catalogRequests = new Map<string, Promise<CatalogInfo[]>>();
const catalogDatabaseRequests = new Map<string, Promise<string[]>>();
async function loadDatabaseOptions(connectionId: string) {
async function loadDatabaseOptions(connectionId: string, catalog?: string) {
const connection = connectionStore.getConfig(connectionId);
if (!connection || loadingDatabaseOptions.value[connectionId]) return;
@@ -87,6 +96,12 @@ export function useDatabaseOptions() {
);
} else if (connection.db_type === "mongodb") {
databaseOptions.value[connectionId] = filterDatabaseNamesForConnection(await api.mongoListDatabases(connectionId), connection);
} else if (catalog && isDorisFamilyCatalogCapable(connection?.db_type, connection?.driver_profile)) {
const dbs = await api.listDorisCatalogDatabases(connectionId, catalog);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
dbs.map((db) => db.name),
connection,
);
} else {
const dbs = await api.listDatabases(connectionId);
databaseOptions.value[connectionId] = databaseOptionsForConnection(
@@ -23,6 +23,7 @@ const pendingImportContent = ref("");
const transferPrefillConnectionId = ref("");
const transferPrefillDatabase = ref("");
const transferPrefillCatalog = ref("");
const transferPrefillSchema = ref("");
const transferPrefillTables = ref<string[]>([]);
const transferPrefillTargetConnectionId = ref("");
@@ -70,6 +71,7 @@ let watchersRegistered = false;
function clearTransferPrefill() {
transferPrefillConnectionId.value = "";
transferPrefillDatabase.value = "";
transferPrefillCatalog.value = "";
transferPrefillSchema.value = "";
transferPrefillTables.value = [];
transferPrefillTargetConnectionId.value = "";
@@ -92,6 +94,7 @@ export function useDialogSources() {
if (v) {
transferPrefillConnectionId.value = v.connectionId;
transferPrefillDatabase.value = v.database;
transferPrefillCatalog.value = v.catalog ?? "";
transferPrefillSchema.value = v.schema ?? "";
transferPrefillTables.value = v.tables ?? [];
transferPrefillTargetConnectionId.value = v.targetConnectionId ?? "";
@@ -331,6 +334,7 @@ export function useDialogSources() {
pendingImportContent,
transferPrefillConnectionId,
transferPrefillDatabase,
transferPrefillCatalog,
transferPrefillSchema,
transferPrefillTables,
transferPrefillTargetConnectionId,
@@ -35,6 +35,7 @@ export interface ExportTask {
totalTables?: number;
currentTable?: string;
targetConnectionId?: string;
targetCatalog?: string;
targetDatabase?: string;
targetSchema?: string;
targetTables?: string[];
@@ -201,6 +202,7 @@ function findActiveOverlappingTransfer(request: api.TransferRequest): string[] {
if (task.kind !== "data-transfer") continue;
if (task.status !== "Running" && task.status !== "Writing") continue;
if (task.targetConnectionId !== request.targetConnectionId) continue;
if ((task.targetCatalog ?? "") !== (request.targetCatalog ?? "")) continue;
if (task.targetDatabase !== request.targetDatabase) continue;
if (task.targetSchema !== request.targetSchema) continue;
@@ -321,6 +323,7 @@ export function useExportTracker() {
const task = existingTask ?? addDataTransferTask(request.transferId, label, request.tables.length);
task.startedAt ??= Date.now();
task.targetConnectionId = request.targetConnectionId;
task.targetCatalog = request.targetCatalog;
task.targetDatabase = request.targetDatabase;
task.targetSchema = request.targetSchema;
task.targetTables = request.tables.map((table) => targetTableName(table, request.targetTableNameCase));
@@ -21,6 +21,7 @@ export function useSidebarTreeToolRuntime(options: SidebarTreeToolRuntimeOptions
connectionStore.transferSource = {
connectionId: activeNode.value.connectionId,
database: activeNode.value.database ?? "",
catalog: activeNode.value.catalog,
};
}
+4
View File
@@ -3475,6 +3475,10 @@ export default {
source: "Source",
target: "Target",
sourceConnection: "Source Connection",
sourceCatalog: "Source Catalog",
targetCatalog: "Target Catalog",
selectCatalog: "Select catalog...",
searchCatalog: "Search catalog...",
sourceDatabase: "Source Database",
sourceSchema: "Source Schema",
targetConnection: "Target Connection",
+4
View File
@@ -3343,6 +3343,10 @@ export default withEnglishFallback({
searchConnection: "Buscar conexiones...",
searchDatabase: "Buscar bases de datos...",
searchSchema: "Buscar esquemas...",
sourceCatalog: "Catalog de origen",
targetCatalog: "Catalog de destino",
selectCatalog: "Seleccione Catalog...",
searchCatalog: "Buscar Catalog...",
},
tableImport: {
title: "Importar datos a tabla",
+4
View File
@@ -3341,6 +3341,10 @@ export default withEnglishFallback({
searchConnection: "Cerca connessioni...",
searchDatabase: "Cerca database...",
searchSchema: "Cerca schemi...",
sourceCatalog: "Catalogo sorgente",
targetCatalog: "Catalogo destinazione",
selectCatalog: "Seleziona Catalogo...",
searchCatalog: "Cerca Catalogo...",
},
tableImport: {
title: "Importa Dati Tabella",
+4
View File
@@ -3342,6 +3342,10 @@ export default withEnglishFallback({
searchConnection: "接続を検索...",
searchDatabase: "データベースを検索...",
searchSchema: "スキーマを検索...",
sourceCatalog: "ソースCatalog",
targetCatalog: "ターゲットCatalog",
selectCatalog: "Catalogを選択してください...",
searchCatalog: "Catalogを検索...",
},
tableImport: {
title: "テーブルデータをインポート",
+4
View File
@@ -3343,6 +3343,10 @@ export default withEnglishFallback({
searchConnection: "Pesquisar conexões...",
searchDatabase: "Pesquisar bancos de dados...",
searchSchema: "Pesquisar schemas...",
sourceCatalog: "Catalog de origem",
targetCatalog: "Catalog de destino",
selectCatalog: "Por favor, selecione Catalog...",
searchCatalog: "Buscar Catalog...",
},
tableImport: {
title: "Importar Dados da Tabela",
+4
View File
@@ -3475,6 +3475,10 @@ export default withEnglishFallback({
source: "源",
target: "目标",
sourceConnection: "源连接",
sourceCatalog: "源 Catalog",
targetCatalog: "目标 Catalog",
selectCatalog: "请选择 Catalog...",
searchCatalog: "搜索 Catalog...",
sourceDatabase: "源数据库",
sourceSchema: "源 Schema",
targetConnection: "目标连接",
+4
View File
@@ -3014,6 +3014,10 @@ export default withEnglishFallback({
searchConnection: "搜尋連線...",
searchDatabase: "搜尋資料庫...",
searchSchema: "搜尋結構描述...",
sourceCatalog: "源 Catalog",
targetCatalog: "目標 Catalog",
selectCatalog: "請選擇 Catalog...",
searchCatalog: "搜尋 Catalog...",
},
tableImport: {
title: "匯入資料表資料",
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { isSameTransferDatabase, normalizeTransferCatalog, type TransferDatabaseSelection } from "@/lib/database/dataTransferSelection";
import type { CatalogInfo } from "@/types/database";
function catalog(name: string, catalog_type: string): CatalogInfo {
return { name, catalog_type, is_current: false };
}
function selection(overrides: Partial<TransferDatabaseSelection> = {}): TransferDatabaseSelection {
return {
connectionId: "connection-1",
catalog: "",
catalogs: [],
database: "sales",
...overrides,
};
}
describe("data transfer database selection", () => {
it("normalizes empty, Doris internal, and StarRocks default catalogs", () => {
expect(normalizeTransferCatalog("", [])).toBe("");
expect(normalizeTransferCatalog("internal", [])).toBe("");
expect(normalizeTransferCatalog("default_catalog", [catalog("default_catalog", "Internal")])).toBe("");
});
it("treats internal catalog aliases as the same transfer database", () => {
expect(isSameTransferDatabase(selection(), selection({ catalog: "internal", catalogs: [catalog("internal", "internal")] }))).toBe(true);
expect(isSameTransferDatabase(selection(), selection({ catalog: "default_catalog", catalogs: [catalog("default_catalog", "Internal")] }))).toBe(true);
});
it("keeps different external catalogs distinct", () => {
expect(isSameTransferDatabase(selection({ catalog: "iceberg", catalogs: [catalog("iceberg", "Iceberg")] }), selection({ catalog: "hive", catalogs: [catalog("hive", "Hive")] }))).toBe(false);
});
it("compares connection and database fields without concatenation collisions", () => {
expect(isSameTransferDatabase(selection({ connectionId: "ab", database: "c" }), selection({ connectionId: "a", database: "bc" }))).toBe(false);
});
});
+2
View File
@@ -3170,9 +3170,11 @@ export interface TransferRequest {
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
sourceCatalog?: string;
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
targetCatalog?: string;
tables: string[];
createTable: boolean;
mode: TransferMode;
@@ -0,0 +1,20 @@
import { isInternalDorisCatalog } from "@/lib/database/databaseFeatureSupport";
import type { CatalogInfo } from "@/types/database";
export interface TransferDatabaseSelection {
connectionId: string;
catalog: string;
catalogs: readonly CatalogInfo[];
database: string;
}
export function normalizeTransferCatalog(catalog: string, catalogs: readonly CatalogInfo[]): string {
const normalizedCatalog = catalog.trim();
if (!normalizedCatalog) return "";
const catalogInfo = catalogs.find((item) => item.name.trim() === normalizedCatalog);
return isInternalDorisCatalog(catalogInfo?.catalog_type, normalizedCatalog) ? "" : normalizedCatalog;
}
export function isSameTransferDatabase(source: TransferDatabaseSelection, target: TransferDatabaseSelection): boolean {
return source.connectionId === target.connectionId && source.database === target.database && normalizeTransferCatalog(source.catalog, source.catalogs) === normalizeTransferCatalog(target.catalog, target.catalogs);
}
@@ -1,12 +1,13 @@
import type { TreeNode } from "@/types/database";
export function findDatabaseTreeNode(nodes: TreeNode[], connectionId: string, database: string): TreeNode | null {
export function findDatabaseTreeNode(nodes: TreeNode[], connectionId: string, database: string, catalog?: string): TreeNode | null {
for (const node of nodes) {
if (node.type === "database" && node.connectionId === connectionId && node.database === database) {
const catalogMatches = catalog ? node.catalog === catalog : !node.catalog;
if (node.type === "database" && node.connectionId === connectionId && node.database === database && catalogMatches) {
return node;
}
if (node.children) {
const found = findDatabaseTreeNode(node.children, connectionId, database);
const found = findDatabaseTreeNode(node.children, connectionId, database, catalog);
if (found) return found;
}
}
@@ -0,0 +1,126 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionConfig, TreeNode } from "@/types/database";
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
function starRocksConnection(): ConnectionConfig {
return {
id: "starrocks-1",
name: "StarRocks",
db_type: "starrocks",
host: "127.0.0.1",
port: 9030,
username: "root",
password: "",
database: "sales",
visible_databases: ["analytics"],
} as ConnectionConfig;
}
function catalogNode(connectionId: string, catalog: string, catalogType: string, children: TreeNode[] = []): TreeNode {
return {
id: `${connectionId}:doris-catalog:${catalog}`,
label: catalog,
type: "doris-catalog",
connectionId,
catalog,
catalogType,
isExpanded: true,
children,
};
}
function databaseNode(connectionId: string, catalog: string, database: string): TreeNode {
return {
id: `${connectionId}:doris-catalog:${catalog}:${database}`,
label: database,
type: "database",
connectionId,
catalog,
database,
isExpanded: true,
children: [],
};
}
describe("connectionStore Doris catalog tree", () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
});
it("applies visible database filtering inside external catalogs", async () => {
const listDorisCatalogDatabases = vi.fn().mockResolvedValue([{ name: "app" }, { name: "analytics" }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
listDorisCatalogDatabases,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = starRocksConnection();
const catalog = catalogNode(connection.id, "hive", "Hive");
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, children: [catalog] }];
await store.loadTreeNodeChildren(catalog, { force: true });
expect(catalog.children?.map((node) => [node.catalog, node.database])).toEqual([["hive", "analytics"]]);
});
it("refreshes the selected catalog when database names collide", async () => {
const listTables = vi.fn().mockResolvedValue([{ name: "orders", table_type: "BASE TABLE", comment: null }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "grouped";
const connection = starRocksConnection();
const hiveDatabase = databaseNode(connection.id, "hive", "sales");
const icebergDatabase = databaseNode(connection.id, "iceberg", "sales");
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [
{
id: connection.id,
label: connection.name,
type: "connection",
connectionId: connection.id,
children: [catalogNode(connection.id, "hive", "Hive", [hiveDatabase]), catalogNode(connection.id, "iceberg", "Iceberg", [icebergDatabase])],
},
];
await store.refreshObjectListTreeNode(connection.id, "sales", "sales", "iceberg");
expect(listTables).toHaveBeenCalledTimes(1);
expect(listTables.mock.calls[0]?.[7]).toBe("iceberg");
expect(hiveDatabase.children).toEqual([]);
expect(icebergDatabase.children?.map((node) => [node.catalog, node.database, node.label])).toEqual([["iceberg", "sales", "orders"]]);
});
});
@@ -82,6 +82,21 @@ describe("queryStore database open state", () => {
expect(store.openTableStructure("doris-1", "sales", undefined, "orders", undefined, undefined, "iceberg_catalog")).toBe(icebergTabId);
});
it("clears the catalog when changing a query tab connection", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("doris-1", "sales", "Query", "query", undefined, undefined, "hive_catalog");
const tab = store.tabs.find((item) => item.id === tabId)!;
store.updateConnection(tabId, "doris-2", "analytics");
expect(tab.connectionId).toBe("doris-2");
expect(tab.catalog).toBeUndefined();
expect(tab.database).toBe("analytics");
expect(tab.schema).toBeUndefined();
});
it("closes data and structure tabs for a dropped table object", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
+20 -12
View File
@@ -358,6 +358,7 @@ export const useConnectionStore = defineStore("connection", () => {
const transferSource = ref<{
connectionId: string;
database: string;
catalog?: string;
schema?: string;
tables?: string[];
targetConnectionId?: string;
@@ -3849,21 +3850,21 @@ export const useConnectionStore = defineStore("connection", () => {
if (useCachedChildren(node, options, load)) return;
const config = getConfig(connectionId);
const databases = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogDatabases(connectionId, catalog), "databases");
const visibleNames = filterDatabaseNamesForConnection(
databases.map((database) => database.name),
config,
);
const visibleNameSet = new Set(visibleNames);
const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name));
let databaseNodes: TreeNode[];
if (isInternalDorisCatalog(node.catalogType, catalog)) {
// The internal catalog's databases are rendered as standard database
// nodes so they reuse the existing table-loading / table-open paths.
// Detection is type-based (catalogType=`internal`), so StarRocks
// `default_catalog` routes here too — its tables carry no catalog.
const visibleNames = filterDatabaseNamesForConnection(
databases.map((database) => database.name),
config,
);
const visibleNameSet = new Set(visibleNames);
const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name));
databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { includeDefaultWhenEmpty: false });
} else {
databaseNodes = sortSidebarDatabases(databases).flatMap((database) => {
databaseNodes = sortSidebarDatabases(visibleDatabases).flatMap((database) => {
const name = database.name.trim();
if (!name) return [];
return [
@@ -5128,24 +5129,31 @@ export const useConnectionStore = defineStore("connection", () => {
await refreshTreeNode(node);
}
async function refreshDatabaseTreeNode(connectionId: string, database: string) {
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database);
async function refreshDatabaseTreeNode(connectionId: string, database: string, catalog?: string) {
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database, catalog);
if (node) {
await refreshTreeNode(node);
return;
}
if (catalog) {
const catalogNode = findNode(treeNodes.value, dorisCatalogId(connectionId, catalog));
if (catalogNode) {
await refreshTreeNode(catalogNode);
return;
}
}
await loadDatabases(connectionId, { force: true });
}
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string) {
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string, catalog?: string) {
invalidateMetadataCaches({ connectionId, database, schema });
const shouldRefreshSchemaNode = !!schema;
const shouldRefreshSchemaNode = !!schema && !catalog;
const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null;
if (node) {
await refreshTreeNode(node);
return;
}
await refreshDatabaseTreeNode(connectionId, database);
await refreshDatabaseTreeNode(connectionId, database, catalog);
}
function isSchemaAwareDatabase(connectionId: string): boolean {
+1
View File
@@ -2579,6 +2579,7 @@ export const useQueryStore = defineStore("query", () => {
void closeResultSession(tab);
void closeClientConnectionSession(tab);
tab.connectionId = connectionId;
tab.catalog = undefined;
tab.database = database;
tab.catalog = undefined;
tab.objectBrowser = undefined;
@@ -506,7 +506,7 @@
"runtimeMode": "native",
"mcpMode": "direct",
"singleConnectionPool": false,
"metadataConnectionScoped": false,
"metadataConnectionScoped": true,
"skipTcpProbe": false,
"defaultPort": 9030,
"supportLevel": "operate",
@@ -535,7 +535,7 @@
"runtimeMode": "native",
"mcpMode": "direct",
"singleConnectionPool": false,
"metadataConnectionScoped": false,
"metadataConnectionScoped": true,
"skipTcpProbe": false,
"defaultPort": 9030,
"supportLevel": "operate",
@@ -564,7 +564,7 @@
"runtimeMode": "native",
"mcpMode": "direct",
"singleConnectionPool": false,
"metadataConnectionScoped": false,
"metadataConnectionScoped": true,
"skipTcpProbe": false,
"defaultPort": 9306,
"supportLevel": "operate",
+129 -13
View File
@@ -304,6 +304,17 @@ pub fn metadata_connection_config(config: &ConnectionConfig) -> ConnectionConfig
}
pub fn database_connection_config(config: &ConnectionConfig, database: Option<&str>) -> ConnectionConfig {
database_connection_config_with_catalog(config, database, None)
}
/// Like [`database_connection_config`], but optionally injects a Doris/StarRocks
/// `catalog=<name>` URL parameter so mysql_async emits `SET catalog` during
/// connection setup (before any `USE <database>`).
pub fn database_connection_config_with_catalog(
config: &ConnectionConfig,
database: Option<&str>,
catalog: Option<&str>,
) -> ConnectionConfig {
let mut db_config = if database.is_some() { config.clone() } else { metadata_connection_config(config) };
if let Some(db) = database {
if !matches!(
@@ -317,9 +328,35 @@ pub fn database_connection_config(config: &ConnectionConfig, database: Option<&s
db_config.database = Some(db.to_string());
}
}
if let Some(catalog) = catalog.map(str::trim).filter(|catalog| !catalog.is_empty()) {
db_config.url_params = Some(upsert_connection_url_param(db_config.url_params.as_deref(), "catalog", catalog));
}
db_config
}
/// Insert or replace a single `key=value` entry in a connection URL-params string.
pub fn upsert_connection_url_param(params: Option<&str>, key: &str, value: &str) -> String {
let key = key.trim();
let value = value.trim();
let key_lower = key.to_ascii_lowercase();
let encoded_value = percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC).to_string();
let mut parts: Vec<String> = params
.unwrap_or("")
.trim()
.trim_start_matches('?')
.split('&')
.filter(|part| !part.trim().is_empty())
.filter(|part| {
part.split_once('=')
.map(|(existing_key, _)| existing_key.trim().to_ascii_lowercase() != key_lower)
.unwrap_or(true)
})
.map(str::to_string)
.collect();
parts.push(format!("{key}={encoded_value}"));
parts.join("&")
}
pub fn prestosql_jdbc_config_for_endpoint(config: &ConnectionConfig, host: &str, port: u16) -> ConnectionConfig {
let mut jdbc_config = config.clone();
jdbc_config.connection_string =
@@ -1138,13 +1175,22 @@ impl AppState {
self.get_or_create_pool_for_session(connection_id, database, None).await
}
pub async fn get_or_create_pool_with_catalog(
&self,
connection_id: &str,
database: Option<&str>,
catalog: Option<&str>,
) -> Result<String, String> {
self.get_or_create_pool_for_session_with_catalog(connection_id, database, catalog, None).await
}
pub async fn get_or_create_pool_for_connection_attempt(
&self,
connection_id: &str,
database: Option<&str>,
attempt: u64,
) -> Result<String, String> {
self.get_or_create_pool_for_session_inner(connection_id, database, None, Some(attempt)).await
self.get_or_create_pool_for_session_inner(connection_id, database, None, None, Some(attempt)).await
}
pub async fn get_or_create_pool_for_session(
@@ -1153,13 +1199,24 @@ impl AppState {
database: Option<&str>,
client_session_id: Option<&str>,
) -> Result<String, String> {
self.get_or_create_pool_for_session_inner(connection_id, database, client_session_id, None).await
self.get_or_create_pool_for_session_with_catalog(connection_id, database, None, client_session_id).await
}
pub async fn get_or_create_pool_for_session_with_catalog(
&self,
connection_id: &str,
database: Option<&str>,
catalog: Option<&str>,
client_session_id: Option<&str>,
) -> Result<String, String> {
self.get_or_create_pool_for_session_inner(connection_id, database, catalog, client_session_id, None).await
}
async fn get_or_create_pool_for_session_inner(
&self,
connection_id: &str,
database: Option<&str>,
catalog: Option<&str>,
client_session_id: Option<&str>,
connection_attempt: Option<u64>,
) -> Result<String, String> {
@@ -1170,8 +1227,9 @@ impl AppState {
validate_connection_url_params(&config)?;
let db_type = Some(config.db_type);
let validate_existing_pool = should_validate_existing_pool_before_reuse(config.db_type);
let catalog = catalog.map(str::trim).filter(|value| !value.is_empty());
let base_pool_key = base_pool_key_for(db_type, connection_id, database, false);
let base_pool_key = base_pool_key_for_with_catalog(db_type, connection_id, database, catalog, false);
let pool_key = session_scoped_pool_key_for(Some(&config), base_pool_key.clone(), client_session_id);
loop {
@@ -1198,7 +1256,7 @@ impl AppState {
break;
}
let db_config = database_connection_config(&config, database);
let db_config = database_connection_config_with_catalog(&config, database, catalog);
validate_h2_file_connection(&db_config)?;
self.ensure_current_connection_attempt(connection_id, connection_attempt).await?;
@@ -2383,13 +2441,24 @@ impl AppState {
connection_id: &str,
database: Option<&str>,
client_session_id: Option<&str>,
) -> Result<String, String> {
self.reconnect_pool_for_session_with_catalog(connection_id, database, None, client_session_id).await
}
pub async fn reconnect_pool_for_session_with_catalog(
&self,
connection_id: &str,
database: Option<&str>,
catalog: Option<&str>,
client_session_id: Option<&str>,
) -> Result<String, String> {
let config = {
let configs = self.configs.read().await;
configs.get(connection_id).cloned()
};
let db_type = config.as_ref().map(|config| config.db_type);
let base_pool_key = base_pool_key_for(db_type, connection_id, database, true);
let catalog = catalog.map(str::trim).filter(|value| !value.is_empty());
let base_pool_key = base_pool_key_for_with_catalog(db_type, connection_id, database, catalog, true);
let pool_key = session_scoped_pool_key_for(config.as_ref(), base_pool_key, client_session_id);
if self.uses_forwarded_transport(connection_id).await {
self.remove_connection_pools(connection_id).await;
@@ -2403,7 +2472,7 @@ impl AppState {
close_pool_kind_with_timeout(pool_key.clone(), pool).await;
}
}
self.get_or_create_pool_for_session(connection_id, database, client_session_id).await
self.get_or_create_pool_for_session_with_catalog(connection_id, database, catalog, client_session_id).await
}
pub async fn close_client_session_pool(
@@ -3558,6 +3627,16 @@ fn base_pool_key_for(
connection_id: &str,
database: Option<&str>,
include_elasticsearch_single_pool: bool,
) -> String {
base_pool_key_for_with_catalog(db_type, connection_id, database, None, include_elasticsearch_single_pool)
}
fn base_pool_key_for_with_catalog(
db_type: Option<DatabaseType>,
connection_id: &str,
database: Option<&str>,
catalog: Option<&str>,
include_elasticsearch_single_pool: bool,
) -> String {
let is_single_connection_pool = db_type.as_ref().is_some_and(|db_type| {
let is_single = database_capabilities::is_single_connection_pool(db_type)
@@ -3573,13 +3652,17 @@ fn base_pool_key_for(
is_single && (!database_capabilities::is_agent_type(db_type) || shares_database_pool_with_connection(db_type))
});
if is_single_connection_pool {
let key = if is_single_connection_pool {
connection_id.to_string()
} else {
match database.filter(|db| !db.trim().is_empty()) {
Some(db) => format!("{connection_id}:{db}"),
None => connection_id.to_string(),
}
};
match catalog.map(str::trim).filter(|value| !value.is_empty()) {
Some(catalog) => format!("{key}:catalog:{catalog}"),
None => key,
}
}
@@ -3784,12 +3867,13 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq
mod tests {
use super::{
agent_connect_timeout, connection_remote_endpoint, connection_url_for_endpoint, database_connection_config,
metadata_connection_config, mysql_metadata_fallback_url, mysql_pool_setup_queries,
oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint,
redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix,
sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver,
task_client_session_id, uses_bare_mysql_pool, uses_tcp_probe, validate_connection_url_params,
validate_h2_database_path, AppState, MysqlMode, PoolKind, PRESTOSQL_JDBC_DRIVER_CLASS,
database_connection_config_with_catalog, metadata_connection_config, mysql_metadata_fallback_url,
mysql_pool_setup_queries, oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries,
prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, redis_sentinel_transport_id,
redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, sqlserver_legacy_driver_error,
sqlserver_uses_legacy_driver, task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool,
uses_tcp_probe, validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind,
PRESTOSQL_JDBC_DRIVER_CLASS,
};
use crate::agent_connection::{
agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver,
@@ -3864,6 +3948,38 @@ mod tests {
}
}
#[test]
fn upsert_connection_url_param_inserts_and_replaces_catalog() {
assert_eq!(upsert_connection_url_param(None, "catalog", "paimon"), "catalog=paimon");
assert_eq!(
upsert_connection_url_param(Some("charset=utf8mb4"), "catalog", "hive_catalog"),
"charset=utf8mb4&catalog=hive%5Fcatalog"
);
assert_eq!(
upsert_connection_url_param(Some("catalog=old&charset=utf8mb4"), "catalog", "new_cat"),
"charset=utf8mb4&catalog=new%5Fcat"
);
}
#[test]
fn database_connection_config_with_catalog_keeps_database_for_use_after_set_catalog() {
let mut config = mysql_config(None);
config.db_type = DatabaseType::StarRocks;
let db_config = database_connection_config_with_catalog(&config, Some("ads"), Some("paimon_catalog"));
assert_eq!(db_config.database.as_deref(), Some("ads"));
assert_eq!(db_config.url_params.as_deref(), Some("catalog=paimon%5Fcatalog"));
let url = db_config.connection_url();
assert!(url.contains("/ads"), "url should include database for USE setup: {url}");
assert!(url.contains("catalog=paimon%5Fcatalog"), "url should include catalog param: {url}");
}
#[test]
fn metadata_connection_config_clears_starrocks_default_database() {
let mut config = mysql_config(Some("ads"));
config.db_type = DatabaseType::StarRocks;
assert_eq!(metadata_connection_config(&config).database, None);
}
#[test]
fn task_client_session_ids_are_stable_and_isolated() {
assert_eq!(task_client_session_id("table-export", "job-1"), "table-export:job-1");
+1
View File
@@ -526,6 +526,7 @@ pub async fn prepare_data_compare_missing_target(
&target_database_type,
&source_database_type,
None,
None,
)
));
pre_sync_statements.extend(
+8 -1
View File
@@ -37,7 +37,14 @@ pub fn is_single_connection_pool(db_type: &DatabaseType) -> bool {
}
pub fn is_metadata_connection_scoped(db_type: &DatabaseType) -> bool {
matches!(db_type, DatabaseType::Mysql)
// MySQL-protocol engines (incl. Doris/StarRocks) must not keep a configured
// default database when the caller asks for an unscoped metadata pool.
// External-catalog databases do not exist in the default catalog, so leaving
// `config.database` in the URL makes handshake/`USE` fail with Unknown database.
matches!(
db_type,
DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch
)
}
pub fn skips_tcp_probe(db_type: &DatabaseType) -> bool {
+7 -6
View File
@@ -1012,7 +1012,7 @@ fn generate_postgres_sequence_owner_ddl(sequence: &PostgresExportSequence, schem
Some(format!(
"ALTER SEQUENCE {} OWNED BY {}.{}",
postgres_sequence_qualified_name(schema, &sequence.name),
crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres),
crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres, None),
quote_identifier(owner_column, &DatabaseType::Postgres)
))
}
@@ -1026,7 +1026,7 @@ fn generate_postgres_sequence_setval_sql(sequence: &PostgresExportSequence, sche
let sequence_literal = quote_postgres_string_literal(&postgres_sequence_qualified_name(schema, &sequence.name));
match (sequence.owner_table.as_deref(), sequence.owner_column.as_deref()) {
(Some(owner_table), Some(owner_column)) => {
let owner_table = crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres);
let owner_table = crate::transfer::qualified_table(owner_table, schema, &DatabaseType::Postgres, None);
let owner_column = quote_identifier(owner_column, &DatabaseType::Postgres);
Some(format!(
"SELECT setval({sequence_literal}, GREATEST(COALESCE(MAX({owner_column}), {last_value}), {last_value}), true) FROM {owner_table}"
@@ -1269,7 +1269,7 @@ fn record_export_error(file: &mut std::fs::File, fail_on_error: bool, message: S
fn database_export_select_sql(columns: &[String], table: &str, schema: &str, db_type: &DatabaseType) -> String {
let columns = columns.iter().map(|column| quote_identifier(column, db_type)).collect::<Vec<_>>().join(", ");
let table = crate::transfer::qualified_table(table, schema, db_type);
let table = crate::transfer::qualified_table(table, schema, db_type, None);
format!("SELECT {columns} FROM {table}")
}
@@ -1283,7 +1283,8 @@ fn write_database_export_rows(
schema: &str,
db_type: &DatabaseType,
) -> Result<(), String> {
let mut insert_sql = crate::transfer::generate_insert_typed(columns, column_types, rows, table, schema, db_type);
let mut insert_sql =
crate::transfer::generate_insert_typed(columns, column_types, rows, table, schema, db_type, None);
if *db_type == DatabaseType::Dameng && selected_columns_include_identity_extras(columns, column_extras) {
insert_sql = wrap_dameng_identity_insert_sql(&insert_sql, table, schema);
}
@@ -1829,7 +1830,7 @@ pub async fn export_database_sql_core(
)
.await?;
} else {
let count_query = crate::transfer::count_sql(table_name, &request.schema, &db_type);
let count_query = crate::transfer::count_sql(table_name, &request.schema, &db_type, None);
let total_rows = match crate::transfer::execute_read_on_pool(state, &pool_key, &count_query).await {
Ok(result) => {
let count = result.rows.first().and_then(|row| row.first()).and_then(|value| match value {
@@ -2137,7 +2138,7 @@ fn filter_export_table_infos(
}
fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseType) -> String {
format!("DROP TABLE IF EXISTS {};", crate::transfer::qualified_table(table_name, schema, db_type))
format!("DROP TABLE IF EXISTS {};", crate::transfer::qualified_table(table_name, schema, db_type, None))
}
fn build_database_export_object_source_sql(
@@ -35,7 +35,7 @@ pub(crate) fn build_import_insert_batches(
.collect::<Vec<_>>();
build_sql_batches(rows.len(), max_rows, "import row", |range| {
generate_insert_typed(&columns, &column_types, &rows[range], table, schema, &DatabaseType::CloudflareD1)
generate_insert_typed(&columns, &column_types, &rows[range], table, schema, &DatabaseType::CloudflareD1, None)
})
.map(|batches| {
batches.into_iter().map(|batch| ImportSqlBatch { sql: batch.sql, row_count: batch.item_count }).collect()
@@ -13,6 +13,7 @@ fn uses_sqlite_upsert_syntax() {
"main",
&DatabaseType::CloudflareD1,
&[String::from("id")],
None,
);
assert!(sql.contains("ON CONFLICT (\"id\") DO UPDATE SET"));
+3 -2
View File
@@ -316,7 +316,7 @@ pub async fn list_doris_catalog_databases_core(
return Ok(vec![]);
}
};
if catalog == "internal" {
if catalog == "internal" || catalog.eq_ignore_ascii_case("default_catalog") {
return Ok(filter_mysql_system_databases_for_config(databases, db_config.as_ref()));
}
Ok(databases)
@@ -460,7 +460,8 @@ pub async fn resolve_external_doris_catalog(
catalog: Option<&str>,
) -> Option<String> {
let catalog = catalog?.trim();
if catalog.is_empty() || catalog == "internal" {
if catalog.is_empty() || catalog.eq_ignore_ascii_case("internal") || catalog.eq_ignore_ascii_case("default_catalog")
{
return None;
}
let config = connection_config(state, connection_id).await?;
+21 -2
View File
@@ -386,9 +386,28 @@ pub(crate) fn quote_transfer_identifier(name: &str, database_type: &DatabaseType
}
}
pub(crate) fn qualified_transfer_table(table_name: &str, schema: &str, database_type: &DatabaseType) -> String {
/// Qualified table name for transfer SQL.
///
/// * Without catalog: produces `schema.table` (or just `table` for MySQL family).
/// * With catalog AND the database type supports external catalogs (Doris/StarRocks):
/// produces `catalog.schema.table` — the 3-part form those engines require to
/// address objects in an external (non-internal) catalog.
pub(crate) fn qualified_transfer_table(
table_name: &str,
schema: &str,
database_type: &DatabaseType,
catalog: Option<&str>,
) -> String {
let table = quote_transfer_identifier(table_name, database_type);
if schema.is_empty() || matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb | DatabaseType::Questdb)
if let Some(catalog) = catalog {
format!(
"{}.{}.{}",
quote_transfer_identifier(catalog, database_type),
quote_transfer_identifier(schema, database_type),
table
)
} else if schema.is_empty()
|| matches!(database_type, DatabaseType::Mysql | DatabaseType::MongoDb | DatabaseType::Questdb)
{
table
} else {
+2 -2
View File
@@ -8,8 +8,8 @@ fn transfer_identifier_policy_preserves_legacy_output() {
assert_eq!(quote_transfer_identifier("user`events", &DatabaseType::Doris), "`user``events`");
assert_eq!(quote_transfer_identifier("user]events", &DatabaseType::SqlServer), "[user]]events]");
assert_eq!(quote_transfer_identifier("user\"events", &DatabaseType::Postgres), "\"user\"\"events\"");
assert_eq!(qualified_transfer_table("events", "warehouse", &DatabaseType::Hive), "`warehouse`.`events`");
assert_eq!(qualified_transfer_table("events", "warehouse", &DatabaseType::Mysql), "`events`");
assert_eq!(qualified_transfer_table("events", "warehouse", &DatabaseType::Hive, None), "`warehouse`.`events`");
assert_eq!(qualified_transfer_table("events", "warehouse", &DatabaseType::Mysql, None), "`events`");
}
#[test]
+2 -1
View File
@@ -229,7 +229,7 @@ fn table_cursor_sql(
col_names: &[String],
primary_keys: &[String],
) -> String {
let full_table = qualified_table(&request.table_name, request.schema.as_deref().unwrap_or(""), db_type);
let full_table = qualified_table(&request.table_name, request.schema.as_deref().unwrap_or(""), db_type, None);
let col_list = col_names.iter().map(|column| quote_identifier(column, db_type)).collect::<Vec<_>>().join(", ");
let predicate = crate::sql_dialect::normalize_where_input(request.where_input.as_deref());
let where_clause = if predicate.is_empty() { String::new() } else { format!(" WHERE ({predicate})") };
@@ -1155,6 +1155,7 @@ async fn export_table_data_core_inner(
request.schema.as_deref().unwrap_or(""),
&db_type,
request.where_input.as_deref(),
None,
);
match execute_table_export_count(state, &pool_key, request, &count_query, cancel_token.clone()).await {
Ok(result) => result
+10 -5
View File
@@ -3068,7 +3068,8 @@ fn build_import_insert_batch_with_plan(
return Ok(None);
}
let mapped_rows = map_import_rows_with_plan(rows, plan, db_type, kingbase_oracle_mode, date_time_format);
let sql = generate_insert_typed(&plan.target_columns, &plan.column_types, &mapped_rows, table, schema, db_type);
let sql =
generate_insert_typed(&plan.target_columns, &plan.column_types, &mapped_rows, table, schema, db_type, None);
Ok((!sql.trim().is_empty()).then_some(ImportSqlBatch { sql, row_count: rows.len() }))
}
@@ -3093,6 +3094,7 @@ fn build_import_insert_batches_with_plan(
table,
schema,
db_type,
None,
SqlBatchLimits::for_database(db_type, rows.len()).with_hard_sql_bytes(hard_sql_bytes),
)?;
Ok(batches.into_iter().map(|(sql, row_count)| ImportSqlBatch { sql, row_count }).collect())
@@ -3359,7 +3361,7 @@ fn build_import_insert_batches_with_format(
}
pub fn truncate_sql(table: &str, schema: &str, db_type: &DatabaseType) -> String {
let full_table = qualified_table(table, schema, db_type);
let full_table = qualified_table(table, schema, db_type, None);
match db_type {
DatabaseType::Sqlite | DatabaseType::CloudflareD1 => format!("DELETE FROM {full_table}"),
_ => format!("TRUNCATE TABLE {full_table}"),
@@ -3643,7 +3645,7 @@ pub fn build_import_create_table_plan(
return Err("No columns mapped for import".to_string());
}
let full_table = qualified_table(table.trim(), schema, db_type);
let full_table = qualified_table(table.trim(), schema, db_type, None);
let column_sql = columns
.iter()
.map(|column| format!("{} {}", quote_identifier(&column.name, db_type), column.data_type))
@@ -3875,7 +3877,7 @@ fn build_postgres_copy_text_row(
}
fn postgres_copy_sql(plan: &CompiledImportPlan, table: &str, schema: &str) -> String {
let table = qualified_table(table, schema, &DatabaseType::Postgres);
let table = qualified_table(table, schema, &DatabaseType::Postgres, None);
let columns = plan
.target_columns
.iter()
@@ -4630,7 +4632,7 @@ fn compile_sqlserver_bulk_import_plan(
target_types.push(column.column.data_type.clone());
}
Ok(SqlServerBulkImportPlan {
target_table: qualified_table(table, schema, &DatabaseType::SqlServer),
target_table: qualified_table(table, schema, &DatabaseType::SqlServer, None),
target_columns: import_plan.target_columns.clone(),
target_types,
requires_identity_insert,
@@ -5185,6 +5187,7 @@ where
&request.database,
&request.schema,
&request.table,
None,
)
.await
.unwrap_or_default()
@@ -5535,6 +5538,7 @@ where
&request.database,
&request.schema,
&request.table,
None,
)
.await
.unwrap_or_default()
@@ -6012,6 +6016,7 @@ where
&request.database,
&request.schema,
&request.table,
None,
)
.await
.unwrap_or_default()
File diff suppressed because it is too large Load Diff
@@ -182,8 +182,9 @@ fn identifies_single_connection_pool_types() {
#[test]
fn identifies_metadata_connections_that_drop_database_scope() {
assert!(is_metadata_connection_scoped(&DatabaseType::Mysql));
assert!(!is_metadata_connection_scoped(&DatabaseType::Doris));
assert!(!is_metadata_connection_scoped(&DatabaseType::StarRocks));
assert!(is_metadata_connection_scoped(&DatabaseType::Doris));
assert!(is_metadata_connection_scoped(&DatabaseType::StarRocks));
assert!(is_metadata_connection_scoped(&DatabaseType::ManticoreSearch));
assert!(!is_metadata_connection_scoped(&DatabaseType::Postgres));
assert!(!is_metadata_connection_scoped(&DatabaseType::Kwdb));
assert!(!is_metadata_connection_scoped(&DatabaseType::Oracle));
@@ -211,9 +211,11 @@ async fn live_postgres_transfer_preserves_data_and_schema_objects() {
source_connection_id: source_connection_id.to_string(),
source_database: source_database.clone(),
source_schema: source_schema.clone(),
source_catalog: None,
target_connection_id: target_connection_id.to_string(),
target_database: target_database.clone(),
target_schema: target_schema.clone(),
target_catalog: None,
tables: vec!["users".to_string(), "audit_logs".to_string(), "files".to_string()],
create_table: true,
mode: TransferMode::Append,
@@ -492,9 +494,11 @@ async fn live_postgres_transfer_skips_create_ddl_for_existing_target_table() {
source_connection_id: source_connection_id.to_string(),
source_database: source_database.clone(),
source_schema: source_schema.clone(),
source_catalog: None,
target_connection_id: target_connection_id.to_string(),
target_database: target_database.clone(),
target_schema: target_schema.clone(),
target_catalog: None,
tables: vec!["items".to_string()],
create_table: true,
mode: TransferMode::Append,
@@ -1354,9 +1354,11 @@ async fn live_sqlserver_transfer_table_skips_rowversion_insert_column() {
source_connection_id: "live-sqlserver-rowversion".to_string(),
source_database: database.clone(),
source_schema: "dbo".to_string(),
source_catalog: None,
target_connection_id: "live-sqlserver-rowversion".to_string(),
target_database: target_database.clone(),
target_schema: "dbo".to_string(),
target_catalog: None,
tables: vec![source_table.clone()],
create_table: true,
mode: dbx_core::transfer::TransferMode::Append,
+62 -25
View File
@@ -116,7 +116,15 @@ pub async fn start_transfer(
}
};
let source_pool_key = match app.get_or_create_pool(&req.source_connection_id, Some(&req.source_database)).await
// External Doris/StarRocks catalogs: pool is created with `catalog=` URL
// setup (SET catalog) and without USE <external-db>. See ensure_transfer_pool.
let source_pool_key = match transfer::ensure_transfer_pool(
&app,
&req.source_connection_id,
&req.source_database,
req.source_catalog.as_deref(),
)
.await
{
Ok(k) => k,
Err(e) => {
@@ -125,7 +133,13 @@ pub async fn start_transfer(
return;
}
};
let target_pool_key = match app.get_or_create_pool(&req.target_connection_id, Some(&req.target_database)).await
let target_pool_key = match transfer::ensure_transfer_pool(
&app,
&req.target_connection_id,
&req.target_database,
req.target_catalog.as_deref(),
)
.await
{
Ok(k) => k,
Err(e) => {
@@ -137,19 +151,36 @@ pub async fn start_transfer(
let tables = req.tables.clone();
// Sort by FK dependency so referenced tables are transferred first.
let tables = transfer::sort_tables_by_fk_dependency(
&app,
&req.source_connection_id,
&req.source_database,
&req.source_schema,
&tables,
true,
)
.await
.unwrap_or_else(|e| {
log::warn!("[transfer] failed to sort tables by FK dependency, using original order: {e}");
tables
});
// Skip for external Doris/StarRocks catalogs — the database name does
// not exist in the default catalog and sorting is unnecessary.
let tables = {
let skip_fk_sort = {
let configs = app.configs.read().await;
configs
.get(&req.source_connection_id)
.and_then(|config| {
transfer::resolve_external_transfer_catalog_for_config(req.source_catalog.as_deref(), config)
})
.is_some()
};
if skip_fk_sort {
tables
} else {
transfer::sort_tables_by_fk_dependency(
&app,
&req.source_connection_id,
&req.source_database,
&req.source_schema,
&tables,
true,
)
.await
.unwrap_or_else(|e| {
log::warn!("[transfer] failed to sort tables by FK dependency, using original order: {e}");
tables
})
}
};
let mut failed_tables: Vec<String> = Vec::new();
if matches!(source_db_type, dbx_core::models::connection::DatabaseType::Postgres)
@@ -375,16 +406,22 @@ pub async fn preview_transfer_ownership(
transfer::validate_transfer_target_table_names(&req).map_err(AppError::from)?;
let source_db_type = transfer::get_db_type(&state.app, &req.source_connection_id).await.map_err(AppError::from)?;
let target_db_type = transfer::get_db_type(&state.app, &req.target_connection_id).await.map_err(AppError::from)?;
let source_pool_key = state
.app
.get_or_create_pool(&req.source_connection_id, Some(&req.source_database))
.await
.map_err(AppError::from)?;
let target_pool_key = state
.app
.get_or_create_pool(&req.target_connection_id, Some(&req.target_database))
.await
.map_err(AppError::from)?;
let source_pool_key = transfer::ensure_transfer_pool(
&state.app,
&req.source_connection_id,
&req.source_database,
req.source_catalog.as_deref(),
)
.await
.map_err(AppError::from)?;
let target_pool_key = transfer::ensure_transfer_pool(
&state.app,
&req.target_connection_id,
&req.target_database,
req.target_catalog.as_deref(),
)
.await
.map_err(AppError::from)?;
let preview = transfer::preview_transfer_ownership(
&state.app,
&req,
+39
View File
@@ -34,3 +34,42 @@ test("finds database refresh targets inside grouped sidebar trees", () => {
test("returns null when the target database node is not loaded", () => {
assert.equal(findDatabaseTreeNode([], "conn-1", "app"), null);
});
test("keeps same-named databases isolated by catalog", () => {
const hive: TreeNode = {
id: "conn-1:doris-catalog:hive:sales",
label: "sales",
type: "database",
connectionId: "conn-1",
catalog: "hive",
database: "sales",
};
const iceberg: TreeNode = {
id: "conn-1:doris-catalog:iceberg:sales",
label: "sales",
type: "database",
connectionId: "conn-1",
catalog: "iceberg",
database: "sales",
};
const internal: TreeNode = {
id: "conn-1:sales",
label: "sales",
type: "database",
connectionId: "conn-1",
database: "sales",
};
const nodes: TreeNode[] = [
{
id: "conn-1",
label: "StarRocks",
type: "connection",
connectionId: "conn-1",
children: [internal, { id: "catalog-hive", label: "hive", type: "doris-catalog", connectionId: "conn-1", catalog: "hive", children: [hive] }, { id: "catalog-iceberg", label: "iceberg", type: "doris-catalog", connectionId: "conn-1", catalog: "iceberg", children: [iceberg] }],
},
];
assert.equal(findDatabaseTreeNode(nodes, "conn-1", "sales"), internal);
assert.equal(findDatabaseTreeNode(nodes, "conn-1", "sales", "hive"), hive);
assert.equal(findDatabaseTreeNode(nodes, "conn-1", "sales", "iceberg"), iceberg);
});
+63 -22
View File
@@ -29,27 +29,58 @@ pub async fn start_transfer(
let target_db_type = get_db_type(&state, &request.target_connection_id).await?;
dbx_core::transfer::validate_transfer_target_table_names(&request)?;
// Ensure pools
let source_pool_key =
state.get_or_create_pool(&request.source_connection_id, Some(&request.source_database)).await?;
let target_pool_key =
state.get_or_create_pool(&request.target_connection_id, Some(&request.target_database)).await?;
// External Doris/StarRocks catalogs: pool is created with `catalog=` URL
// setup (SET catalog) and without USE <external-db>. See ensure_transfer_pool.
let source_pool_key = dbx_core::transfer::ensure_transfer_pool(
&state,
&request.source_connection_id,
&request.source_database,
request.source_catalog.as_deref(),
)
.await?;
let target_pool_key = dbx_core::transfer::ensure_transfer_pool(
&state,
&request.target_connection_id,
&request.target_database,
request.target_catalog.as_deref(),
)
.await?;
tokio::spawn(async move {
// Sort tables by FK dependency so referenced tables are transferred first.
let sorted_tables = dbx_core::transfer::sort_tables_by_fk_dependency(
&state,
&request.source_connection_id,
&request.source_database,
&request.source_schema,
&request.tables,
true,
)
.await
.unwrap_or_else(|e| {
log::warn!("[transfer] failed to sort tables by FK dependency, using original order: {e}");
request.tables.clone()
});
// Skip for external Doris/StarRocks catalogs — the database name does not
// exist in the default catalog and sorting is unnecessary (no FK constraints).
let sorted_tables = {
let skip_fk_sort = {
let configs = state.configs.read().await;
configs
.get(&request.source_connection_id)
.and_then(|config| {
dbx_core::transfer::resolve_external_transfer_catalog_for_config(
request.source_catalog.as_deref(),
config,
)
})
.is_some()
};
if skip_fk_sort {
request.tables.clone()
} else {
dbx_core::transfer::sort_tables_by_fk_dependency(
&state,
&request.source_connection_id,
&request.source_database,
&request.source_schema,
&request.tables,
true,
)
.await
.unwrap_or_else(|e| {
log::warn!("[transfer] failed to sort tables by FK dependency, using original order: {e}");
request.tables.clone()
})
}
};
let total_tables = sorted_tables.len();
log::info!("[transfer] starting transfer_id={} tables={}", transfer_id, total_tables);
@@ -291,10 +322,20 @@ pub async fn preview_transfer_ownership(
let source_db_type = get_db_type(&state, &request.source_connection_id).await?;
let target_db_type = get_db_type(&state, &request.target_connection_id).await?;
dbx_core::transfer::validate_transfer_target_table_names(&request)?;
let source_pool_key =
state.get_or_create_pool(&request.source_connection_id, Some(&request.source_database)).await?;
let target_pool_key =
state.get_or_create_pool(&request.target_connection_id, Some(&request.target_database)).await?;
let source_pool_key = dbx_core::transfer::ensure_transfer_pool(
&state,
&request.source_connection_id,
&request.source_database,
request.source_catalog.as_deref(),
)
.await?;
let target_pool_key = dbx_core::transfer::ensure_transfer_pool(
&state,
&request.target_connection_id,
&request.target_database,
request.target_catalog.as_deref(),
)
.await?;
dbx_core::transfer::preview_transfer_ownership(
&state,