mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
fix(export): include all paginated query result rows
This commit is contained in:
@@ -216,6 +216,7 @@ const props = defineProps<{
|
||||
loading?: boolean;
|
||||
cacheKey?: string;
|
||||
onExecuteSql?: (sql: string) => Promise<void>;
|
||||
fullExportResult?: () => Promise<QueryResult | undefined>;
|
||||
customSave?: (changes: {
|
||||
dirtyRows: Map<number, Map<number, string | number | boolean | null>>;
|
||||
newRows: (string | number | boolean | null)[][];
|
||||
@@ -2944,6 +2945,7 @@ const {
|
||||
getRowItem: (rowId: number) => visibleDisplayItems.value.find((item) => item.id === rowId),
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
fullExportResult: props.fullExportResult,
|
||||
});
|
||||
|
||||
const pageSizeMenuItems = computed(() =>
|
||||
|
||||
@@ -465,6 +465,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
:count-sql="activeTab.resultCountSql"
|
||||
:total-row-count="activeTab.resultTotalRowCount"
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
:full-export-result="() => queryStore.fetchTabResultForExport(activeTab.id)"
|
||||
@reload="
|
||||
(
|
||||
sql?: string,
|
||||
@@ -667,6 +668,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
:page-offset="activeTab.resultPageOffset"
|
||||
:page-limit="activeTab.resultPageLimit"
|
||||
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
|
||||
:full-export-result="() => queryStore.fetchTabResultForExport(activeTab.id)"
|
||||
@update:where-input="(v: string) => (activeTab.whereInput = v)"
|
||||
@reload="
|
||||
(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { displayCellValue, type CellValue } from "@/lib/cellValue";
|
||||
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/actionActivation";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements } from "@/lib/dataGridSql";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
interface RowItem {
|
||||
id: number;
|
||||
@@ -44,6 +44,7 @@ export interface UseDataGridExportOptions {
|
||||
getRowItem: (rowId: number) => RowItem | undefined;
|
||||
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
|
||||
hasRowSelection: ComputedRef<boolean>;
|
||||
fullExportResult?: () => Promise<QueryResult | undefined>;
|
||||
}
|
||||
|
||||
interface CopyStatementCache {
|
||||
@@ -90,6 +91,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
getRowItem,
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
fullExportResult,
|
||||
} = options;
|
||||
|
||||
async function copyText(text: string) {
|
||||
@@ -107,6 +109,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
return displayItems.value.filter((item) => rowIdSet.has(item.id));
|
||||
}
|
||||
|
||||
async function resultToExport(rowIds?: number[]): Promise<{ columns: string[]; rows: CellValue[][] }> {
|
||||
if (!rowIds?.length && fullExportResult) {
|
||||
const result = await fullExportResult();
|
||||
if (result) return { columns: result.columns, rows: result.rows };
|
||||
}
|
||||
return {
|
||||
columns: columns.value,
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
};
|
||||
}
|
||||
|
||||
function targetedRows(): RowItem[] {
|
||||
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
|
||||
return displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
|
||||
@@ -427,7 +440,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
async function exportCsv(rowIds?: number[]) {
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c)));
|
||||
const result = await resultToExport(rowIds);
|
||||
const rows = result.rows.map((row) => row.map((c) => displayCellValue(c)));
|
||||
let outputPath = "export.csv";
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -438,7 +452,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultCsv(outputPath, columns.value, rows);
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, rows);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -459,11 +473,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultJson(
|
||||
outputPath,
|
||||
columns.value,
|
||||
rowsToExport(rowIds).map((item) => item.data),
|
||||
);
|
||||
const result = await resultToExport(rowIds);
|
||||
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -484,11 +495,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultMarkdown(
|
||||
outputPath,
|
||||
columns.value,
|
||||
rowsToExport(rowIds).map((item) => item.data),
|
||||
);
|
||||
const result = await resultToExport(rowIds);
|
||||
await api.exportQueryResultMarkdown(outputPath, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
@@ -509,11 +517,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
const result = await resultToExport(rowIds);
|
||||
await api.exportQueryResultXlsx(
|
||||
outputPath,
|
||||
tableMeta.value?.tableName || "Export",
|
||||
columns.value,
|
||||
rowsToExport(rowIds).map((item) => item.data),
|
||||
result.columns,
|
||||
result.rows,
|
||||
);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/lib/mongoShellCommand";
|
||||
import { AGENT_DRIVER_TYPES } from "@/lib/databaseCapabilities";
|
||||
import { editablePrimaryKeys } from "@/lib/tableEditing";
|
||||
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/tableDataExport";
|
||||
import * as api from "@/lib/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -1073,6 +1074,80 @@ export const useQueryStore = defineStore("query", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTabResultForExport(id: string): Promise<QueryResult | undefined> {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab?.result) return undefined;
|
||||
if (tab.mode !== "query") return tab.result;
|
||||
|
||||
const sql = tab.resultSortedSql ?? tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
|
||||
if (!sql.trim()) return tab.result;
|
||||
|
||||
const connStore = useConnectionStore();
|
||||
await connStore.ensureConnected(tab.connectionId);
|
||||
const conn = connStore.getConfig(tab.connectionId);
|
||||
const useAgentCursor = !!conn?.db_type && AGENT_DRIVER_TYPES.has(conn.db_type);
|
||||
const queryBaseSql = tab.resultBaseSql ?? sql;
|
||||
const pageLimit = Math.max(tab.resultPageLimit ?? 0, TABLE_DATA_EXPORT_PAGE_SIZE);
|
||||
const rows: QueryResult["rows"] = [];
|
||||
let columns: string[] = [];
|
||||
let executionTimeMs = 0;
|
||||
let offset = 0;
|
||||
let sessionId: string | undefined;
|
||||
const clientSessionId = `${tab.id}:export`;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const plan = await api.prepareQueryPaginationExecutionPlan({
|
||||
sql,
|
||||
queryBaseSql,
|
||||
databaseType: conn?.db_type,
|
||||
pagination: { limit: pageLimit, offset, sessionId },
|
||||
useAgentCursor,
|
||||
});
|
||||
if (typeof plan.pageLimit !== "number" || typeof plan.pageOffset !== "number") return tab.result;
|
||||
const executionOptions = plan.useAgentResultSession
|
||||
? {
|
||||
maxRows: plan.pageLimit,
|
||||
fetchSize: plan.pageLimit,
|
||||
pageSize: plan.pageLimit,
|
||||
resultSessionId: sessionId,
|
||||
clientSessionId,
|
||||
}
|
||||
: { maxRows: plan.pageLimit, fetchSize: plan.pageLimit };
|
||||
const results = await api.executeMulti(
|
||||
tab.connectionId,
|
||||
tab.database,
|
||||
plan.sqlToExecute,
|
||||
tab.schema,
|
||||
undefined,
|
||||
executionOptions,
|
||||
);
|
||||
const result = results[0];
|
||||
if (!result) break;
|
||||
if (columns.length === 0) columns = result.columns;
|
||||
rows.push(...result.rows);
|
||||
executionTimeMs += result.execution_time_ms ?? 0;
|
||||
sessionId = result.session_id ?? undefined;
|
||||
const shouldFetchNextPage = plan.useAgentResultSession
|
||||
? result.has_more === true
|
||||
: result.rows.length >= plan.pageLimit;
|
||||
if (!shouldFetchNextPage) break;
|
||||
offset += result.rows.length;
|
||||
}
|
||||
} finally {
|
||||
if (sessionId) void api.closeQuerySession(tab.connectionId, tab.database, sessionId, clientSessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
columns: columns.length ? columns : tab.result.columns,
|
||||
rows,
|
||||
affected_rows: 0,
|
||||
execution_time_ms: executionTimeMs,
|
||||
truncated: false,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTabId,
|
||||
@@ -1102,6 +1177,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
cancelTabExecution,
|
||||
cancelTabExplain,
|
||||
reloadEvictedTab,
|
||||
fetchTabResultForExport,
|
||||
notifyConnectionMayBeLost,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,6 +364,76 @@ test("data tab execution preserves pagination offset metadata", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("query result export fetches every paginated page", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
const connectionStore = useConnectionStore();
|
||||
const store = useQueryStore();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const preparedOffsets: number[] = [];
|
||||
const executedSqls: string[] = [];
|
||||
|
||||
connectionStore.addEphemeralConnection(conn("conn-1"));
|
||||
const tabId = store.createTab("conn-1", "db");
|
||||
const tab = store.tabs.find((item) => item.id === tabId);
|
||||
assert.ok(tab);
|
||||
tab.lastExecutedSql = "select id from users";
|
||||
tab.resultPageLimit = 100;
|
||||
tab.resultPageOffset = 0;
|
||||
tab.result = {
|
||||
columns: ["id"],
|
||||
rows: [[1]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
truncated: false,
|
||||
has_more: true,
|
||||
};
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/query/prepare-pagination-plan") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
const offset = Number(body.options.pagination.offset);
|
||||
const limit = Number(body.options.pagination.limit);
|
||||
preparedOffsets.push(offset);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
sqlToExecute: `select id from users /* offset:${offset} */`,
|
||||
pageSql: `select id from users /* offset:${offset} */`,
|
||||
pageLimit: limit,
|
||||
pageOffset: offset,
|
||||
useAgentResultSession: false,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === "/api/query/execute-multi") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
executedSqls.push(body.sql);
|
||||
const rows = String(body.sql).includes("offset:0")
|
||||
? Array.from({ length: 10_000 }, (_, index) => [index + 1])
|
||||
: [[10_001], [10_002]];
|
||||
return new Response(JSON.stringify([{ columns: ["id"], rows, affected_rows: 0, execution_time_ms: 1 }]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("unexpected request", { status: 500 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const exported = await store.fetchTabResultForExport(tabId);
|
||||
|
||||
assert.deepEqual(preparedOffsets, [0, 10_000]);
|
||||
assert.deepEqual(executedSqls, ["select id from users /* offset:0 */", "select id from users /* offset:10000 */"]);
|
||||
assert.equal(exported?.rows.length, 10_002);
|
||||
assert.deepEqual(exported?.rows.at(-1), [10_002]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
|
||||
test("query execution finishes without waiting for metadata analysis", async () => {
|
||||
const restoreStorage = installMemoryStorage();
|
||||
setActivePinia(createPinia());
|
||||
|
||||
Reference in New Issue
Block a user