diff --git a/apps/desktop/src/lib/__tests__/sql/groupByColumnMapping.spec.ts b/apps/desktop/src/lib/__tests__/sql/groupByColumnMapping.spec.ts new file mode 100644 index 0000000000..0cb51bd7b8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/groupByColumnMapping.spec.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, resolveSourceColumnsByOrdinal, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; + +/** + * Mirrors queryStore.editableQuerySources: single-source analyses store the + * source on the analysis root, so tests normalize it exactly like the store + * before resolving result columns by ordinal. + */ +function displayTableSources(analysis: EditableQueryInfo, columns: readonly string[]): Array<{ source: EditableQuerySource; columns: { name: string }[] }> { + const source: EditableQuerySource = analysis.sources?.length + ? analysis.sources[0]! + : { + key: `${analysis.tableAlias ?? analysis.tableName}:0`, + catalog: analysis.catalog, + catalogQuoted: analysis.catalogQuoted, + schema: analysis.schema, + schemaQuoted: analysis.schemaQuoted, + tableName: analysis.tableName, + tableNameQuoted: analysis.tableNameQuoted, + alias: analysis.tableAlias, + }; + return [{ source, columns: columns.map((name) => ({ name })) }]; +} + +/** + * GROUP BY / HAVING queries stay read-only (`aggregation`) but still expose a + * display-only structural analysis so the result grid can resolve comments for + * result columns that map directly back to a base-table column. Aggregate, + * computed and ambiguous projections must stay unresolved (no guessed comment). + */ +describe("grouped query display column mapping", () => { + describe("display structure analysis", () => { + it("parses projections and sources for a GROUP BY query without making it editable", () => { + const editability = analyzeEditableQueryEditability("SELECT department, COUNT(*) AS total FROM users GROUP BY department"); + expect(editability).toEqual({ editable: false, reason: "aggregation" }); + + const analysis = analyzeSelectStructureForDisplay("SELECT department, COUNT(*) AS total FROM users GROUP BY department"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + expect(analysis.tableName).toBe("users"); + expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "total"]); + expect(analysis.columns[0]).toMatchObject({ sourceName: "department" }); + // Aggregate expression has no direct source column. + expect(analysis.columns[1]?.sourceName).toBeUndefined(); + }); + + it("parses HAVING queries without an explicit GROUP BY", () => { + const editability = analyzeEditableQueryEditability("SELECT department, COUNT(*) AS total FROM users GROUP BY department HAVING COUNT(*) > 1"); + expect(editability).toEqual({ editable: false, reason: "aggregation" }); + + const analysis = analyzeSelectStructureForDisplay("SELECT department, COUNT(*) AS total FROM users GROUP BY department HAVING COUNT(*) > 1"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "total"]); + }); + + it("parses multi-source grouped queries with per-source qualifiers", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT u.department, COUNT(o.id) AS order_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.department"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + expect(analysis.sources!.map((source) => source.tableName)).toEqual(["users", "orders"]); + expect(analysis.columns.map((column) => column.resultName)).toEqual(["department", "order_count"]); + expect(analysis.columns[0]?.sourceKey).toBe("u:0"); + }); + + it("returns null for unparseable / dangerous display shapes", () => { + expect(analyzeSelectStructureForDisplay("WITH x AS (SELECT 1) SELECT * FROM x")).toBeNull(); + expect(analyzeSelectStructureForDisplay("SELECT a FROM (SELECT 1 AS a) t")).toBeNull(); + expect(analyzeSelectStructureForDisplay("INSERT INTO t VALUES (1)")).toBeNull(); + }); + }); + + describe("ordinal resolution", () => { + it("maps direct physical columns and leaves aggregate expressions unresolved (T1)", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT department, COUNT(*) AS total FROM users GROUP BY department"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["department", "id"]), 2); + expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]); + }); + + it("resolves aliased physical columns back to the base column (T2)", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT department AS dept, COUNT(*) AS total FROM users GROUP BY department"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["department", "id"]), 2); + expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]); + }); + + it("maps multiple grouped physical columns (T5)", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT department, status, COUNT(*) FROM users GROUP BY department, status"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["department", "status", "id"]), 3); + expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, { sourceKey: "users:0", sourceColumn: "status" }, undefined]); + }); + + it("never lets an aggregate expression inherit its operand's comment (T6/T7)", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT DATE(created_at) AS day, SUM(amount) AS total_amount FROM orders GROUP BY DATE(created_at)"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const resolved = resolveSourceColumnsByOrdinal("mysql", analysis, displayTableSources(analysis, ["created_at", "amount", "id"]), 2); + expect(resolved).toEqual([undefined, undefined]); + }); + + it("resolves qualified grouped columns across joined sources (T4) and keeps aggregates unresolved", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT u.department, COUNT(o.id) AS order_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.department"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const usersSource = analysis.sources![0]!; + const ordersSource = analysis.sources![1]!; + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + analysis, + [ + { source: usersSource, columns: [{ name: "id" }, { name: "department" }] }, + { source: ordersSource, columns: [{ name: "id" }, { name: "user_id" }, { name: "amount" }] }, + ], + 2, + ); + expect(resolved).toEqual([{ sourceKey: "u:0", sourceColumn: "department" }, undefined]); + }); + + it("keeps quoted identifier semantics for grouped columns (T8)", () => { + const analysis = analyzeSelectStructureForDisplay('SELECT "ID", COUNT(*) AS total FROM users GROUP BY "ID"'); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const resolved = resolveSourceColumnsByOrdinal("postgres", analysis, displayTableSources(analysis, ["id", "ID"]), 2); + // Quoted "ID" matches the physical quoted column exactly; unquoted id stays distinct. + expect(resolved).toEqual([{ sourceKey: "users:0", sourceColumn: "ID" }, undefined]); + }); + + it("leaves ambiguous unqualified grouped columns unresolved (T9)", () => { + const analysis = analyzeSelectStructureForDisplay("SELECT id, COUNT(*) AS total FROM users u JOIN orders o ON o.user_id = u.id GROUP BY id"); + expect(analysis).not.toBeNull(); + if (!analysis) return; + + const resolved = resolveSourceColumnsByOrdinal( + "mysql", + analysis, + [ + { source: analysis.sources![0]!, columns: [{ name: "id" }, { name: "department" }] }, + { source: analysis.sources![1]!, columns: [{ name: "id" }, { name: "user_id" }] }, + ], + 2, + ); + expect(resolved).toEqual([undefined, undefined]); + }); + }); +}); diff --git a/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts b/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts new file mode 100644 index 0000000000..70a4fdfa56 --- /dev/null +++ b/apps/desktop/src/lib/metadata/__tests__/tableMetadataCache.columns.spec.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ColumnInfo } from "@/types/database"; + +/** + * Trigger-count regression suite for the columns facet of table metadata. + * + * These assert the public contract the display-only (grouped-query comment) + * loader depends on, so a later refactor of the cache internals keeps the + * behaviour stable: + * + * - columns-only loads issue `getColumns` but never `listIndexes`; + * - cold vs warm request counts; + * - in-flight deduplication for concurrent same-table loads; + * - full<->columns interoperability (no duplicate `getColumns`); + * - invalidation and per-table isolation. + */ + +const mocks = vi.hoisted(() => ({ + getColumns: vi.fn(), + listIndexes: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + getColumns: mocks.getColumns, + listIndexes: mocks.listIndexes, +})); + +import { clearTableMetadataCache, getCachedTableMetadata, invalidateTableMetadataCache, loadTableColumns, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS } from "@/lib/metadata/tableMetadataCache"; + +// The coordinator starts its loader on a microtask (Promise.resolve().then), so +// flush before asserting remote call counts. +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function column(name: string): ColumnInfo { + return { name, data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }; +} + +const usersRequest = { connectionId: "c1", database: "db", schema: "public", tableName: "users", databaseType: "postgres" } as const; +const ordersRequest = { connectionId: "c1", database: "db", schema: "public", tableName: "orders", databaseType: "postgres" } as const; +const paymentsRequest = { connectionId: "c1", database: "db", schema: "public", tableName: "payments", databaseType: "postgres" } as const; + +function callCount(mock: ReturnType): number { + return mock.mock.calls.length; +} + +describe("tableMetadataCache columns facet request counts", () => { + beforeEach(() => { + clearTableMetadataCache(); + vi.clearAllMocks(); + mocks.getColumns.mockImplementation(async (_c: string, _d: string, _s: string, table: string) => [column(table)]); + mocks.listIndexes.mockResolvedValue([]); + }); + + it("R1 — cold single-source columns load: 1 getColumns, 0 listIndexes", async () => { + const result = await loadTableColumns({ ...usersRequest }); + expect(result.columns[0]?.name).toBe("users"); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(0); + }); + + it("R2 — warm repeat of the same columns load: 0 additional remote calls", async () => { + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(0); + }); + + it("R3 — cold multi-source columns loads: N getColumns, 0 listIndexes", async () => { + await Promise.all([loadTableColumns({ ...usersRequest }), loadTableColumns({ ...ordersRequest }), loadTableColumns({ ...paymentsRequest })]); + expect(callCount(mocks.getColumns)).toBe(3); + expect(callCount(mocks.listIndexes)).toBe(0); + }); + + it("R5 — concurrent same-source columns loads dedupe to one getColumns", async () => { + let releaseFirst: (value: ColumnInfo[]) => void = () => {}; + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + releaseFirst = resolve; + }), + ); + const first = loadTableColumns({ ...usersRequest }); + const second = loadTableColumns({ ...usersRequest }); + await flush(); + // The second call must join the first's in-flight load, not start another. + expect(callCount(mocks.getColumns)).toBe(1); + releaseFirst([column("id")]); + const [a, b] = await Promise.all([first, second]); + expect(a.columns[0]?.name).toBe("id"); + expect(b.columns[0]?.name).toBe("id"); + expect(callCount(mocks.getColumns)).toBe(1); + }); + + it("R5b — concurrent full metadata and columns-only loads share the columns call", async () => { + // loadTableMetadata starts its columns facet synchronously, so a concurrent + // display-only columns load dedupes against it: exactly one getColumns. + let releaseColumns: (value: ColumnInfo[]) => void = () => {}; + mocks.getColumns.mockReturnValueOnce( + new Promise((resolve) => { + releaseColumns = resolve; + }), + ); + const full = loadTableMetadata({ ...usersRequest }); + const colsOnly = loadTableColumns({ ...usersRequest }); + await flush(); + expect(callCount(mocks.getColumns)).toBe(1); + releaseColumns([column("id")]); + const [fullResult, colsResult] = await Promise.all([full, colsOnly]); + expect(fullResult.metadata.columns[0]?.name).toBe("id"); + expect(colsResult.columns[0]?.name).toBe("id"); + expect(callCount(mocks.getColumns)).toBe(1); + // listIndexes is only needed by the full load, and only once. + expect(callCount(mocks.listIndexes)).toBe(1); + }); + + it("R6 — full metadata first, then columns-only: 0 additional getColumns / 0 listIndexes", async () => { + await loadTableMetadata({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(1); + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(1); + }); + + it("R7 — columns-only first, then full metadata: 0 additional getColumns, 1 listIndexes", async () => { + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(0); + await loadTableMetadata({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(1); + }); + + it("R7b — promoting near-expiry columns does not reset the full metadata TTL", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-08-18T00:00:00Z")); + await loadTableColumns({ ...usersRequest }); + vi.advanceTimersByTime(TABLE_METADATA_CACHE_TTL_MS - 100); + + await loadTableMetadata({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + expect(callCount(mocks.listIndexes)).toBe(1); + + vi.advanceTimersByTime(200); + expect(getCachedTableMetadata({ ...usersRequest })).toBeUndefined(); + + await loadTableMetadata({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(2); + expect(callCount(mocks.listIndexes)).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("R8 — invalidating a table forces the next columns load to re-query", async () => { + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + invalidateTableMetadataCache({ connectionId: "c1", database: "db", tableName: "users" }); + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(2); + expect(callCount(mocks.listIndexes)).toBe(0); + }); + + it("R9 — caching one table does not satisfy a different table", async () => { + await loadTableColumns({ ...usersRequest }); + expect(callCount(mocks.getColumns)).toBe(1); + await loadTableColumns({ ...ordersRequest }); + expect(callCount(mocks.getColumns)).toBe(2); + expect(callCount(mocks.listIndexes)).toBe(0); + }); + + it("R10 — a failing columns-only load rejects and never triggers listIndexes", async () => { + mocks.getColumns.mockRejectedValue(new Error("metadata unavailable")); + await expect(loadTableColumns({ ...usersRequest })).rejects.toThrow("metadata unavailable"); + expect(callCount(mocks.listIndexes)).toBe(0); + // A subsequent load is not poisoned by the failed attempt. + mocks.getColumns.mockResolvedValueOnce([column("id")]); + const retry = await loadTableColumns({ ...usersRequest }); + expect(retry.columns[0]?.name).toBe("id"); + }); +}); diff --git a/apps/desktop/src/lib/metadata/metadataResultCache.ts b/apps/desktop/src/lib/metadata/metadataResultCache.ts index dc6beee197..2240669bbb 100644 --- a/apps/desktop/src/lib/metadata/metadataResultCache.ts +++ b/apps/desktop/src/lib/metadata/metadataResultCache.ts @@ -51,7 +51,7 @@ export class MetadataResultCache { private readonly now: () => number; constructor(private readonly options: MetadataResultCacheOptions) { - this.now = options.now ?? Date.now; + this.now = options.now ?? (() => Date.now()); } get(scope: MetadataScopeInput, options?: { allowStale?: boolean }): MetadataCacheHit | undefined { diff --git a/apps/desktop/src/lib/metadata/tableMetadataCache.ts b/apps/desktop/src/lib/metadata/tableMetadataCache.ts index 336e36526f..e57dd006b4 100644 --- a/apps/desktop/src/lib/metadata/tableMetadataCache.ts +++ b/apps/desktop/src/lib/metadata/tableMetadataCache.ts @@ -39,6 +39,31 @@ export interface TableMetadataLoadResult { ageMs: number; } +/** + * Columns-only table metadata. Used by read-only/display consumers (e.g. query + * result column comments for grouped results) that must not pay for index + * discovery. Shares the same scope/TTL/invalidation contract as the full table + * metadata cache, and the full loader reuses this column facet so both paths + * never issue duplicate `getColumns` calls. + */ +export interface TableColumnsMetadata { + schema?: string; + tableName: string; + tableType?: string; + catalog?: string; + database?: string; + columns: ColumnInfo[]; + cachedAt: number; +} + +export interface TableColumnsLoadResult { + columns: ColumnInfo[]; + tableType?: string; + cacheStatus: MetadataLoadCacheStatus; + ageMs: number; + cachedAt: number; +} + const tableMetadataCache = new MetadataResultCache({ ttlMs: TABLE_METADATA_CACHE_TTL_MS, maxEntries: TABLE_METADATA_CACHE_MAX_ENTRIES, @@ -49,6 +74,50 @@ const tableMetadataCoordinator = new MetadataLoadCoordinator((event) => { }); const tableIndexesLoads = new Map; promise: Promise; expiresAt: number }>(); +// Columns facet: shared by the full table-metadata loader and the display-only +// loader, so a grouped-query comment enrichment and an editable query never +// issue duplicate `getColumns` calls for the same table (and the display path +// never triggers `listIndexes`). Uses the same TTL, scope keys, in-flight +// coordinator and invalidation stamps as the full table metadata cache. +const TABLE_COLUMNS_CACHE_MAX_ENTRIES = 240; +const tableColumnsCache = new MetadataResultCache({ + ttlMs: TABLE_METADATA_CACHE_TTL_MS, + maxEntries: TABLE_COLUMNS_CACHE_MAX_ENTRIES, +}); + +const tableColumnsCoordinator = new MetadataLoadCoordinator((event) => { + console.debug("[DBX][metadata-load:columns-coordinator]", event); +}); + +interface InFlightTableColumnsScope { + parts: ReturnType; + count: number; +} +const inFlightTableColumnsScopes = new Map(); +const tableColumnsInvalidationStamps = new Map(); + +function registerInFlightTableColumnsScope(scopeKey: string, scope: MetadataScopeInput): void { + const entry = inFlightTableColumnsScopes.get(scopeKey); + if (entry) { + entry.count++; + } else { + inFlightTableColumnsScopes.set(scopeKey, { parts: metadataScopeParts(scope), count: 1 }); + } +} + +function unregisterInFlightTableColumnsScope(scopeKey: string): void { + const entry = inFlightTableColumnsScopes.get(scopeKey); + if (!entry) return; + entry.count--; + if (entry.count > 0) return; + inFlightTableColumnsScopes.delete(scopeKey); + tableColumnsInvalidationStamps.delete(scopeKey); +} + +function bumpTableColumnsInvalidationStamp(scopeKey: string): void { + tableColumnsInvalidationStamps.set(scopeKey, (tableColumnsInvalidationStamps.get(scopeKey) ?? 0) + 1); +} + // 失效代数(按 scope key 隔离):跨越失效边界的旧加载完成后不得写缓存—— // 结构变更后 force 拉到的新值可能被保存前启动、最后返回的在途加载回填覆盖。 // 只登记在途 scope 并只对匹配失效条件的 key 递增代数,避免失效表 A 时 @@ -103,6 +172,91 @@ export function getCachedTableMetadata(request: Pick): TableColumnsLoadResult | undefined { + const hit = tableColumnsCache.get(tableMetadataScope(request)); + if (!hit) return undefined; + return { columns: hit.value.columns, tableType: hit.value.tableType, cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs, cachedAt: hit.cachedAt }; +} + +/** + * Load only the column metadata for a table, with the same TTL cache, in-flight + * deduplication and invalidation contract as full table metadata. + * + * Reuse rules (guaranteed by this implementation and asserted in the request + * counts regression tests): + * + * - Case A: if a fresh full table-metadata entry already exists for the table, + * its columns are returned directly — a display enrichment never re-fetches + * columns (or indexes). + * - If a fresh columns-only entry exists, it is returned with no remote call. + * - Absent a cache hit, a single remote `api.getColumns` is issued (no + * `listIndexes`), deduplicated for concurrent callers via the shared columns + * coordinator. + */ +export async function loadTableColumns(request: TableMetadataRequest): Promise { + const scope = tableMetadataScope(request); + const trace = createMetadataLoadTrace(scope); + if (!request.force) { + // Case A: reuse a fresh full table-metadata cache entry so a display + // enrichment that follows a full metadata load issues zero remote calls. + const full = tableMetadataCache.get(scope); + if (full) { + logMetadataLoadTrace(request.traceLogger, trace, "cache-hit", { + cacheStatus: full.stale ? "stale" : "hit", + resultCount: full.value.columns.length, + stale: full.stale, + }); + return { columns: full.value.columns, tableType: full.value.tableType, cacheStatus: full.stale ? "stale" : "hit", ageMs: full.ageMs, cachedAt: full.cachedAt }; + } + const cached = tableColumnsCache.get(scope); + if (cached) { + logMetadataLoadTrace(request.traceLogger, trace, "cache-hit", { + cacheStatus: cached.stale ? "stale" : "hit", + resultCount: cached.value.columns.length, + stale: cached.stale, + }); + return { columns: cached.value.columns, tableType: cached.value.tableType, cacheStatus: cached.stale ? "stale" : "hit", ageMs: cached.ageMs, cachedAt: cached.cachedAt }; + } + } + + logMetadataLoadTrace(request.traceLogger, trace, "cache-miss", { cacheStatus: request.force ? "refresh" : "miss", force: request.force === true }); + const scopeKey = metadataScopeKey(scope); + const invalidationStampAtStart = tableColumnsInvalidationStamps.get(scopeKey) ?? 0; + registerInFlightTableColumnsScope(scopeKey, scope); + let metadata: TableColumnsMetadata; + try { + metadata = await tableColumnsCoordinator.run( + scope, + async () => { + // Display-only loader: columns only, never index discovery. + const columns = await api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); + return { + schema: request.schema || undefined, + tableName: request.tableName, + tableType: request.tableType, + catalog: request.catalog, + database: request.database, + columns, + cachedAt: Date.now(), + }; + }, + { force: request.force, kind: scope.kind }, + ); + + if (invalidationStampAtStart === (tableColumnsInvalidationStamps.get(scopeKey) ?? 0)) { + tableColumnsCache.set(scope, metadata); + } + } finally { + unregisterInFlightTableColumnsScope(scopeKey); + } + logMetadataLoadTrace(request.traceLogger, trace, "done", { + cacheStatus: request.force ? "refresh" : "miss", + resultCount: metadata.columns.length, + force: request.force === true, + }); + return { columns: metadata.columns, tableType: metadata.tableType, cacheStatus: request.force ? "refresh" : "miss", ageMs: 0, cachedAt: metadata.cachedAt }; +} + export function tableMetadataToDataTabMeta(metadata: TableMetadata, overrides?: { schema?: string }): NonNullable { return { schema: overrides ? overrides.schema : metadata.schema, @@ -156,6 +310,12 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< registerInFlightTableMetadataScope(scopeKey, scope); let metadata: TableMetadata; try { + // Start the columns facet synchronously (before the first await) so its + // in-flight registration is visible to any synchronous invalidation that + // follows — otherwise a follower could dedupe against a stale column load + // that invalidation was too early to clear. Concurrent display-only column + // requests dedupe against this same coordinator entry. + const columnsPromise = loadTableColumns(request); metadata = await tableMetadataCoordinator.run( scope, async () => { @@ -163,9 +323,9 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< // discovery independently unless an unqualified Vastbase relation must // first report its visible schema for the index lookup. const resolveVastbaseSchema = request.databaseType === "vastbase" && !request.schema; - const columnsPromise = api.getColumns(request.connectionId, request.database, request.schema ?? "", request.tableName, request.catalog); const indexesPromise = resolveVastbaseSchema ? undefined : loadTableIndexes(request).catch((): IndexInfo[] => []); - const columns = await columnsPromise; + const columnsResult = await columnsPromise; + const columns = columnsResult.columns; const resolvedSchema = resolveVastbaseSchema ? columns.find((column) => column.resolved_schema)?.resolved_schema : request.schema; const indexes = columns.length > 0 ? await (indexesPromise ?? loadTableIndexes({ ...request, schema: resolvedSchema }).catch((): IndexInfo[] => [])) : []; const primaryKeys = editableRowIdentifierColumns(request.databaseType as DatabaseType, columns, indexes, request.tableType); @@ -178,7 +338,7 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< columns, indexes, primaryKeys, - cachedAt: Date.now(), + cachedAt: columnsResult.cachedAt, }; }, { force: request.force, kind: scope.kind }, @@ -186,7 +346,7 @@ export async function loadTableMetadata(request: TableMetadataRequest): Promise< // 必须在 unregister 前比较:最后一个在途加载注销时会顺带清掉代数记录 if (invalidationStampAtStart === (tableMetadataInvalidationStamps.get(scopeKey) ?? 0)) { - tableMetadataCache.set(scope, metadata); + tableMetadataCache.set(scope, metadata, { cachedAt: metadata.cachedAt }); } } finally { unregisterInFlightTableMetadataScope(scopeKey); @@ -210,17 +370,34 @@ export function invalidateTableMetadataCache(match: MetadataCacheInvalidation): bumpTableMetadataInvalidationStamp(scopeKey); tableMetadataCoordinator.clear(scopeKey); } + // Same invalidation contract for the columns facet: stale in-flight column + // results must not write back across the invalidation boundary either. + for (const [scopeKey, entry] of inFlightTableColumnsScopes) { + if (!matches(entry.parts)) continue; + bumpTableColumnsInvalidationStamp(scopeKey); + tableColumnsCoordinator.clear(scopeKey); + } for (const [scopeKey, entry] of tableIndexesLoads) { if (matches(entry.parts)) tableIndexesLoads.delete(scopeKey); } - return tableMetadataCache.invalidate(match); + const removedFull = tableMetadataCache.invalidate(match); + // Columns facet is invalidated as a side effect but, to preserve the existing + // public return contract (count of table-metadata entries invalidated), it is + // not added to the returned total. + tableColumnsCache.invalidate(match); + return removedFull; } export function clearTableMetadataCache(): void { for (const scopeKey of inFlightTableMetadataScopes.keys()) { bumpTableMetadataInvalidationStamp(scopeKey); } + for (const scopeKey of inFlightTableColumnsScopes.keys()) { + bumpTableColumnsInvalidationStamp(scopeKey); + } tableMetadataCoordinator.clear(); + tableColumnsCoordinator.clear(); tableIndexesLoads.clear(); tableMetadataCache.clear(); + tableColumnsCache.clear(); } diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index a3f1452beb..ab57c79bf2 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -281,6 +281,73 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability { }; } +/** + * Parse a SELECT statement's projection columns and source tables far enough to + * resolve display-only result metadata (result-column → source-column mapping + * by projection ordinal) WITHOUT deciding editability. + * + * GROUP BY / HAVING queries are classified `aggregation` (read-only) by + * `analyzeEditableQueryEditability`, so that function bails out before parsing + * projections or sources. Their directly projected base-table columns are still + * resolvable for column comments, so the display layer uses this function to + * recover that structure. It is consumed ONLY by display metadata enrichment + * and must never feed row mutation logic — editability is decided exclusively + * by `analyzeEditableQueryEditability`. + * + * Returns `null` when the statement cannot be structurally parsed for display: + * CTEs, set operations, subquery/external sources, or non-SELECT statements. + */ +export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo | null { + const normalized = stripSqlComments(sql) + .replace(/;+\s*$/, "") + .trim(); + if (!normalized) return null; + if (/^\s*WITH\b/i.test(normalized)) return null; + if (!/^SELECT\b/i.test(normalized)) return null; + if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT", "MINUS"])) return null; + if (normalized.includes(";")) return null; + + const fromIndex = findTopLevelKeyword(normalized, "FROM", 0); + if (fromIndex < 0) return null; + + const rawSelectBody = normalized.slice("SELECT".length, fromIndex).trim(); + const distinct = /^DISTINCT\b/i.test(rawSelectBody); + const selectBodyWithoutDistinct = distinct ? rawSelectBody.replace(/^DISTINCT\b/i, "").trimStart() : rawSelectBody; + const selectBody = stripSqlServerTopClause(selectBodyWithoutDistinct); + + // Locate the FROM body end including GROUP/HAVING so a grouped query still + // extracts a clean source list for metadata loading. + const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR"], fromIndex + "FROM".length); + const fromBody = normalized.slice(fromIndex + "FROM".length, fromEnd < 0 ? normalized.length : fromEnd).trim(); + if (isExternalFromSource(fromBody)) return null; + const sources = parseFromSources(fromBody); + if (!sources.length) return null; + const source = sources[0]!; + + const selectStar = sources.length === 1 && 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; + + const analysis: EditableQueryInfo = { + catalog: source.catalog, + catalogQuoted: source.catalogQuoted, + schema: source.schema, + schemaQuoted: source.schemaQuoted, + tableName: source.tableName, + tableNameQuoted: source.tableNameQuoted, + tableAlias: source.alias, + selectStar, + columns, + ...(distinct ? { distinct: true } : {}), + }; + if (sources.length > 1) { + analysis.sources = sources; + analysis.multiSource = true; + } + return analysis; +} + export function queryEditabilityMessageKey(reason: QueryEditabilityReason): string { return `grid.queryEditUnsupported.${reason}`; } diff --git a/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.requestCounts.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.requestCounts.spec.ts new file mode 100644 index 0000000000..636412cf2a --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.requestCounts.spec.ts @@ -0,0 +1,243 @@ +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Request-count regression for grouped-query display metadata enrichment. + * + * Proves the contract from the #6463 review: + * - GROUP BY result-comment enrichment issues `getColumns` only (never + * `listIndexes` for display); + * - cold-cache counts are linear in the number of distinct source tables; + * - warm repeat issues zero additional metadata calls; + * - concurrent same-table enrichments dedupe to a single `getColumns`; + * - multi-source loads are scheduled under bounded concurrency; + * - metadata failure never affects the query result or editability. + */ + +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 usersColumns = [column("id", "用户ID", true), column("department", "所属部门")]; +const ordersColumns = [column("id", "订单ID", true), column("user_id", "下单用户")]; +const paymentsColumns = [column("id", "支付ID", true), column("user_id", "支付用户")]; + +function columnSetFor(table: string) { + if (table === "orders") return ordersColumns; + if (table === "payments") return paymentsColumns; + return usersColumns; +} + +describe("queryStore grouped-result metadata request counts", () => { + 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) => columnSetFor(table)); + listIndexes.mockResolvedValue([]); + listObjects.mockResolvedValue([]); + lookupLocalCompletionTables.mockReturnValue([]); + buildSortedQuerySql.mockResolvedValue({ ok: true, sql: `${"SELECT *"} ORDER BY 1` }); + buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM `users`"); + executeQuery.mockResolvedValue({ + columns: ["row_count"], + rows: [[0]], + affected_rows: 0, + execution_time_ms: 1, + }); + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + }); + + afterEach(() => { + expect(listObjects).not.toHaveBeenCalled(); + }); + + function groupedResult(columns: string[]) { + return [{ columns, rows: columns.map(() => [0]), affected_rows: 0, execution_time_ms: 1 }]; + } + + // Robust under the full-suite worker load (maxWorkers=4): never rely on the + // default 1s vi.waitFor window for an async enrichment pipeline. + const waitFor = (fn: () => void | Promise) => vi.waitFor(fn, { timeout: 8000, interval: 25 }); + + it("R1 — cold single-source grouped query: 1 getColumns, 0 listIndexes", async () => { + executeMulti.mockResolvedValue(groupedResult(["department", "total"])); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + await store.executeTabSql(tabId, "SELECT department, COUNT(*) AS total FROM users GROUP BY department"); + const tab = store.tabs.find((item) => item.id === tabId)!; + await waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + + expect(tab.queryEditabilityReason).toBe("aggregation"); + expect(getColumns).toHaveBeenCalledTimes(1); + expect(listIndexes).toHaveBeenCalledTimes(0); + }, 20000); + + it("R2 — warm repeat of the same grouped query: 0 additional metadata calls", async () => { + executeMulti.mockResolvedValue(groupedResult(["department", "total"])); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + await store.executeTabSql(tabId, "SELECT department, COUNT(*) AS total FROM users GROUP BY department"); + let tab = store.tabs.find((item) => item.id === tabId)!; + await waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(getColumns).toHaveBeenCalledTimes(1); + + const tabId2 = store.createTab("mysql-1", "app", "Query"); + await store.executeTabSql(tabId2, "SELECT department, COUNT(*) AS total FROM users GROUP BY department"); + tab = store.tabs.find((item) => item.id === tabId2)!; + await waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + // The second execution reuses the warm columns cache: no new metadata calls. + expect(getColumns).toHaveBeenCalledTimes(1); + expect(listIndexes).toHaveBeenCalledTimes(0); + }); + + it("R3 — cold 3-source grouped query: 3 getColumns, 0 listIndexes", async () => { + executeMulti.mockResolvedValue(groupedResult(["department", "c1", "c2"])); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + await store.executeTabSql(tabId, "SELECT u.department, COUNT(o.id), COUNT(p.id) FROM users u JOIN orders o ON o.user_id = u.id JOIN payments p ON p.user_id = u.id GROUP BY u.department"); + const tab = store.tabs.find((item) => item.id === tabId)!; + await waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + + expect(tab.queryEditabilityReason).toBe("aggregation"); + expect(getColumns).toHaveBeenCalledTimes(3); + expect(listIndexes).toHaveBeenCalledTimes(0); + }, 20000); + + it("R5 — concurrent same-table grouped enrichments dedupe to 1 getColumns", async () => { + executeMulti.mockResolvedValue(groupedResult(["department", "total"])); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const firstTab = store.createTab("mysql-1", "app", "Query"); + const secondTab = store.createTab("mysql-1", "app", "Query"); + await Promise.all([store.executeTabSql(firstTab, "SELECT department, COUNT(*) AS total FROM users GROUP BY department"), store.executeTabSql(secondTab, "SELECT department, COUNT(*) AS total FROM users GROUP BY department")]); + const first = store.tabs.find((item) => item.id === firstTab)!; + const second = store.tabs.find((item) => item.id === secondTab)!; + await waitFor(() => expect(first.resultColumnComments).toBeDefined()); + await waitFor(() => expect(second.resultColumnComments).toBeDefined()); + // Both enrichments resolve users.department; the shared columns + // cache/in-flight coordinator collapses them to a single getColumns. + expect(getColumns).toHaveBeenCalledTimes(1); + expect(listIndexes).toHaveBeenCalledTimes(0); + }); + + it("R4 — multi-source loads run under bounded concurrency (3rd waits for a slot)", async () => { + const releases: Record | PromiseLike>) => void> = {}; + const started: string[] = []; + getColumns.mockImplementation((_connectionId: string, _database: string, _schema: string, table: string) => { + started.push(table); + return new Promise((resolve) => { + releases[table] = resolve; + }); + }); + executeMulti.mockResolvedValue(groupedResult(["department", "c1", "c2"])); + + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("mysql-1", "app", "Query"); + const exec = store.executeTabSql(tabId, "SELECT u.department, COUNT(o.id), COUNT(p.id) FROM users u JOIN orders o ON o.user_id = u.id JOIN payments p ON p.user_id = u.id GROUP BY u.department"); + + // The limiter (maxActive=2) admits exactly two column loads; the third + // source stays queued until a slot frees. Waiting on the condition (not a + // fixed sleep) keeps this deterministic. + await vi.waitFor(() => expect(started).toHaveLength(2), { timeout: 8000, interval: 25 }); + // The third source has NOT started yet: concurrency is bounded. + expect(started).toHaveLength(2); + + // Free one slot: the third source then begins. + releases[started[0]!]!(columnSetFor(started[0]!)); + await vi.waitFor(() => expect(started).toHaveLength(3), { timeout: 8000, interval: 25 }); + + // Release everything so the store execution can drain. + for (const table of started) { + releases[table]?.(columnSetFor(table)); + } + await exec; + expect(listIndexes).toHaveBeenCalledTimes(0); + }); + + it("R10 — metadata failure shows the result, keeps aggregation read-only, and never fetches indexes", async () => { + getColumns.mockRejectedValue(new Error("metadata unavailable")); + executeMulti.mockResolvedValue([ + { + columns: ["department", "total"], + rows: [["sales", 12]], + 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 department, COUNT(*) AS total FROM users GROUP BY department"); + const tab = store.tabs.find((item) => item.id === tabId)!; + await waitFor(() => expect(tab.queryEditabilityReason).toBe("aggregation")); + + // Best-effort: no comments claimed, query result preserved, editability + // unchanged, and no index fallback was triggered by the display failure. + expect(tab.resultColumnComments).toBeUndefined(); + expect(tab.tableMeta).toBeUndefined(); + expect(tab.result?.rows.length).toBe(1); + expect(listIndexes).toHaveBeenCalledTimes(0); + expect(getColumns).toHaveBeenCalledTimes(1); + }, 20000); +}); diff --git a/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts new file mode 100644 index 0000000000..cd69e4f82d --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.groupByColumnComments.spec.ts @@ -0,0 +1,302 @@ +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 usersColumns = [column("id", "用户ID", true), column("department", "所属部门"), column("status", "用户状态")]; +const ordersColumns = [column("id", "订单ID", true), column("user_id", "下单用户"), column("amount", "订单金额"), column("created_at", "创建时间")]; + +describe("queryStore grouped-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([]); + buildSortedQuerySql.mockResolvedValue({ ok: true, sql: `${"SELECT *"} ORDER BY 1` }); + buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM `users`"); + executeQuery.mockResolvedValue({ + columns: ["row_count"], + rows: [[0]], + affected_rows: 0, + execution_time_ms: 1, + }); + }); + + afterEach(() => { + expect(listObjects).not.toHaveBeenCalled(); + }); + + it("shows physical column comments for grouped results while staying read-only (T1)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + executeMulti.mockResolvedValue([ + { + columns: ["department", "total"], + rows: [["sales", 12]], + 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 department, COUNT(*) AS total FROM users GROUP BY department"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + + // Grouped results stay read-only: no editable analysis, no tableMeta. + expect(tab.queryEditabilityReason).toBe("aggregation"); + expect(tab.queryAnalysis).toBeUndefined(); + expect(tab.querySourceColumns).toBeUndefined(); + expect(tab.tableMeta).toBeUndefined(); + + // Only the directly projected physical column carries its comment; the + // aggregate expression resolves to nothing and must not guess. + expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]); + }); + + it("resolves aliased grouped columns back to their physical column (T2)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + executeMulti.mockResolvedValue([ + { + columns: ["dept", "total"], + rows: [["sales", 12]], + 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 department AS dept, COUNT(*) AS total FROM users GROUP BY department"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.queryEditabilityReason).toBe("aggregation"); + expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "users:0", sourceColumn: "department" }, undefined]); + }); + + it("keeps comments for HAVING queries (T3)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + executeMulti.mockResolvedValue([ + { + columns: ["department", "total"], + rows: [["sales", 12]], + 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 department, COUNT(*) AS total FROM users GROUP BY department HAVING COUNT(*) > 1"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.queryEditabilityReason).toBe("aggregation"); + expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); + }); + + it("resolves qualified grouped columns in a JOIN without leaking other sources (T4)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + executeMulti.mockResolvedValue([ + { + columns: ["department", "order_count"], + rows: [["sales", 3]], + 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 u.department, COUNT(o.id) AS order_count FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.department"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.queryEditabilityReason).toBe("aggregation"); + expect(tab.resultColumnComments).toEqual(["所属部门", undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([{ sourceKey: "u:0", sourceColumn: "department" }, undefined]); + }); + + it("maps multiple grouped physical columns (T5)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + executeMulti.mockResolvedValue([ + { + columns: ["department", "status", "count"], + rows: [["sales", "active", 12]], + 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 department, status, COUNT(*) AS count FROM users GROUP BY department, status"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + expect(tab.resultColumnComments).toEqual(["所属部门", "用户状态", undefined]); + }); + + it("does not claim a comment for computed/aggregate expressions (T6/T7)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + executeMulti.mockResolvedValue([ + { + columns: ["day", "total_amount"], + rows: [["2026-01-01", 100]], + 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 DATE(created_at) AS day, SUM(amount) AS total_amount FROM orders GROUP BY user_id"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.resultColumnComments).toBeDefined()); + // `day` is DATE(created_at) and `total_amount` is SUM(amount): neither may + // inherit the operand column's comment. + expect(tab.resultColumnComments).toEqual([undefined, undefined]); + expect(tab.queryDisplaySourceColumns).toEqual([undefined, undefined]); + }); + + it("keeps ordinary editable queries free of the new display fields (T10)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ + editable: true, + analysis: { + schema: undefined, + tableName: "users", + tableAlias: undefined, + selectStar: false, + columns: [ + { sourceName: "id", sourceKey: "users:0", resultName: "id", expression: "id" }, + { sourceName: "department", sourceKey: "users:0", resultName: "department", expression: "department" }, + ], + }, + }); + executeMulti.mockResolvedValue([ + { + columns: ["id", "department"], + rows: [[1, "sales"]], + 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, department FROM users"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.tableMeta?.tableName).toBe("users")); + expect(tab.resultColumnComments).toBeUndefined(); + expect(tab.queryDisplaySourceColumns).toBeUndefined(); + expect(tab.querySourceColumns).toEqual(["id", "department"]); + }); + + it("falls back gracefully when metadata loading fails (best-effort)", async () => { + analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "aggregation" }); + getColumns.mockRejectedValue(new Error("metadata unavailable")); + executeMulti.mockResolvedValue([ + { + columns: ["department", "total"], + rows: [["sales", 12]], + 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 department, COUNT(*) AS total FROM users GROUP BY department"); + + // The result stays queryEditabilityReason=aggregation; enrichment failure + // must never fail the query nor claim comments. + const tab = store.tabs.find((item) => item.id === tabId)!; + await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("aggregation")); + expect(tab.resultColumnComments).toBeUndefined(); + expect(tab.queryDisplaySourceColumns).toBeUndefined(); + expect(tab.tableMeta).toBeUndefined(); + expect(tab.result?.rows.length).toBe(1); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 09a267b4fa..ac969eefd3 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -7,7 +7,7 @@ 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, resolveSourceColumnsByOrdinal, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; +import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, 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 { @@ -41,7 +41,8 @@ import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; -import { getCachedTableMetadata, loadTableIndexes, loadTableMetadata, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; +import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; +import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; @@ -82,6 +83,15 @@ import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/ const ORACLE_LIKE_METADATA_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]); const ORACLE_DEFERRED_LOB_TYPES = new Set(["CLOB", "NCLOB", "BLOB", "BFILE"]); + +// Bounded concurrency for grouped-query display column loads, scoped per +// connection so different connections never block each other. Matches the +// project's existing completion-metadata concurrency policy (<2>). +const GROUPED_DISPLAY_METADATA_CONCURRENCY = 2; +const GROUPED_DISPLAY_LIMITER_SCOPE_PREFIX = "query-column-comments:"; +const groupedDisplayMetadataLimiter = new MetadataTaskLimiter(GROUPED_DISPLAY_METADATA_CONCURRENCY, (event) => { + console.debug("[DBX][metadata-load:grouped-display-limiter]", event); +}); const UPPERCASE_FOLDED_METADATA_TYPES = new Set([...ORACLE_LIKE_METADATA_TYPES, "saphana"]); const HIDDEN_QUERY_KEY_DATABASE_TYPES = new Set(["mysql", "postgres", "sqlserver", "oracle"]); const QUERY_RESULT_EXPORT_UNSUPPORTED_ERROR = "Streaming export is unsupported for this query. Simplify it or use a supported driver."; @@ -3566,6 +3576,25 @@ export const useQueryStore = defineStore("query", () => { }; } + function loadedEditableSourceFromColumns(target: EditableSourceMetadataTarget, loadedColumns: Awaited>): LoadedEditableSource { + return { + source: target.source, + analysis: target.analysis, + tableMeta: { + catalog: target.request.catalog, + database: target.request.database, + schema: target.writeSchema, + tableName: target.request.tableName, + tableType: loadedColumns.tableType, + columns: loadedColumns.columns, + // Display-only enrichment never resolves row identity (indexes), so + // there are no primary keys to carry; resolveMultiSourceColumnInfo only + // consumes `source` + `columns`. + primaryKeys: [], + }, + }; + } + async function loadEditableQuerySource(tab: QueryTab, analysis: EditableQueryInfo, source: EditableQuerySource, conn: ConnectionConfig | undefined, dbType: string, executionDatabase: string, traceId?: string, elapsed?: () => string): Promise { const target = resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase); queryExecutionLog("info", "metadata:table:start", { @@ -3696,6 +3725,51 @@ export const useQueryStore = defineStore("query", () => { } } + /** + * Display-only result-column enrichment for aggregated (GROUP BY / HAVING) + * results: parse the SELECT structure, load every source table's metadata, + * resolve each result column back to exactly one base-table column by + * projection ordinal, and return the resolved comments + display mapping. + * Best-effort by design: returns `undefined` when the statement cannot be + * structurally parsed or source metadata cannot be loaded — the query result + * itself and editability are never affected. + */ + async function resolveAggregationDisplayColumnInfo(tab: QueryTab, sql: string, executionDatabase: string, traceId: string | undefined): Promise<{ comments: Array; mapping: Array } | undefined> { + if (tab.mode !== "query" || !tab.connectionId || !tab.result || !tab.result.columns.length) return undefined; + const conn = useConnectionStore().getConfig(tab.connectionId); + const dbType = conn?.db_type || ""; + const analysis = analyzeSelectStructureForDisplay(sql); + if (!analysis) return undefined; + const sources = editableQuerySources(analysis); + if (!sources.length) return undefined; + try { + // Resolve every source's metadata target up front, then load *columns + // only* (display payload — no index discovery) under a connection-scoped + // bounded-concurrency limiter. The shared table-column cache/in-flight + // coordinator deduplicates identical tables across concurrent callers, + // so this path issues no indexes requests and never loads full editable + // metadata. + const loadedSources: LoadedEditableSource[] = []; + const targets = sources.map((source) => resolveEditableSourceMetadataTarget(tab, analysis, source, conn, dbType, executionDatabase)); + const limiterScope = `${GROUPED_DISPLAY_LIMITER_SCOPE_PREFIX}${tab.connectionId}`; + await Promise.all( + targets.map((target) => + groupedDisplayMetadataLimiter.run(limiterScope, "query-column-comments", async () => { + const loadedColumns = await loadTableColumns({ + ...target.request, + traceLogger: (event) => queryExecutionLog("debug", "metadata:table-columns-trace", { sourceTraceId: traceId, ...event }), + }); + loadedSources.push(loadedEditableSourceFromColumns(target, loadedColumns)); + }), + ), + ); + return resolveMultiSourceColumnInfo(dbType, analysis, tab.result.columns, loadedSources); + } catch (err) { + console.error("[DBX] ERROR fetching columns for grouped query metadata:", err); + return undefined; + } + } + async function buildQueryMetadataPatch(tab: QueryTab, sql: string, executionDatabase: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise { if (tab.mode !== "query") return; if (!tab.result || !tab.result.columns.length) { @@ -3716,11 +3790,18 @@ export const useQueryStore = defineStore("query", () => { elapsed: elapsed?.(), }); if (!editability.editable) { + // Grouped (aggregation) results stay read-only, but their directly + // projected base-table columns can still be resolved for display metadata + // (column comments). This enrichment is display-only — it never enables + // row mutation for aggregated results (fixes #6463). Every other + // non-editable reason keeps the previous behavior. + const displayInfo = editability.reason === "aggregation" ? await resolveAggregationDisplayColumnInfo(tab, sql, executionDatabase, traceId) : undefined; return { queryAnalysis: undefined, querySourceColumns: undefined, queryEditabilityReason: editability.reason, tableMeta: undefined, + ...(displayInfo ? { resultColumnComments: displayInfo.comments, queryDisplaySourceColumns: displayInfo.mapping } : {}), }; } const analysis = editability.analysis;