From f9dd85b78570fa6b303f9dd8a5711e2c03212b79 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 17 Sep 2026 12:55:48 +0800 Subject: [PATCH] fix(grid): show field comments for Oracle joined bare-star queries --- .../sql/multiSourceColumnMapping.spec.ts | 23 +++++++++- apps/desktop/src/lib/sql/sqlAnalysis.ts | 5 ++- ...eryStore.multiSourceColumnComments.spec.ts | 28 ++++++++++++ apps/desktop/src/stores/queryStore.ts | 45 ++++++++++++------- 4 files changed, 82 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts b/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts index 2f6eccc52a..68b727c97f 100644 --- a/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis"; +import { analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis"; /** * Result columns resolve by projection ordinal, each carrying its source @@ -7,6 +7,27 @@ import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from " * per-source comments instead of first-source-wins on name clashes. */ describe("multi-source result column mapping", () => { + it("expands a bare star in a joined result for display metadata", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT * FROM orders a JOIN users b ON a.user_id = b.id"); + expect(analysis?.selectStar).toBe(true); + + const resolved = resolveSourceColumnsByOrdinal( + "oracle", + analysis!, + [ + { source: analysis!.sources![0]!, columns: [{ name: "ID" }, { name: "USER_ID" }] }, + { source: analysis!.sources![1]!, columns: [{ name: "ID" }, { name: "NAME" }] }, + ], + 4, + ); + expect(resolved).toEqual([ + { sourceKey: "a:0", sourceColumn: "ID" }, + { sourceKey: "a:0", sourceColumn: "USER_ID" }, + { sourceKey: "b:1", sourceColumn: "ID" }, + { sourceKey: "b:1", sourceColumn: "NAME" }, + ]); + }); + it("parses a JOIN as multi-source with per-source columns", () => { const result = analyzeEditableQueryEditability("SELECT a.id, a.user_id, b.name FROM orders a JOIN users b ON a.user_id = b.id"); expect(result.editable).toBe(true); diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 4f3030f12b..5cbf4873e2 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -338,7 +338,10 @@ export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo const hasWindowClause = findTopLevelKeyword(normalized, "OVER", "SELECT".length) >= 0 || findTopLevelKeyword(normalized, "WINDOW", fromIndex + "FROM".length) >= 0; const hasRightJoinClause = hasTopLevelRightJoin(fromBody); - const selectStar = sources.length === 1 && isSelectStar(selectBody, source.alias); + // A bare `*` in a multi-source query expands in source/projection order. + // Keep it as a display-only star so joined result columns can still resolve + // to their physical metadata without making the query editable. + const selectStar = isSelectStar(selectBody, source.alias); const columns = selectStar ? [] : parseSelectColumns(selectBody, sources); if (!selectStar && columns.length === 0) return null; if (sources.length > 1 && columns.some((column) => column.star && !column.sourceKey)) return null; diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts index 3ab93cf94f..e903cc6941 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts @@ -152,6 +152,34 @@ describe("queryStore multi-source result column comments", () => { ]); }); + it("loads display comments for a joined bare-star result without enabling edits", async () => { + getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "XE", query_timeout_secs: 30 }); + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "complex-source" }); + const oracleOrdersColumns = ordersColumns.map((item) => ({ ...item, name: item.name.toUpperCase() })); + const oracleUsersColumns = usersColumns.map((item) => ({ ...item, name: item.name.toUpperCase() })); + getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => (table === "ORDERS_10K" ? oracleOrdersColumns : oracleUsersColumns)); + executeMulti.mockResolvedValue([ + { + columns: ["ID", "ORDER_NO", "ID", "NAME"], + rows: [[1, "O1", 7, "Alice"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "XE", "Query"); + + await store.executeTabSql(tabId, "SELECT * FROM ORDERS_10K a JOIN USERS b ON a.ID = b.ID"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.resultColumnComments).toEqual(["订单ID", "下单用户", "订单金额", "用户ID"]); + expect(tab.queryAnalysis).toBeUndefined(); + expect(tab.queryEditabilityReason).toBe("complex-source"); + }); + it("resolves a uniquely qualified unqualified alias back to its physical column", async () => { analyzeEditableQueryEditability.mockResolvedValue({ editable: true, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 40f01534fc..0ca096c1e7 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -5626,14 +5626,21 @@ export const useQueryStore = defineStore("query", () => { } /** - * Resolve grouped result columns by projection ordinal. All databases use the - * mapping for comments. MySQL may additionally edit direct columns from one - * uniquely identifiable base table; aggregate expressions and columns from - * every other source remain read-only. + * Resolve read-only query result columns by projection ordinal. All databases + * use the mapping for comments. MySQL aggregation may additionally edit + * direct columns from one uniquely identifiable base table. */ - async function resolveAggregationQueryMetadata(tab: QueryTab, sql: string, executionDatabase: string, traceId: string | undefined): Promise { + async function resolveQueryDisplayMetadata( + tab: QueryTab, + sql: string, + executionDatabase: string, + traceId: string | undefined, + connection: ConnectionConfig | undefined, + allowMysqlEditing = true, + readOnlyReason: "aggregation" | "complex-source" = "aggregation", + ): Promise { if (tab.mode !== "query" || !tab.connectionId || !tab.result || !tab.result.columns.length) return undefined; - const conn = useConnectionStore().getConfig(tab.connectionId); + const conn = connection ?? useConnectionStore().getConfig(tab.connectionId); const dbType = conn?.db_type || ""; const analysis = analyzeSelectStructureForDisplay(sql); if (!analysis) return undefined; @@ -5664,12 +5671,12 @@ export const useQueryStore = defineStore("query", () => { const readOnlyPatch: QueryMetadataPatch = { queryAnalysis: undefined, querySourceColumns: undefined, - queryEditabilityReason: "aggregation", + queryEditabilityReason: readOnlyReason, tableMeta: undefined, resultColumnComments: displayInfo.comments, queryDisplaySourceColumns: displayInfo.mapping, }; - if (dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch; + if (!allowMysqlEditing || dbType !== "mysql" || (conn?.driver_profile || conn?.db_type) !== "mysql") return readOnlyPatch; // Mutation safety boundary: the FROM root must remain on the preserved // side of the join tree, and GROUP BY must resolve to exactly that table's // declared primary key. This makes every editable result row identify one @@ -5723,7 +5730,7 @@ export const useQueryStore = defineStore("query", () => { } } - async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise { + async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [], connection?: ConnectionConfig): Promise { if (tab.mode !== "query") return; if (!tab.result || !tab.result.columns.length) { return { @@ -5743,8 +5750,13 @@ export const useQueryStore = defineStore("query", () => { elapsed: elapsed?.(), }); if (!editability.editable) { - const aggregationPatch = editability.reason === "aggregation" ? await resolveAggregationQueryMetadata(tab, sql, executionDatabase, traceId) : undefined; - if (aggregationPatch) return aggregationPatch; + let displayPatch: QueryMetadataPatch | undefined; + if (editability.reason === "aggregation") { + displayPatch = await resolveQueryDisplayMetadata(tab, sql, executionDatabase, traceId, connection); + } else if (editability.reason === "complex-source") { + displayPatch = await resolveQueryDisplayMetadata(tab, sql, executionDatabase, traceId, connection, false, "complex-source"); + } + if (displayPatch) return displayPatch; return { queryAnalysis: undefined, querySourceColumns: undefined, @@ -5763,8 +5775,7 @@ export const useQueryStore = defineStore("query", () => { }; } - const connStore = useConnectionStore(); - const conn = connStore.getConfig(tab.connectionId); + const conn = connection ?? useConnectionStore().getConfig(tab.connectionId); const dbType = conn?.db_type || ""; const sources = editableQuerySources(analysis); const loadedSources: LoadedEditableSource[] = []; @@ -5915,14 +5926,14 @@ export const useQueryStore = defineStore("query", () => { } } - function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []) { + function analyzeQueryMetadataInBackground(tabId: string, sql: string, result: QueryResult, executionDatabase: string, traceId: string, elapsed: () => string, databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = [], connection?: ConnectionConfig) { void (async () => { const tab = tabs.value.find((t) => t.id === tabId); if (!tab || tab.result !== result) return; queryExecutionLog("info", "metadata:start", { traceId, elapsed: elapsed() }); // Metadata requests outlive the displayed result when another query starts // or a retained run is selected. Analyze this result, not the live tab. - const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys); + const patch = await buildQueryMetadataPatch({ ...tab, result }, sql, executionDatabase, traceId, elapsed, hiddenPrimaryKeys, connection); if (patch?.queryAnalysis && hasHiddenPhysicalRowKey(databaseType, hiddenPrimaryKeys)) { patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false }; } @@ -7365,7 +7376,7 @@ export const useQueryStore = defineStore("query", () => { elapsed: elapsed(), }); if (current.mode === "query" && current.result) { - analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, effectiveDbType, hiddenPrimaryKeys); + analyzeQueryMetadataInBackground(id, displayedQueryMetadataSql(current, queryMetadataSql), current.result, executionDatabase, traceId, elapsed, effectiveDbType, hiddenPrimaryKeys, conn); } } else { queryExecutionLog("warn", "stale-result", { @@ -7981,7 +7992,7 @@ export const useQueryStore = defineStore("query", () => { const metadataStartedAt = performance.now(); const connection = useConnectionStore().getConfig(tab.connectionId); const executionDatabase = dataTabExecutionDatabase(connection, tab.database, tab.catalog); - analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`, effectiveDatabaseTypeForConnection(connection)); + analyzeQueryMetadataInBackground(id, sourceStatement, tab.result, executionDatabase, uuid().slice(0, 8), () => `${Math.round(performance.now() - metadataStartedAt)}ms`, effectiveDatabaseTypeForConnection(connection), [], connection); } }