diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index efd82ab752..7f0e26c020 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -71,7 +71,7 @@ import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWor import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue"; import EnumCellEditor from "@/components/grid/EnumCellEditor.vue"; import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue"; -import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab } from "@/types/database"; +import type { QueryResult, ColumnInfo, DatabaseType, ForeignKeyInfo, IndexInfo, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef } from "@/types/database"; import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError"; import { tableObjectSourceKind } from "@/lib/table/tableObjectSourceKind"; import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation"; @@ -335,6 +335,22 @@ interface DataGridProps { context?: "results" | "table-data"; autoTransposeSingleRow?: boolean; sourceColumns?: Array; + /** + * Column comments for a multi-source query result (e.g. JOIN), indexed by + * result-column ordinal (projection order). Populated even when the result is + * not editable, so joined results still show comments. `undefined` for a + * column that cannot be resolved back to exactly one base column (ambiguous + * or computed) — the grid shows no comment instead of a wrong one. + */ + resultColumnComments?: Array; + /** + * Display-only result-column -> source mapping for multi-source results, + * indexed by result-column ordinal; each entry carries the source identity + * (sourceKey + canonical source column name). Used to resolve column + * comments per source instead of first-source-wins; never used for row + * identity or editing. + */ + queryDisplaySourceColumns?: Array; initialWhereInput?: string; initialOrderByInput?: string; sortColumn?: string; @@ -575,6 +591,11 @@ const readonlyTextCell = ref<{ } | null>(null); function resolvedColumnComment(column: string, actualColIdx: number): string | undefined { + // Multi-source results resolve comments per result ordinal; ambiguous or + // unresolved columns yield undefined instead of falling back to a + // first-source-wins name map. + const ordinalComments = props.resultColumnComments; + if (ordinalComments) return ordinalComments[actualColIdx]; return dataGridColumnCommentFor(columnCommentMap.value, column, props.sourceColumns?.[actualColIdx]); } @@ -2031,7 +2052,13 @@ const allColumnTypes = computed(() => const visibleColumnTypes = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypes.value[index])); const allColumnTypeVisualKinds = computed(() => allColumnTypes.value.map((type) => resolveDataGridTypeVisualKind(type, resolvedDatabaseType.value))); const visibleColumnTypeVisualKinds = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypeVisualKinds.value[index] ?? "unknown")); -const visibleColumnComments = computed(() => visibleColumnIndexes.value.map((index) => dataGridColumnCommentFor(columnCommentMap.value, props.result.columns[index] ?? "", props.sourceColumns?.[index]))); +const visibleColumnComments = computed(() => + visibleColumnIndexes.value.map((index) => { + const ordinalComments = props.resultColumnComments; + if (ordinalComments) return ordinalComments[index]; + return dataGridColumnCommentFor(columnCommentMap.value, props.result.columns[index] ?? "", props.sourceColumns?.[index]); + }), +); const visibleColumnCount = computed(() => visibleColumnIndexes.value.length); const numericColumnRightAlign = computed(() => (settingsStore.editorSettings.numericColumnRightAlign ?? true) && !showTranspose.value); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridColumnComments.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridColumnComments.spec.ts index 2f86826a52..1bf5302d0b 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridColumnComments.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridColumnComments.spec.ts @@ -4,8 +4,10 @@ import { describe, expect, it } from "vitest"; const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8"); describe("DataGrid column comments", () => { - it("uses source column metadata for both inline and tooltip header comments", () => { - expect(dataGridSource).toMatch(/function resolvedColumnComment\(column: string, actualColIdx: number\)[\s\S]*?dataGridColumnCommentFor\([\s\S]*?props\.sourceColumns\?\.\[actualColIdx\][\s\S]*?\);\s*\}/); + it("uses per-ordinal multi-source comments, falling back to source metadata for single-source grids", () => { + expect(dataGridSource).toMatch( + /function resolvedColumnComment\(column: string, actualColIdx: number\)[\s\S]*?const ordinalComments = props\.resultColumnComments;[\s\S]*?if \(ordinalComments\) return ordinalComments\[actualColIdx\];[\s\S]*?dataGridColumnCommentFor\([\s\S]*?props\.sourceColumns\?\.\[actualColIdx\][\s\S]*?\);\s*\}/, + ); expect(dataGridSource).toContain(':column-comment="headerColumnComment(col.name, col.actualColIdx)"'); expect(dataGridSource).toContain(':tooltip-column-comment="resolvedColumnComment(col.name, col.actualColIdx)"'); expect(dataGridSource).toContain("(column, index) => headerColumnComment(column, index)"); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 50a7d28d0c..d6a691f8f1 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1567,6 +1567,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :loading="activeTab.isExecuting" :editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler" :source-columns="activeTab.querySourceColumns" + :result-column-comments="activeTab.resultColumnComments" + :query-display-source-columns="activeTab.queryDisplaySourceColumns" :custom-save-handler="mongoQueryResultSaveHandler" :mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined" :query-editability-reason="activeTab.queryEditabilityReason" diff --git a/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts b/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts new file mode 100644 index 0000000000..2f6eccc52a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/multiSourceColumnMapping.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { analyzeEditableQueryEditability, resolveSourceColumnsByOrdinal } from "@/lib/sql/sqlAnalysis"; + +/** + * Result columns resolve by projection ordinal, each carrying its source + * identity (sourceKey + canonical source column), so joined results keep + * per-source comments instead of first-source-wins on name clashes. + */ +describe("multi-source result column mapping", () => { + 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); + if (!result.editable) return; + + const sources = result.analysis.sources!; + expect(sources.map((source) => source.tableName)).toEqual(["orders", "users"]); + expect(result.analysis.columns.map((column) => column.resultName)).toEqual(["id", "user_id", "name"]); + expect(result.analysis.columns.map((column) => column.sourceKey)).toEqual(["a:0", "a:0", "b:1"]); + }); + + it("maps each JOIN result column back to (source, column) by ordinal", () => { + 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); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] }, + ], + 3, + ); + expect(resolved).toEqual([ + { sourceKey: "a:0", sourceColumn: "id" }, + { sourceKey: "a:0", sourceColumn: "user_id" }, + { sourceKey: "b:1", sourceColumn: "name" }, + ]); + }); + + it("keeps duplicate result column names resolved per source (both tables have id)", () => { + const result = analyzeEditableQueryEditability("SELECT a.id, b.id FROM orders a JOIN users b ON a.user_id = b.id"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] }, + ], + 2, + ); + // Result column #0 is orders.id, #1 is users.id — no first-source-wins. + expect(resolved).toEqual([ + { sourceKey: "a:0", sourceColumn: "id" }, + { sourceKey: "b:1", sourceColumn: "id" }, + ]); + }); + + it("resolves a uniquely qualified unqualified alias (name AS username) back to its physical column", () => { + const result = analyzeEditableQueryEditability("SELECT name AS username FROM orders a JOIN users b ON a.user_id = b.id"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] }, + ], + 1, + ); + expect(resolved).toEqual([{ sourceKey: "b:1", sourceColumn: "name" }]); + }); + + it("returns undefined for an ambiguous unqualified column shared by several sources", () => { + const result = analyzeEditableQueryEditability("SELECT id FROM orders a JOIN users b ON a.user_id = b.id"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] }, + ], + 1, + ); + expect(resolved).toEqual([undefined]); + }); + + it("resolves quoted mixed-case identifiers exactly (case preserved)", () => { + const result = analyzeEditableQueryEditability('SELECT a."ID", b."Name" FROM orders a JOIN users b ON a.user_id = b.id'); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "postgres", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "ID" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "Name" }] }, + ], + 2, + ); + expect(resolved).toEqual([ + { sourceKey: "a:0", sourceColumn: "ID" }, + { sourceKey: "b:1", sourceColumn: "Name" }, + ]); + }); + + it("does not collapse an unquoted lower-case name onto a distinct quoted mixed-case column", () => { + const result = analyzeEditableQueryEditability('SELECT a.id, b."ID" FROM orders a JOIN users b ON a.user_id = b.id'); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "postgres", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "ID" }, { name: "name" }] }, + ], + 2, + ); + // a.id folds to postgres `id`; b."ID" matches the quoted column exactly. + expect(resolved).toEqual([ + { sourceKey: "a:0", sourceColumn: "id" }, + { sourceKey: "b:1", sourceColumn: "ID" }, + ]); + }); + + it("expands a qualified star projection in projection order", () => { + const result = analyzeEditableQueryEditability("SELECT a.*, b.name FROM orders a JOIN users b ON a.user_id = b.id"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] }, + ], + 4, + ); + expect(resolved).toEqual([ + { sourceKey: "a:0", sourceColumn: "id" }, + { sourceKey: "a:0", sourceColumn: "user_id" }, + { sourceKey: "a:0", sourceColumn: "amount" }, + { sourceKey: "b:1", sourceColumn: "name" }, + ]); + }); + + it("returns undefined for computed columns and extra result columns", () => { + const result = analyzeEditableQueryEditability("SELECT a.id, a.amount + b.id AS total, b.name FROM orders a JOIN users b ON a.user_id = b.id"); + expect(result.editable).toBe(true); + if (!result.editable) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + result.analysis, + [ + { source: result.analysis.sources![0]!, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] }, + { source: result.analysis.sources![1]!, columns: [{ name: "id" }, { name: "name" }] }, + ], + 3, + ); + expect(resolved).toEqual([{ sourceKey: "a:0", sourceColumn: "id" }, undefined, { sourceKey: "b:1", sourceColumn: "name" }]); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts index 6a12e7ee0d..79fda6b2e7 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts @@ -193,6 +193,60 @@ describe("tab result cache statement execution metadata", () => { expect(restored?.querySourceColumns).toEqual(["id", "name"]); }); + it("restores multi-source column comments and their source identity from the snapshot", () => { + const snapshot = { + result: { + columns: ["id", "user_id", "id_1", "name"], + rows: [[1, 100, 7, "Alice"]], + affected_rows: 0, + execution_time_ms: 1, + }, + resultRuns: [ + { + id: "run-join", + title: "Run 1", + sequence: 1, + sql: "SELECT a.id, a.user_id, b.id, b.name FROM orders a JOIN users b ON a.user_id = b.id", + createdAt: 1, + result: { + columns: ["id", "user_id", "id_1", "name"], + rows: [[1, 100, 7, "Alice"]], + affected_rows: 0, + execution_time_ms: 1, + }, + resultColumnComments: ["订单ID", "下单用户", "用户ID", "用户名"], + queryDisplaySourceColumns: [ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "a", sourceColumn: "user_id" }, + { sourceKey: "b", sourceColumn: "id" }, + { sourceKey: "b", sourceColumn: "name" }, + ], + }, + ], + activeResultRunId: "run-join", + resultColumnComments: ["订单ID", "下单用户", "用户ID", "用户名"], + queryDisplaySourceColumns: [ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "a", sourceColumn: "user_id" }, + { sourceKey: "b", sourceColumn: "id" }, + { sourceKey: "b", sourceColumn: "name" }, + ], + cachedAt: 1, + }; + + const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(snapshot)); + + expect(restored?.resultColumnComments).toEqual(["订单ID", "下单用户", "用户ID", "用户名"]); + expect(restored?.queryDisplaySourceColumns).toEqual([ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "a", sourceColumn: "user_id" }, + { sourceKey: "b", sourceColumn: "id" }, + { sourceKey: "b", sourceColumn: "name" }, + ]); + expect(restored?.resultRuns?.[0]?.resultColumnComments).toEqual(["订单ID", "下单用户", "用户ID", "用户名"]); + expect(restored?.resultRuns?.[0]?.queryDisplaySourceColumns?.[2]).toEqual({ sourceKey: "b", sourceColumn: "id" }); + }); + it("treats corrupt and unsupported snapshots as missing", () => { expect(decodeTabResultSnapshot(new Uint8Array([0xff, 0x00]))).toBeUndefined(); const encoded = encodeTabResultSnapshot(queryResultLifecycleSnapshot()); diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index b8b30b6bfa..a3f1452beb 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -86,6 +86,114 @@ export function resolveMetadataColumnName(databaseType: string, sourceName: stri return caseOnlyMatches.length === 1 ? caseOnlyMatches[0] : undefined; } +export interface ResolvedSourceColumnRef { + sourceKey: string; + sourceColumn: string; +} + +/** + * Expand `*` / `alias.*` projections against each source table's columns so the + * returned stream aligns 1:1 with the executed result columns (projection + * order). A star whose source table is not among `tableSources` collapses to + * `undefined` (unresolvable). Whole-table `SELECT *` is expanded against the + * single source table when present. + */ +function expandProjectionColumnsForSources(analysis: EditableQueryInfo, tableSources: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }>): Array { + if (analysis.selectStar || analysis.columns.length === 0) { + return tableSources.flatMap(({ source, columns }) => + columns.map((column) => ({ + sourceName: column.name, + sourceNameQuoted: false, + sourceKey: source.key, + resultName: column.name, + expression: column.name, + })), + ); + } + const expanded: Array = []; + for (const column of analysis.columns) { + if (!column.star) { + expanded.push(column); + continue; + } + const tableSource = tableSources.find((entry) => entry.source.key === column.sourceKey); + if (!tableSource) { + expanded.push(undefined); + continue; + } + for (const tableColumn of tableSource.columns) { + expanded.push({ + ...column, + star: false, + sourceName: tableColumn.name, + sourceNameQuoted: false, + resultName: tableColumn.name, + expression: column.sourceQualifier ? `${column.sourceQualifier}.${tableColumn.name}` : tableColumn.name, + }); + } + } + return expanded; +} + +function resolveProjectionColumnToSource(databaseType: string, column: EditableQueryColumn | undefined, tableSources: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }>): ResolvedSourceColumnRef | undefined { + if (!column || column.star || !column.sourceName) return undefined; + // A qualified reference whose qualifier could not be bound to a unique source + // stays unresolved rather than guessing from the bare column name. + if (column.sourceQualifier && !column.sourceKey) return undefined; + + if (column.sourceKey) { + const tableIndex = tableSources.findIndex((entry) => entry.source.key === column.sourceKey); + if (tableIndex < 0) return undefined; + const canonicalName = resolveMetadataColumnName( + databaseType, + column.sourceName, + column.sourceNameQuoted, + tableSources[tableIndex]!.columns.map((entry) => entry.name), + ); + return canonicalName ? { sourceKey: column.sourceKey, sourceColumn: canonicalName } : undefined; + } + + // Unqualified reference: bind only when exactly one source resolves it, so an + // ambiguous name shared by several tables yields undefined instead of + // first-source-wins. + const matches: ResolvedSourceColumnRef[] = []; + for (const tableSource of tableSources) { + const canonicalName = resolveMetadataColumnName( + databaseType, + column.sourceName, + column.sourceNameQuoted, + tableSource.columns.map((entry) => entry.name), + ); + if (canonicalName) matches.push({ sourceKey: tableSource.source.key, sourceColumn: canonicalName }); + } + return matches.length === 1 ? matches[0] : undefined; +} + +/** + * Resolve each result column — in projection (ordinal) order — back to exactly + * one base-table column across the query sources, using the same + * database-aware identifier canonicalization as the editability binder + * (`resolveMetadataColumnName`): quoted identifiers match metadata exactly + * (case preserved), unquoted identifiers fold per the database's rules + * (PostgreSQL-compatible lower, Oracle-compatible upper, others + * case-insensitive when unambiguous). + * + * Star projections are expanded against the source table columns so the + * returned array aligns 1:1 with the executed result columns. An entry is + * `undefined` when the result column cannot be resolved to a single source + * column: ambiguous unqualified references, computed expressions, unknown + * columns, or a star whose source table is unknown. Consumers must show no + * comment for such columns rather than guessing. + */ +export function resolveSourceColumnsByOrdinal(databaseType: string, analysis: EditableQueryInfo, tableSources: Array<{ source: EditableQuerySource; columns: readonly { name: string }[] }>, columnCount: number): Array { + const expanded = expandProjectionColumnsForSources(analysis, tableSources); + const resolved: Array = []; + for (let index = 0; index < columnCount; index++) { + resolved.push(resolveProjectionColumnToSource(databaseType, expanded[index], tableSources)); + } + return resolved; +} + export type QueryEditabilityReason = "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable"; export type QueryEditability = { editable: true; analysis: EditableQueryInfo } | { editable: false; reason: QueryEditabilityReason }; diff --git a/apps/desktop/src/lib/tabs/tabResultCache.ts b/apps/desktop/src/lib/tabs/tabResultCache.ts index 056e6bd62c..3af0b1cf22 100644 --- a/apps/desktop/src/lib/tabs/tabResultCache.ts +++ b/apps/desktop/src/lib/tabs/tabResultCache.ts @@ -34,6 +34,8 @@ export interface TabResultSnapshot { activeResultRunId?: string; queryAnalysis?: QueryTab["queryAnalysis"]; querySourceColumns?: QueryTab["querySourceColumns"]; + resultColumnComments?: QueryTab["resultColumnComments"]; + queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"]; queryEditabilityReason?: QueryTab["queryEditabilityReason"]; mongoEditTarget?: QueryTab["mongoEditTarget"]; tableMeta?: QueryTab["tableMeta"]; @@ -318,7 +320,11 @@ async function deleteIndexedDbCacheOwner(ownerId: string): Promise { function clonePlain(value: T): T { const raw = toRaw(value); if (typeof structuredClone === "function") return structuredClone(raw); - return JSON.parse(JSON.stringify(raw)) as T; + try { + return JSON.parse(JSON.stringify(raw)) as T; + } catch { + return raw; + } } function stripSessionIds(result: QueryResult | undefined): QueryResult | undefined { @@ -727,6 +733,8 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef activeResultRunId: tab.activeResultRunId, queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined, querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined, + resultColumnComments: tab.resultColumnComments ? clonePlain(tab.resultColumnComments) : undefined, + queryDisplaySourceColumns: tab.queryDisplaySourceColumns ? [...tab.queryDisplaySourceColumns] : undefined, queryEditabilityReason: tab.queryEditabilityReason, mongoEditTarget: tab.mongoEditTarget ? clonePlain(tab.mongoEditTarget) : undefined, tableMeta: tab.tableMeta ? clonePlain(tab.tableMeta) : undefined, diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts new file mode 100644 index 0000000000..463e890060 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.multiSourceColumnComments.spec.ts @@ -0,0 +1,369 @@ +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const executeMulti = vi.fn(); +const executeQuery = vi.fn(); +const analyzeEditableQueryEditability = vi.fn(); +const getColumns = vi.fn(); +const listIndexes = vi.fn(); +const listObjects = vi.fn(); +const getConnectionConfig = vi.fn(); +const lookupLocalCompletionTables = vi.fn(); +const buildSortedQuerySql = vi.fn(); +const buildDataGridCountSql = vi.fn(); +const prepareQueryPaginationExecutionPlan = vi.fn(async (options) => ({ + sqlToExecute: options.sql, + pageSql: undefined, + pageLimit: undefined, + pageOffset: undefined, + countSql: undefined, + useAgentResultSession: false, +})); +const editorSettings = { + pageSize: 100, + autoCalculateTotalRows: false, +}; + +vi.mock("@/lib/backend/api", () => ({ + analyzeEditableQueryEditability, + buildDataGridCountSql, + buildSortedQuerySql, + closeClientConnectionSession: vi.fn().mockResolvedValue(undefined), + closeQuerySession: vi.fn().mockResolvedValue(undefined), + executeMulti, + executeQuery, + getColumns, + listIndexes, + listObjects, + prepareQueryPaginationExecutionPlan, + saveOpenTabsState: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: vi.fn().mockResolvedValue(undefined), + getConfig: getConnectionConfig, + lookupLocalCompletionTables, + recordConnectionLostError: vi.fn(), + }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings, + }), +})); + +function column(name: string, comment: string | null, isPrimaryKey = false) { + return { name, data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: isPrimaryKey, extra: null, comment }; +} + +const ordersColumns = [column("id", "订单ID", true), column("user_id", "下单用户"), column("amount", "订单金额")]; +const usersColumns = [column("id", "用户ID", true), column("name", "用户名")]; + +/** SELECT a.id, a.user_id, b.id, b.name FROM orders a JOIN users b ON a.user_id = b.id */ +const joinAnalysis = { + editable: true, + analysis: { + schema: undefined, + tableName: "orders", + tableAlias: "a", + selectStar: false, + columns: [ + { sourceName: "id", sourceKey: "a", resultName: "id", expression: "a.id" }, + { sourceName: "user_id", sourceKey: "a", resultName: "user_id", expression: "a.user_id" }, + { sourceName: "id", sourceKey: "b", resultName: "id_1", expression: "b.id" }, + { sourceName: "name", sourceKey: "b", resultName: "name", expression: "b.name" }, + ], + sources: [ + { key: "a", tableName: "orders", alias: "a" }, + { key: "b", tableName: "users", alias: "b" }, + ], + multiSource: true, + allowInsertDelete: false, + }, +}; + +describe("queryStore multi-source result column comments", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { clearTableMetadataCache } = await import("@/lib/metadata/tableMetadataCache"); + clearTableMetadataCache(); + setActivePinia(createPinia()); + getConnectionConfig.mockReturnValue({ id: "mysql-1", name: "MySQL", db_type: "mysql", database: "app", query_timeout_secs: 30 }); + getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => (table === "orders" ? ordersColumns : usersColumns)); + listIndexes.mockResolvedValue([]); + listObjects.mockResolvedValue([]); + lookupLocalCompletionTables.mockReturnValue([]); + analyzeEditableQueryEditability.mockResolvedValue(joinAnalysis); + buildSortedQuerySql.mockResolvedValue({ ok: true, sql: `${"SELECT *"} ORDER BY 1` }); + buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM `orders`"); + executeQuery.mockResolvedValue({ + columns: ["row_count"], + rows: [[0]], + affected_rows: 0, + execution_time_ms: 1, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "user_id", "id_1", "name"], + rows: [[1, 100, 7, "Alice"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + }); + + afterEach(() => { + expect(listObjects).not.toHaveBeenCalled(); + }); + + it("stores per-ordinal comments and source identity for every JOIN source column", async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + + await store.executeTabSql(tabId, "SELECT a.id, a.user_id, b.id, b.name FROM orders a JOIN users b ON a.user_id = b.id"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + + // Multi-source results stay non-editable: no single tableMeta. + expect(tab.tableMeta).toBeUndefined(); + expect(tab.queryEditabilityReason).toBe("complex-source"); + expect(tab.querySourceColumns).toBeUndefined(); + + // Comments are indexed by result ordinal, so the second `id` (users.id) + // keeps its own comment instead of first-source-wins on the name. + expect(tab.resultColumnComments).toEqual(["订单ID", "下单用户", "用户ID", "用户名"]); + + // The display mapping carries source identity per ordinal. + expect(tab.queryDisplaySourceColumns).toEqual([ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "a", sourceColumn: "user_id" }, + { sourceKey: "b", sourceColumn: "id" }, + { sourceKey: "b", sourceColumn: "name" }, + ]); + }); + + it("resolves a uniquely qualified unqualified alias back to its physical column", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "orders", + tableAlias: "a", + selectStar: false, + columns: [ + { sourceName: "id", sourceKey: "a", resultName: "id", expression: "a.id" }, + { sourceName: "name", sourceKey: undefined, resultName: "username", expression: "name" }, + ], + sources: [ + { key: "a", tableName: "orders", alias: "a" }, + { key: "b", tableName: "users", alias: "b" }, + ], + multiSource: true, + allowInsertDelete: false, + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "username"], + rows: [[1, "Alice"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + + // `name` exists only in users across the joined sources, so the unqualified + // alias resolves back to users.name despite the binder never seeing a key. + await store.executeTabSql(tabId, "SELECT a.id, name AS username FROM orders a JOIN users b ON a.user_id = b.id"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.resultColumnComments).toEqual(["订单ID", "用户名"]); + expect(tab.queryDisplaySourceColumns).toEqual([ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "b", sourceColumn: "name" }, + ]); + }); + + it("returns no comment for an ambiguous unqualified column shared by several sources", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "orders", + tableAlias: "a", + selectStar: false, + columns: [{ sourceName: "id", sourceKey: undefined, resultName: "id", expression: "id" }], + sources: [ + { key: "a", tableName: "orders", alias: "a" }, + { key: "b", tableName: "users", alias: "b" }, + ], + multiSource: true, + allowInsertDelete: false, + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + + // Both orders and users expose `id`; the unqualified reference is + // ambiguous, so the result column must not claim either table's comment. + await store.executeTabSql(tabId, "SELECT id FROM orders a JOIN users b ON a.user_id = b.id"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.resultColumnComments).toEqual([undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([undefined]); + }); + + it("resolves quoted mixed-case identifiers exactly, per the database's rules", async () => { + getConnectionConfig.mockReturnValue({ id: "pg-1", name: "PostgreSQL", db_type: "postgres", database: "app", query_timeout_secs: 30 }); + const quotedOrders = [column("id", "订单ID", true), column("ID", "大写ID"), column("user_id", "下单用户")]; + const quotedUsers = [column("id", "用户ID", true), column("Name", "大写Name")]; + getColumns.mockImplementation(async (_connectionId: string, _database: string, _schema: string, table: string) => (table === "orders" ? quotedOrders : quotedUsers)); + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "orders", + tableAlias: "a", + selectStar: false, + columns: [ + { sourceName: "id", sourceNameQuoted: false, sourceKey: "a", resultName: "id", expression: "a.id" }, + { sourceName: "ID", sourceNameQuoted: true, sourceKey: "a", resultName: "ID", expression: 'a."ID"' }, + { sourceName: "Name", sourceNameQuoted: true, sourceKey: "b", resultName: "Name", expression: 'b."Name"' }, + ], + sources: [ + { key: "a", tableName: "orders", alias: "a" }, + { key: "b", tableName: "users", alias: "b" }, + ], + multiSource: true, + allowInsertDelete: false, + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "ID", "Name"], + rows: [[1, 9, "Alice"]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("pg-1", "app", "Query"); + + await store.executeTabSql(tabId, 'SELECT a.id, a."ID", b."Name" FROM orders a JOIN users b ON a.user_id = b.id'); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + + // Quoted `"ID"` stays distinct from unquoted `id`; the global lower-casing + // of the previous map would have collapsed them. + expect(tab.resultColumnComments).toEqual(["订单ID", "大写ID", "大写Name"]); + expect(tab.queryDisplaySourceColumns).toEqual([ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "a", sourceColumn: "ID" }, + { sourceKey: "b", sourceColumn: "Name" }, + ]); + }); + + it("keeps duplicate result column names resolved in projection order", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "orders", + tableAlias: "a", + selectStar: false, + columns: [ + { sourceName: "id", sourceKey: "a", resultName: "id", expression: "a.id" }, + { sourceName: "id", sourceKey: "b", resultName: "id", expression: "b.id" }, + ], + sources: [ + { key: "a", tableName: "orders", alias: "a" }, + { key: "b", tableName: "users", alias: "b" }, + ], + multiSource: true, + allowInsertDelete: false, + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "id_1"], + rows: [[1, 7]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + + // The driver renames the second `id` to `id_1`; ordinal mapping still + // resolves column #1 to users.id instead of failing to match by name. + await store.executeTabSql(tabId, "SELECT a.id, b.id FROM orders a JOIN users b ON a.user_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.queryDisplaySourceColumns).toEqual([ + { sourceKey: "a", sourceColumn: "id" }, + { sourceKey: "b", sourceColumn: "id" }, + ]); + }); + + it("keeps single-source results free of multi-source comment fields", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "orders", + selectStar: false, + columns: [ + { sourceName: "id", sourceKey: "orders:0", resultName: "id", expression: "id" }, + { sourceName: "amount", sourceKey: "orders:0", resultName: "amount", expression: "amount" }, + ], + }, + }); + getColumns.mockResolvedValue(ordersColumns); + executeMulti.mockResolvedValue([ + { + columns: ["id", "amount"], + rows: [[1, 9.99]], + affected_rows: 0, + execution_time_ms: 1, + }, + ]); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + + await store.executeTabSql(tabId, "SELECT id, amount FROM orders"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.tableMeta?.tableName).toBe("orders")); + expect(tab.resultColumnComments).toBeUndefined(); + expect(tab.queryDisplaySourceColumns).toBeUndefined(); + expect(tab.querySourceColumns).toEqual(["id", "amount"]); + }); +}); diff --git a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts index ac6d6eb6f0..fb8ca1c98c 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.switchTab.spec.ts @@ -117,6 +117,20 @@ describe("queryStore switchTab", () => { expect(queryStore.tabs[1].catalog).toBe("paimon_catalog"); }); + it("clones result column comments when duplicating a query tab", () => { + const queryStore = useQueryStore(); + const tabId = queryStore.createTab("pg-1", "app", undefined, "query", undefined, "SELECT 1"); + const original = queryStore.tabs.find((tab) => tab.id === tabId)!; + original.resultColumnComments = ["identifier", "display name"]; + + queryStore.duplicateTab(tabId); + + const duplicate = queryStore.tabs[1]; + expect(Array.isArray(duplicate.resultColumnComments)).toBe(true); + expect(duplicate.resultColumnComments).toEqual(original.resultColumnComments); + expect(duplicate.resultColumnComments).not.toBe(original.resultColumnComments); + }); + it("switches catalog and database as one query context", () => { const queryStore = useQueryStore(); const tabId = queryStore.createTab("sr-1", "internal_db", undefined, "query"); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f0c8b7f27c..21b61d5bd1 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2,12 +2,12 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; +import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan"; import { mysqlExplainCompatibilityHint } from "@/lib/diagram/mysqlExplainCompatibility"; -import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, resolveMetadataColumnName, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; +import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, resolveMetadataColumnName, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; import { buildQueryWithHiddenPrimaryKeys, hiddenResultColumnIndexes, type HiddenPrimaryKeyProjection } from "@/lib/sql/editableQueryHiddenKeys"; import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence"; import { @@ -965,6 +965,8 @@ export const useQueryStore = defineStore("query", () => { tab.resultEstimatedBytes = undefined; tab.queryAnalysis = undefined; tab.querySourceColumns = undefined; + tab.resultColumnComments = undefined; + tab.queryDisplaySourceColumns = undefined; tab.queryEditabilityReason = undefined; tab.mongoEditTarget = undefined; if (tab.mode === "query") tab.tableMeta = undefined; @@ -1016,6 +1018,8 @@ export const useQueryStore = defineStore("query", () => { run.resultEstimatedBytes = undefined; run.queryAnalysis = undefined; run.querySourceColumns = undefined; + run.resultColumnComments = undefined; + run.queryDisplaySourceColumns = undefined; run.queryEditabilityReason = undefined; run.mongoEditTarget = undefined; run.tableMeta = undefined; @@ -1058,6 +1062,8 @@ export const useQueryStore = defineStore("query", () => { tab.resultEvicted = run.resultEvicted; tab.queryAnalysis = run.queryAnalysis; tab.querySourceColumns = run.querySourceColumns; + tab.resultColumnComments = run.resultColumnComments; + tab.queryDisplaySourceColumns = run.queryDisplaySourceColumns; tab.queryEditabilityReason = run.queryEditabilityReason; tab.mongoEditTarget = run.mongoEditTarget; tab.tableMeta = run.tableMeta; @@ -1217,6 +1223,8 @@ export const useQueryStore = defineStore("query", () => { activeResultRunId: run.id, queryAnalysis: run.queryAnalysis, querySourceColumns: run.querySourceColumns, + resultColumnComments: run.resultColumnComments, + queryDisplaySourceColumns: run.queryDisplaySourceColumns, queryEditabilityReason: run.queryEditabilityReason, tableMeta: run.tableMeta, resultPageSql: run.resultPageSql, @@ -1296,6 +1304,8 @@ export const useQueryStore = defineStore("query", () => { resultEvicted: tab.resultEvicted, queryAnalysis: tab.queryAnalysis, querySourceColumns: tab.querySourceColumns, + resultColumnComments: tab.resultColumnComments, + queryDisplaySourceColumns: tab.queryDisplaySourceColumns, queryEditabilityReason: tab.queryEditabilityReason, mongoEditTarget: tab.mongoEditTarget, tableMeta: tab.tableMeta, @@ -1394,6 +1404,8 @@ export const useQueryStore = defineStore("query", () => { resultEvicted: tab.resultEvicted, queryAnalysis: tab.queryAnalysis, querySourceColumns: tab.querySourceColumns, + resultColumnComments: tab.resultColumnComments, + queryDisplaySourceColumns: tab.queryDisplaySourceColumns, queryEditabilityReason: tab.queryEditabilityReason, mongoEditTarget: tab.mongoEditTarget, tableMeta: tab.tableMeta, @@ -1633,7 +1645,7 @@ export const useQueryStore = defineStore("query", () => { const tab: QueryTab = { id, title: title || `query_${tabs.value.length + 1}`, - customTitle: mode === "query" && !!title ? true : undefined, + customTitle: mode === "query" && title ? true : undefined, forceWordWrap: options.forceWordWrap, connectionId, database, @@ -2685,6 +2697,8 @@ export const useQueryStore = defineStore("query", () => { tableMeta: original.tableMeta ? { ...original.tableMeta, columns: [...original.tableMeta.columns], primaryKeys: [...original.tableMeta.primaryKeys] } : undefined, queryAnalysis: original.queryAnalysis ? { ...original.queryAnalysis, sources: original.queryAnalysis.sources?.map((source) => ({ ...source })), columns: original.queryAnalysis.columns.map((c) => ({ ...c })) } : undefined, querySourceColumns: original.querySourceColumns ? [...original.querySourceColumns] : undefined, + resultColumnComments: original.resultColumnComments ? [...original.resultColumnComments] : undefined, + queryDisplaySourceColumns: original.queryDisplaySourceColumns ? [...original.queryDisplaySourceColumns] : undefined, queryEditabilityReason: original.queryEditabilityReason, resultEvicted: undefined, whereInput: original.whereInput, @@ -3380,7 +3394,7 @@ export const useQueryStore = defineStore("query", () => { return producedResult; } - type QueryMetadataPatch = Pick; + type QueryMetadataPatch = Pick; type LoadedEditableSource = { source: EditableQuerySource; @@ -3395,6 +3409,38 @@ export const useQueryStore = defineStore("query", () => { writeSchema?: string; }; + /** + * Resolve multi-source result columns (by projection ordinal) back to exactly + * one base column per source, then surface the resolved column comments and a + * display-only result->source mapping. Reuses the same database-aware binder + * as the editability analysis, so `name AS username` (uniquely resolvable + * unqualified alias) maps back to its physical column and quoted mixed-case + * identifiers keep exact casing. Ambiguous or unresolved columns yield + * `undefined` (no comment) instead of first-source-wins on a shared name. + */ + function resolveMultiSourceColumnInfo(dbType: string, analysis: EditableQueryInfo, resultColumns: string[], loadedSources: LoadedEditableSource[]): { comments: Array; mapping: Array } { + const refs = resolveSourceColumnsByOrdinal( + dbType, + analysis, + loadedSources.map((loaded) => ({ source: loaded.source, columns: loaded.tableMeta.columns })), + resultColumns.length, + ); + const comments: Array = []; + const mapping: Array = []; + for (const ref of refs) { + if (!ref) { + comments.push(undefined); + mapping.push(undefined); + continue; + } + const loaded = loadedSources.find((entry) => entry.source.key === ref.sourceKey); + const comment = loaded?.tableMeta.columns.find((column) => column.name === ref.sourceColumn)?.comment?.trim(); + comments.push(comment || undefined); + mapping.push(ref); + } + return { comments, mapping }; + } + function canInsertIntoEditableQuerySource(tab: QueryTab, databaseType: DatabaseType | undefined, loaded: LoadedEditableSource, sourceColumns: readonly (string | undefined)[] | undefined): boolean { if (!canInsertTableRows(databaseType) || !sourceColumns?.length || !sourceColumns.every(Boolean)) return false; const knownTableType = @@ -3418,6 +3464,8 @@ export const useQueryStore = defineStore("query", () => { tab.queryEditabilityReason = patch.queryEditabilityReason; tab.mongoEditTarget = undefined; tab.tableMeta = patch.tableMeta; + tab.resultColumnComments = patch.resultColumnComments; + tab.queryDisplaySourceColumns = patch.queryDisplaySourceColumns; } function resolveEditableSourceMetadataTarget(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string): EditableSourceMetadataTarget { @@ -3733,12 +3781,20 @@ export const useQueryStore = defineStore("query", () => { }; } + // Multi-source results cannot carry a single tableMeta, but every source + // table's metadata is already loaded. Surface per-ordinal column comments + // and a display-only result->source mapping so the data grid can still + // show comments for joined results (fixes #2129 / #6352). + const multiSourceInfo = loadedSources.length > 1 ? resolveMultiSourceColumnInfo(dbType, analysis, tab.result.columns, loadedSources) : undefined; + if (candidates.length === 0) { return { queryAnalysis: undefined, querySourceColumns: undefined, queryEditabilityReason: loadedSources.some((loaded) => loaded.tableMeta.primaryKeys.length > 0) ? "primary-key-not-returned" : "no-primary-key", tableMeta: undefined, + resultColumnComments: multiSourceInfo?.comments, + queryDisplaySourceColumns: multiSourceInfo?.mapping, }; } @@ -3748,6 +3804,8 @@ export const useQueryStore = defineStore("query", () => { querySourceColumns: undefined, queryEditabilityReason: "complex-source", tableMeta: undefined, + resultColumnComments: multiSourceInfo?.comments, + queryDisplaySourceColumns: multiSourceInfo?.mapping, }; } @@ -3763,6 +3821,8 @@ export const useQueryStore = defineStore("query", () => { querySourceColumns: target.sourceColumns, queryEditabilityReason: undefined, tableMeta: target.tableMeta, + resultColumnComments: multiSourceInfo?.comments, + queryDisplaySourceColumns: multiSourceInfo?.mapping, }; } catch (err) { console.error("[DBX] ERROR fetching columns for query metadata:", err); @@ -4079,6 +4139,8 @@ export const useQueryStore = defineStore("query", () => { touchResult(current); current.queryAnalysis = undefined; current.querySourceColumns = undefined; + current.resultColumnComments = undefined; + current.queryDisplaySourceColumns = undefined; current.queryEditabilityReason = undefined; current.mongoEditTarget = undefined; current.tableMeta = undefined; @@ -4461,6 +4523,8 @@ export const useQueryStore = defineStore("query", () => { touchResult(current); current.queryAnalysis = undefined; current.querySourceColumns = undefined; + current.resultColumnComments = undefined; + current.queryDisplaySourceColumns = undefined; current.queryEditabilityReason = undefined; current.mongoEditTarget = mongoCommands.length === 1 ? mongoEditTarget : undefined; current.tableMeta = undefined; @@ -4526,6 +4590,8 @@ export const useQueryStore = defineStore("query", () => { touchResult(current); current.queryAnalysis = undefined; current.querySourceColumns = undefined; + current.resultColumnComments = undefined; + current.queryDisplaySourceColumns = undefined; current.queryEditabilityReason = undefined; current.mongoEditTarget = undefined; current.tableMeta = undefined; @@ -4908,6 +4974,8 @@ export const useQueryStore = defineStore("query", () => { } current.queryAnalysis = undefined; current.querySourceColumns = undefined; + current.resultColumnComments = undefined; + current.queryDisplaySourceColumns = undefined; current.queryEditabilityReason = undefined; current.mongoEditTarget = undefined; if (current.mode !== "data") current.tableMeta = undefined; @@ -5388,6 +5456,8 @@ export const useQueryStore = defineStore("query", () => { touchResult(tab, Date.now(), { reuseEstimatedBytes: true }); tab.queryAnalysis = undefined; tab.querySourceColumns = undefined; + tab.resultColumnComments = undefined; + tab.queryDisplaySourceColumns = undefined; tab.queryEditabilityReason = undefined; tab.mongoEditTarget = undefined; syncActiveResultRunFromDisplayed(tab); @@ -5511,6 +5581,8 @@ export const useQueryStore = defineStore("query", () => { tab.queryAnalysis = snapshot.queryAnalysis; tab.querySourceColumns = snapshot.querySourceColumns; + tab.resultColumnComments = snapshot.resultColumnComments; + tab.queryDisplaySourceColumns = snapshot.queryDisplaySourceColumns; tab.queryEditabilityReason = snapshot.queryEditabilityReason; tab.mongoEditTarget = snapshot.mongoEditTarget; // Data tab 的结果快照可能早于最近一次结构变更。已持有真实元数据时, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 47457cfc76..9e6d8473d0 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -773,6 +773,11 @@ export interface SpatialColumn { srid: number | null; } +export interface QueryResultSourceColumnRef { + sourceKey: string; + sourceColumn: string; +} + export interface QueryResultRun { id: string; title: string; @@ -813,6 +818,8 @@ export interface QueryResultRun { resultEvicted?: boolean; queryAnalysis?: QueryTab["queryAnalysis"]; querySourceColumns?: QueryTab["querySourceColumns"]; + resultColumnComments?: QueryTab["resultColumnComments"]; + queryDisplaySourceColumns?: QueryTab["queryDisplaySourceColumns"]; queryEditabilityReason?: QueryTab["queryEditabilityReason"]; mongoEditTarget?: QueryTab["mongoEditTarget"]; tableMeta?: QueryTab["tableMeta"]; @@ -1273,6 +1280,25 @@ export interface QueryTab { }[]; }; querySourceColumns?: Array; + /** + * Column comments for a multi-source query result (e.g. JOIN), indexed by + * result-column ordinal (projection order). Each entry is the comment of the + * single base column that result column resolves to; `undefined` when the + * column is ambiguous (e.g. an unqualified name present in several sources) + * or cannot be resolved back to a base column, so the grid shows no comment + * instead of a wrong one. Populated even when the result is not editable + * (e.g. multi-table JOIN), so joined results still show column comments. + */ + resultColumnComments?: Array; + /** + * Display-only result-column to source mapping for multi-source results, + * indexed by result-column ordinal. Each entry carries the source identity + * (sourceKey + canonical source column name), so comments resolve per source + * instead of first-source-wins on name clashes. Unlike querySourceColumns it + * is also populated for multi-source results that are not editable, and must + * never be used for row identity or editing. + */ + queryDisplaySourceColumns?: Array; queryEditabilityReason?: "not-select" | "cte" | "set-operation" | "aggregation" | "external-source" | "complex-source" | "computed-columns" | "no-table" | "no-primary-key" | "primary-key-not-returned" | "aliased-columns" | "metadata-unavailable"; mongoEditTarget?: { collection: string;