From 682b9d53c5ed87b5275e9b50bf886b220d265965 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 30 Jul 2026 00:07:45 +0800 Subject: [PATCH] fix(editor): scope alias column completion by parent table --- .../__tests__/sql/semantic/completion.spec.ts | 21 +++++++++ .../connectionStore.completion.spec.ts | 44 +++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 22 +++++++--- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts index f05b88d946..9e678dd722 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/completion.spec.ts @@ -230,6 +230,27 @@ describe("semantic SQL completion candidates", () => { expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id", "status"]); }); + it("keeps a SELECT-list alias scoped when comma-separated sources follow the cursor", () => { + const columnsByTable = new Map([ + ["tb_kpi_set_score", ["id", "score_name"].map((name) => ({ name, table: "tb_kpi_set_score" }))], + ["tb_kpi_set_score_detail", ["id", "fk_kpi_set_score_id", "detail_score"].map((name) => ({ name, table: "tb_kpi_set_score_detail" }))], + ["tb_kpi_set_score_relationship", ["priority", "exclude_users_account"].map((name) => ({ name, table: "tb_kpi_set_score_relationship" }))], + ]); + + const { context, items } = semanticCompletion( + `SELECT + b.| +FROM tb_kpi_set_score a, + tb_kpi_set_score_detail b +WHERE a.id = b.fk_kpi_set_score_id`, + { columnsByTable }, + { databaseType: "mysql", dialect: "mysql" }, + ); + + expect(context.referencedTables).toEqual(expect.arrayContaining([expect.objectContaining({ name: "tb_kpi_set_score_detail", alias: "b" })])); + expect(items.filter((item) => item.type === "column").map((item) => item.label)).toEqual(["id", "fk_kpi_set_score_id", "detail_score"]); + }); + it("completes correlation columns for generic PostgreSQL table functions", () => { const { context, items } = semanticCompletion("SELECT * FROM generate_series(1, 3) g(value) WHERE g.|", {}, { databaseType: "postgres", dialect: "postgres" }); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 16b75a5c80..5027f0fc13 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -25,6 +25,17 @@ function postgresConnection(): ConnectionConfig { } as ConnectionConfig; } +function mysqlConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "mysql-1", + name: "MySQL", + db_type: "mysql", + port: 3306, + username: "root", + } as ConnectionConfig; +} + function oracleConnection(): ConnectionConfig { return { ...postgresConnection(), @@ -284,6 +295,39 @@ describe("connectionStore completion assistant", () => { expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "ORDERS")).toEqual([]); }); + it("rejects assistant columns returned for a different MySQL parent table", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [ + { name: "status", kind: "column", schema: "app", parent_schema: "app", parent_name: "TB_KPI_SET_SCORE_DETAIL", data_type: "tinyint" }, + { name: "priority", kind: "column", schema: "app", parent_schema: "app", parent_name: "tb_kpi_set_score_relationship", data_type: "smallint" }, + { name: "archived_status", kind: "column", schema: "archive", parent_schema: "archive", parent_name: "tb_kpi_set_score_detail", data_type: "tinyint" }, + { name: "legacy_flag", kind: "column", schema: "app", data_type: "tinyint" }, + ], + incomplete: false, + fallback_used: false, + }); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns: vi.fn(), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [mysqlConnection()]; + store.connectedIds.add("mysql-1"); + + const columns = await store.listCompletionColumns("mysql-1", "app", "tb_kpi_set_score_detail", "app"); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ parent_name: "tb_kpi_set_score_detail", parent_schema: "app" })); + expect(columns.map((column) => [column.name, column.table])).toEqual([ + ["status", "tb_kpi_set_score_detail"], + ["legacy_flag", "tb_kpi_set_score_detail"], + ]); + }); + it("normalizes unquoted Oracle aliases while preserving quoted case before catalog lookup", async () => { const completionAssistantSearch = vi.fn(); const getColumns = vi.fn().mockResolvedValue([]); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 7328f98bfe..4cf3e240ca 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -5304,9 +5304,21 @@ export const useConnectionStore = defineStore("connection", () => { return 0; } - function completionAssistantColumns(candidates: CompletionAssistantCandidate[], table: string, schema?: string): SqlCompletionColumn[] { + function completionAssistantIdentifierMatches(candidate: string, requested: string, quoted?: boolean): boolean { + return quoted ? candidate === requested : candidate.toLowerCase() === requested.toLowerCase(); + } + + function completionAssistantColumns(candidates: CompletionAssistantCandidate[], table: string, schema?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] { + const requestedSchema = schema?.trim(); return candidates - .filter((candidate) => candidate.kind === "column") + .filter((candidate) => { + if (candidate.kind !== "column") return false; + const parentName = candidate.parent_name?.trim(); + if (parentName && !completionAssistantIdentifierMatches(parentName, table, context?.tableQuoted)) return false; + const parentSchema = candidate.parent_schema?.trim() || candidate.schema?.trim(); + if (requestedSchema && parentSchema && !completionAssistantIdentifierMatches(parentSchema, requestedSchema, context?.schemaQuoted)) return false; + return true; + }) .map((candidate) => ({ name: candidate.name, table: candidate.parent_name ?? table, @@ -5371,7 +5383,7 @@ export const useConnectionStore = defineStore("connection", () => { return objects; } - async function listCompletionAssistantColumns(connectionId: string, database: string, table: string, schema?: string): Promise { + async function listCompletionAssistantColumns(connectionId: string, database: string, table: string, schema?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): Promise { const response = await completionAssistantSearch({ connection_id: connectionId, database, @@ -5383,7 +5395,7 @@ export const useConnectionStore = defineStore("connection", () => { parent_name: table, match_mode: "prefix", }); - const columns = completionAssistantColumns(response.candidates, table, schema); + const columns = completionAssistantColumns(response.candidates, table, schema, context); if (columns.length > 0) indexCompletionColumns(connectionId, database, table, schema, columns); return columns; } @@ -5924,7 +5936,7 @@ export const useConnectionStore = defineStore("connection", () => { await ensureConnected(connectionId); if (!usesOracleCurrentSchema && !catalog) { try { - const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema); + const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema, context); if (assistantColumns.length > 0) { completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({ name: column.name,