fix(grid): show column comments for grouped query results

This commit is contained in:
jearhe authored and GitHub committed 2026-08-18 11:50:49 +08:00
1 parent 4c260b1a2f
commit 8271ba2a71
8 files changed
+1220 -8

No files matched your search

@@ -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]);
});
});
});
@@ -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<typeof vi.fn>): 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<ColumnInfo[]>((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<ColumnInfo[]>((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");
});
});
@@ -51,7 +51,7 @@ export class MetadataResultCache<T> {
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<T> | undefined {
@@ -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<TableMetadata>({
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<string, { parts: ReturnType<typeof metadataScopeParts>; promise: Promise<IndexInfo[]>; 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<TableColumnsMetadata>({
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<typeof metadataScopeParts>;
count: number;
}
const inFlightTableColumnsScopes = new Map<string, InFlightTableColumnsScope>();
const tableColumnsInvalidationStamps = new Map<string, number>();
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<TableMetadataRequest, "conn
return { metadata: hit.value, cacheStatus: hit.stale ? "stale" : "hit", ageMs: hit.ageMs };
}
export function getCachedTableColumns(request: Pick<TableMetadataRequest, "connectionId" | "database" | "schema" | "tableName" | "tableType" | "driverProfile" | "databaseType" | "catalog">): 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<TableColumnsLoadResult> {
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<QueryTab["tableMeta"]> {
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();
}
+67
View File
@@ -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}`;
}
@@ -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<void>) => 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<string, (value: ReturnType<typeof columnSetFor> | PromiseLike<ReturnType<typeof columnSetFor>>) => 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);
});
@@ -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);
});
});
+83 -2
View File
@@ -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<string>(["oracle", "dameng", "oceanbase-oracle"]);
const ORACLE_DEFERRED_LOB_TYPES = new Set<string>(["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<string>([...ORACLE_LIKE_METADATA_TYPES, "saphana"]);
const HIDDEN_QUERY_KEY_DATABASE_TYPES = new Set<DatabaseType>(["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<ReturnType<typeof loadTableColumns>>): 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<LoadedEditableSource> {
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<string | undefined>; mapping: Array<QueryResultSourceColumnRef | undefined> } | 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<QueryMetadataPatch | undefined> {
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;