fix(export): split XLSX sheets and stream worksheet XML
This commit is contained in:
1 parent
ecac4800c4
commit
c43e8455c4
16 files changed
+1006
-250
No files matched your search
@@ -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: "],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1414,7 +1414,6 @@ export default withEnglishFallback({
|
||||
emptyStringValue: "空文字",
|
||||
},
|
||||
exportProgress: {
|
||||
xlsxRowLimit: "XLSX は最大 {limit} 行のデータに対応しています。完全な結果を得るには CSV エクスポートを使用してください。",
|
||||
streamingUnsupported: "このクエリはストリーミングエクスポートに対応していません。クエリを簡略化するか、対応しているドライバーを使用してください。",
|
||||
agentSessionMissing: "ストリーミングエクスポートには結果セットのセッションが必要ですが、このドライバーは session_id を返しませんでした。",
|
||||
title: "テーブルデータをエクスポート中",
|
||||
|
||||
@@ -1425,7 +1425,6 @@ export default withEnglishFallback({
|
||||
truncatedHint: "{count}행 로드 후 결과가 잘렸습니다. 로드된 데이터를 탐색하려면 푸터 페이지 매김을 사용하세요. 전체 결과를 내보내면 데이터베이스 쿼리를 다시 실행합니다.",
|
||||
},
|
||||
exportProgress: {
|
||||
xlsxRowLimit: "XLSX는 최대 {limit}개의 데이터 행을 지원합니다. 전체 결과는 CSV 내보내기를 사용하세요.",
|
||||
streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
|
||||
agentSessionMissing: "스트리밍 내보내기에는 결과 집합 세션이 필요하지, 이 드라이버는 session_id를 반환하지 않았습니다.",
|
||||
title: "테이블 데이터 내보내는 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1448,7 +1448,6 @@ export default withEnglishFallback({
|
||||
truncatedHint: "结果已截断,已加载前 {count} 行。可通过底部分页浏览已加载数据;导出完整结果时会重新查询数据库。",
|
||||
},
|
||||
exportProgress: {
|
||||
xlsxRowLimit: "XLSX 最多支持 {limit} 行数据,请改用 CSV 导出完整结果。",
|
||||
streamingUnsupported: "当前查询暂不支持流式导出,请简化查询或使用受支持的驱动。",
|
||||
agentSessionMissing: "流式导出需要结果集会话,但当前驱动未返回 session_id。",
|
||||
title: "导出表数据",
|
||||
|
||||
@@ -1388,7 +1388,6 @@ export default withEnglishFallback({
|
||||
xlsxHeaderComment: "表頭使用註解",
|
||||
},
|
||||
exportProgress: {
|
||||
xlsxRowLimit: "XLSX 最多支援 {limit} 列資料,請改用 CSV 匯出完整結果。",
|
||||
streamingUnsupported: "目前查詢暫不支援串流匯出,請簡化查詢或使用支援的驅動程式。",
|
||||
agentSessionMissing: "串流匯出需要結果集工作階段,但目前驅動程式未回傳 session_id。",
|
||||
title: "匯出資料表資料",
|
||||
|
||||
@@ -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) })),
|
||||
]);
|
||||
}
|
||||
Reference in new issue
Block a user