mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(database): support Doris and StarRocks catalogs
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3342,6 +3342,10 @@ export default withEnglishFallback({
|
||||
searchConnection: "接続を検索...",
|
||||
searchDatabase: "データベースを検索...",
|
||||
searchSchema: "スキーマを検索...",
|
||||
sourceCatalog: "ソースCatalog",
|
||||
targetCatalog: "ターゲットCatalog",
|
||||
selectCatalog: "Catalogを選択してください...",
|
||||
searchCatalog: "Catalogを検索...",
|
||||
},
|
||||
tableImport: {
|
||||
title: "テーブルデータをインポート",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3475,6 +3475,10 @@ export default withEnglishFallback({
|
||||
source: "源",
|
||||
target: "目标",
|
||||
sourceConnection: "源连接",
|
||||
sourceCatalog: "源 Catalog",
|
||||
targetCatalog: "目标 Catalog",
|
||||
selectCatalog: "请选择 Catalog...",
|
||||
searchCatalog: "搜索 Catalog...",
|
||||
sourceDatabase: "源数据库",
|
||||
sourceSchema: "源 Schema",
|
||||
targetConnection: "目标连接",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -526,6 +526,7 @@ pub async fn prepare_data_compare_missing_target(
|
||||
&target_database_type,
|
||||
&source_database_type,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
));
|
||||
pre_sync_statements.extend(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+469
-120
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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user