mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(export): export decimal columns as numeric in full XLSX export
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user