fix(export): export decimal columns as numeric in full XLSX export

This commit is contained in:
lxk955
2026-10-11 09:14:50 +08:00
committed by GitHub
parent d84539fc42
commit adedde8e46
5 changed files with 142 additions and 4 deletions
@@ -7,6 +7,7 @@ import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
exportQueryResultsXlsx: vi.fn(),
startQueryResultExport: vi.fn().mockResolvedValue({ exportId: "export-1", tableName: "Query Result", rowsExported: 1, totalRows: 1, status: "Done" }),
toast: vi.fn(),
}));
@@ -65,6 +66,8 @@ vi.mock("@/stores/settingsStore", () => ({
vi.mock("@/lib/backend/api", () => ({
exportQueryResultsXlsx: mocks.exportQueryResultsXlsx,
startQueryResultExport: mocks.startQueryResultExport,
cancelQueryResultExport: vi.fn(),
}));
function queryResult(columns: string[], rows: QueryResult["rows"]): QueryResult {
@@ -144,4 +147,32 @@ describe("useDataGridExport XLSX headers", () => {
expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"], autoFilter: false })]), false, undefined);
});
it("passes exportColumnTypes for backend full xlsx query result export", async () => {
const options = createOptions();
options.allColumns = computed(() => ["id", "amount"]);
options.allColumnTypes = computed(() => ["int", "decimal(18, 3)"]);
options.queryResultExportRequest = vi.fn(async (request) => ({
...request,
connectionId: "conn",
database: "dbx",
databaseType: "duckdb",
sql: "SELECT id, amount FROM tab1",
queryBaseSql: "SELECT id, amount FROM tab1",
useAgentCursor: false,
pageSize: 1000,
keysetOptimizationEnabled: true,
}));
const state = useDataGridExport(options);
await state.exportXlsx();
expect(mocks.startQueryResultExport).toHaveBeenCalledWith(
expect.objectContaining({
format: "xlsx",
exportColumnTypes: ["int", "decimal(18, 3)"],
}),
expect.any(Function),
);
});
});
@@ -1601,7 +1601,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
includeSqlSheet,
exportTableName: insertTarget?.tableName,
exportSchema: insertTarget?.tableName ? sqlInsertTargetSchema(insertTarget) : undefined,
exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined,
exportColumnTypes: format === "sql" || format === "xlsx" ? allColumnTypes.value?.map((type) => type ?? null) : undefined,
exportColumnExtras: format === "sql" ? sqlExportColumnExtras(allColumns.value) : undefined,
...(format === "sql" && sqlExportOptions ? { insertMode: sqlExportOptions.insertMode } : {}),
});
@@ -101,6 +101,12 @@ const NUMERIC_COLUMN_TYPE_BASES = new Set([
"int",
"integer",
"bigint",
"hugeint",
"uhugeint",
"utinyint",
"usmallint",
"uinteger",
"ubigint",
"serial",
"smallserial",
"bigserial",
@@ -262,6 +262,27 @@ fn query_sql_worksheets(request: &QueryResultExportRequest) -> Vec<XlsxWorksheet
}]
}
fn resolve_export_column_types(
columns: &[String],
inferred_types: &[String],
overrides: Option<&[Option<String>]>,
) -> Vec<String> {
let overrides = overrides.unwrap_or(&[]);
columns
.iter()
.enumerate()
.map(|(index, _col)| {
if let Some(Some(override_type)) = overrides.get(index) {
let trimmed = override_type.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
inferred_types.get(index).map(|t| t.trim().to_string()).unwrap_or_default()
})
.collect()
}
fn start_query_result_xlsx_workbook<W: Write + Seek>(
writer: W,
request: &QueryResultExportRequest,
@@ -270,11 +291,13 @@ fn start_query_result_xlsx_workbook<W: Write + Seek>(
) -> Result<StreamingXlsxWriter<W>, String> {
let trailing_sheets = query_sql_worksheets(request);
let column_comments: &[Option<String>] = request.column_comments.as_deref().unwrap_or(&[]);
let effective_column_types =
resolve_export_column_types(columns, column_types, request.export_column_types.as_deref());
start_streaming_xlsx_workbook_with_options(
writer,
Some("Result"),
columns,
column_types,
&effective_column_types,
column_comments,
&trailing_sheets,
request.date_time_format.as_deref(),
@@ -1085,7 +1108,8 @@ async fn export_query_result_core_inner(
let page_column_types = if columns.is_empty() {
columns = result.columns.clone();
column_types = result.column_types.clone();
column_types =
resolve_export_column_types(&columns, &result.column_types, request.export_column_types.as_deref());
if let Some(writer) = sql_writer.as_mut() {
writer.set_columns(columns.clone(), &column_types, &result.spatial_columns, request)?;
}
@@ -1097,7 +1121,9 @@ async fn export_query_result_core_inner(
// must be aligned by column name; writing page rows positionally
// dropped/duplicated values into the wrong columns.
let remap = export_column_remap(&result.columns, &columns);
let page_column_types = realign_page_cells(&remap, result.column_types.clone());
let page_inferred = realign_page_cells(&remap, result.column_types.clone());
let page_column_types =
resolve_export_column_types(&columns, &page_inferred, request.export_column_types.as_deref());
result.rows = realign_page_rows(&remap, mem::take(&mut result.rows));
if !result.spatial_values.is_empty() {
result.spatial_values = realign_page_rows(&remap, mem::take(&mut result.spatial_values));
@@ -3184,4 +3210,53 @@ mod tests {
assert_eq!(task.await.unwrap(), Err(QUERY_CANCELED.to_string()));
}
#[test]
fn resolve_export_column_types_handles_overrides_and_inferred() {
let cols = vec!["id".to_string(), "amount".to_string(), "name".to_string()];
// Overrides take precedence when non-empty
let overrides = Some(vec![Some("bigint".to_string()), Some("decimal(18, 2)".to_string()), None]);
let inferred = vec!["int".to_string(), "".to_string(), "varchar".to_string()];
let resolved = resolve_export_column_types(&cols, &inferred, overrides.as_deref());
assert_eq!(resolved, vec!["bigint", "decimal(18, 2)", "varchar"]);
// Empty inferred (e.g. DuckDB) with overrides
let duckdb_overrides = Some(vec![Some("int".to_string()), Some("decimal".to_string()), None]);
let resolved = resolve_export_column_types(&cols, &[], duckdb_overrides.as_deref());
assert_eq!(resolved, vec!["int", "decimal", ""]);
// No overrides
let resolved = resolve_export_column_types(&cols, &inferred, None);
assert_eq!(resolved, vec!["int", "", "varchar"]);
}
#[test]
fn start_query_result_xlsx_workbook_uses_export_column_types_override() {
use std::io::Read;
let mut req = request("xlsx", None, None);
req.export_column_types = Some(vec![Some("decimal(18, 2)".to_string())]);
let mut cursor = std::io::Cursor::new(Vec::new());
// Inferred column_types is empty (like DuckDB query result)
let mut writer =
start_query_result_xlsx_workbook(&mut cursor, &req, &["amount".to_string()], &[]).expect("start workbook");
writer.write_row(&[serde_json::Value::String("123.45".to_string())]).expect("write row");
finish_streaming_xlsx_workbook(writer).expect("finish workbook");
let bytes = cursor.into_inner();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("open zip");
let mut sheet_file = zip.by_name("xl/worksheets/sheet1.xml").expect("sheet1.xml");
let mut sheet_xml = String::new();
sheet_file.read_to_string(&mut sheet_xml).expect("read sheet1");
// The decimal value must be exported as a numeric cell (<v>123.45</v>) rather than text
assert!(sheet_xml.contains("<v>123.45</v>"), "expected numeric cell in sheet XML, got: {sheet_xml}");
assert!(
!sheet_xml.contains(r#"<c r="A2" t="inlineStr""#),
"did not expect cell A2 to be inlineStr, got: {sheet_xml}"
);
assert!(!sheet_xml.contains("<t>123.45</t>"), "did not expect 123.45 to be exported as text, got: {sheet_xml}");
}
}
+26
View File
@@ -858,6 +858,12 @@ fn is_numeric_column_type(column_type: Option<&String>) -> bool {
| "int"
| "integer"
| "bigint"
| "hugeint"
| "uhugeint"
| "utinyint"
| "usmallint"
| "uinteger"
| "ubigint"
| "serial"
| "smallserial"
| "bigserial"
@@ -2137,6 +2143,26 @@ mod tests {
}
}
#[test]
fn duckdb_numeric_types_recognized_by_classifier() {
for duckdb_type in [
"DECIMAL(18, 3)",
"decimal",
"NUMERIC(10, 2)",
"hugeint",
"uhugeint",
"utinyint",
"usmallint",
"uinteger",
"ubigint",
] {
assert!(
is_numeric_column_type(Some(&duckdb_type.to_string())),
"expected DuckDB numeric type: {duckdb_type}"
);
}
}
// -----------------------------------------------------------------------
// Multi-sheet splitting tests
// -----------------------------------------------------------------------