feat(grid): support result source labels

This commit is contained in:
二丫讲梵
2026-06-28 13:58:23 +08:00
committed by GitHub
parent a7f3e6bbb4
commit 433907e96e
11 changed files with 184 additions and 16 deletions
@@ -4746,6 +4746,7 @@ const {
fullExportResult: props.fullExportResult,
queryResultExportRequest: props.queryResultExportRequest,
allExportResults: computed(() => props.allExportResults),
currentResultLabel: computed(() => props.result.sourceLabel),
exportFileBaseName: computed(() => props.exportFileBaseName),
exportProgressDialog,
exportProgressState,
@@ -245,10 +245,11 @@ const activeQueryError = computed(() => {
return String(result.rows[0]?.[0] ?? "");
});
const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true);
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined)));
const tabularResults = computed(() => tabularResultItems(props.activeTab.results));
const allResultExportSheets = computed(() =>
tabularResults.value.map((item) => ({
sheetName: t("tabs.resultN", { n: item.n }),
sheetName: item.label || t("tabs.resultN", { n: item.n }),
result: item.result,
})),
);
@@ -259,7 +260,7 @@ const resultArchiveExporting = ref(false);
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
watch(
() => tabularResults.value.map((item) => item.index).join(","),
() => visibleResultItems.value.map((item) => item.index).join(","),
() => {
nextTick(updateResultTabsScrollbar);
},
@@ -268,7 +269,7 @@ const summaryItems = computed(() => executionSummaryItems(props.activeTab));
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
const hasTabularResult = computed(() => {
if (props.activeTab.result?.columns.length) return true;
return tabularResults.value.length > 0;
return visibleResultItems.value.length > 0;
});
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
const resultsPaneOpen = ref(false);
@@ -655,7 +656,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</DropdownMenuContent>
</DropdownMenu>
</template>
<template v-if="tabularResults.length > 1">
<template v-if="visibleResultItems.length > 0">
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
<div class="relative min-w-0 flex-1 self-stretch">
<div v-if="hasResultTabOverflow" class="result-tab-scrollbar" :class="{ 'result-tab-scrollbar--dragging': isResultTabsScrollbarDragging }" @pointerdown="startResultTabsScrollbarDrag">
@@ -663,17 +664,18 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</div>
<div ref="resultTabsScrollerRef" class="result-tab-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :style="resultTabsScrollerStyle" @scroll="updateResultTabsScrollbar" @wheel="onResultTabsWheel">
<Button
v-for="item in tabularResults"
v-for="item in visibleResultItems"
:key="item.index"
size="sm"
:variant="activeOutputView === 'result' && activeTab.activeResultIndex === item.index ? 'default' : 'ghost'"
class="h-6 px-2 text-xs shrink-0"
:variant="activeOutputView === 'result' && (activeTab.activeResultIndex ?? 0) === item.index ? 'default' : 'ghost'"
class="h-6 max-w-48 shrink-0 overflow-hidden text-ellipsis whitespace-nowrap px-2 text-xs"
:title="item.label || t('tabs.resultN', { n: item.n })"
@click="
queryStore.setActiveResultIndex(activeTab.id, item.index);
emit('update:activeOutputView', 'result');
"
>
{{ t("tabs.resultN", { n: item.n }) }}
{{ item.label || t("tabs.resultN", { n: item.n }) }}
</Button>
</div>
</div>
@@ -49,6 +49,7 @@ export interface UseDataGridExportOptions {
fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<QueryResult | undefined>;
queryResultExportRequest?: (options: { exportId: string; filePath: string; format: "csv" | "xlsx" }) => Promise<QueryResultExportRequest | undefined>;
allExportResults?: ComputedRef<Array<{ sheetName: string; result: QueryResult }> | undefined>;
currentResultLabel?: ComputedRef<string | undefined>;
exportFileBaseName?: ComputedRef<string | undefined>;
exportProgressDialog?: Ref<boolean>;
exportProgressState?: Ref<{
@@ -117,6 +118,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
fullExportResult,
queryResultExportRequest,
allExportResults,
currentResultLabel,
exportFileBaseName,
exportProgressDialog,
exportProgressState,
@@ -149,6 +151,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
};
}
function currentXlsxSheetName(): string {
return currentResultLabel?.value || tableMeta.value?.tableName || "Export";
}
function targetedRows(): RowItem[] {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
return displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
@@ -715,7 +721,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
totalRows: result.rows.length,
};
}
await api.exportQueryResultXlsx(outputPath, tableMeta.value?.tableName || "Export", result.columns, result.rows);
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.rows);
if (needsFullExport && exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
@@ -752,7 +758,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
outputPath = path as string;
}
const result = await resultToExport(undefined, undefined, false);
await api.exportQueryResultXlsx(outputPath, tableMeta.value?.tableName || "Export", result.columns, result.rows);
await api.exportQueryResultXlsx(outputPath, currentXlsxSheetName(), result.columns, result.rows);
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
+2 -2
View File
@@ -123,12 +123,12 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v
return lines;
}
export function tabularResultItems(results: QueryResult[] | undefined): { result: QueryResult; index: number; n: number }[] {
export function tabularResultItems(results: QueryResult[] | undefined): { result: QueryResult; index: number; n: number; label?: string }[] {
if (!results) return [];
return results
.map((result, index) => ({ result, index }))
.filter((item) => item.result.columns.length > 0)
.map((item, ordinal) => ({ ...item, n: ordinal + 1 }));
.map((item, ordinal) => ({ ...item, n: ordinal + 1, label: item.result.sourceLabel }));
}
export function activeResultRun(tab: Pick<QueryTab, "resultRuns" | "activeResultRunId">) {
+8
View File
@@ -38,6 +38,8 @@ interface ColumnarQueryResult {
execution_time_ms: number;
truncated?: boolean;
has_more?: boolean;
sourceLabel?: string;
sourceStatement?: string;
}
type QueryResultRunSnapshot = NonNullable<QueryTab["resultRuns"]>[number];
@@ -129,6 +131,8 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
truncated: result.truncated,
session_id: undefined,
has_more: result.has_more,
sourceLabel: result.sourceLabel,
sourceStatement: result.sourceStatement,
};
}
@@ -157,6 +161,8 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
execution_time_ms: result.execution_time_ms,
truncated: result.truncated,
has_more: result.has_more,
sourceLabel: result.sourceLabel,
sourceStatement: result.sourceStatement,
});
}
@@ -172,6 +178,8 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
truncated: result.truncated,
session_id: undefined,
has_more: result.has_more,
sourceLabel: result.sourceLabel,
sourceStatement: result.sourceStatement,
};
}
+25 -2
View File
@@ -7,7 +7,7 @@ import { orderPinnedFirst } from "@/lib/pinnedItems";
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/explainPlan";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sqlAnalysis";
import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sqlAnalysis";
import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence";
import {
evaluateMongoAggregateSafety,
@@ -36,6 +36,7 @@ import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionCont
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGridSort";
import { normalizeResultPageSize } from "@/lib/paginationPageSize";
import { splitSqlStatementRanges } from "@/lib/sqlStatementRanges";
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabResultCache";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/queryResultArchive";
@@ -83,6 +84,27 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable<QueryTab["resultRuns
return resultRuns;
}
function queryResultSourceLabel(sql: string, database: string | undefined): string | undefined {
const analysis = analyzeEditableQuery(sql);
if (!analysis) return undefined;
const qualifier = analysis.schema || database?.trim();
return qualifier ? `${qualifier}.${analysis.tableName}` : analysis.tableName;
}
function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType): QueryResult[] {
const statements = splitSqlStatementRanges(sql, databaseType);
let statementIndex = 0;
for (const result of results) {
const statement = statements[statementIndex++];
if (result.columns.length === 0) continue;
if (!statement) continue;
result.sourceStatement = statement.sql;
const label = queryResultSourceLabel(statement.sql, database);
if (label) result.sourceLabel = label;
}
return results;
}
async function withFrontendQueryTimeout<T>(promise: Promise<T>, timeoutSecs: number, message: string): Promise<T> {
if (timeoutSecs === 0) return promise;
@@ -1762,7 +1784,8 @@ export const useQueryStore = defineStore("query", () => {
});
const executionPromise = api.executeMulti(tab.connectionId, tab.database, sqlToExecute, executionSchema, executionId, executionOptions);
const frontendTimeoutSecs = Math.max(queryTimeoutSecs * 2, 60);
const results = markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, queryTimeoutSecs === 0 ? 0 : frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs })));
const sourceLabelDatabase = tab.database || conn?.database;
const results = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, queryTimeoutSecs === 0 ? 0 : frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType);
console.info("[DBX][executeTabSql:execute-multi:done]", {
traceId,
resultCount: results.length,
+2
View File
@@ -395,6 +395,8 @@ export interface QueryResult {
truncated?: boolean;
session_id?: string | null;
has_more?: boolean;
sourceLabel?: string;
sourceStatement?: string;
}
export interface QueryResultRun {
+86
View File
@@ -2466,6 +2466,92 @@ test("multi statement execution shows the first result set by default", async ()
}
});
test("query results keep readable table source labels with active database context", async () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
const connectionStore = useConnectionStore();
const store = useQueryStore();
const originalFetch = globalThis.fetch;
let currentSql = "";
connectionStore.addEphemeralConnection({ ...conn("conn-1"), database: "aaa" });
const tabId = store.createTab("conn-1", "db", "Query");
const defaultDatabaseTabId = store.createTab("conn-1", "", "Default database query");
globalThis.fetch = withConnectionHealthMock(async (input, init) => {
const url = String(input);
if (url === "/api/query/prepare-pagination-plan") {
const body = JSON.parse(String(init?.body ?? "{}"));
currentSql = body.options.sql;
return new Response(JSON.stringify({ sqlToExecute: currentSql, useAgentResultSession: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url === "/api/query/execute-multi") {
const results =
currentSql === "select * from users; select * from orders"
? [
{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
]
: currentSql === "SELECT *\nFROM apis AS ap\nLIMIT 10;\n\nSELECT *\nFROM menus AS mn\nLIMIT 10;"
? [
{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["id"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
]
: currentSql === "select * from public.users"
? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]
: currentSql === "select u.id from users u join orders o on o.user_id = u.id"
? [{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]
: [
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 },
{ columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
];
return new Response(JSON.stringify(results), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/query/analyze-editability") {
return new Response(JSON.stringify({ editable: false, reason: "complex-source" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response("unexpected request", { status: 500 });
});
try {
await store.executeTabSql(tabId, "select * from users; select * from orders");
let tab = store.tabs.find((item) => item.id === tabId);
assert.deepEqual(tab?.results?.map((result) => result.sourceLabel), ["db.users", "db.orders"]);
assert.deepEqual(tab?.results?.map((result) => result.sourceStatement), ["select * from users", "select * from orders"]);
await store.executeTabSql(
defaultDatabaseTabId,
"SELECT *\nFROM apis AS ap\nLIMIT 10;\n\nSELECT *\nFROM menus AS mn\nLIMIT 10;",
);
tab = store.tabs.find((item) => item.id === defaultDatabaseTabId);
assert.deepEqual(tab?.results?.map((result) => result.sourceLabel), ["aaa.apis", "aaa.menus"]);
await store.executeTabSql(tabId, "select * from public.users");
tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.result?.sourceLabel, "public.users");
await store.executeTabSql(tabId, "select u.id from users u join orders o on o.user_id = u.id");
tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.result?.sourceLabel, undefined);
assert.equal(tab?.result?.sourceStatement, "select u.id from users u join orders o on o.user_id = u.id");
await store.executeTabSql(tabId, "update users set active = true; select * from users");
tab = store.tabs.find((item) => item.id === tabId);
assert.equal(tab?.results?.[0]?.sourceLabel, undefined);
assert.equal(tab?.results?.[1]?.sourceLabel, "db.users");
assert.equal(tab?.results?.[1]?.sourceStatement, "select * from users");
} finally {
globalThis.fetch = originalFetch;
restoreStorage();
}
});
test("tab reuse is scoped by mode and schema instead of title alone", () => {
const restoreStorage = installMemoryStorage();
try {
+15 -1
View File
@@ -58,12 +58,13 @@ function queryTab(overrides: Partial<QueryTab> = {}): QueryTab {
};
}
function result(columns: string[]): QueryResult {
function result(columns: string[], overrides: Partial<QueryResult> = {}): QueryResult {
return {
columns,
rows: [],
affected_rows: 0,
execution_time_ms: 1,
...overrides,
};
}
@@ -148,6 +149,19 @@ test("tabular result items hide statement results without returned columns", ()
assert.deepEqual(tabularResultItems(undefined), []);
});
test("tabular result items expose source labels when available", () => {
const results = [result([]), result(["id"], { sourceLabel: "public.users" }), result(["name"])];
assert.deepEqual(
tabularResultItems(results).map((item) => ({ index: item.index, n: item.n, label: item.label })),
[
{ index: 1, n: 1, label: "public.users" },
{ index: 2, n: 2, label: undefined },
],
);
assert.deepEqual(tabularResultItems([result(["id"], { sourceLabel: "db.users" })]).map((item) => item.label), ["db.users"]);
});
test("result run items expose ordered labels and active state", () => {
const tab = queryTab({
activeResultRunId: "run-2",
+12
View File
@@ -24,6 +24,8 @@ test("result snapshots strip live session handles and clone result rows", () =>
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-session",
sourceLabel: "public.users",
sourceStatement: "select * from public.users",
},
results: [
{
@@ -40,6 +42,8 @@ test("result snapshots strip live session handles and clone result rows", () =>
const snapshot = buildTabResultSnapshot(tab);
assert.equal(snapshot?.result?.session_id, undefined);
assert.equal(snapshot?.result?.sourceLabel, "public.users");
assert.equal(snapshot?.result?.sourceStatement, "select * from public.users");
assert.equal(snapshot?.results?.[0]?.session_id, undefined);
assert.deepEqual(snapshot?.result?.rows, [[1]]);
tab.result!.rows[0]![0] = 2;
@@ -61,6 +65,8 @@ test("result snapshots strip session handles from result runs", () => {
affected_rows: 0,
execution_time_ms: 1,
session_id: "live-run-session",
sourceLabel: "users",
sourceStatement: "select * from users",
},
},
],
@@ -69,6 +75,8 @@ test("result snapshots strip session handles from result runs", () => {
const snapshot = buildTabResultSnapshot(tab);
assert.equal(snapshot?.resultRuns?.[0]?.result?.session_id, undefined);
assert.equal(snapshot?.resultRuns?.[0]?.result?.sourceLabel, "users");
assert.equal(snapshot?.resultRuns?.[0]?.result?.sourceStatement, "select * from users");
assert.deepEqual(snapshot?.resultRuns?.[0]?.result?.rows, [[1]]);
});
@@ -85,6 +93,8 @@ test("result snapshots encode as binary columnar payloads and decode back to row
execution_time_ms: 3,
session_id: "live-session",
has_more: true,
sourceLabel: "public.users",
sourceStatement: "select id, name, active from public.users",
},
}),
);
@@ -101,5 +111,7 @@ test("result snapshots encode as binary columnar payloads and decode back to row
]);
assert.equal(decoded?.result?.session_id, undefined);
assert.equal(decoded?.result?.has_more, true);
assert.equal(decoded?.result?.sourceLabel, "public.users");
assert.equal(decoded?.result?.sourceStatement, "select id, name, active from public.users");
assert.equal(decoded?.cachedAt, snapshot.cachedAt);
});
+15 -1
View File
@@ -41,7 +41,7 @@ function installMemoryStorage() {
};
}
function buildExportHarness(options: { exportFileBaseName?: string } = {}) {
function buildExportHarness(options: { currentResultLabel?: string; exportFileBaseName?: string } = {}) {
const exportProgressDialog = ref(false);
const exportProgressState = ref({
title: "",
@@ -106,6 +106,7 @@ function buildExportHarness(options: { exportFileBaseName?: string } = {}) {
hasRowSelection: computed(() => false),
fullExportResult,
queryResultExportRequest,
currentResultLabel: computed(() => options.currentResultLabel),
exportFileBaseName: computed(() => options.exportFileBaseName),
exportProgressDialog,
exportProgressState,
@@ -326,6 +327,19 @@ test("selected query result CSV export keeps the existing in-memory path", async
assert.deepEqual(apiMock.exportQueryResultCsv.mock.calls[0][2], [[1, "Ada"]]);
});
test("selected query result XLSX export uses the current source label as the sheet name", async () => {
const { composable, queryResultExportRequest } = buildExportHarness({ currentResultLabel: "aaa.apis" });
await composable.exportXlsx([1]);
assert.equal(queryResultExportRequest.mock.calls.length, 0);
assert.equal(apiMock.startQueryResultExport.mock.calls.length, 0);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls.length, 1);
assert.equal(apiMock.exportQueryResultXlsx.mock.calls[0][1], "aaa.apis");
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][2], ["id", "name"]);
assert.deepEqual(apiMock.exportQueryResultXlsx.mock.calls[0][3], [[1, "Ada"]]);
});
test("cancelled query result CSV export clears the cancel handler without using the in-memory path", async () => {
const { composable, fullExportResult, exportProgressState, exportCancelHandler } = buildExportHarness();
apiMock.startQueryResultExport.mockImplementationOnce(async (_request, onProgress) => {