diff --git a/apps/desktop/src/components/transfer/DataTransferDialog.vue b/apps/desktop/src/components/transfer/DataTransferDialog.vue index e936c2371..5c3ee2b57 100644 --- a/apps/desktop/src/components/transfer/DataTransferDialog.vue +++ b/apps/desktop/src/components/transfer/DataTransferDialog.vue @@ -15,7 +15,7 @@ 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 } from "@/composables/useDatabaseOptions"; +import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection, namespaceOptionsAreSchemas } from "@/composables/useDatabaseOptions"; import { useExportTracker } from "@/composables/useExportTracker"; import { ArrowRightLeft, ArrowLeftRight, Loader2, Square, CheckSquare } from "@lucide/vue"; @@ -99,8 +99,9 @@ async function loadDatabases(connectionId: string, target: "source" | "target") if (!connectionId) return; try { await store.ensureConnected(connectionId); - const rawNames = isMongoConnection(connectionId) ? await api.mongoListDatabases(connectionId) : (await api.listDatabases(connectionId)).map((d) => d.name); - const names = databaseOptionsForConnection(rawNames, store.getConfig(connectionId)); + const config = store.getConfig(connectionId); + if (!config) return; + const names = isMongoConnection(connectionId) ? databaseOptionsForConnection(await api.mongoListDatabases(connectionId), config) : await fetchNamespaceOptionsForConnection(connectionId, config); if (target === "source") { sourceDatabases.value = names; sourceDatabase.value = names.length === 1 ? names[0] : ""; @@ -188,7 +189,12 @@ watch(sourceConnectionId, (id) => { watch(sourceDatabase, async (db) => { if (db) { const config = store.getConfig(sourceConnectionId.value); - if (isSchemaAware(config?.db_type)) { + if (namespaceOptionsAreSchemas(config)) { + // Dameng has no selectable catalog, so the top-level namespace option is + // also the schema used for metadata lookup and qualified transfer SQL. + sourceSchemas.value = []; + sourceSchema.value = db; + } else if (isSchemaAware(config?.db_type)) { await loadSchemas(sourceConnectionId.value, db, "source"); } else { sourceSchema.value = db; @@ -208,7 +214,10 @@ watch(targetConnectionId, (id) => { watch(targetDatabase, async (db) => { if (db) { const config = store.getConfig(targetConnectionId.value); - if (isSchemaAware(config?.db_type)) { + if (namespaceOptionsAreSchemas(config)) { + targetSchemas.value = []; + targetSchema.value = db; + } else if (isSchemaAware(config?.db_type)) { await loadSchemas(targetConnectionId.value, db, "target"); } else { targetSchema.value = db; diff --git a/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts b/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts index 0a6c4af96..4f945ba4e 100644 --- a/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDatabaseOptions.spec.ts @@ -1,7 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions"; +import { databaseOptionsForConnection, fetchNamespaceOptionsForConnection, fetchSqlFileTargetOptions, namespaceOptionsAreSchemas, useDatabaseOptions } from "@/composables/useDatabaseOptions"; const mocks = vi.hoisted(() => ({ + ensureConnected: vi.fn(), + getConfig: vi.fn(), listDatabases: vi.fn(), listSchemas: vi.fn(), })); @@ -12,10 +14,13 @@ vi.mock("@/lib/backend/api", () => ({ })); vi.mock("@/stores/connectionStore", () => ({ - useConnectionStore: vi.fn(), + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: mocks.getConfig, + }), })); -describe("fetchSqlFileTargetOptions", () => { +describe("namespace options", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -23,7 +28,7 @@ describe("fetchSqlFileTargetOptions", () => { it("uses Dameng schemas so independent schemas remain selectable", async () => { mocks.listSchemas.mockResolvedValue(["APP_USER", "REPORTING", "SYS"]); - const options = await fetchSqlFileTargetOptions("connection-1", { + const options = await fetchNamespaceOptionsForConnection("connection-1", { db_type: "dameng", database: "APP_USER", visible_databases: ["APP_USER", "REPORTING"], @@ -37,7 +42,7 @@ describe("fetchSqlFileTargetOptions", () => { it("honors the configured Dameng schema filter before the legacy database filter", async () => { mocks.listSchemas.mockResolvedValue(["APP_USER", "REPORTING", "ARCHIVE"]); - const options = await fetchSqlFileTargetOptions("connection-1", { + const options = await fetchNamespaceOptionsForConnection("connection-1", { db_type: "dameng", database: "APP_USER", visible_databases: ["APP_USER", "REPORTING"], @@ -50,7 +55,7 @@ describe("fetchSqlFileTargetOptions", () => { it("preserves listDatabases and visible database filtering for other databases", async () => { mocks.listDatabases.mockResolvedValue([{ name: "app" }, { name: "analytics" }, { name: "postgres" }]); - const options = await fetchSqlFileTargetOptions("connection-2", { + const options = await fetchNamespaceOptionsForConnection("connection-2", { db_type: "postgres", database: "app", visible_databases: ["analytics"], @@ -61,15 +66,53 @@ describe("fetchSqlFileTargetOptions", () => { expect(mocks.listSchemas).not.toHaveBeenCalled(); }); + it("preserves visible database filtering for MongoDB transfer options", () => { + expect( + databaseOptionsForConnection(["app", "analytics", "admin"], { + db_type: "mongodb", + visible_databases: ["analytics"], + }), + ).toEqual(["analytics"]); + }); + it("propagates metadata loading errors", async () => { const error = new Error("schema metadata failed"); mocks.listSchemas.mockRejectedValue(error); + await expect( + fetchNamespaceOptionsForConnection("connection-1", { + db_type: "dameng", + database: "APP_USER", + }), + ).rejects.toBe(error); + }); + + it("keeps the SQL file target on the shared namespace loader", async () => { + mocks.listSchemas.mockResolvedValue(["APP_USER", "REPORTING"]); + await expect( fetchSqlFileTargetOptions("connection-1", { db_type: "dameng", database: "APP_USER", }), - ).rejects.toBe(error); + ).resolves.toEqual(["APP_USER", "REPORTING"]); + }); + + it("identifies only Dameng top-level options as schemas", () => { + expect(namespaceOptionsAreSchemas({ db_type: "dameng" })).toBe(true); + expect(namespaceOptionsAreSchemas({ db_type: "oracle" })).toBe(false); + expect(namespaceOptionsAreSchemas({ db_type: "postgres" })).toBe(false); + }); + + it("does not expand the global database options composable to Dameng schemas", async () => { + mocks.getConfig.mockReturnValue({ db_type: "dameng" }); + mocks.listDatabases.mockResolvedValue([]); + + const { databaseOptions, loadDatabaseOptions } = useDatabaseOptions(); + await loadDatabaseOptions("connection-1"); + + expect(databaseOptions.value["connection-1"]).toEqual([]); + expect(mocks.listDatabases).toHaveBeenCalledWith("connection-1"); + expect(mocks.listSchemas).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/composables/useDatabaseOptions.ts b/apps/desktop/src/composables/useDatabaseOptions.ts index 235591465..4e204dc4a 100644 --- a/apps/desktop/src/composables/useDatabaseOptions.ts +++ b/apps/desktop/src/composables/useDatabaseOptions.ts @@ -5,7 +5,7 @@ import { usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; import type { ConnectionConfig } from "@/types/database"; import * as api from "@/lib/backend/api"; -type SqlFileTargetConnection = Pick; +type NamespaceOptionsConnection = Pick; export function databaseOptionsForConnection(databaseNames: string[], connection: Pick | undefined): string[] { const names = filterDatabaseNamesForConnection(databaseNames, connection); @@ -13,7 +13,11 @@ export function databaseOptionsForConnection(databaseNames: string[], connection return names; } -export async function fetchSqlFileTargetOptions(connectionId: string, connection: SqlFileTargetConnection): Promise { +export function namespaceOptionsAreSchemas(connection: Pick | undefined): boolean { + return connection?.db_type === "dameng"; +} + +export async function fetchNamespaceOptionsForConnection(connectionId: string, connection: NamespaceOptionsConnection): Promise { if (connection.db_type === "dameng") { const database = connection.database || ""; // Dameng users and schemas are not interchangeable: independent schemas @@ -29,6 +33,10 @@ export async function fetchSqlFileTargetOptions(connectionId: string, connection ); } +export async function fetchSqlFileTargetOptions(connectionId: string, connection: NamespaceOptionsConnection): Promise { + return fetchNamespaceOptionsForConnection(connectionId, connection); +} + export function useDatabaseOptions() { const connectionStore = useConnectionStore();