fix(export): split XLSX sheets and stream worksheet XML

This commit is contained in:
Abeautifulsnow 2026-07-31 19:45:37 +08:00 committed by GitHub
parent ecac4800c4
commit c43e8455c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1006 additions and 250 deletions

View File

@ -38,12 +38,6 @@ const WINDOWS_JRE_REMOVE_ERROR = [
// Every backend message changed away from hardcoded Chinese, paired with the
// key and params it must resolve to.
const CASES: { name: string; message: string; key: string; params?: Record<string, string> }[] = [
{
name: "XLSX row limit",
message: "XLSX supports at most 1,048,575 data rows. Use CSV export for the full result.",
key: "exportProgress.xlsxRowLimit",
params: { limit: "1,048,575" },
},
{
name: "streaming export unsupported",
message: "Streaming export is unsupported for this query. Simplify it or use a supported driver.",
@ -172,7 +166,6 @@ describe("backend error wording is pinned to the Rust sources", () => {
const rust = (path: string) => readFileSync(new URL(`../../../../../${path}`, import.meta.url), "utf8");
test.each([
["crates/dbx-core/src/query_result_export.rs", "XLSX supports at most 1,048,575 data rows. Use CSV export for the full result."],
["crates/dbx-core/src/query_result_export.rs", "Streaming export is unsupported for this query. Simplify it or use a supported driver."],
["crates/dbx-core/src/query_result_export.rs", "Streaming export needs a result-set session, but this driver returned no session_id."],
["crates/dbx-core/src/agent_service.rs", "Failed to remove the old JRE directory: "],

View File

@ -64,7 +64,6 @@ const patterns: [RegExp, string][] = [
[/^Unsupported SOCKS bound address type: (\d+)$/, "settings.tunnelsSocksUnsupportedAddrType"],
// Query result export limits (crates/dbx-core/src/query_result_export.rs)
[/^XLSX supports at most ([\d,]+) data rows\. Use CSV export for the full result\.$/, "exportProgress.xlsxRowLimit"],
[/^Streaming export is unsupported for this query\. Simplify it or use a supported driver\.$/, "exportProgress.streamingUnsupported"],
[/^Streaming export needs a result-set session, but this driver returned no session_id\.$/, "exportProgress.agentSessionMissing"],
@ -105,7 +104,6 @@ const paramNames: Record<string, string | string[]> = {
"settings.tunnelsSocksUnsupportedAuth": "method",
"settings.tunnelsSocksConnectRejected": "code",
"settings.tunnelsSocksUnsupportedAddrType": "type",
"exportProgress.xlsxRowLimit": "limit",
"driverStore.jreDirRemoveFailedWindows": ["path", "error"],
"driverStore.jreDirRemoveFailed": ["path", "error"],
"driverStore.jreInUseByDrivers": ["jre", "drivers"],

View File

@ -1447,7 +1447,6 @@ export default {
truncatedHint: "Results were truncated after loading {count} rows. Use the footer pagination to browse loaded data; exporting the full result reruns the database query.",
},
exportProgress: {
xlsxRowLimit: "XLSX supports at most {limit} data rows. Use CSV export for the full result.",
streamingUnsupported: "Streaming export is unsupported for this query. Simplify it or use a supported driver.",
agentSessionMissing: "Streaming export needs a result-set session, but this driver returned no session_id.",
title: "Exporting Table Data",

View File

@ -1389,7 +1389,6 @@ export default withEnglishFallback({
xlsxHeaderComment: "Encabezado usando comentarios",
},
exportProgress: {
xlsxRowLimit: "XLSX admite como máximo {limit} filas de datos. Use la exportación CSV para obtener el resultado completo.",
streamingUnsupported: "La exportación en streaming no es compatible con esta consulta. Simplifíquela o use un controlador compatible.",
agentSessionMissing: "La exportación en streaming requiere una sesión de conjunto de resultados, pero este controlador no devolvió session_id.",
title: "Exportando datos de la tabla",

View File

@ -1387,7 +1387,6 @@ export default withEnglishFallback({
xlsxHeaderComment: "Intestazione con commento",
},
exportProgress: {
xlsxRowLimit: "XLSX supporta al massimo {limit} righe di dati. Usa l'esportazione CSV per il risultato completo.",
streamingUnsupported: "L'esportazione in streaming non è supportata per questa query. Semplificala o usa un driver supportato.",
agentSessionMissing: "L'esportazione in streaming richiede una sessione del set di risultati, ma questo driver non ha restituito session_id.",
title: "Esportazione Dati Tabella",

View File

@ -1414,7 +1414,6 @@ export default withEnglishFallback({
emptyStringValue: "空文字",
},
exportProgress: {
xlsxRowLimit: "XLSX は最大 {limit} 行のデータに対応しています。完全な結果を得るには CSV エクスポートを使用してください。",
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
agentSessionMissing: "ストリーミングエクスポートには結果セットのセッションが必要ですが、このドライバーは session_id を返しませんでした。",
title: "テーブルデータをエクスポート中",

View File

@ -1425,7 +1425,6 @@ export default withEnglishFallback({
truncatedHint: "{count}행 로드 후 결과가 잘렸습니다. 로드된 데이터를 탐색하려면 푸터 페이지 매김을 사용하세요. 전체 결과를 내보내면 데이터베이스 쿼리를 다시 실행합니다.",
},
exportProgress: {
xlsxRowLimit: "XLSX는 최대 {limit}개의 데이터 행을 지원합니다. 전체 결과는 CSV 내보내기를 사용하세요.",
streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
agentSessionMissing: "스트리밍 내보내기에는 결과 집합 세션이 필요하지, 이 드라이버는 session_id를 반환하지 않았습니다.",
title: "테이블 데이터 내보내는 중",

View File

@ -1389,7 +1389,6 @@ export default withEnglishFallback({
xlsxHeaderComment: "Cabeçalho usa comentário",
},
exportProgress: {
xlsxRowLimit: "O XLSX suporta no máximo {limit} linhas de dados. Use a exportação CSV para obter o resultado completo.",
streamingUnsupported: "A exportação em streaming não é compatível com esta consulta. Simplifique-a ou use um driver compatível.",
agentSessionMissing: "A exportação em streaming requer uma sessão de conjunto de resultados, mas este driver não retornou session_id.",
title: "Exportando Dados da Tabela",

View File

@ -1448,7 +1448,6 @@ export default withEnglishFallback({
truncatedHint: "结果已截断,已加载前 {count} 行。可通过底部分页浏览已加载数据;导出完整结果时会重新查询数据库。",
},
exportProgress: {
xlsxRowLimit: "XLSX 最多支持 {limit} 行数据,请改用 CSV 导出完整结果。",
streamingUnsupported: "当前查询暂不支持流式导出,请简化查询或使用受支持的驱动。",
agentSessionMissing: "流式导出需要结果集会话,但当前驱动未返回 session_id。",
title: "导出表数据",

View File

@ -1388,7 +1388,6 @@ export default withEnglishFallback({
xlsxHeaderComment: "表頭使用註解",
},
exportProgress: {
xlsxRowLimit: "XLSX 最多支援 {limit} 列資料,請改用 CSV 匯出完整結果。",
streamingUnsupported: "目前查詢暫不支援串流匯出,請簡化查詢或使用支援的驅動程式。",
agentSessionMissing: "串流匯出需要結果集工作階段,但目前驅動程式未回傳 session_id。",
title: "匯出資料表資料",

View File

@ -11,6 +11,13 @@ export interface XlsxWorksheetData {
numericColumnRightAlign?: boolean;
}
interface XlsxWorksheetSegment {
worksheet: XlsxWorksheetData;
sheetName?: string;
rowStart: number;
rowEnd: number;
}
type ZipEntry = {
path: string;
data: Uint8Array;
@ -20,6 +27,7 @@ type ZipEntry = {
const encoder = new TextEncoder();
const CRC_TABLE = buildCrcTable();
const XLSX_MAX_DATA_ROWS = 1_048_575;
function buildCrcTable(): number[] {
const table: number[] = [];
@ -84,15 +92,20 @@ function normalizeSheetName(value?: string): string {
return name.slice(0, 31);
}
function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string[] {
function appendSheetSuffix(base: string, suffix: number): string {
const suffixText = ` (${suffix})`;
const maxBaseLength = Math.max(0, 31 - [...suffixText].length);
return `${[...base].slice(0, maxBaseLength).join("")}${suffixText}`;
}
function normalizeUniqueSheetNames(sheets: readonly { sheetName?: string }[]): string[] {
const names: string[] = [];
sheets.forEach((sheet, index) => {
const base = normalizeSheetName(sheet.sheetName || `Sheet${index + 1}`);
let candidate = base;
let suffix = 2;
while (names.includes(candidate)) {
const suffixText = ` (${suffix})`;
candidate = `${base.slice(0, 31 - suffixText.length)}${suffixText}`;
candidate = appendSheetSuffix(base, suffix);
suffix += 1;
}
names.push(candidate);
@ -100,10 +113,10 @@ function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string
return names;
}
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[], columnComments?: readonly (string | null)[]): number[] {
function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[], rowStart: number, rowEnd: number, columnComments?: readonly (string | null)[]): number[] {
return columns.map((column, colIndex) => {
const headerText = columnComments?.[colIndex] || column;
const values = rows.slice(0, 100).map((row) => row[colIndex]);
const values = Array.from({ length: Math.min(100, rowEnd - rowStart) }, (_, index) => rows[rowStart + index]?.[colIndex]);
const maxLen = [headerText, ...values.map((value) => (value == null ? "" : String(value)))].map((value) => Math.min(value.length, 60)).reduce((max, length) => Math.max(max, length), 8);
return Math.max(10, Math.min(60, maxLen + 2));
});
@ -143,22 +156,22 @@ function cellXml(value: XlsxCellValue, rowIndex: number, colIndex: number, style
return `<c r="${ref}" t="inlineStr"${styleAttr}><is><t>${escapeXml(String(value))}</t></is></c>`;
}
function worksheetXml(data: XlsxWorksheetData): string {
function worksheetXml(segment: XlsxWorksheetSegment): string {
const data = segment.worksheet;
const columns = data.columns;
const rows = data.rows;
const totalRows = rows.length + 1;
const totalRows = segment.rowEnd - segment.rowStart + 1;
const range = sheetRange(columns.length, totalRows);
const widths = estimateColumnWidths(columns, rows, data.columnComments);
const widths = estimateColumnWidths(columns, rows, segment.rowStart, segment.rowEnd, data.columnComments);
const rightAlignEnabled = data.numericColumnRightAlign !== false;
const colsXml = widths.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join("");
const headerXml = `<row r="1">${columns.map((column, index) => cellXml(data.columnComments?.[index] || column, 0, index, 1)).join("")}</row>`;
const bodyXml = rows
.map((row, rowIndex) => {
const excelRowIndex = rowIndex + 2;
const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, numericColumnStyle(data.columnTypes?.[colIndex], rightAlignEnabled), data.columnTypes?.[colIndex])).join("");
return `<row r="${excelRowIndex}">${cells}</row>`;
})
.join("");
const bodyXml = Array.from({ length: segment.rowEnd - segment.rowStart }, (_, rowIndex) => {
const row = rows[segment.rowStart + rowIndex]!;
const excelRowIndex = rowIndex + 2;
const cells = columns.map((_, colIndex) => cellXml(row[colIndex], excelRowIndex - 1, colIndex, numericColumnStyle(data.columnTypes?.[colIndex], rightAlignEnabled), data.columnTypes?.[colIndex])).join("");
return `<row r="${excelRowIndex}">${cells}</row>`;
}).join("");
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
@ -171,6 +184,30 @@ function worksheetXml(data: XlsxWorksheetData): string {
</worksheet>`;
}
function splitWorksheetsForMaxRows(sheets: readonly XlsxWorksheetData[], maxDataRowsPerSheet: number): XlsxWorksheetSegment[] {
const maxRows = Number.isFinite(maxDataRowsPerSheet) ? Math.max(1, Math.floor(maxDataRowsPerSheet)) : XLSX_MAX_DATA_ROWS;
const segments: XlsxWorksheetSegment[] = [];
for (const worksheet of sheets) {
if (worksheet.rows.length <= maxRows) {
segments.push({ worksheet, sheetName: worksheet.sheetName, rowStart: 0, rowEnd: worksheet.rows.length });
continue;
}
const baseName = normalizeSheetName(worksheet.sheetName);
for (let rowStart = 0, chunkIndex = 0; rowStart < worksheet.rows.length; rowStart += maxRows, chunkIndex += 1) {
segments.push({
worksheet,
sheetName: chunkIndex === 0 ? baseName : appendSheetSuffix(baseName, chunkIndex + 1),
rowStart,
rowEnd: Math.min(rowStart + maxRows, worksheet.rows.length),
});
}
}
return segments;
}
function contentTypesXml(sheetCount = 1): string {
const worksheetOverrides = Array.from({ length: sheetCount }, (_, index) => ` <Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
@ -278,14 +315,19 @@ export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array {
}
export function buildXlsxWorkbookMulti(sheets: readonly XlsxWorksheetData[]): Uint8Array {
return buildXlsxWorkbookMultiWithMaxRows(sheets, XLSX_MAX_DATA_ROWS);
}
export function buildXlsxWorkbookMultiWithMaxRows(sheets: readonly XlsxWorksheetData[], maxDataRowsPerSheet: number): Uint8Array {
if (sheets.length === 0) throw new Error("At least one worksheet is required");
const sheetNames = normalizeUniqueSheetNames(sheets);
const segments = splitWorksheetsForMaxRows(sheets, maxDataRowsPerSheet);
const sheetNames = normalizeUniqueSheetNames(segments);
return createZip([
{ path: "[Content_Types].xml", content: contentTypesXml(sheets.length) },
{ path: "[Content_Types].xml", content: contentTypesXml(segments.length) },
{ path: "_rels/.rels", content: rootRelsXml() },
{ path: "xl/workbook.xml", content: workbookXml(sheetNames) },
{ path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(sheets.length) },
{ path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(segments.length) },
{ path: "xl/styles.xml", content: stylesXml() },
...sheets.map((sheet, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(sheet) })),
...segments.map((segment, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(segment) })),
]);
}

View File

@ -984,6 +984,7 @@ pub async fn run_codex_agent(
#[cfg(test)]
mod tests {
use super::codex_process_env_with_system_proxy;
#[cfg(not(windows))]
use super::shell_quote;
use super::{
@ -996,7 +997,7 @@ mod tests {
DEFAULT_CODEX_MODELS,
};
#[cfg(not(windows))]
use super::{codex_process_env, codex_process_env_with_system_proxy, common_executable_dirs, merged_path_with_dir};
use super::{codex_process_env, common_executable_dirs, merged_path_with_dir};
#[cfg(windows)]
use super::{
direct_program_path, first_windows_program_path, program_path_candidates, resolve_codex_command,

View File

@ -35,8 +35,6 @@ use sqlparser::parser::Parser;
use tokio_util::sync::CancellationToken;
const AGENT_UNBOUNDED_ROW_LIMIT: usize = i32::MAX as usize;
pub const XLSX_MAX_DATA_ROWS: usize = 1_048_575;
const XLSX_ROW_LIMIT_ERROR: &str = "XLSX supports at most 1,048,575 data rows. Use CSV export for the full result.";
const STREAMING_PAGINATION_UNSUPPORTED_ERROR: &str =
"Streaming export is unsupported for this query. Simplify it or use a supported driver.";
const AGENT_SESSION_MISSING_ERROR: &str =
@ -241,11 +239,7 @@ fn progress(
status: ExportStatus,
error_message: Option<String>,
) -> TableExportProgress {
let total_rows = request.total_rows.map(|total| {
let format = request.format.to_lowercase();
let limit = effective_row_limit(&format, request);
limit.map_or(total, |limit| total.min(limit as u64))
});
let total_rows = request.total_rows.map(|total| request.row_limit.map_or(total, |limit| total.min(limit as u64)));
TableExportProgress {
export_id: request.export_id.clone(),
table_name: String::new(),
@ -387,16 +381,8 @@ impl SqlInsertWriter {
}
}
fn effective_row_limit(format: &str, request: &QueryResultExportRequest) -> Option<usize> {
if format == "xlsx" {
Some(request.row_limit.map_or(XLSX_MAX_DATA_ROWS, |limit| limit.min(XLSX_MAX_DATA_ROWS)))
} else {
request.row_limit
}
}
fn xlsx_hard_limit_active(format: &str, request: &QueryResultExportRequest) -> bool {
format == "xlsx" && request.row_limit.is_none_or(|limit| limit > XLSX_MAX_DATA_ROWS)
fn effective_row_limit(request: &QueryResultExportRequest) -> Option<usize> {
request.row_limit
}
fn format_text_export_header(format: &str, columns: &[String]) -> String {
@ -619,18 +605,8 @@ async fn export_query_result_core_inner(
}
let page_size = request.page_size.max(1);
let effective_row_limit = effective_row_limit(&format, request);
let xlsx_hard_limit_active = xlsx_hard_limit_active(&format, request);
if xlsx_hard_limit_active && request.total_rows.is_some_and(|total| total > XLSX_MAX_DATA_ROWS as u64) {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
let agent_max_rows = if xlsx_hard_limit_active {
XLSX_MAX_DATA_ROWS + 1
} else {
effective_row_limit.unwrap_or(AGENT_UNBOUNDED_ROW_LIMIT)
}
.max(1);
let effective_row_limit = effective_row_limit(request);
let agent_max_rows = effective_row_limit.unwrap_or(AGENT_UNBOUNDED_ROW_LIMIT).max(1);
on_progress(progress(request, 0, ExportStatus::Running, None));
@ -695,11 +671,6 @@ async fn export_query_result_core_inner(
break;
}
let this_page = remaining.map_or(page_size, |rem| rem.min(page_size)).max(1);
let fetch_limit = if xlsx_hard_limit_active && remaining.is_some_and(|rem| rem <= page_size) {
this_page.saturating_add(1)
} else {
this_page
};
let (sql_to_execute, plan_limit, use_agent_result_session) = if let Some(plan) = keyset_plan.as_ref() {
(
@ -710,9 +681,9 @@ async fn export_query_result_core_inner(
&request.database_type,
&plan.primary_keys,
&plan.last_pk_values,
fetch_limit,
this_page,
),
fetch_limit,
this_page,
false,
)
} else {
@ -720,7 +691,7 @@ async fn export_query_result_core_inner(
sql: request.sql.clone(),
query_base_sql: request.query_base_sql.clone(),
database_type: Some(request.database_type),
pagination: QueryPagination { limit: fetch_limit, offset, session_id: session_id.clone() },
pagination: QueryPagination { limit: this_page, offset, session_id: session_id.clone() },
use_agent_cursor: request.use_agent_cursor,
first_page_uses_actual_sql: true,
});
@ -801,12 +772,6 @@ async fn export_query_result_core_inner(
}
}
let fetched_row_count = result.rows.len();
if xlsx_hard_limit_active {
let remaining_rows = XLSX_MAX_DATA_ROWS.saturating_sub(rows_exported as usize);
if fetched_row_count > remaining_rows {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
}
if result.rows.len() > this_page {
result.rows.truncate(this_page);
}
@ -964,10 +929,8 @@ async fn try_export_postgres_query_result_stream(
state.touch_pool_activity(&pool_key).await;
let _activity_touch = state.pool_activity_touch(&pool_key);
let xlsx_hard_limit_active = xlsx_hard_limit_active(format, request);
let row_limit = effective_row_limit(format, request);
let stream_row_limit =
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let row_limit = effective_row_limit(request);
let stream_row_limit = row_limit;
let progress_row_interval = request.page_size.max(1) as u64;
let mut columns: Vec<String> = Vec::new();
let mut temporal_column_types: Vec<String> = Vec::new();
@ -1020,9 +983,6 @@ async fn try_export_postgres_query_result_stream(
}
}
crate::db::postgres::PostgresQueryStreamItem::Row(row) => {
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
&row,
&temporal_column_types,
@ -1161,10 +1121,8 @@ async fn try_export_mysql_query_result_stream(
crate::query_execution_sql::check_read_only(&request.sql, &name, database_type)?;
}
let xlsx_hard_limit_active = xlsx_hard_limit_active(format, request);
let row_limit = effective_row_limit(format, request);
let stream_row_limit =
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let row_limit = effective_row_limit(request);
let stream_row_limit = row_limit;
let progress_row_interval = request.page_size.max(1) as u64;
let mut columns: Vec<String> = Vec::new();
let mut temporal_column_types: Vec<String> = Vec::new();
@ -1266,9 +1224,6 @@ async fn try_export_mysql_query_result_stream(
}
}
crate::db::mysql::MySqlQueryStreamItem::Row(row) => {
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
&row,
&temporal_column_types,
@ -1427,10 +1382,8 @@ async fn try_export_clickhouse_query_result_stream(
state.touch_pool_activity(&pool_key).await;
let _activity_touch = state.pool_activity_touch(&pool_key);
let xlsx_hard_limit_active = xlsx_hard_limit_active(format, request);
let row_limit = effective_row_limit(format, request);
let stream_row_limit =
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let row_limit = effective_row_limit(request);
let stream_row_limit = row_limit;
let progress_row_interval = request.page_size.max(1) as u64;
let mut columns: Vec<String> = Vec::new();
let mut temporal_column_types: Vec<String> = Vec::new();
@ -1484,9 +1437,6 @@ async fn try_export_clickhouse_query_result_stream(
}
}
crate::db::clickhouse_driver::ClickHouseQueryStreamItem::Row(row) => {
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
&row,
&temporal_column_types,
@ -1600,10 +1550,8 @@ async fn try_export_sqlserver_query_result_stream(
state.running_queries.set_pool_key(execution_id, pool_key);
}
let xlsx_hard_limit_active = xlsx_hard_limit_active(format, request);
let row_limit = effective_row_limit(format, request);
let stream_row_limit =
if xlsx_hard_limit_active { row_limit.map(|limit| limit.saturating_add(1)) } else { row_limit };
let row_limit = effective_row_limit(request);
let stream_row_limit = row_limit;
let mut columns: Vec<String> = Vec::new();
let mut temporal_column_types: Vec<String> = Vec::new();
let mut rows_exported = 0_u64;
@ -1667,9 +1615,6 @@ async fn try_export_sqlserver_query_result_stream(
}
}
crate::db::sqlserver::SqlServerStreamItem::Row(row) => {
if xlsx_hard_limit_active && rows_exported as usize >= XLSX_MAX_DATA_ROWS {
return Err(XLSX_ROW_LIMIT_ERROR.to_string());
}
let formatted = crate::temporal_format::format_temporal_export_row_with_string_types(
row,
&temporal_column_types,
@ -1865,12 +1810,12 @@ mod tests {
#[test]
fn csv_unlimited_export_has_no_effective_row_limit() {
assert_eq!(effective_row_limit("csv", &request("csv", None, None)), None);
assert_eq!(effective_row_limit(&request("csv", None, None)), None);
}
#[test]
fn txt_unlimited_export_has_no_effective_row_limit() {
assert_eq!(effective_row_limit("txt", &request("txt", None, None)), None);
assert_eq!(effective_row_limit(&request("txt", None, None)), None);
}
#[test]
@ -1879,31 +1824,22 @@ mod tests {
}
#[test]
fn xlsx_unlimited_export_uses_excel_hard_limit() {
assert_eq!(effective_row_limit("xlsx", &request("xlsx", None, None)), Some(XLSX_MAX_DATA_ROWS));
fn xlsx_no_row_limit_has_no_query_layer_cap() {
// Without the old hard limit, XLSX uses the writer's internal splitting.
assert_eq!(effective_row_limit(&request("xlsx", None, None)), None);
}
#[test]
fn xlsx_row_limit_caps_to_excel_hard_limit() {
assert_eq!(
effective_row_limit("xlsx", &request("xlsx", Some(XLSX_MAX_DATA_ROWS + 10), None)),
Some(XLSX_MAX_DATA_ROWS)
);
fn xlsx_user_row_limit_still_respected() {
assert_eq!(effective_row_limit(&request("xlsx", Some(500), None)), Some(500));
}
#[test]
fn xlsx_known_total_above_hard_limit_errors_before_export() {
let req = request("xlsx", None, Some(XLSX_MAX_DATA_ROWS as u64 + 1));
assert!(xlsx_hard_limit_active("xlsx", &req));
assert!(req.total_rows.is_some_and(|total| total > XLSX_MAX_DATA_ROWS as u64));
}
#[test]
fn sql_insert_export_has_no_xlsx_row_cap() {
// SQL format should not be limited by XLSX_MAX_DATA_ROWS.
let req = request("sql", None, None);
assert!(!xlsx_hard_limit_active("sql", &req));
assert_eq!(effective_row_limit("sql", &req), None);
fn xlsx_total_rows_above_sheet_limit_no_longer_errors() {
// total_rows > 1M no longer triggers a pre-check error; the writer splits.
let req = request("xlsx", None, Some(2_000_000));
assert!(effective_row_limit(&req).is_none());
// The function that used to check this (xlsx_hard_limit_active) no longer exists.
}
#[test]

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@ const dialogMock = vi.hoisted(() => ({ save: vi.fn() }));
const toastMock = vi.hoisted(() => vi.fn());
const translateMock = vi.hoisted(() =>
vi.fn((key: string, params?: Record<string, unknown>) => {
if (key === "exportProgress.xlsxRowLimit") return `XLSX 最多支持 ${params?.limit} 行数据,请使用 CSV 导出完整结果。`;
if (key === "exportProgress.streamingUnsupported") return "当前查询暂不支持流式导出,请简化查询或使用受支持的驱动。";
if (key === "grid.exportFailed") return `导出失败:${params?.message}`;
return key;
}),
@ -397,10 +397,10 @@ test("full query result CSV export streams through the backend without loading a
assert.equal(exportProgressState.value.filePath, apiMock.startQueryResultExport.mock.calls[0][0].filePath);
});
test("streaming query result export translates terminal backend errors before the toast", async () => {
const rawMessage = "XLSX supports at most 1,048,575 data rows. Use CSV export for the full result.";
test("streaming query result export translates streaming unsupported error before the toast", async () => {
const rawMessage = "Streaming export is unsupported for this query. Simplify it or use a supported driver.";
apiMock.startQueryResultExport.mockImplementationOnce(async (request, onProgress) => {
onProgress({ exportId: request.exportId, tableName: "", rowsExported: 0, totalRows: 1_048_576, status: "Error", errorMessage: rawMessage });
onProgress({ exportId: request.exportId, tableName: "", rowsExported: 0, totalRows: 0, status: "Error", errorMessage: rawMessage });
throw new Error(rawMessage);
});
const { composable, exportProgressState } = buildExportHarness();
@ -408,7 +408,7 @@ test("streaming query result export translates terminal backend errors before th
await composable.exportXlsx();
assert.equal(exportProgressState.value.errorMessage, rawMessage);
assert.deepEqual(toastMock.mock.calls.at(-1), ["导出失败:XLSX 最多支持 1,048,575 行数据,请使用 CSV 导出完整结果。", 5000]);
assert.deepEqual(toastMock.mock.calls.at(-1), ["导出失败:当前查询暂不支持流式导出,请简化查询或使用受支持的驱动。", 5000]);
});
test("complete local query result XLSX export does not re-execute the query", async () => {

View File

@ -1,8 +1,24 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildXlsxWorkbook, buildXlsxWorkbookMulti } from "../../apps/desktop/src/lib/export/xlsxExport.ts";
import { buildXlsxWorkbook, buildXlsxWorkbookMulti, buildXlsxWorkbookMultiWithMaxRows } from "../../apps/desktop/src/lib/export/xlsxExport.ts";
import { buildXlsxSqlWorksheet } from "../../apps/desktop/src/lib/export/xlsxSqlSheet.ts";
function readStoredZipEntry(workbook: Uint8Array, entryPath: string): string {
const view = new DataView(workbook.buffer, workbook.byteOffset, workbook.byteLength);
let offset = 0;
while (offset + 30 <= workbook.length && view.getUint32(offset, true) === 0x04034b50) {
const compressedSize = view.getUint32(offset + 18, true);
const fileNameLength = view.getUint16(offset + 26, true);
const extraLength = view.getUint16(offset + 28, true);
const fileNameStart = offset + 30;
const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = new TextDecoder().decode(workbook.subarray(fileNameStart, fileNameStart + fileNameLength));
if (fileName === entryPath) return new TextDecoder().decode(workbook.subarray(dataStart, dataStart + compressedSize));
offset = dataStart + compressedSize;
}
throw new Error(`Missing ZIP entry: ${entryPath}`);
}
test("builds an xlsx workbook zip with worksheet data", () => {
const workbook = buildXlsxWorkbook({
sheetName: "Users",
@ -64,6 +80,60 @@ test("builds a result workbook with a separate SQL worksheet", () => {
assert.match(text, /SELECT id, name FROM users WHERE active = true/);
});
test("web in-memory XLSX export splits oversized worksheets", () => {
const workbook = buildXlsxWorkbookMultiWithMaxRows(
[
{
sheetName: "Result",
columns: ["id", "name"],
rows: [
[1, "row_1"],
[2, "row_2"],
[3, "row_3"],
[4, "row_4"],
[5, "row_5"],
],
},
],
2,
);
const workbookXml = readStoredZipEntry(workbook, "xl/workbook.xml");
assert.match(workbookXml, /name="Result"/);
assert.match(workbookXml, /name="Result \(2\)"/);
assert.match(workbookXml, /name="Result \(3\)"/);
const sheet1 = readStoredZipEntry(workbook, "xl/worksheets/sheet1.xml");
const sheet2 = readStoredZipEntry(workbook, "xl/worksheets/sheet2.xml");
const sheet3 = readStoredZipEntry(workbook, "xl/worksheets/sheet3.xml");
assert.match(sheet1, /row_1/);
assert.match(sheet1, /row_2/);
assert.doesNotMatch(sheet1, /row_3/);
assert.match(sheet2, /row_3/);
assert.match(sheet2, /row_4/);
assert.doesNotMatch(sheet2, /row_5/);
assert.match(sheet3, /row_5/);
assert.equal(sheet1.match(/<row r="/g)?.length, 3);
assert.equal(sheet2.match(/<row r="/g)?.length, 3);
assert.equal(sheet3.match(/<row r="/g)?.length, 2);
});
test("web in-memory XLSX export handles a realistic large result", () => {
const rowCount = 20_001;
const rows = Array.from({ length: rowCount }, (_, index) => [index + 1, `user_${index + 1}`, `user_${index + 1}@example.com`, index % 2 === 0, `note-${String(index + 1).padStart(6, "0")}-${"x".repeat(48)}`]);
const workbook = buildXlsxWorkbookMultiWithMaxRows([{ sheetName: "Users", columns: ["id", "name", "email", "active", "notes"], rows }], 10_000);
const workbookXml = readStoredZipEntry(workbook, "xl/workbook.xml");
assert.equal(workbookXml.match(/<sheet /g)?.length, 3);
assert.match(workbookXml, /name="Users \(3\)"/);
assert.match(readStoredZipEntry(workbook, "xl/worksheets/sheet1.xml"), /user_10000@example\.com/);
assert.match(readStoredZipEntry(workbook, "xl/worksheets/sheet2.xml"), /user_20000@example\.com/);
const finalSheet = readStoredZipEntry(workbook, "xl/worksheets/sheet3.xml");
assert.match(finalSheet, /user_20001@example\.com/);
assert.equal(finalSheet.match(/<row r="/g)?.length, 2);
assert.ok(workbook.byteLength > 5_000_000);
});
test("maps multiple result statements and splits SQL at the Excel cell limit", () => {
const bmpPrefix = "x".repeat(32_766);
const longSql = `${bmpPrefix}😀tail`;
@ -130,24 +200,7 @@ test("numeric right-align style is applied consistently across cross-database nu
// Ensures the front-end XLSX classifier covers the same cross-database
// numeric types as the Rust classifier and the grid (ClickHouse wide
// integers, Oracle/Dameng binary floats, SQL Server internal names, etc.).
const columnTypes = [
"Int16",
"Int32",
"Int64",
"Int128",
"UInt256",
"Decimal128(18, 2)",
"Float16",
"BINARY_FLOAT",
"BINARY_DOUBLE",
"decimaln",
"numericn",
"intn",
"floatn",
"moneyn",
"smallmoneyn",
"varchar(50)",
];
const columnTypes = ["Int16", "Int32", "Int64", "Int128", "UInt256", "Decimal128(18, 2)", "Float16", "BINARY_FLOAT", "BINARY_DOUBLE", "decimaln", "numericn", "intn", "floatn", "moneyn", "smallmoneyn", "varchar(50)"];
const workbook = buildXlsxWorkbook({
sheetName: "CrossDb",
columns: columnTypes.map((t) => t.toLowerCase()),