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(() => {
@@ -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();
+ }
+});