From 0f1b5f8b5316590baf76ceb08814bab271a296ad Mon Sep 17 00:00:00 2001 From: uicky <48382673+uicky@users.noreply.github.com> Date: Fri, 19 Jun 2026 11:15:18 +0800 Subject: [PATCH] fix(export): resolve query result export progress --- apps/desktop/src/components/grid/DataGrid.vue | 2 +- .../src/components/layout/ContentArea.vue | 4 +- .../src/composables/useDataGridExport.ts | 38 ++++++-- apps/desktop/src/lib/exportFormats.ts | 2 +- apps/desktop/src/stores/queryStore.ts | 10 ++- crates/dbx-core/src/csv_export.rs | 31 ++++++- crates/dbx-core/src/query_result_sql.rs | 90 ++++++++++++++++--- packages/app-tests/exportFormats.test.ts | 7 ++ packages/app-tests/queryStore.test.ts | 79 +++++++++++++++- src-tauri/src/commands/csv_export.rs | 4 +- 10 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 packages/app-tests/exportFormats.test.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index fbd0d2112d..1329b5a901 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -196,7 +196,7 @@ const props = defineProps<{ loading?: boolean; cacheKey?: string; onExecuteSql?: (sql: string) => Promise; - fullExportResult?: () => Promise; + fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler; }>(); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 0e6474e97d..1326162142 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -762,7 +762,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); :total-row-count="activeTab.resultTotalRowCount" :total-row-count-loading="activeTab.resultTotalRowCountLoading" :on-execute-sql="async (sql: string) => emit('executeSql', sql)" - :full-export-result="() => queryStore.fetchTabResultForExport(activeTab.id)" + :full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)" @update:order-by-input="(v: string) => (activeTab.orderByInput = v)" @reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)" @paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)" @@ -949,7 +949,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)" + :full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)" @update:where-input="(v: string) => (activeTab.whereInput = v)" @update:order-by-input="(v: string) => (activeTab.orderByInput = v)" @reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)" diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index dc7107583c..3febb0fd90 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -44,7 +44,7 @@ export interface UseDataGridExportOptions { getRowItem: (rowId: number) => RowItem | undefined; selectedRowIds: Ref> | ComputedRef>; hasRowSelection: ComputedRef; - fullExportResult?: () => Promise; + fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; exportProgressDialog?: Ref; exportProgressState?: Ref<{ title: string; @@ -128,9 +128,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return displayItems.value.filter((item) => rowIdSet.has(item.id)); } - async function resultToExport(rowIds?: number[]): Promise<{ columns: string[]; rows: CellValue[][] }> { + async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<{ columns: string[]; rows: CellValue[][] }> { if (!rowIds?.length && fullExportResult) { - const result = await fullExportResult(); + const result = await fullExportResult(onProgress); if (result) return { columns: result.columns, rows: result.rows }; } return { @@ -472,13 +472,29 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }; exportProgressDialog.value = true; } - const result = await resultToExport(rowIds); - const rows = result.rows.map((row) => row.map((c) => displayCellValue(c))); + const result = await resultToExport(rowIds, (info) => { + if (needsFullExport && exportProgressState && exportProgressState.value.status === "Running") { + // Guard against the COUNT estimate being too low: if the real + // fetched count exceeds it, bump totalRows so the progress bar + // never shows 100 % while data is still being fetched. + const adjustedTotal = info.totalRows !== null && info.rowsExported > info.totalRows ? info.rowsExported : info.totalRows; + exportProgressState.value = { + ...exportProgressState.value, + rowsExported: info.rowsExported, + totalRows: adjustedTotal, + }; + } + }); + // Hand the raw rows straight to the Rust command. Formatting (NULL→"", + // bool/number→text, etc.) happens there on a spawn_blocking thread, so + // we avoid mapping every cell synchronously on the UI thread. + const rows = result.rows; if (needsFullExport && exportProgressState) { exportProgressState.value = { ...exportProgressState.value, status: "Writing", rowsExported: result.rows.length, + totalRows: result.rows.length, }; } let outputPath = "export.csv"; @@ -593,12 +609,22 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }; exportProgressDialog.value = true; } - const result = await resultToExport(rowIds); + const result = await resultToExport(rowIds, (info) => { + if (needsFullExport && exportProgressState && exportProgressState.value.status === "Running") { + const adjustedTotal = info.totalRows !== null && info.rowsExported > info.totalRows ? info.rowsExported : info.totalRows; + exportProgressState.value = { + ...exportProgressState.value, + rowsExported: info.rowsExported, + totalRows: adjustedTotal, + }; + } + }); if (needsFullExport && exportProgressState) { exportProgressState.value = { ...exportProgressState.value, status: "Writing", rowsExported: result.rows.length, + totalRows: result.rows.length, }; } await api.exportQueryResultXlsx(outputPath, tableMeta.value?.tableName || "Export", result.columns, result.rows); diff --git a/apps/desktop/src/lib/exportFormats.ts b/apps/desktop/src/lib/exportFormats.ts index 4634c16274..5b4f52c562 100644 --- a/apps/desktop/src/lib/exportFormats.ts +++ b/apps/desktop/src/lib/exportFormats.ts @@ -6,7 +6,7 @@ export type ExportCellValue = string | number | boolean | null; export function formatCsv(columns: string[], rows: ExportCellValue[][]): string { const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; const header = columns.map(esc).join(","); - const body = rows.map((row) => row.map((c) => esc(c === null ? "" : String(c))).join(",")).join("\n"); + const body = rows.map((row) => row.map((c) => esc(c === null ? "NULL" : String(c))).join(",")).join("\n"); return `${header}\n${body}`; } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 8a50e0afaf..47386e8b87 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1928,7 +1928,7 @@ export const useQueryStore = defineStore("query", () => { }); } - async function fetchTabResultForExport(id: string): Promise { + async function fetchTabResultForExport(id: string, onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise { const tab = tabs.value.find((t) => t.id === id); if (!tab?.result) return undefined; @@ -1939,6 +1939,9 @@ export const useQueryStore = defineStore("query", () => { const tableMeta = tableMetaForDataTab(tab); if (!tableMeta?.tableName) return tab.result; + // Use the already-computed total row count as a progress estimate so the + // export dialog shows a moving bar instead of a stuck 0 while paginating. + const totalRows = typeof tab.resultTotalRowCount === "number" ? tab.resultTotalRowCount : null; const pageLimit = TABLE_DATA_EXPORT_PAGE_SIZE; const effectiveDbType = effectiveDatabaseTypeForConnection(conn); const primaryKeys = tab.tableMeta ? editablePrimaryKeys(effectiveDbType, tab.tableMeta.columns, tab.tableMeta.tableType) : tableMeta.primaryKeys; @@ -1974,6 +1977,7 @@ export const useQueryStore = defineStore("query", () => { if (columns.length === 0) columns = result.columns; rows.push(...result.rows); executionTimeMs += result.execution_time_ms ?? 0; + onProgress?.({ rowsExported: rows.length, totalRows }); if (result.rows.length < pageLimit) break; offset += result.rows.length; } @@ -2000,6 +2004,9 @@ export const useQueryStore = defineStore("query", () => { const queryTimeoutSecs = queryTimeoutSecsForConnection(conn); const useAgentCursor = conn?.db_type === "jdbc" || supportsDatabaseFeature(conn?.db_type, "driverManagement"); const queryBaseSql = tab.resultBaseSql ?? sql; + // Use the already-computed total row count as a progress estimate so the + // export dialog shows a moving bar instead of a stuck 0 while paginating. + const totalRows = typeof tab.resultTotalRowCount === "number" ? tab.resultTotalRowCount : null; const pageLimit = Math.max(tab.resultPageLimit ?? 0, TABLE_DATA_EXPORT_PAGE_SIZE); const rows: QueryResult["rows"] = []; let columns: string[] = []; @@ -2033,6 +2040,7 @@ export const useQueryStore = defineStore("query", () => { if (columns.length === 0) columns = result.columns; rows.push(...result.rows); executionTimeMs += result.execution_time_ms ?? 0; + onProgress?.({ rowsExported: rows.length, totalRows }); sessionId = result.session_id ?? undefined; const shouldFetchNextPage = plan.useAgentResultSession ? result.has_more === true : result.rows.length >= plan.pageLimit; if (!shouldFetchNextPage) break; diff --git a/crates/dbx-core/src/csv_export.rs b/crates/dbx-core/src/csv_export.rs index bc4886d6c9..9800d49e12 100644 --- a/crates/dbx-core/src/csv_export.rs +++ b/crates/dbx-core/src/csv_export.rs @@ -41,16 +41,35 @@ pub(crate) fn value_to_csv_text(value: &Value) -> String { } } -pub fn format_csv(columns: &[String], rows: &[Vec]) -> String { +fn value_to_query_result_csv_text(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + other => value_to_csv_text(other), + } +} + +fn format_csv_with_value_formatter( + columns: &[String], + rows: &[Vec], + value_formatter: fn(&Value) -> String, +) -> String { let header = columns.iter().map(|col| escape_csv(col)).collect::>().join(","); let body = rows .iter() - .map(|row| row.iter().map(|cell| escape_csv(&value_to_csv_text(cell))).collect::>().join(",")) + .map(|row| row.iter().map(|cell| escape_csv(&value_formatter(cell))).collect::>().join(",")) .collect::>() .join("\n"); format!("{header}\n{body}") } +pub fn format_csv(columns: &[String], rows: &[Vec]) -> String { + format_csv_with_value_formatter(columns, rows, value_to_csv_text) +} + +pub fn format_query_result_csv(columns: &[String], rows: &[Vec]) -> String { + format_csv_with_value_formatter(columns, rows, value_to_query_result_csv_text) +} + fn write_csv_row(writer: &mut impl Write, values: impl IntoIterator) -> Result<(), String> { let mut first = true; for value in values { @@ -143,7 +162,7 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor #[cfg(test)] mod tests { - use super::format_csv; + use super::{format_csv, format_query_result_csv}; use serde_json::json; #[test] @@ -158,5 +177,11 @@ mod tests { assert_eq!(out, "\"id\",\"note\"\n\"1\",\"\""); } + #[test] + fn formats_query_result_null_as_null_text() { + let out = format_query_result_csv(&["id".to_string(), "note".to_string()], &[vec![json!(1), Value::Null]]); + assert_eq!(out, "\"id\",\"note\"\n\"1\",\"NULL\""); + } + use serde_json::Value; } diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index 5a3a932c74..fa2c35d9fe 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -1,15 +1,9 @@ -use regex::Regex; use serde::{Deserialize, Serialize}; -use std::sync::LazyLock; use crate::models::connection::DatabaseType; use crate::sql::find_statement_at_cursor; use crate::sql_dialect::{pagination_strategy, quote_table_identifier, PaginationContext, TablePaginationStrategy}; -static LIMIT_OFFSET_STRIP_RE: LazyLock = LazyLock::new(|| { - Regex::new(r"(?i)(\s+LIMIT\s+\d+(\s+OFFSET\s+\d+)?|\s+LIMIT\s+\d+(\s*,\s*\d+)?|\s+OFFSET\s+\d+(\s+LIMIT\s+\d+)?|\s+OFFSET\s+\d+\s+ROWS?\s+FETCH\s+(?:FIRST|NEXT)\s+\d+\s+ROWS?\s+ONLY|\s+FETCH\s+(?:FIRST|NEXT)\s+\d+\s+ROWS?\s+ONLY)\s*$").unwrap() -}); - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QuerySqlBuildResult { @@ -196,7 +190,9 @@ pub fn build_paginated_query_sql(options: PaginatedQuerySqlOptions) -> QuerySqlB TablePaginationStrategy::Rownum => ok(add_rownum_limit(&statement, safe_limit, safe_offset)), TablePaginationStrategy::AgentMaxRows | TablePaginationStrategy::Unbounded => ok(format!("{statement};")), TablePaginationStrategy::IrisTop => ok(add_iris_top_limit(&statement, safe_limit)), - TablePaginationStrategy::LimitOffset => ok(add_standard_limit(&statement, safe_limit, safe_offset)), + TablePaginationStrategy::LimitOffset => { + ok(add_standard_limit(&statement, options.database_type, safe_limit, safe_offset)) + } } } @@ -213,8 +209,6 @@ pub fn build_count_query_sql(options: CountQuerySqlOptions) -> QuerySqlBuildResu return err("unsupported"); } - let statement = LIMIT_OFFSET_STRIP_RE.replace(&statement, "").to_string(); - let alias = quote_table_identifier(options.database_type, "dbx_count"); let wrapped_sql = if options.database_type == Some(DatabaseType::SqlServer) { sql_server_statement_for_derived_table(&statement) @@ -431,6 +425,9 @@ fn add_iris_top_limit(statement: &str, limit: usize) -> String { fn add_questdb_limit(statement: &str, limit: usize, offset: usize) -> String { if has_top_level_limit(statement) { + if offset > 0 { + return add_outer_standard_limit(statement, Some(DatabaseType::Questdb), limit, offset); + } return format!("{statement};"); } if offset > 0 { @@ -480,6 +477,10 @@ fn has_top_level_offset_fetch_next(sql: &str) -> bool { fn add_fetch_first_limit(statement: &str, limit: usize, offset: usize) -> String { if has_top_level_fetch_first(statement) { + if offset > 0 { + let alias = quote_table_identifier(None, "dbx_page"); + return format!("SELECT * FROM ({statement}) {alias} OFFSET {offset} ROWS FETCH FIRST {limit} ROWS ONLY;"); + } return format!("{statement};"); } let offset_sql = if offset > 0 { format!(" OFFSET {offset} ROWS") } else { String::new() }; @@ -499,14 +500,27 @@ fn add_rownum_limit(statement: &str, limit: usize, offset: usize) -> String { ) } -fn add_standard_limit(statement: &str, limit: usize, offset: usize) -> String { +fn add_standard_limit(statement: &str, database_type: Option, limit: usize, offset: usize) -> String { if has_top_level_limit(statement) { + if offset > 0 { + return add_outer_standard_limit(statement, database_type, limit, offset); + } return format!("{statement};"); } let offset_sql = if offset > 0 { format!(" OFFSET {offset}") } else { String::new() }; format!("{statement} LIMIT {limit}{offset_sql};") } +fn add_outer_standard_limit( + statement: &str, + database_type: Option, + limit: usize, + offset: usize, +) -> String { + let alias = quote_table_identifier(database_type, "dbx_page"); + format!("SELECT * FROM ({statement}) {alias} LIMIT {limit} OFFSET {offset};") +} + fn find_top_level_trailing_order_by(sql: &str) -> Option { let tokens = top_level_sql_tokens(sql); for index in (0..tokens.len().saturating_sub(1)).rev() { @@ -990,6 +1004,36 @@ mod tests { assert_eq!(result.sql.unwrap(), "SELECT id FROM users LIMIT 20;"); } + #[test] + fn mysql_pagination_wraps_existing_limit_for_later_pages() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT * FROM dy_promotion_item WHERE create_time < '2026-06-01' LIMIT 10000;".to_string(), + database_type: Some(DatabaseType::Mysql), + limit: 1000, + offset: 1000, + }); + + assert_eq!( + result.sql.unwrap(), + "SELECT * FROM (SELECT * FROM dy_promotion_item WHERE create_time < '2026-06-01' LIMIT 10000) `dbx_page` LIMIT 1000 OFFSET 1000;" + ); + } + + #[test] + fn standard_pagination_wraps_existing_limit_for_later_pages() { + let result = build_paginated_query_sql(PaginatedQuerySqlOptions { + original_sql: "SELECT id FROM users LIMIT 20;".to_string(), + database_type: Some(DatabaseType::Postgres), + limit: 5, + offset: 10, + }); + + assert_eq!( + result.sql.unwrap(), + "SELECT * FROM (SELECT id FROM users LIMIT 20) \"dbx_page\" LIMIT 5 OFFSET 10;" + ); + } + #[test] fn rejects_multiple_statements_for_pagination() { let result = build_paginated_query_sql(PaginatedQuerySqlOptions { @@ -1027,6 +1071,32 @@ mod tests { ); } + #[test] + fn count_query_preserves_user_limit() { + let result = build_count_query_sql(CountQuerySqlOptions { + original_sql: "SELECT * FROM dy_promotion_item WHERE create_time < '2026-06-01' LIMIT 10000".to_string(), + database_type: Some(DatabaseType::Mysql), + }); + + assert_eq!( + result.sql.unwrap(), + "SELECT COUNT(*) AS dbx_total_rows FROM (SELECT * FROM dy_promotion_item WHERE create_time < '2026-06-01' LIMIT 10000) `dbx_count`;" + ); + } + + #[test] + fn count_query_preserves_user_limit_offset() { + let result = build_count_query_sql(CountQuerySqlOptions { + original_sql: "SELECT * FROM users WHERE active = 1 LIMIT 100 OFFSET 50".to_string(), + database_type: Some(DatabaseType::Postgres), + }); + + assert_eq!( + result.sql.unwrap(), + "SELECT COUNT(*) AS dbx_total_rows FROM (SELECT * FROM users WHERE active = 1 LIMIT 100 OFFSET 50) \"dbx_count\";" + ); + } + #[test] fn builds_agent_cursor_pagination_plan() { let plan = build_query_pagination_execution_plan(QueryPaginationExecutionPlanOptions { diff --git a/packages/app-tests/exportFormats.test.ts b/packages/app-tests/exportFormats.test.ts new file mode 100644 index 0000000000..1fdf686d9a --- /dev/null +++ b/packages/app-tests/exportFormats.test.ts @@ -0,0 +1,7 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { formatCsv } from "../../apps/desktop/src/lib/exportFormats.ts"; + +test("formatCsv preserves query result null display text", () => { + assert.equal(formatCsv(["id", "note"], [[1, null]]), "\"id\",\"note\"\n\"1\",\"NULL\""); +}); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 4a410b7972..43f3ae7a5f 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1297,7 +1297,9 @@ test("query result export fetches every paginated page", async () => { const body = JSON.parse(String(init?.body ?? "{}")); executedSqls.push(body.sql); timeoutSecs.push(body.timeoutSecs); - const rows = String(body.sql).includes("offset:0") ? Array.from({ length: 10_000 }, (_, index) => [index + 1]) : [[10_001], [10_002]]; + 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" }, @@ -1320,6 +1322,81 @@ test("query result export fetches every paginated page", async () => { } }); +test("query result export treats the known query total as a progress estimate", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const preparedOffsets: number[] = []; + const executedSqls: string[] = []; + const progress: Array<{ rowsExported: number; totalRows: number | null }> = []; + + 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.resultBaseSql = tab.lastExecutedSql; + tab.resultPageLimit = 100; + tab.resultPageOffset = 0; + tab.resultTotalRowCount = 5; + tab.result = { + columns: ["id"], + rows: [[1], [2], [3], [4], [5]], + 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, (info) => progress.push(info)); + + 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]); + assert.deepEqual(progress, [ + { rowsExported: 10_000, totalRows: 5 }, + { rowsExported: 10_002, totalRows: 5 }, + ]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("jdbc query pagination uses result sessions without capping max rows to one page", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/src-tauri/src/commands/csv_export.rs b/src-tauri/src/commands/csv_export.rs index 1381aab7e8..790d28755b 100644 --- a/src-tauri/src/commands/csv_export.rs +++ b/src-tauri/src/commands/csv_export.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::commands::connection::AppState; -use dbx_core::csv_export::{export_table_data_csv_core, format_csv, TableCsvExportOptions}; +use dbx_core::csv_export::{export_table_data_csv_core, format_query_result_csv, TableCsvExportOptions}; use serde::Deserialize; use serde_json::Value; use tauri::State; @@ -17,7 +17,7 @@ pub struct QueryResultCsvExportRequest { #[tauri::command] pub async fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { - let csv = format_csv(&request.columns, &request.rows); + let csv = format_query_result_csv(&request.columns, &request.rows); std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string()) }) .await