fix(grid): show column comments for grouped query results
This commit is contained in:
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();
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
+243
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user