diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index b5d4dd59c..0a9f66a98 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -26,6 +26,19 @@ function postgresConnection(): ConnectionConfig { } as ConnectionConfig; } +function spannerConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "spanner-1", + name: "Cloud Spanner", + db_type: "spanner", + host: "spanner.googleapis.com", + port: 443, + username: "", + database: "projects/test-project/instances/test-instance/databases/app", + } as ConnectionConfig; +} + function mysqlConnection(): ConnectionConfig { return { ...postgresConnection(), @@ -789,6 +802,104 @@ describe("connectionStore completion assistant", () => { expect(columns).toEqual([expect.objectContaining({ name: "REPORT_ID", table: "ORDERS", schema: undefined, dataType: "NUMBER" })]); }); + it("loads Cloud Spanner columns from the empty GoogleSQL default schema", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [{ name: "SingerId", kind: "column", schema: "", parent_schema: "", parent_name: "Singers", data_type: "INT64" }], + incomplete: false, + fallback_used: false, + }); + const getColumns = vi.fn(); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [spannerConnection()]; + store.connectedIds.add("spanner-1"); + + const columns = await store.listCompletionColumns("spanner-1", spannerConnection().database, "Singers", ""); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "", parent_schema: "", parent_name: "Singers" })); + expect(getColumns).not.toHaveBeenCalled(); + expect(columns).toEqual([expect.objectContaining({ name: "SingerId", table: "Singers", schema: "", dataType: "INT64" })]); + }); + + it.each(["sales", "public"])("keeps Cloud Spanner column completion scoped to the named %s schema", async (schema) => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [{ name: "OrderId", kind: "column", schema, parent_schema: schema, parent_name: "Orders", data_type: schema === "public" ? "bigint" : "INT64" }], + incomplete: false, + fallback_used: false, + }); + const getColumns = vi.fn(); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [spannerConnection()]; + store.connectedIds.add("spanner-1"); + + const columns = await store.listCompletionColumns("spanner-1", spannerConnection().database, "Orders", schema); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema, parent_schema: schema, parent_name: "Orders" })); + expect(getColumns).not.toHaveBeenCalled(); + expect(columns).toEqual([expect.objectContaining({ name: "OrderId", table: "Orders", schema })]); + }); + + it("still skips column metadata for another schema-aware database without a selected schema", async () => { + const completionAssistantSearch = vi.fn(); + const getColumns = vi.fn(); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [postgresConnection()]; + store.connectedIds.add("pg-1"); + + await expect(store.listCompletionColumns("pg-1", "app", "orders", undefined)).resolves.toEqual([]); + expect(completionAssistantSearch).not.toHaveBeenCalled(); + expect(getColumns).not.toHaveBeenCalled(); + }); + + it.each(["empty", "error"] as const)("falls back to canonical Cloud Spanner metadata after an %s assistant result", async (assistantResult) => { + const completionAssistantSearch = assistantResult === "empty" ? vi.fn().mockResolvedValue({ candidates: [], incomplete: false, fallback_used: false }) : vi.fn().mockRejectedValue(new Error("assistant unavailable")); + const getColumns = vi.fn().mockResolvedValue([{ name: "SingerId", data_type: "INT64", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [spannerConnection()]; + store.connectedIds.add("spanner-1"); + + const columns = await store.listCompletionColumns("spanner-1", spannerConnection().database, "Singers", ""); + + expect(completionAssistantSearch).toHaveBeenCalledOnce(); + expect(getColumns).toHaveBeenCalledWith("spanner-1", spannerConnection().database, "", "Singers", undefined, undefined); + expect(columns).toEqual([expect.objectContaining({ name: "SingerId", table: "Singers", schema: "", dataType: "INT64" })]); + }); + it("uses the Dameng login schema for unqualified column completion", async () => { const completionAssistantSearch = vi.fn().mockRejectedValue(new Error("assistant unavailable")); const getColumns = vi.fn().mockResolvedValue([{ name: "ID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index a5b693193..ad619c933 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -9059,10 +9059,12 @@ export const useConnectionStore = defineStore("connection", () => { const oracleIdentifier = effectiveDbType === "oracle" || effectiveDbType === "oceanbase-oracle"; const uppercaseUnquotedIdentifier = oracleIdentifier || effectiveDbType === "saphana"; const completionTable = uppercaseUnquotedIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table; - const rawCompletionSchema = schema?.trim() || (effectiveDbType === "dameng" ? config?.username?.trim() || undefined : undefined); + const normalizedSchema = schema?.trim(); + const rawCompletionSchema = effectiveDbType === "spanner" ? normalizedSchema : normalizedSchema || (effectiveDbType === "dameng" ? config?.username?.trim() || undefined : undefined); const completionSchema = uppercaseUnquotedIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema; const usesCurrentSchema = usesOracleCurrentSchemaCompletion(effectiveDbType, completionSchema); - if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesCurrentSchema) { + const hasCompletionSchema = completionSchema != null && (completionSchema !== "" || effectiveDbType === "spanner"); + if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !hasCompletionSchema && !usesCurrentSchema) { return []; } const sessionCacheScope = usesCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : "";