From 42541d11466a3e6530430528d71646ad15521fa9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 25 Jun 2026 17:01:15 +0800 Subject: [PATCH] fix(export): align row limits and progress --- .../export/ExportProgressDialog.vue | 26 +++- .../export/ExportProgressPopover.vue | 24 +++- .../src/components/objects/ObjectBrowser.vue | 2 + .../src/components/sidebar/TreeItem.vue | 2 + .../src/composables/useDataGridExport.ts | 6 +- apps/desktop/src/i18n/locales/en.ts | 6 +- apps/desktop/src/i18n/locales/zh-CN.ts | 6 +- apps/desktop/src/lib/tauri.ts | 1 + apps/desktop/src/stores/queryStore.ts | 13 +- apps/desktop/src/stores/settingsStore.ts | 2 +- crates/dbx-core/src/query_result_export.rs | 40 +++++- crates/dbx-core/src/table_export.rs | 79 ++++++++--- packages/app-tests/queryStore.test.ts | 118 +++++++++++++++- packages/app-tests/settingsStore.test.ts | 8 +- packages/app-tests/useDataGridExport.test.ts | 128 ++++++++++++++++++ 15 files changed, 419 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/components/export/ExportProgressDialog.vue b/apps/desktop/src/components/export/ExportProgressDialog.vue index c59f8c0be3..d20df2e63c 100644 --- a/apps/desktop/src/components/export/ExportProgressDialog.vue +++ b/apps/desktop/src/components/export/ExportProgressDialog.vue @@ -61,7 +61,12 @@ const rowsText = computed(() => {
-
+
@@ -111,3 +116,22 @@ const rowsText = computed(() => { + + diff --git a/apps/desktop/src/components/export/ExportProgressPopover.vue b/apps/desktop/src/components/export/ExportProgressPopover.vue index 5b7ec03d4f..e2bf676a24 100644 --- a/apps/desktop/src/components/export/ExportProgressPopover.vue +++ b/apps/desktop/src/components/export/ExportProgressPopover.vue @@ -102,7 +102,10 @@ function toggleShowAll() {
-
+
+
+
+
@@ -142,3 +145,22 @@ function toggleShowAll() { + + diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index b4c02fe760..b51a8fe474 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -838,6 +838,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") { task = addExportTask(row.name, format, filePath); const currentTask = task; + const rowLimit = settingsStore.editorSettings.exportRowLimitEnabled ? settingsStore.editorSettings.exportRowLimit : null; const request: api.TableExportRequest = { exportId: currentTask.exportId, connectionId: props.connection.id, @@ -848,6 +849,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") { format, columns: queryColumns, batchSize: settingsStore.editorSettings.exportBatchSize, + rowLimit, }; const terminalProgress = await api.startTableExport(request, (progress) => { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index b1d65d892d..cc985f526d 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -2817,6 +2817,7 @@ async function exportTableData(format: "csv" | "xlsx") { const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((c) => c.name) : undefined; // Step 4: Start streaming export (background, non-blocking) + const rowLimit = settingsStore.editorSettings.exportRowLimitEnabled ? settingsStore.editorSettings.exportRowLimit : null; const request: api.TableExportRequest = { exportId: currentTask.exportId, connectionId, @@ -2827,6 +2828,7 @@ async function exportTableData(format: "csv" | "xlsx") { format, columns: queryColumns, batchSize: settingsStore.editorSettings.exportBatchSize, + rowLimit, }; await api.startTableExport(request, (progress) => { diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 13fc41aa59..a4e62e4a08 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -10,6 +10,7 @@ import { copyToClipboard } from "@/lib/clipboard"; import { buildDataGridCopyInsertStatement, buildDataGridCopyUpdateStatements, type DataGridTableMeta } from "@/lib/dataGridSql"; import { formatSqlInsert } from "@/lib/exportFormats"; import { uuid } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settingsStore"; import type { DatabaseType, QueryResult } from "@/types/database"; import type { QueryResultExportRequest } from "@/lib/api"; @@ -825,6 +826,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { if (exportCancelHandler) { exportCancelHandler.value = () => api.cancelTableExport(exportId); } + const editorSettings = useSettingsStore().editorSettings; + const rowLimit = editorSettings.exportRowLimitEnabled ? editorSettings.exportRowLimit : null; try { const progress = await api.startTableExport( @@ -841,8 +844,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { primaryKeys: meta.primaryKeys, whereInput: whereInput.value, orderBy: orderBy.value, - skipCount: true, + skipCount: false, batchSize: exportBatchSize.value, + rowLimit, }, (progress) => { if (exportProgressState) { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 58c9bc7194..86c57ef674 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2548,9 +2548,9 @@ export default { exportBatchSize: "Export batch size", exportBatchSizeDescription: "Rows fetched per batch when exporting data (100-100000).", exportRowLimitEnabled: "Limit exported rows", - exportRowLimitEnabledDescription: "When on, query result CSV/XLSX exports stop at the row limit below.", - exportRowLimit: "Query result export row limit", - exportRowLimitDescription: "Maximum rows for query result exports.", + exportRowLimitEnabledDescription: "When on, query result and table data exports stop at the row limit below.", + exportRowLimit: "Data export row limit", + exportRowLimitDescription: "Maximum rows for query result and table data exports.", exportRowLimitUnlimited: "Unlimited", queryExportKeysetOptimizationEnabled: "Try keyset optimization", queryExportKeysetOptimizationEnabledDescription: "Only applies to safely recognized single-table queries; complex queries automatically fall back.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ef8b30d65b..294192156e 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2572,9 +2572,9 @@ export default { exportBatchSize: "导出批次大小", exportBatchSizeDescription: "导出数据时每批读取的行数(100-100000)。", exportRowLimitEnabled: "限制导出行数", - exportRowLimitEnabledDescription: "开启时,查询结果 CSV/XLSX 导出最多为下方设置的行数。", - exportRowLimit: "查询结果导出最多行数", - exportRowLimitDescription: "查询结果导出的总行数上限。", + exportRowLimitEnabledDescription: "开启时,查询结果和表数据导出最多为下方设置的行数。", + exportRowLimit: "数据导出最多行数", + exportRowLimitDescription: "查询结果和表数据导出的总行数上限。", exportRowLimitUnlimited: "无限制", queryExportKeysetOptimizationEnabled: "尝试 keyset 优化", queryExportKeysetOptimizationEnabledDescription: "仅对可安全识别的单表查询生效,复杂查询会自动回退。", diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 57e5916cc5..2d188eed53 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1749,6 +1749,7 @@ export interface TableExportRequest { orderBy?: string; skipCount?: boolean; batchSize?: number; + rowLimit?: number | null; } export interface TableCsvExportOptions { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 262f876dfe..0784e7b849 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2220,10 +2220,12 @@ 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; - const exportRowLimit = useSettingsStore().editorSettings.exportRowLimit; + const exportSettings = useSettingsStore().editorSettings; + const exportRowLimit = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : Number.POSITIVE_INFINITY; + const agentExportMaxRows = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : 2_147_483_647; // 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 totalRows = typeof tab.resultTotalRowCount === "number" ? Math.min(tab.resultTotalRowCount, exportRowLimit) : null; const pageLimit = Math.max(tab.resultPageLimit ?? 0, TABLE_DATA_EXPORT_PAGE_SIZE); const rows: QueryResult["rows"] = []; let columns: string[] = []; @@ -2248,7 +2250,7 @@ export const useQueryStore = defineStore("query", () => { if (typeof plan.pageLimit !== "number" || typeof plan.pageOffset !== "number") return tab.result; const executionOptions = plan.useAgentResultSession ? { - maxRows: exportRowLimit, + maxRows: agentExportMaxRows, fetchSize: plan.pageLimit, pageSize: plan.pageLimit, resultSessionId: sessionId, @@ -2298,7 +2300,8 @@ export const useQueryStore = defineStore("query", () => { if (!effectiveDbType) return undefined; const useAgentCursor = conn?.db_type === "jdbc" || supportsDatabaseFeature(conn?.db_type, "driverManagement"); const queryBaseSql = tab.resultBaseSql ?? sql; - const totalRows = typeof tab.resultTotalRowCount === "number" ? tab.resultTotalRowCount : null; + const rowLimit = settings.exportRowLimitEnabled ? settings.exportRowLimit : null; + const totalRows = typeof tab.resultTotalRowCount === "number" ? (rowLimit === null ? tab.resultTotalRowCount : Math.min(tab.resultTotalRowCount, rowLimit)) : null; const clientSessionId = tabClientSessionId(tab, "export"); return { @@ -2313,7 +2316,7 @@ export const useQueryStore = defineStore("query", () => { filePath: options.filePath, format: options.format, pageSize: settings.exportBatchSize, - rowLimit: settings.exportRowLimitEnabled ? settings.exportRowLimit : null, + rowLimit, totalRows, timeoutSecs: queryTimeoutSecsForConnection(conn), keysetOptimizationEnabled: settings.queryExportKeysetOptimizationEnabled, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index ca71a0ee9a..78f02f98a0 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -447,7 +447,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { snippets: DEFAULT_SQL_SNIPPETS, tableColumnTemplateFields: [...DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS], exportBatchSize: 2000, - exportRowLimitEnabled: true, + exportRowLimitEnabled: false, exportRowLimit: 100000, queryExportKeysetOptimizationEnabled: true, toolbarItems: { ...DEFAULT_TOOLBAR_ITEMS }, diff --git a/crates/dbx-core/src/query_result_export.rs b/crates/dbx-core/src/query_result_export.rs index 8f70f077ba..c59eefa503 100644 --- a/crates/dbx-core/src/query_result_export.rs +++ b/crates/dbx-core/src/query_result_export.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fs::File; use std::io::{BufWriter, Write}; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::connection::{AppState, PoolKind}; use crate::csv_export::{format_query_result_csv, format_query_result_csv_rows}; @@ -28,6 +28,7 @@ pub const XLSX_MAX_DATA_ROWS: usize = 1_048_575; const XLSX_ROW_LIMIT_ERROR: &str = "XLSX 最多支持 1,048,575 行数据,请改用 CSV 导出完整结果。"; const STREAMING_PAGINATION_UNSUPPORTED_ERROR: &str = "当前查询暂不支持流式导出,请简化查询或使用受支持的驱动。"; const AGENT_SESSION_MISSING_ERROR: &str = "查询结果流式导出需要驱动返回结果集会话,但当前驱动未返回 session_id。"; +const STREAM_PROGRESS_TIME_INTERVAL: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -92,6 +93,17 @@ fn xlsx_hard_limit_active(format: &str, request: &QueryResultExportRequest) -> b format == "xlsx" && request.row_limit.map_or(true, |limit| limit > XLSX_MAX_DATA_ROWS) } +fn should_emit_stream_progress( + rows_exported: u64, + last_progress_rows: u64, + row_interval: u64, + elapsed_since_last_progress: Duration, +) -> bool { + rows_exported > last_progress_rows + && (rows_exported.saturating_sub(last_progress_rows) >= row_interval.max(1) + || elapsed_since_last_progress >= STREAM_PROGRESS_TIME_INTERVAL) +} + fn query_export_timeout(timeout_secs: Option) -> Option { match timeout_secs { Some(0) => None, @@ -572,6 +584,9 @@ async fn try_export_sqlserver_query_result_stream( if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit }; let mut columns: Vec = Vec::new(); let mut rows_exported = 0_u64; + let mut last_progress_rows = 0_u64; + let mut last_progress_at = Instant::now(); + let progress_row_interval = request.page_size.max(1) as u64; let mut csv_file = if format == "csv" { let mut file = BufWriter::new(File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?); @@ -632,7 +647,17 @@ async fn try_export_sqlserver_query_result_stream( } } rows_exported += 1; - on_progress(progress(request, rows_exported, ExportStatus::Running, None)); + let now = Instant::now(); + if should_emit_stream_progress( + rows_exported, + last_progress_rows, + progress_row_interval, + now.duration_since(last_progress_at), + ) { + on_progress(progress(request, rows_exported, ExportStatus::Running, None)); + last_progress_rows = rows_exported; + last_progress_at = now; + } } } Ok(()) @@ -646,6 +671,9 @@ async fn try_export_sqlserver_query_result_stream( }; drop(client); + if rows_exported != last_progress_rows { + on_progress(progress(request, rows_exported, ExportStatus::Running, None)); + } on_progress(progress(request, rows_exported, ExportStatus::Writing, None)); if let Some(file) = csv_file.as_mut() { file.flush().map_err(|e| format!("Failed to flush CSV file: {e}"))?; @@ -710,6 +738,14 @@ mod tests { assert!(req.total_rows.is_some_and(|total| total > XLSX_MAX_DATA_ROWS as u64)); } + #[test] + fn sqlserver_stream_progress_is_throttled() { + assert!(!should_emit_stream_progress(19_999, 0, 20_000, Duration::from_millis(100))); + assert!(should_emit_stream_progress(20_000, 0, 20_000, Duration::from_millis(100))); + assert!(should_emit_stream_progress(10, 0, 20_000, STREAM_PROGRESS_TIME_INTERVAL)); + assert!(!should_emit_stream_progress(20_000, 20_000, 20_000, STREAM_PROGRESS_TIME_INTERVAL)); + } + #[test] fn non_agent_pages_continue_after_trimming_probe_row() { assert!(should_fetch_next_page(false, false, 101, 100, 100)); diff --git a/crates/dbx-core/src/table_export.rs b/crates/dbx-core/src/table_export.rs index da6e41f0c2..cf7c5d56bd 100644 --- a/crates/dbx-core/src/table_export.rs +++ b/crates/dbx-core/src/table_export.rs @@ -39,6 +39,8 @@ pub struct TableExportRequest { pub skip_count: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub batch_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub row_limit: Option, } #[derive(Debug, Clone, Serialize)] @@ -147,6 +149,14 @@ fn table_page_sql( } } +fn next_export_batch_size(row_limit: Option, rows_exported: u64, batch_size: usize) -> Option { + let remaining = row_limit.map(|limit| limit.saturating_sub(rows_exported as usize)); + if matches!(remaining, Some(0)) { + return None; + } + Some(remaining.map_or(batch_size, |value| value.min(batch_size)).max(1)) +} + pub async fn export_table_data_core( state: &AppState, request: &TableExportRequest, @@ -208,6 +218,7 @@ pub async fn export_table_data_core( // 6. Get total row count for progress estimation when requested. Data // grid exports skip this by default because COUNT can be the slowest query // on large HANA/JDBC tables, especially with filters. + let row_limit = request.row_limit; let total_rows = if request.skip_count { None } else { @@ -218,11 +229,16 @@ pub async fn export_table_data_core( request.where_input.as_deref(), ); match execute_on_pool(state, &pool_key, &count_query).await { - Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v { - Value::Number(n) => n.as_u64(), - Value::String(s) => s.parse::().ok(), - _ => None, - }), + Ok(result) => result + .rows + .first() + .and_then(|r| r.first()) + .and_then(|v| match v { + Value::Number(n) => n.as_u64(), + Value::String(s) => s.parse::().ok(), + _ => None, + }) + .map(|total| row_limit.map_or(total, |limit| total.min(limit as u64))), Err(_) => None, } }; @@ -269,6 +285,9 @@ pub async fn export_table_data_core( return Ok(()); } + let Some(active_batch_size) = next_export_batch_size(row_limit, rows_exported, batch_size) else { + break; + }; let sql = table_page_sql( request, &db_type, @@ -277,10 +296,10 @@ pub async fn export_table_data_core( use_keyset, &last_pk_values, offset, - batch_size, + active_batch_size, ); - let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(batch_size)).await?; + let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(active_batch_size)).await?; let row_count = result.rows.len(); if row_count == 0 { break; @@ -319,7 +338,7 @@ pub async fn export_table_data_core( error_message: None, }); - if row_count < batch_size { + if row_count < active_batch_size { break; } } @@ -347,6 +366,9 @@ pub async fn export_table_data_core( return Ok(()); } + let Some(active_batch_size) = next_export_batch_size(row_limit, rows_exported, batch_size) else { + break; + }; let sql = table_page_sql( request, &db_type, @@ -355,10 +377,10 @@ pub async fn export_table_data_core( use_keyset, &last_pk_values, offset, - batch_size, + active_batch_size, ); - let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(batch_size)).await?; + let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(active_batch_size)).await?; let row_count = result.rows.len(); if row_count == 0 { break; @@ -387,7 +409,7 @@ pub async fn export_table_data_core( error_message: None, }); - if row_count < batch_size { + if row_count < active_batch_size { break; } } @@ -426,6 +448,9 @@ pub async fn export_table_data_core( return Ok(()); } + let Some(active_batch_size) = next_export_batch_size(row_limit, rows_exported, batch_size) else { + break; + }; let sql = table_page_sql( request, &db_type, @@ -434,9 +459,9 @@ pub async fn export_table_data_core( use_keyset, &last_pk_values, offset, - batch_size, + active_batch_size, ); - let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(batch_size)).await?; + let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(active_batch_size)).await?; let row_count = result.rows.len(); if row_count == 0 { break; @@ -468,7 +493,7 @@ pub async fn export_table_data_core( error_message: None, }); - if row_count < batch_size { + if row_count < active_batch_size { break; } } @@ -493,6 +518,9 @@ pub async fn export_table_data_core( return Ok(()); } + let Some(active_batch_size) = next_export_batch_size(row_limit, rows_exported, batch_size) else { + break; + }; let sql = table_page_sql( request, &db_type, @@ -501,9 +529,9 @@ pub async fn export_table_data_core( use_keyset, &last_pk_values, offset, - batch_size, + active_batch_size, ); - let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(batch_size)).await?; + let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(active_batch_size)).await?; let row_count = result.rows.len(); if row_count == 0 { break; @@ -536,7 +564,7 @@ pub async fn export_table_data_core( error_message: None, }); - if row_count < batch_size { + if row_count < active_batch_size { break; } } @@ -559,6 +587,9 @@ pub async fn export_table_data_core( return Ok(()); } + let Some(active_batch_size) = next_export_batch_size(row_limit, rows_exported, batch_size) else { + break; + }; let sql = table_page_sql( request, &db_type, @@ -567,9 +598,9 @@ pub async fn export_table_data_core( use_keyset, &last_pk_values, offset, - batch_size, + active_batch_size, ); - let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(batch_size)).await?; + let result = execute_on_pool_with_max_rows(state, &pool_key, &sql, Some(active_batch_size)).await?; let row_count = result.rows.len(); if row_count == 0 { break; @@ -612,7 +643,7 @@ pub async fn export_table_data_core( error_message: None, }); - if row_count < batch_size { + if row_count < active_batch_size { break; } } @@ -706,6 +737,14 @@ mod tests { assert_eq!(out, "\"just\",\"one\""); } + #[test] + fn export_batch_size_respects_row_limit_remaining_rows() { + assert_eq!(next_export_batch_size(None, 12_000, 10_000), Some(10_000)); + assert_eq!(next_export_batch_size(Some(15_000), 0, 10_000), Some(10_000)); + assert_eq!(next_export_batch_size(Some(15_000), 10_000, 10_000), Some(5_000)); + assert_eq!(next_export_batch_size(Some(15_000), 15_000, 10_000), None); + } + #[test] fn writes_json_row_without_allocating_object_map() { let mut out = Vec::new(); diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 36809f9d40..43bb6ace99 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1790,7 +1790,7 @@ test("agent-session query export raises maxRows to the configured export limit", setActivePinia(createPinia()); const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); - settingsStore.updateEditorSettings({ exportRowLimit: 50_000 }); + settingsStore.updateEditorSettings({ exportRowLimit: 50_000, exportRowLimitEnabled: true }); const store = useQueryStore(); const originalFetch = globalThis.fetch; const executeBodies: any[] = []; @@ -1862,6 +1862,83 @@ test("agent-session query export raises maxRows to the configured export limit", } }); +test("query export stops at the configured row limit when enabled", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ exportRowLimit: 15_000, exportRowLimitEnabled: true }); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + const planLimits: number[] = []; + const executeMaxRows: number[] = []; + + connectionStore.addEphemeralConnection(conn("conn-1")); + const tabId = store.createTab("conn-1", "analytics", "Query", "query", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + tab.lastExecutedSql = "SELECT * FROM events"; + tab.result = { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + truncated: false, + has_more: true, + }; + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + const url = String(input); + if (url === "/api/query/prepare-pagination-plan") { + const body = JSON.parse(String(init?.body ?? "{}")); + const pagination = body.options.pagination; + planLimits.push(pagination.limit); + return new Response( + JSON.stringify({ + sqlToExecute: `SELECT * FROM events LIMIT ${pagination.limit} OFFSET ${pagination.offset}`, + pageLimit: pagination.limit, + pageOffset: pagination.offset, + useAgentResultSession: false, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/execute-multi") { + const body = JSON.parse(String(init?.body ?? "{}")); + executeMaxRows.push(body.maxRows); + const start = executeMaxRows.length === 1 ? 1 : 10_001; + return new Response( + JSON.stringify([ + { + columns: ["id"], + rows: Array.from({ length: body.maxRows }, (_, index) => [start + index]), + affected_rows: 0, + execution_time_ms: 1, + has_more: true, + }, + ]), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "/api/query/close-client-session") { + return new Response(JSON.stringify(true), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const exported = await store.fetchTabResultForExport(tabId); + + assert.deepEqual(planLimits, [10_000, 5_000]); + assert.deepEqual(executeMaxRows, [10_000, 5_000]); + assert.equal(exported?.rows.length, 15_000); + assert.deepEqual(exported?.rows.at(-1), [15_000]); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("buildQueryResultExportRequest uses sorted SQL and independent row-limit settings", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); @@ -1961,6 +2038,45 @@ test("buildQueryResultExportRequest uses exportRowLimit when enabled", async () } }); +test("buildQueryResultExportRequest caps progress total when export row limit is enabled", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ exportBatchSize: 2500, exportRowLimit: 100000, exportRowLimitEnabled: true }); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-1")); + const tabId = store.createTab("conn-1", "analytics", "Query", "query", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + tab.lastExecutedSql = "SELECT * FROM events"; + tab.resultTotalRowCount = 120000; + tab.result = { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + }; + + globalThis.fetch = withConnectionHealthMock(async () => new Response("unexpected request", { status: 500 })); + + try { + const request = await store.buildQueryResultExportRequest(tabId, { + exportId: "export-3", + filePath: "C:\\tmp\\events.csv", + format: "csv", + }); + + assert.equal(request?.rowLimit, 100000); + assert.equal(request?.totalRows, 100000); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("query execution finishes without waiting for metadata analysis", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 97cee58de5..fbe0ae1e2b 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -88,14 +88,14 @@ test("keeps a manually saved 10000 export batch size after migration", () => { }); test("defaults query-result export row limit settings", () => { - assert.equal(DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled, true); + assert.equal(DEFAULT_EDITOR_SETTINGS.exportRowLimitEnabled, false); assert.equal(DEFAULT_EDITOR_SETTINGS.exportRowLimit, 100000); assert.equal(DEFAULT_EDITOR_SETTINGS.queryExportKeysetOptimizationEnabled, true); - assert.equal(normalizeEditorSettings({}).exportRowLimitEnabled, true); + assert.equal(normalizeEditorSettings({}).exportRowLimitEnabled, false); assert.equal(normalizeEditorSettings({}).exportRowLimit, 100000); assert.equal(normalizeEditorSettings({}).queryExportKeysetOptimizationEnabled, true); - assert.equal(normalizeEditorSettings({ exportRowLimitEnabled: false }).exportRowLimitEnabled, false); - assert.equal(normalizeEditorSettings({ exportRowLimitEnabled: "nope" as any }).exportRowLimitEnabled, true); + assert.equal(normalizeEditorSettings({ exportRowLimitEnabled: true }).exportRowLimitEnabled, true); + assert.equal(normalizeEditorSettings({ exportRowLimitEnabled: "nope" as any }).exportRowLimitEnabled, false); assert.equal(normalizeEditorSettings({ exportRowLimit: 250000 }).exportRowLimit, 250000); assert.equal(normalizeEditorSettings({ exportRowLimit: 10 }).exportRowLimit, 100000); assert.equal(normalizeEditorSettings({ queryExportKeysetOptimizationEnabled: false }).queryExportKeysetOptimizationEnabled, false); diff --git a/packages/app-tests/useDataGridExport.test.ts b/packages/app-tests/useDataGridExport.test.ts index 26e05c2cd8..016025ca59 100644 --- a/packages/app-tests/useDataGridExport.test.ts +++ b/packages/app-tests/useDataGridExport.test.ts @@ -1,6 +1,8 @@ import { strict as assert } from "node:assert"; import { computed, ref } from "vue"; import { beforeEach, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; const apiMock = vi.hoisted(() => ({ startQueryResultExport: vi.fn(), @@ -21,6 +23,24 @@ vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); const { useDataGridExport } = await import("../../apps/desktop/src/composables/useDataGridExport.ts"); +function installMemoryStorage() { + const values = new Map(); + const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear(), + }, + }); + return () => { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); + }; +} + function buildExportHarness() { const exportProgressDialog = ref(false); const exportProgressState = ref({ @@ -101,12 +121,75 @@ function buildExportHarness() { }; } +function buildTableDataExportHarness() { + const exportProgressDialog = ref(false); + const exportProgressState = ref({ + title: "", + tableName: "", + format: "csv", + rowsExported: 0, + totalRows: null as number | null, + status: "", + errorMessage: null as string | null, + }); + const exportCancelHandler = ref<(() => Promise) | null>(null); + + const composable = useDataGridExport({ + columns: computed(() => ["id", "name"]), + displayItems: computed(() => [ + { id: 1, data: [1, "Ada"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" }, + { id: 2, data: [2, "Lin"], isNew: false, isDeleted: false, isDirtyCol: [false, false], status: "" }, + ]), + sql: computed(() => undefined), + tableMeta: computed(() => ({ + schema: "public", + tableName: "users", + columns: [ + { name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + primaryKeys: ["id"], + })), + databaseType: computed(() => "postgres"), + connectionId: computed(() => "conn-1"), + database: computed(() => "db"), + context: computed(() => "table-data"), + sourceColumns: computed(() => undefined), + columnTypes: computed(() => ["integer", "text"]), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + selectedRange: computed(() => null), + contextCell: ref(null), + getRowItem: () => undefined, + selectedRowIds: ref(new Set()), + hasRowSelection: computed(() => false), + exportProgressDialog, + exportProgressState, + exportCancelHandler, + }); + + return { + composable, + exportProgressDialog, + exportProgressState, + exportCancelHandler, + }; +} + beforeEach(() => { + setActivePinia(createPinia()); vi.clearAllMocks(); apiMock.startQueryResultExport.mockImplementation(async (_request, onProgress) => { onProgress({ exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" }); return { exportId: _request.exportId, tableName: "", rowsExported: 2, totalRows: 2, status: "Done" }; }); + apiMock.startTableExport.mockImplementation(async (_request, onProgress) => { + onProgress({ exportId: _request.exportId, tableName: _request.tableName, rowsExported: 2, totalRows: 2, status: "Done" }); + return { exportId: _request.exportId, tableName: _request.tableName, rowsExported: 2, totalRows: 2, status: "Done" }; + }); }); test("full query result CSV export streams through the backend without loading all rows", async () => { @@ -214,3 +297,48 @@ test("cancelled query result CSV export clears the cancel handler without using assert.equal(exportProgressState.value.errorMessage, "Export cancelled"); assert.equal(exportCancelHandler.value, null); }); + +test("table data export leaves row limit unset by default", async () => { + const restoreStorage = installMemoryStorage(); + try { + const { composable } = buildTableDataExportHarness(); + + await composable.exportCsv(); + + assert.equal(apiMock.startTableExport.mock.calls.length, 1); + assert.equal(apiMock.startTableExport.mock.calls[0][0].rowLimit, null); + } finally { + restoreStorage(); + } +}); + +test("table data export requests row count for determinate progress", async () => { + const restoreStorage = installMemoryStorage(); + try { + const { composable, exportProgressState } = buildTableDataExportHarness(); + + await composable.exportCsv(); + + assert.equal(apiMock.startTableExport.mock.calls.length, 1); + assert.equal(apiMock.startTableExport.mock.calls[0][0].skipCount, false); + assert.equal(exportProgressState.value.totalRows, 2); + } finally { + restoreStorage(); + } +}); + +test("table data export passes row limit when enabled", async () => { + const restoreStorage = installMemoryStorage(); + try { + const settingsStore = useSettingsStore(); + settingsStore.updateEditorSettings({ exportRowLimitEnabled: true, exportRowLimit: 12_345 }); + const { composable } = buildTableDataExportHarness(); + + await composable.exportCsv(); + + assert.equal(apiMock.startTableExport.mock.calls.length, 1); + assert.equal(apiMock.startTableExport.mock.calls[0][0].rowLimit, 12_345); + } finally { + restoreStorage(); + } +});