From 261d43c1b77ebcbdb31576fc0a423289535eeb33 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 19 May 2026 11:31:38 +0800 Subject: [PATCH] fix(grid): respect page size row limits --- .../editor/EditorSettingsDialog.vue | 30 ------- apps/desktop/src/components/grid/DataGrid.vue | 39 +++++--- .../src/components/layout/ContentArea.vue | 2 + apps/desktop/src/i18n/locales/en.ts | 6 +- apps/desktop/src/i18n/locales/zh-CN.ts | 5 +- apps/desktop/src/stores/queryStore.ts | 6 +- crates/dbx-core/src/db/clickhouse_driver.rs | 27 ++++-- crates/dbx-core/src/db/mysql.rs | 26 ++++-- crates/dbx-core/src/db/postgres.rs | 35 ++++++-- crates/dbx-core/src/db/sqlite.rs | 19 +++- crates/dbx-core/src/db/sqlserver.rs | 44 +++++++-- crates/dbx-core/src/query.rs | 89 ++++++++++++++----- packages/app-tests/paginationPageSize.test.ts | 76 ++++++++++++++++ 13 files changed, 302 insertions(+), 102 deletions(-) diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 194df4202..ef31b99ae 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -24,7 +24,6 @@ import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { aiTestConnection } from "@/lib/api"; import { eventToShortcut } from "@/lib/keyboardShortcuts"; -import { MAX_RESULT_PAGE_SIZE, MIN_RESULT_PAGE_SIZE, normalizeResultPageSize } from "@/lib/paginationPageSize"; import { SHORTCUT_DEFINITIONS, findShortcutConflict, @@ -52,7 +51,6 @@ const editFontFamily = ref(settingsStore.editorSettings.fontFamily); const editFontSize = ref(settingsStore.editorSettings.fontSize); const editTheme = ref(settingsStore.editorSettings.theme); const editExecuteMode = ref(settingsStore.editorSettings.executeMode); -const editPageSize = ref(settingsStore.editorSettings.pageSize); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editRedisScanPageSize = ref(settingsStore.editorSettings.redisScanPageSize); @@ -69,7 +67,6 @@ watch( editFontSize.value = settingsStore.editorSettings.fontSize; editTheme.value = settingsStore.editorSettings.theme; editExecuteMode.value = settingsStore.editorSettings.executeMode; - editPageSize.value = settingsStore.editorSettings.pageSize; editWordWrap.value = settingsStore.editorSettings.wordWrap; editAppLayout.value = settingsStore.editorSettings.appLayout; editRedisScanPageSize.value = settingsStore.editorSettings.redisScanPageSize; @@ -93,7 +90,6 @@ function hasChanges(): boolean { editFontSize.value !== settingsStore.editorSettings.fontSize || editTheme.value !== settingsStore.editorSettings.theme || editExecuteMode.value !== settingsStore.editorSettings.executeMode || - editPageSize.value !== settingsStore.editorSettings.pageSize || editWordWrap.value !== settingsStore.editorSettings.wordWrap || editAppLayout.value !== settingsStore.editorSettings.appLayout || editRedisScanPageSize.value !== settingsStore.editorSettings.redisScanPageSize || @@ -109,7 +105,6 @@ function applySettings() { fontSize: editFontSize.value, theme: editTheme.value, executeMode: editExecuteMode.value, - pageSize: normalizeResultPageSize(editPageSize.value), wordWrap: editWordWrap.value, appLayout: editAppLayout.value, redisScanPageSize: editRedisScanPageSize.value, @@ -124,7 +119,6 @@ function resetDefaults() { editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize; editTheme.value = DEFAULT_EDITOR_SETTINGS.theme; editExecuteMode.value = DEFAULT_EDITOR_SETTINGS.executeMode; - editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editAppLayout.value = DEFAULT_EDITOR_SETTINGS.appLayout; editRedisScanPageSize.value = DEFAULT_EDITOR_SETTINGS.redisScanPageSize; @@ -136,10 +130,6 @@ function onExecuteModeChange(v: any) { if (v === "all" || v === "current") editExecuteMode.value = v; } -function onPageSizeInput(event: Event) { - editPageSize.value = normalizeResultPageSize((event.target as HTMLInputElement).value); -} - function onFontFamilyChange(v: any) { if (typeof v === "string") editFontFamily.value = v; } @@ -574,26 +564,6 @@ watch( - -
-
- - - {{ t("settings.resultPageSizeOption", { count: editPageSize }) }} - -
- -

{{ t("settings.resultPageSizeDescription") }}

-
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index d14ce9e2e..33b7ba061 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1130,6 +1130,12 @@ const customPageSizeInput = ref(String(pageSize.value)); watch(pageSize, (value) => { customPageSizeInput.value = String(value); }); +watch( + () => settingsStore.editorSettings.pageSize, + (value) => { + pageSize.value = normalizeResultPageSize(value, pageSize.value); + }, +); watch( () => [props.pageOffset, props.pageLimit], ([offset, limit]) => { @@ -1141,6 +1147,9 @@ watch( ); const canGoNextPage = computed(() => props.result.has_more === true || props.result.rows.length >= pageSize.value); const canJumpLastPage = computed(() => canGoNextPage.value && (!!props.tableMeta || !!props.countSql)); +const showTruncationWarning = computed( + () => props.result.truncated === true && typeof props.pageLimit !== "number" && props.result.has_more !== true, +); const isResultsContext = computed(() => props.context === "results"); const resultEditStatus = computed(() => { if (!isResultsContext.value || !hasData.value) return null; @@ -2773,10 +2782,10 @@ defineExpose({
- {{ t("grid.truncatedHint") }} + {{ t("grid.truncatedHint", { count: pageSize }) }}
@@ -3947,7 +3956,7 @@ defineExpose({
{{ t("grid.totalRows", { count: result.rows.length }) }} - (truncated) + (truncated) {{ t("grid.rowsAffected", { count: result.affected_rows }) }} {{ result.execution_time_ms }}ms {{ selectionSummary }} @@ -3966,26 +3975,36 @@ defineExpose({ {{ pageSize }}{{ t("grid.rowsPerPageShort") }} - + {{ s }} {{ t("grid.rowsPerPageShort") }} {{ t("grid.customRowsPerPage") }} -
+
- + + + + + {{ t("grid.applyPageSize") }} +
diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index a45d67db0..cd10f174c 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -534,6 +534,8 @@ defineExpose({ focusSearch, refreshData }); :connection-id="activeTab.connectionId" :database="activeTab.database" :table-meta="activeTab.tableMeta" + :page-offset="activeTab.resultPageOffset" + :page-limit="activeTab.resultPageLimit" :on-execute-sql="async (sql: string) => emit('executeSql', sql)" @update:where-input="(v: string) => (activeTab.whereInput = v)" @reload=" diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5615851ec..f5647040f 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -403,7 +403,7 @@ export default { "metadata-unavailable": "DBX could not load table metadata, so result editing is disabled.", }, sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.", - truncatedHint: "Results truncated to 10,000 rows. Use LIMIT/OFFSET in your query to paginate.", + truncatedHint: "Results truncated to {count} rows. Use the footer pagination or adjust rows per page.", }, welcome: { title: "Database Workspace", @@ -1139,10 +1139,6 @@ export default { executeMode: "Execute Mode (Cmd+Enter)", executeModeAll: "Execute all SQL", executeModeCurrent: "Execute statement at cursor", - resultPageSize: "Query result rows per page", - resultPageSizeDescription: - "Used for new queries, table browsing, and result pagination. Very large values may slow queries and rendering.", - resultPageSizeOption: "{count} rows/page", wordWrap: "Word wrap", wordWrapDescription: "Wrap long SQL lines within the editor width", redisScanPageSize: "Redis scan count", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 00ccf5640..4705eaea1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -395,7 +395,7 @@ export default { "metadata-unavailable": "无法读取目标表元数据,暂不能启用结果编辑。", }, sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。", - truncatedHint: "结果已截断,仅显示前 10,000 行。如需更多数据,请使用 LIMIT/OFFSET 分页查询。", + truncatedHint: "结果已截断,仅显示前 {count} 行。可通过底部分页继续加载,或调整每页行数。", }, welcome: { title: "数据库工作台", @@ -1116,9 +1116,6 @@ export default { executeMode: "执行模式 (Cmd+Enter)", executeModeAll: "执行全部 SQL", executeModeCurrent: "执行光标所在语句", - resultPageSize: "查询结果每页行数", - resultPageSizeDescription: "用于新查询、表数据浏览和分页跳转。设置过大可能会降低查询和渲染速度。", - resultPageSizeOption: "{count} 行/页", wordWrap: "自动换行", wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示", redisScanPageSize: "Redis 扫描数量", diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index fb8a2bc55..7f01d4370 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -466,9 +466,9 @@ export const useQueryStore = defineStore("query", () => { const connStore = useConnectionStore(); const conn = connStore.getConfig(tab.connectionId); const useAgentCursor = !!conn?.db_type && AGENT_DRIVER_TYPES.has(conn.db_type); + const settingsStore = useSettingsStore(); await closeResultSession(tab, options?.pagination?.sessionId); if (tab.mode === "query") { - const settingsStore = useSettingsStore(); const pagination = options?.pagination ?? { limit: settingsStore.editorSettings.pageSize, offset: 0 }; const plan = buildQueryPaginationExecutionPlan({ sql, @@ -483,6 +483,8 @@ export const useQueryStore = defineStore("query", () => { pageOffset = plan.pageOffset; countSql = plan.countSql; useAgentResultSession = plan.useAgentResultSession; + } else if (tab.mode === "data") { + pageLimit = settingsStore.editorSettings.pageSize; } const mongoFind = conn?.db_type === "mongodb" ? parseMongoFindCommand(sql) : null; if (mongoFind) { @@ -522,7 +524,7 @@ export const useQueryStore = defineStore("query", () => { typeof pageLimit === "number" ? useAgentResultSession ? { - maxRows: 10000, + maxRows: pageLimit, fetchSize: pageLimit, pageSize: pageLimit, resultSessionId: options?.pagination?.sessionId, diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index b8ee736fb..7e2bcb989 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -96,12 +96,17 @@ async fn ch_query_with_limit( resp.json::().await.map_err(|e| format!("ClickHouse parse error: {e}")) } -fn limited_query_result(result: ChJsonResult, execution_time_ms: u128) -> QueryResult { +fn query_result_row_limit(max_rows: Option) -> usize { + max_rows.unwrap_or(MAX_ROWS).max(1) +} + +fn limited_query_result(result: ChJsonResult, execution_time_ms: u128, max_rows: Option) -> QueryResult { let columns: Vec = result.meta.iter().map(|c| c.name.clone()).collect(); let mut rows = result.data; - let truncated = rows.len() > MAX_ROWS; + let row_limit = query_result_row_limit(max_rows); + let truncated = rows.len() > row_limit; if truncated { - rows.truncate(MAX_ROWS); + rows.truncate(row_limit); } QueryResult { columns, rows, affected_rows: 0, execution_time_ms, truncated, session_id: None, has_more: false } } @@ -188,11 +193,21 @@ pub async fn get_columns(client: &ChClient, database: &str, table: &str) -> Resu } pub async fn execute_query(client: &ChClient, database: &str, sql: &str) -> Result { + execute_query_with_max_rows(client, database, sql, None).await +} + +pub async fn execute_query_with_max_rows( + client: &ChClient, + database: &str, + sql: &str, + max_rows: Option, +) -> Result { let start = Instant::now(); + let row_limit = query_result_row_limit(max_rows); if starts_with_executable_sql_keyword(sql, &["SELECT", "SHOW", "DESCRIBE", "EXPLAIN", "WITH"]) { - let result = ch_query_with_limit(client, sql, Some(database), QueryResultLimit::Limited(MAX_ROWS + 1)).await?; - Ok(limited_query_result(result, start.elapsed().as_millis())) + let result = ch_query_with_limit(client, sql, Some(database), QueryResultLimit::Limited(row_limit + 1)).await?; + Ok(limited_query_result(result, start.elapsed().as_millis(), Some(row_limit))) } else { let url = build_query_url(&client.base_url, Some(database), QueryResultLimit::Unlimited); let req = build_request(client, client.http.post(&url).body(sql.to_string())); @@ -239,7 +254,7 @@ mod tests { rows: crate::query::MAX_ROWS + 1, }; - let result = limited_query_result(result, 12); + let result = limited_query_result(result, 12, None); assert_eq!(result.columns, vec!["id"]); assert_eq!(result.rows.len(), crate::query::MAX_ROWS); diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index e2e6808e7..5e662895b 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -314,8 +314,22 @@ pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Resul .collect()) } +fn query_result_row_limit(max_rows: Option) -> usize { + max_rows.unwrap_or(crate::query::MAX_ROWS).max(1) +} + pub async fn execute_query(pool: &MySqlPool, sql: &str, bare: bool) -> Result { + execute_query_with_max_rows(pool, sql, bare, None).await +} + +pub async fn execute_query_with_max_rows( + pool: &MySqlPool, + sql: &str, + bare: bool, + max_rows: Option, +) -> Result { let start = Instant::now(); + let row_limit = query_result_row_limit(max_rows); if is_result_set_query(sql) { if bare || requires_text_protocol_query(sql) { @@ -335,14 +349,14 @@ pub async fn execute_query(pool: &MySqlPool, sql: &str, bare: bool) -> Result crate::query::MAX_ROWS { + if result_rows.len() > row_limit { break; } } - let truncated = result_rows.len() > crate::query::MAX_ROWS; + let truncated = result_rows.len() > row_limit; if truncated { - result_rows.truncate(crate::query::MAX_ROWS); + result_rows.truncate(row_limit); } Ok(QueryResult { @@ -369,14 +383,14 @@ pub async fn execute_query(pool: &MySqlPool, sql: &str, bare: bool) -> Result crate::query::MAX_ROWS { + if result_rows.len() > row_limit { break; } } - let truncated = result_rows.len() > crate::query::MAX_ROWS; + let truncated = result_rows.len() > row_limit; if truncated { - result_rows.truncate(crate::query::MAX_ROWS); + result_rows.truncate(row_limit); } Ok(QueryResult { diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index cdac59854..46c3a3c5b 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -333,8 +333,21 @@ pub async fn get_columns(pool: &PgPool, schema: &str, table: &str) -> Result) -> usize { + max_rows.unwrap_or(crate::query::MAX_ROWS).max(1) +} + pub async fn execute_query(pool: &PgPool, sql: &str) -> Result { + execute_query_with_max_rows(pool, sql, None).await +} + +pub async fn execute_query_with_max_rows( + pool: &PgPool, + sql: &str, + max_rows: Option, +) -> Result { let start = Instant::now(); + let row_limit = query_result_row_limit(max_rows); if starts_with_executable_sql_keyword(sql, &["SELECT", "SHOW", "EXPLAIN", "WITH", "TABLE"]) { let mut stream = sqlx::query(sql).persistent(false).fetch(pool); @@ -354,7 +367,7 @@ pub async fn execute_query(pool: &PgPool, sql: &str) -> Result crate::query::MAX_ROWS { + if result_rows.len() > row_limit { break; } } @@ -364,9 +377,9 @@ pub async fn execute_query(pool: &PgPool, sql: &str) -> Result crate::query::MAX_ROWS; + let truncated = result_rows.len() > row_limit; if truncated { - result_rows.truncate(crate::query::MAX_ROWS); + result_rows.truncate(row_limit); } Ok(QueryResult { @@ -394,11 +407,21 @@ pub async fn execute_query(pool: &PgPool, sql: &str) -> Result Result { + execute_query_with_schema_and_max_rows(pool, schema, sql, None).await +} + +pub async fn execute_query_with_schema_and_max_rows( + pool: &PgPool, + schema: &str, + sql: &str, + max_rows: Option, +) -> Result { let mut conn = pool.acquire().await.map_err(|e| e.to_string())?; let set_path = format!("SET search_path TO \"{}\", public", schema); sqlx::query(&set_path).execute(&mut *conn).await.map_err(|e| e.to_string())?; let start = Instant::now(); + let row_limit = query_result_row_limit(max_rows); if starts_with_executable_sql_keyword(sql, &["SELECT", "SHOW", "EXPLAIN", "WITH", "TABLE"]) { let mut stream = sqlx::query(sql).persistent(false).fetch(&mut *conn); @@ -418,7 +441,7 @@ pub async fn execute_query_with_schema(pool: &PgPool, schema: &str, sql: &str) - .map(|i| pg_value_to_json(&row, i, column_types.get(i).map(String::as_str).unwrap_or(""))) .collect(), ); - if result_rows.len() > crate::query::MAX_ROWS { + if result_rows.len() > row_limit { break; } } @@ -429,9 +452,9 @@ pub async fn execute_query_with_schema(pool: &PgPool, schema: &str, sql: &str) - columns = desc.columns().iter().map(|c| c.name().to_string()).collect(); } - let truncated = result_rows.len() > crate::query::MAX_ROWS; + let truncated = result_rows.len() > row_limit; if truncated { - result_rows.truncate(crate::query::MAX_ROWS); + result_rows.truncate(row_limit); } Ok(QueryResult { diff --git a/crates/dbx-core/src/db/sqlite.rs b/crates/dbx-core/src/db/sqlite.rs index 4d2f0e7bb..e77341458 100644 --- a/crates/dbx-core/src/db/sqlite.rs +++ b/crates/dbx-core/src/db/sqlite.rs @@ -162,7 +162,20 @@ pub async fn list_triggers(pool: &SqlitePool, _schema: &str, table: &str) -> Res } pub async fn execute_query(pool: &SqlitePool, sql: &str) -> Result { + execute_query_with_max_rows(pool, sql, None).await +} + +fn query_result_row_limit(max_rows: Option) -> usize { + max_rows.unwrap_or(crate::query::MAX_ROWS).max(1) +} + +pub async fn execute_query_with_max_rows( + pool: &SqlitePool, + sql: &str, + max_rows: Option, +) -> Result { let start = Instant::now(); + let row_limit = query_result_row_limit(max_rows); if starts_with_executable_sql_keyword(sql, &["SELECT", "PRAGMA", "EXPLAIN", "WITH"]) { let desc = pool.describe(sql).await.map_err(|e| e.to_string())?; @@ -191,14 +204,14 @@ pub async fn execute_query(pool: &SqlitePool, sql: &str) -> Result crate::query::MAX_ROWS { + if result_rows.len() > row_limit { break; } } - let truncated = result_rows.len() > crate::query::MAX_ROWS; + let truncated = result_rows.len() > row_limit; if truncated { - result_rows.truncate(crate::query::MAX_ROWS); + result_rows.truncate(row_limit); } Ok(QueryResult { diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 0fbb20c75..15dd43e33 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -13,6 +13,10 @@ use crate::types::{ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, QueryRes pub type SqlServerClient = Client>; const SIMPLE_QUERY_MODULE_KEYWORDS: &[&str] = &["FUNCTION", "PROC", "PROCEDURE", "TRIGGER", "VIEW"]; +fn query_result_row_limit(max_rows: Option) -> usize { + max_rows.unwrap_or(MAX_ROWS).max(1) +} + pub async fn connect( host: &str, port: u16, @@ -64,7 +68,12 @@ fn columns_from_metadata(metadata: &tiberius::ResultMetadata) -> Vec { metadata.columns().iter().map(|c| c.name().to_string()).collect() } -async fn collect_first_result_limited(mut stream: QueryStream<'_>, start: Instant) -> Result { +async fn collect_first_result_limited( + mut stream: QueryStream<'_>, + start: Instant, + max_rows: Option, +) -> Result { + let row_limit = query_result_row_limit(max_rows); let mut columns: Vec = vec![]; let mut rows: Vec> = Vec::new(); let mut truncated = false; @@ -76,7 +85,7 @@ async fn collect_first_result_limited(mut stream: QueryStream<'_>, start: Instan } QueryItem::Metadata(_) => {} QueryItem::Row(row) if row.result_index() == 0 => { - if rows.len() < MAX_ROWS { + if rows.len() < row_limit { rows.push(row_to_json(&row)); } else { truncated = true; @@ -120,7 +129,12 @@ fn push_sqlserver_result_set(results: &mut Vec, result: Option, start: Instant) -> Result, String> { +async fn collect_result_sets_limited( + mut stream: QueryStream<'_>, + start: Instant, + max_rows: Option, +) -> Result, String> { + let row_limit = query_result_row_limit(max_rows); let mut results = Vec::new(); let mut current: Option = None; @@ -140,7 +154,7 @@ async fn collect_result_sets_limited(mut stream: QueryStream<'_>, start: Instant rows: Vec::new(), truncated: false, }); - if result.rows.len() < MAX_ROWS { + if result.rows.len() < row_limit { result.rows.push(row_to_json(&row)); } else { result.truncated = true; @@ -527,14 +541,22 @@ pub async fn list_triggers( } pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result { + execute_query_with_max_rows(client, sql, None).await +} + +pub async fn execute_query_with_max_rows( + client: &mut SqlServerClient, + sql: &str, + max_rows: Option, +) -> Result { let start = Instant::now(); if starts_with_executable_sql_keyword(sql, &["SELECT", "EXEC", "WITH", "TABLE"]) { let stream = client.query(sql, &[]).await.map_err(|e| e.to_string())?; - collect_first_result_limited(stream, start).await + collect_first_result_limited(stream, start, max_rows).await } else if requires_simple_query_batch(sql) { let stream = client.simple_query(sql).await.map_err(|e| e.to_string())?; - let _ = collect_result_sets_limited(stream, start).await?; + let _ = collect_result_sets_limited(stream, start, max_rows).await?; Ok(QueryResult { columns: vec![], rows: vec![], @@ -559,9 +581,17 @@ pub async fn execute_query(client: &mut SqlServerClient, sql: &str) -> Result Result, String> { + execute_batch_with_max_rows(client, sql, None).await +} + +pub async fn execute_batch_with_max_rows( + client: &mut SqlServerClient, + sql: &str, + max_rows: Option, +) -> Result, String> { let start = Instant::now(); let stream = client.simple_query(sql).await.map_err(|e| e.to_string())?; - let mut results = collect_result_sets_limited(stream, start).await?; + let mut results = collect_result_sets_limited(stream, start, max_rows).await?; if results.is_empty() { results.push(QueryResult { diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 3ec29a46b..94c15015d 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -19,8 +19,21 @@ pub struct QueryExecutionOptions { pub result_session_id: Option, } +fn query_result_row_limit(max_rows: Option) -> usize { + max_rows.unwrap_or(MAX_ROWS).max(1) +} + pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result { + duckdb_execute_with_max_rows(con, sql, None) +} + +pub fn duckdb_execute_with_max_rows( + con: &duckdb::Connection, + sql: &str, + max_rows: Option, +) -> Result { let start = std::time::Instant::now(); + let row_limit = query_result_row_limit(max_rows); if starts_with_executable_sql_keyword(sql, &["SELECT", "SHOW", "DESCRIBE", "EXPLAIN", "WITH", "PRAGMA"]) { let mut stmt = con.prepare(sql).map_err(|e| e.to_string())?; @@ -33,9 +46,6 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result= MAX_ROWS { - break; - } let vals: Vec = (0..col_count) .map(|i| { row.get::<_, String>(i) @@ -53,9 +63,15 @@ pub fn duckdb_execute(con: &duckdb::Connection, sql: &str) -> Result row_limit { + break; + } } - let truncated = result_rows.len() >= MAX_ROWS; + let truncated = result_rows.len() > row_limit; + if truncated { + result_rows.truncate(row_limit); + } Ok(db::QueryResult { columns, rows: result_rows, @@ -84,6 +100,7 @@ fn duckdb_execute_for_database( attached_names: &[String], database: Option<&str>, sql: &str, + max_rows: Option, ) -> Result { if let Some(database) = database.map(str::trim).filter(|database| !database.is_empty()) { let catalog = if database == "main" { @@ -93,16 +110,21 @@ fn duckdb_execute_for_database( }; con.execute_batch(&format!("USE {}", duckdb_quote_ident(&catalog))).map_err(|e| e.to_string())?; } - duckdb_execute(con, sql) + duckdb_execute_with_max_rows(con, sql, max_rows) } fn duckdb_quote_ident(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } -pub fn truncate_result(mut result: db::QueryResult) -> db::QueryResult { - if result.rows.len() > MAX_ROWS { - result.rows.truncate(MAX_ROWS); +pub fn truncate_result(result: db::QueryResult) -> db::QueryResult { + truncate_result_with_max_rows(result, None) +} + +pub fn truncate_result_with_max_rows(mut result: db::QueryResult, max_rows: Option) -> db::QueryResult { + let row_limit = query_result_row_limit(max_rows); + if result.rows.len() > row_limit { + result.rows.truncate(row_limit); result.truncated = true; } result @@ -243,11 +265,12 @@ pub async fn do_execute( let sql = sql.to_string(); let database = database.map(str::to_string); let attached_names = duckdb_attached_names; + let max_rows = options.max_rows; drop(connections); wait_for_query(cancel_token, async move { let task = tokio::task::spawn_blocking(move || { let con = con.lock().map_err(|e| e.to_string())?; - duckdb_execute_for_database(&con, &attached_names, database.as_deref(), &sql) + duckdb_execute_for_database(&con, &attached_names, database.as_deref(), &sql, max_rows) }); task.await.map_err(|e| e.to_string())? }) @@ -256,34 +279,46 @@ pub async fn do_execute( PoolKind::Mysql(p, mode) => { let p = p.clone(); let bare = *mode == crate::connection::MysqlMode::Bare; + let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, db::mysql::execute_query(&p, sql, bare)).await + wait_for_query(cancel_token, db::mysql::execute_query_with_max_rows(&p, sql, bare, max_rows)).await } PoolKind::Postgres(p) => { let p = p.clone(); let schema = schema.map(|s| s.to_string()); + let max_rows = options.max_rows; drop(connections); if let Some(schema) = schema { - wait_for_query(cancel_token, db::postgres::execute_query_with_schema(&p, &schema, sql)).await + wait_for_query( + cancel_token, + db::postgres::execute_query_with_schema_and_max_rows(&p, &schema, sql, max_rows), + ) + .await } else { - wait_for_query(cancel_token, db::postgres::execute_query(&p, sql)).await + wait_for_query(cancel_token, db::postgres::execute_query_with_max_rows(&p, sql, max_rows)).await } } PoolKind::Sqlite(p) => { let p = p.clone(); + let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, db::sqlite::execute_query(&p, sql)).await + wait_for_query(cancel_token, db::sqlite::execute_query_with_max_rows(&p, sql, max_rows)).await } PoolKind::ClickHouse(client) => { let client = client.clone(); let database = pool_key.split(':').nth(1).unwrap_or("default").to_string(); + let max_rows = options.max_rows; drop(connections); - wait_for_query(cancel_token, db::clickhouse_driver::execute_query(&client, &database, sql)) - .await - .map(truncate_result) + wait_for_query( + cancel_token, + db::clickhouse_driver::execute_query_with_max_rows(&client, &database, sql, max_rows), + ) + .await + .map(|result| truncate_result_with_max_rows(result, max_rows)) } PoolKind::SqlServer(client) => { let client = client.clone(); + let max_rows = options.max_rows; drop(connections); let mut client = match cancel_token.as_ref() { Some(token) => tokio::select! { @@ -293,15 +328,18 @@ pub async fn do_execute( }, None => client.lock().await, }; - wait_for_query(cancel_token, db::sqlserver::execute_query(&mut client, sql)).await.map(truncate_result) + wait_for_query(cancel_token, db::sqlserver::execute_query_with_max_rows(&mut client, sql, max_rows)) + .await + .map(|result| truncate_result_with_max_rows(result, max_rows)) } PoolKind::Elasticsearch(client) => { let client = client.clone(); let sql = sql.to_string(); + let max_rows = options.max_rows; drop(connections); wait_for_query(cancel_token, db::elasticsearch_driver::execute_rest_query(&client, &sql)) .await - .map(truncate_result) + .map(|result| truncate_result_with_max_rows(result, max_rows)) } PoolKind::Redis(_) => Err("Use Redis-specific commands".to_string()), PoolKind::MongoDb(_) => Err("Use MongoDB-specific commands".to_string()), @@ -309,6 +347,7 @@ pub async fn do_execute( let client = client.clone(); let sql = sql.to_string(); let schema = schema.map(|s| s.to_string()); + let max_rows = options.max_rows; drop(connections); wait_for_query(cancel_token, async move { let mut client = client.lock().await; @@ -324,7 +363,7 @@ pub async fn do_execute( } }) .await - .map(truncate_result) + .map(|result| truncate_result_with_max_rows(result, max_rows)) } PoolKind::ExternalTabular(ext_pool) => { if !starts_with_executable_sql_keyword(sql, &["SELECT", "WITH", "SHOW", "DESCRIBE", "EXPLAIN", "PRAGMA"]) { @@ -332,11 +371,12 @@ pub async fn do_execute( } let con = ext_pool.cache.clone(); let sql = sql.to_string(); + let max_rows = options.max_rows; drop(connections); wait_for_query(cancel_token, async move { let task = tokio::task::spawn_blocking(move || { let con = con.lock().map_err(|e| e.to_string())?; - duckdb_execute(&con, &sql) + duckdb_execute_with_max_rows(&con, &sql, max_rows) }); task.await.map_err(|e| e.to_string())? }) @@ -348,13 +388,14 @@ pub async fn do_execute( let sql = sql.to_string(); let schema = schema.map(str::to_string); let database = config.effective_database().unwrap_or("").to_string(); + let max_rows = options.max_rows; drop(connections); wait_for_query(cancel_token, async move { let params = external_driver_query_params(&config, &sql, &database, schema.as_deref()); session.invoke::("executeQuery", params).await }) .await - .map(truncate_result) + .map(|result| truncate_result_with_max_rows(result, max_rows)) } } } @@ -490,7 +531,7 @@ pub async fn execute_multi_core_with_options( }; if is_sqlserver { - return execute_multi_sqlserver(state, &pool_key, sql, cancel_token).await; + return execute_multi_sqlserver(state, &pool_key, sql, cancel_token, options).await; } let statements = split_sql_statements(sql); @@ -547,9 +588,11 @@ async fn execute_multi_sqlserver( pool_key: &str, sql: &str, cancel_token: Option, + options: QueryExecutionOptions, ) -> Result, String> { let batches = split_sql_batches(sql); let mut all_results = Vec::new(); + let max_rows = options.max_rows; for batch in &batches { if is_canceled(&cancel_token) { @@ -582,7 +625,7 @@ async fn execute_multi_sqlserver( None => client.lock().await, }; - match db::sqlserver::execute_batch(&mut client, batch).await { + match db::sqlserver::execute_batch_with_max_rows(&mut client, batch, max_rows).await { Ok(results) => all_results.extend(results), Err(e) => { all_results.push(db::QueryResult { diff --git a/packages/app-tests/paginationPageSize.test.ts b/packages/app-tests/paginationPageSize.test.ts index 4064cd672..d5ab8aec2 100644 --- a/packages/app-tests/paginationPageSize.test.ts +++ b/packages/app-tests/paginationPageSize.test.ts @@ -28,3 +28,79 @@ test("data grid page size menu exposes a custom input", () => { assert.match(source, /settingsStore\.updateEditorSettings\(\{ pageSize: normalizedSize \}\)/); assert.match(source, /t\("grid\.customRowsPerPage"\)/); }); + +test("data grid page size follows the global editor setting", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + + assert.match(source, /\(\) => settingsStore\.editorSettings\.pageSize/); + assert.match(source, /pageSize\.value = normalizeResultPageSize\(value, pageSize\.value\)/); +}); + +test("truncated result copy uses the active page size", () => { + const gridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + const zhSource = readFileSync("apps/desktop/src/i18n/locales/zh-CN.ts", "utf8"); + const enSource = readFileSync("apps/desktop/src/i18n/locales/en.ts", "utf8"); + + assert.match(gridSource, /const showTruncationWarning = computed/); + assert.match(gridSource, /v-if="showTruncationWarning"/); + assert.match(gridSource, /t\("grid\.truncatedHint", \{ count: pageSize \}\)/); + assert.match(zhSource, /结果已截断,仅显示前 \{count\} 行/); + assert.match(enSource, /Results truncated to \{count\} rows/); + assert.doesNotMatch(zhSource, /仅显示前 10,000 行/); + assert.doesNotMatch(enSource, /truncated to 10,000 rows/); +}); + +test("query execution sends the selected page size to agent drivers", () => { + const source = readFileSync("apps/desktop/src/stores/queryStore.ts", "utf8"); + + assert.match(source, /if \(tab\.mode === "data"\) \{/); + assert.match(source, /pageLimit = settingsStore\.editorSettings\.pageSize/); + assert.match(source, /maxRows: pageLimit,\s*fetchSize: pageLimit,\s*pageSize: pageLimit/s); + assert.doesNotMatch(source, /maxRows: 10000,\s*fetchSize: pageLimit,\s*pageSize: pageLimit/s); +}); + +test("native sql drivers receive the selected row limit", () => { + const querySource = readFileSync("crates/dbx-core/src/query.rs", "utf8"); + const postgresSource = readFileSync("crates/dbx-core/src/db/postgres.rs", "utf8"); + const mysqlSource = readFileSync("crates/dbx-core/src/db/mysql.rs", "utf8"); + const sqliteSource = readFileSync("crates/dbx-core/src/db/sqlite.rs", "utf8"); + const sqlserverSource = readFileSync("crates/dbx-core/src/db/sqlserver.rs", "utf8"); + const clickhouseSource = readFileSync("crates/dbx-core/src/db/clickhouse_driver.rs", "utf8"); + + assert.match(querySource, /let max_rows = options\.max_rows/); + assert.match(querySource, /db::postgres::execute_query_with_max_rows\(&p, sql, max_rows\)/); + assert.match(querySource, /db::mysql::execute_query_with_max_rows\(&p, sql, bare, max_rows\)/); + assert.match(querySource, /db::sqlite::execute_query_with_max_rows\(&p, sql, max_rows\)/); + assert.match(querySource, /db::clickhouse_driver::execute_query_with_max_rows\(&client, &database, sql, max_rows\)/); + assert.match(querySource, /db::sqlserver::execute_query_with_max_rows\(&mut client, sql, max_rows\)/); + assert.match(querySource, /truncate_result_with_max_rows\(result, max_rows\)/); + assert.match(postgresSource, /let row_limit = query_result_row_limit\(max_rows\)/); + assert.match(mysqlSource, /let row_limit = query_result_row_limit\(max_rows\)/); + assert.match(sqliteSource, /let row_limit = query_result_row_limit\(max_rows\)/); + assert.match(sqlserverSource, /let row_limit = query_result_row_limit\(max_rows\)/); + assert.match(clickhouseSource, /let row_limit = query_result_row_limit\(max_rows\)/); +}); + +test("table data grid receives pagination context", () => { + const source = readFileSync("apps/desktop/src/components/layout/ContentArea.vue", "utf8"); + + assert.match(source, /:page-offset="activeTab\.resultPageOffset"/); + assert.match(source, /:page-limit="activeTab\.resultPageLimit"/); +}); + +test("data grid page size menu keeps the custom control compact", () => { + const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); + + assert.match(source, /DropdownMenuContent align="end" class="w-36"/); + assert.match(source, /class="h-7 w-24 text-xs tabular-nums/); + assert.match(source, /:aria-label="t\('grid\.applyPageSize'\)"/); + assert.doesNotMatch(source, /\s*\{\{ t\("grid\.applyPageSize"\) \}\}/); +}); + +test("editor settings dialog does not duplicate result page size controls", () => { + const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8"); + + assert.doesNotMatch(source, /editPageSize/); + assert.doesNotMatch(source, /settings\.resultPageSize/); + assert.doesNotMatch(source, /pageSize: normalizeResultPageSize/); +});