feat(grid): right-align numeric result columns
This commit is contained in:
1 parent
66c768061a
commit
e2b35944ce
35 files changed
+1082
-109
No files matched your search
@@ -86,6 +86,8 @@ pub struct QueryResultExportRequest {
|
||||
pub execution_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_time_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub numeric_column_right_align: bool,
|
||||
}
|
||||
|
||||
fn safe_postgres_temp_setup_sql(setup_sql: &[String]) -> Option<Vec<String>> {
|
||||
@@ -144,6 +146,7 @@ fn query_sql_worksheets(request: &QueryResultExportRequest) -> Vec<XlsxWorksheet
|
||||
columns: vec!["SQL".to_string()],
|
||||
column_types: Vec::new(),
|
||||
rows: split_excel_cell_text(&request.sql).into_iter().map(|sql| vec![Value::String(sql)]).collect(),
|
||||
numeric_column_right_align: false,
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -161,6 +164,7 @@ fn start_query_result_xlsx_workbook<W: Write + Seek>(
|
||||
column_types,
|
||||
&trailing_sheets,
|
||||
request.date_time_format.as_deref(),
|
||||
request.numeric_column_right_align,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1518,6 +1522,7 @@ mod tests {
|
||||
client_session_id: None,
|
||||
execution_id: None,
|
||||
date_time_format: None,
|
||||
numeric_column_right_align: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::transfer::{
|
||||
pagination_sql_with_filter_order, qualified_table, quote_identifier,
|
||||
};
|
||||
use crate::types::QueryResult;
|
||||
use crate::xlsx_export::{finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook};
|
||||
use crate::xlsx_export::{finish_streaming_xlsx_workbook, start_streaming_xlsx_workbook_with_options};
|
||||
|
||||
const DEFAULT_BATCH_SIZE: usize = 10_000;
|
||||
const SQL_INSERT_BATCH_SIZE: usize = 100;
|
||||
@@ -59,6 +59,8 @@ pub struct TableExportRequest {
|
||||
pub row_limit: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_time_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub numeric_column_right_align: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -763,11 +765,14 @@ async fn try_export_native_table_stream(
|
||||
let xlsx_column_types = export_column_types(request);
|
||||
let xlsx_file =
|
||||
std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
let mut writer = start_streaming_xlsx_workbook(
|
||||
let mut writer = start_streaming_xlsx_workbook_with_options(
|
||||
BufWriter::new(xlsx_file),
|
||||
Some(&request.table_name),
|
||||
col_names,
|
||||
&xlsx_column_types,
|
||||
&[],
|
||||
request.date_time_format.as_deref(),
|
||||
request.numeric_column_right_align,
|
||||
)?;
|
||||
let result = stream_native_table_rows(
|
||||
state,
|
||||
@@ -1387,11 +1392,14 @@ async fn export_table_data_core_inner(
|
||||
// sharing a file descriptor between two independent buffers.
|
||||
let xlsx_file =
|
||||
std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create XLSX file: {e}"))?;
|
||||
let mut writer = start_streaming_xlsx_workbook(
|
||||
let mut writer = start_streaming_xlsx_workbook_with_options(
|
||||
BufWriter::new(xlsx_file),
|
||||
Some(&request.table_name),
|
||||
&col_names,
|
||||
&xlsx_column_types,
|
||||
&[],
|
||||
request.date_time_format.as_deref(),
|
||||
request.numeric_column_right_align,
|
||||
)?;
|
||||
|
||||
loop {
|
||||
@@ -1869,6 +1877,7 @@ mod tests {
|
||||
batch_size: Some(batch_size),
|
||||
row_limit,
|
||||
date_time_format: None,
|
||||
numeric_column_right_align: false,
|
||||
};
|
||||
|
||||
ExternalDriverExportFixture { state, request, calls, output, dir }
|
||||
@@ -2029,6 +2038,7 @@ mod tests {
|
||||
batch_size: Some(500),
|
||||
row_limit: Some(1000),
|
||||
date_time_format: None,
|
||||
numeric_column_right_align: false,
|
||||
};
|
||||
|
||||
let sql = table_cursor_sql(
|
||||
@@ -2077,6 +2087,7 @@ mod tests {
|
||||
batch_size: Some(100),
|
||||
row_limit: None,
|
||||
date_time_format: None,
|
||||
numeric_column_right_align: false,
|
||||
};
|
||||
let sql = table_cursor_sql(&request, &DatabaseType::Oracle, &columns, &primary_keys);
|
||||
assert_eq!(sql, "SELECT \"ID\", \"NAME\" FROM \"APP\".\"USERS\"");
|
||||
@@ -2395,6 +2406,7 @@ mod tests {
|
||||
vec![json!(2), json!("Bob"), json!(82000)],
|
||||
vec![json!(3), Value::Null, json!(0)],
|
||||
],
|
||||
numeric_column_right_align: false,
|
||||
};
|
||||
let workbook = build_xlsx_workbook(&data).expect("XLSX build should succeed");
|
||||
|
||||
|
||||
@@ -5860,12 +5860,14 @@ mod tests {
|
||||
columns: vec!["id".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![serde_json::json!(1)]],
|
||||
numeric_column_right_align: false,
|
||||
},
|
||||
XlsxWorksheetData {
|
||||
sheet_name: Some("Second".to_string()),
|
||||
columns: vec!["name".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![serde_json::json!("Ada")]],
|
||||
numeric_column_right_align: false,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
@@ -5902,12 +5904,14 @@ mod tests {
|
||||
columns: vec!["id".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![serde_json::json!(1)]],
|
||||
numeric_column_right_align: false,
|
||||
},
|
||||
XlsxWorksheetData {
|
||||
sheet_name: Some("Second".to_string()),
|
||||
columns: vec!["name".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![serde_json::json!("Ada")], vec![serde_json::json!("Grace")]],
|
||||
numeric_column_right_align: false,
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
@@ -6052,6 +6056,7 @@ mod tests {
|
||||
vec![serde_json::json!(1), serde_json::json!("Ada")],
|
||||
vec![serde_json::json!(2), serde_json::json!("Grace")],
|
||||
],
|
||||
numeric_column_right_align: false,
|
||||
}])
|
||||
.unwrap();
|
||||
std::fs::write(&path, workbook).unwrap();
|
||||
@@ -6409,6 +6414,7 @@ mod tests {
|
||||
vec![serde_json::json!(2), serde_json::json!("Grace")],
|
||||
vec![serde_json::json!("summary"), serde_json::json!(2)],
|
||||
],
|
||||
numeric_column_right_align: false,
|
||||
}])
|
||||
.unwrap();
|
||||
std::fs::write(&path, workbook).unwrap();
|
||||
@@ -6832,6 +6838,7 @@ mod tests {
|
||||
vec![serde_json::json!(2), serde_json::json!("Grace")],
|
||||
vec![serde_json::json!("summary"), serde_json::json!(2)],
|
||||
],
|
||||
numeric_column_right_align: false,
|
||||
}])
|
||||
.unwrap();
|
||||
std::fs::write(&path, workbook).unwrap();
|
||||
|
||||
@@ -6,6 +6,8 @@ use crate::temporal_format::{excel_temporal_serial, ExcelTemporalKind};
|
||||
|
||||
const XLSX_DATE_STYLE: usize = 2;
|
||||
const XLSX_DATETIME_STYLE: usize = 3;
|
||||
const NUMERIC_RIGHT_ALIGN_STYLE: usize = 4;
|
||||
const NUMERIC_LEFT_ALIGN_STYLE: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -15,6 +17,8 @@ pub struct XlsxWorksheetData {
|
||||
#[serde(default)]
|
||||
pub column_types: Vec<String>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
#[serde(default)]
|
||||
pub numeric_column_right_align: bool,
|
||||
}
|
||||
|
||||
/// Streaming XLSX writer that incrementally writes rows to a ZIP-backed
|
||||
@@ -27,6 +31,7 @@ pub struct StreamingXlsxWriter<W: Write + Seek> {
|
||||
next_row_number: usize,
|
||||
trailing_sheets: Vec<XlsxWorksheetData>,
|
||||
date_time_format: Option<String>,
|
||||
numeric_right_align: bool,
|
||||
}
|
||||
|
||||
/// Estimate column widths from header names only (used by the streaming path
|
||||
@@ -65,19 +70,15 @@ fn data_row_xml_with_date_time_format(
|
||||
column_types: &[String],
|
||||
row: &[Value],
|
||||
date_time_format: Option<&str>,
|
||||
numeric_right_align: bool,
|
||||
) -> String {
|
||||
let cells = columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_index, _)| {
|
||||
typed_cell_xml(
|
||||
row.get(col_index),
|
||||
column_types.get(col_index),
|
||||
row_number - 1,
|
||||
col_index,
|
||||
None,
|
||||
date_time_format,
|
||||
)
|
||||
let col_type = column_types.get(col_index);
|
||||
let align_style = numeric_column_style(col_type, numeric_right_align);
|
||||
typed_cell_xml(row.get(col_index), col_type, row_number - 1, col_index, align_style, date_time_format)
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("<row r=\"{row_number}\">{cells}</row>")
|
||||
@@ -99,13 +100,14 @@ fn write_zip_entry<W: Write + Seek>(zip: &mut zip::ZipWriter<W>, path: &str, con
|
||||
/// column widths (estimated from header names) and the header row are written
|
||||
/// immediately. Callers then feed data rows via [`StreamingXlsxWriter::write_row`]
|
||||
/// and finalize with [`StreamingXlsxWriter::finish`].
|
||||
#[cfg(test)]
|
||||
pub(crate) fn start_streaming_xlsx_workbook<W: Write + Seek>(
|
||||
writer: W,
|
||||
sheet_name: Option<&str>,
|
||||
columns: &[String],
|
||||
column_types: &[String],
|
||||
) -> Result<StreamingXlsxWriter<W>, String> {
|
||||
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], None)
|
||||
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, &[], None, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -116,7 +118,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_trailing_sheets<W: Write + Seek
|
||||
column_types: &[String],
|
||||
trailing_sheets: &[XlsxWorksheetData],
|
||||
) -> Result<StreamingXlsxWriter<W>, String> {
|
||||
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, trailing_sheets, None)
|
||||
start_streaming_xlsx_workbook_with_options(writer, sheet_name, columns, column_types, trailing_sheets, None, false)
|
||||
}
|
||||
|
||||
pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
|
||||
@@ -126,12 +128,14 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
|
||||
column_types: &[String],
|
||||
trailing_sheets: &[XlsxWorksheetData],
|
||||
date_time_format: Option<&str>,
|
||||
numeric_right_align: bool,
|
||||
) -> Result<StreamingXlsxWriter<W>, String> {
|
||||
let primary_sheet = XlsxWorksheetData {
|
||||
sheet_name: sheet_name.map(str::to_string),
|
||||
columns: columns.to_vec(),
|
||||
column_types: column_types.to_vec(),
|
||||
rows: Vec::new(),
|
||||
numeric_column_right_align: numeric_right_align,
|
||||
};
|
||||
let all_sheets = std::iter::once(primary_sheet).chain(trailing_sheets.iter().cloned()).collect::<Vec<_>>();
|
||||
let sheet_names = normalize_unique_sheet_names(&all_sheets);
|
||||
@@ -173,6 +177,7 @@ pub(crate) fn start_streaming_xlsx_workbook_with_options<W: Write + Seek>(
|
||||
next_row_number: 2,
|
||||
trailing_sheets: trailing_sheets.to_vec(),
|
||||
date_time_format: date_time_format.map(str::to_string),
|
||||
numeric_right_align,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,6 +192,7 @@ impl<W: Write + Seek> StreamingXlsxWriter<W> {
|
||||
&self.column_types,
|
||||
row,
|
||||
self.date_time_format.as_deref(),
|
||||
self.numeric_right_align,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
@@ -326,20 +332,39 @@ fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Op
|
||||
}
|
||||
|
||||
fn is_numeric_column_type(column_type: Option<&String>) -> bool {
|
||||
let normalized = column_type.map(|value| value.trim().to_ascii_lowercase()).unwrap_or_default();
|
||||
let mut normalized = column_type.map(|value| value.trim().to_ascii_lowercase()).unwrap_or_default();
|
||||
while normalized.ends_with(')') {
|
||||
let Some(open_index) = normalized.find('(') else {
|
||||
break;
|
||||
};
|
||||
if !matches!(normalized[..open_index].trim(), "nullable" | "lowcardinality") {
|
||||
break;
|
||||
}
|
||||
normalized = normalized[open_index + 1..normalized.len() - 1].trim().to_string();
|
||||
}
|
||||
let base = normalized.split(['(', ' ', '[']).next().unwrap_or_default();
|
||||
matches!(
|
||||
base,
|
||||
"bit"
|
||||
| "tinyint"
|
||||
"tinyint"
|
||||
| "smallint"
|
||||
| "mediumint"
|
||||
| "int"
|
||||
| "integer"
|
||||
| "bigint"
|
||||
| "serial"
|
||||
| "smallserial"
|
||||
| "bigserial"
|
||||
| "int2"
|
||||
| "int4"
|
||||
| "int8"
|
||||
| "int1"
|
||||
| "int16"
|
||||
| "int32"
|
||||
| "int64"
|
||||
| "int128"
|
||||
| "int256"
|
||||
| "intn"
|
||||
| "uint"
|
||||
| "uint8"
|
||||
| "uint16"
|
||||
| "uint32"
|
||||
@@ -349,18 +374,44 @@ fn is_numeric_column_type(column_type: Option<&String>) -> bool {
|
||||
| "float"
|
||||
| "float4"
|
||||
| "float8"
|
||||
| "float16"
|
||||
| "float32"
|
||||
| "float64"
|
||||
| "floatn"
|
||||
| "real"
|
||||
| "double"
|
||||
| "decimal"
|
||||
| "decimal32"
|
||||
| "decimal64"
|
||||
| "decimal128"
|
||||
| "decimal256"
|
||||
| "decimaln"
|
||||
| "numeric"
|
||||
| "numericn"
|
||||
| "number"
|
||||
| "dec"
|
||||
| "fixed"
|
||||
| "money"
|
||||
| "money4"
|
||||
| "moneyn"
|
||||
| "smallmoney"
|
||||
| "smallmoneyn"
|
||||
| "binary_float"
|
||||
| "binary_double"
|
||||
)
|
||||
}
|
||||
|
||||
fn numeric_column_style(column_type: Option<&String>, enabled: bool) -> Option<usize> {
|
||||
if !is_numeric_column_type(column_type) {
|
||||
return None;
|
||||
}
|
||||
if enabled {
|
||||
Some(NUMERIC_RIGHT_ALIGN_STYLE)
|
||||
} else {
|
||||
Some(NUMERIC_LEFT_ALIGN_STYLE)
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_excel_number(value: &str) -> Option<&str> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || trimmed.parse::<f64>().ok().is_none_or(|number| !number.is_finite()) {
|
||||
@@ -444,14 +495,9 @@ fn worksheet_xml(data: &XlsxWorksheetData) -> String {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_index, _)| {
|
||||
typed_cell_xml(
|
||||
row.get(col_index),
|
||||
data.column_types.get(col_index),
|
||||
excel_row - 1,
|
||||
col_index,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
let col_type = data.column_types.get(col_index);
|
||||
let align_style = numeric_column_style(col_type, data.numeric_column_right_align);
|
||||
typed_cell_xml(row.get(col_index), col_type, excel_row - 1, col_index, align_style, None)
|
||||
})
|
||||
.collect::<String>();
|
||||
format!("<row r=\"{excel_row}\">{cells}</row>")
|
||||
@@ -634,7 +680,7 @@ fn styles_xml(date_time_format: Option<&str>) -> String {
|
||||
"<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>",
|
||||
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
|
||||
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>",
|
||||
"<cellXfs count=\"4\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/></cellXfs>",
|
||||
"<cellXfs count=\"6\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/><xf numFmtId=\"164\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"165\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyNumberFormat=\"1\"/><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyAlignment=\"1\"><alignment horizontal=\"right\"/></xf><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyAlignment=\"1\"><alignment horizontal=\"left\"/></xf></cellXfs>",
|
||||
"<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>",
|
||||
"</styleSheet>"
|
||||
),
|
||||
@@ -697,12 +743,12 @@ pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result<Vec<u8>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_xlsx_workbook, build_xlsx_workbook_multi, start_streaming_xlsx_workbook,
|
||||
build_xlsx_workbook, build_xlsx_workbook_multi, is_numeric_column_type, start_streaming_xlsx_workbook,
|
||||
start_streaming_xlsx_workbook_with_options, start_streaming_xlsx_workbook_with_trailing_sheets,
|
||||
XlsxWorksheetData,
|
||||
};
|
||||
use calamine::{open_workbook_auto, Reader};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
|
||||
@@ -737,6 +783,7 @@ mod tests {
|
||||
columns: vec!["id".to_string(), "name".to_string(), "active".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![json!(1), json!("Ada & Bob"), json!(true)], vec![json!(2), json!(null), json!(false)]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
|
||||
@@ -761,12 +808,13 @@ mod tests {
|
||||
columns: vec!["quantity".to_string(), "amount".to_string(), "code".to_string()],
|
||||
column_types: vec!["decimal(10,5)".to_string(), "numeric".to_string(), "varchar".to_string()],
|
||||
rows: vec![vec![json!("1.00000"), json!("2800.000000"), json!("00123")]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
|
||||
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
||||
assert!(sheet.contains("<c r=\"A2\"><v>1.00000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"B2\"><v>2800.000000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"A2\" s=\"5\"><v>1.00000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"B2\" s=\"5\"><v>2800.000000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>00123</t></is></c>"));
|
||||
}
|
||||
|
||||
@@ -798,6 +846,7 @@ mod tests {
|
||||
json!("2800.000000"),
|
||||
json!("2024-02-25T13:02:15+08:00"),
|
||||
]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
|
||||
@@ -807,7 +856,7 @@ mod tests {
|
||||
assert!(sheet.contains("<c r=\"B2\" s=\"3\"><v>45347.543229166666</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"C2\" t=\"inlineStr\"><is><t>2024-02-25</t></is></c>"));
|
||||
assert!(sheet.contains("<c r=\"D2\" t=\"inlineStr\"><is><t>not-a-date</t></is></c>"));
|
||||
assert!(sheet.contains("<c r=\"E2\"><v>2800.000000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"E2\" s=\"5\"><v>2800.000000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"F2\" t=\"inlineStr\"><is><t>2024-02-25T13:02:15+08:00</t></is></c>"));
|
||||
assert!(styles.contains("numFmtId=\"164\" formatCode=\"yyyy-mm-dd\""));
|
||||
assert!(styles.contains("numFmtId=\"165\" formatCode=\"yyyy-mm-dd hh:mm:ss\""));
|
||||
@@ -847,6 +896,7 @@ mod tests {
|
||||
json!("987654.321"),
|
||||
json!("2800.000000"),
|
||||
]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
|
||||
@@ -861,7 +911,7 @@ mod tests {
|
||||
("G2", "987654.321"),
|
||||
("H2", "2800.000000"),
|
||||
] {
|
||||
assert!(sheet.contains(&format!("<c r=\"{reference}\"><v>{value}</v></c>")), "sheet={sheet}");
|
||||
assert!(sheet.contains(&format!("<c r=\"{reference}\" s=\"5\"><v>{value}</v></c>")), "sheet={sheet}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,6 +922,7 @@ mod tests {
|
||||
columns: vec!["large_id".to_string(), "precise_amount".to_string()],
|
||||
column_types: vec!["bigint".to_string(), "decimal(30,10)".to_string()],
|
||||
rows: vec![vec![json!("9223372036854775807"), json!("123456789012345.6789000000")]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
|
||||
@@ -887,6 +938,7 @@ mod tests {
|
||||
columns: vec!["value".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![json!("ok")]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
let workbook_xml = read_zip_entry(&workbook, "xl/workbook.xml");
|
||||
@@ -902,12 +954,14 @@ mod tests {
|
||||
columns: vec!["id".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![json!(1)]],
|
||||
numeric_column_right_align: false,
|
||||
},
|
||||
XlsxWorksheetData {
|
||||
sheet_name: Some("Result 2".to_string()),
|
||||
columns: vec!["name".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![json!("Ada")]],
|
||||
numeric_column_right_align: false,
|
||||
},
|
||||
])
|
||||
.expect("build multi-sheet workbook");
|
||||
@@ -959,9 +1013,9 @@ mod tests {
|
||||
|
||||
let bytes = fs::read(&path).expect("read workbook");
|
||||
let sheet = read_zip_entry(&bytes, "xl/worksheets/sheet1.xml");
|
||||
assert!(sheet.contains("<c r=\"A2\"><v>42</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"B2\"><v>123.5</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"C2\"><v>2800.000000</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"A2\" s=\"5\"><v>42</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"B2\" s=\"5\"><v>123.5</v></c>"));
|
||||
assert!(sheet.contains("<c r=\"C2\" s=\"5\"><v>2800.000000</v></c>"));
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -979,6 +1033,7 @@ mod tests {
|
||||
&column_types,
|
||||
&[],
|
||||
Some("YYYY/MM/DD HH:mm:ss.SSS"),
|
||||
false,
|
||||
)
|
||||
.expect("start workbook");
|
||||
writer.write_row(&[json!("2024/02/25 13:02:15.125")]).expect("write temporal row");
|
||||
@@ -1003,6 +1058,7 @@ mod tests {
|
||||
columns: vec!["SQL".to_string()],
|
||||
column_types: vec![],
|
||||
rows: vec![vec![json!("SELECT id, name FROM users")]],
|
||||
numeric_column_right_align: false,
|
||||
};
|
||||
let mut writer = start_streaming_xlsx_workbook_with_trailing_sheets(
|
||||
file,
|
||||
@@ -1024,4 +1080,95 @@ mod tests {
|
||||
assert_eq!(sql.get_value((1, 0)), Some(&calamine::Data::String("SELECT id, name FROM users".to_string())));
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_right_align_enabled_applies_style_4() {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: Some("Aligned".to_string()),
|
||||
columns: vec!["amount".to_string(), "label".to_string()],
|
||||
column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()],
|
||||
rows: vec![vec![json!(1.5), json!("row")]],
|
||||
numeric_column_right_align: true,
|
||||
})
|
||||
.expect("build workbook");
|
||||
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
||||
assert!(sheet.contains(r#"<c r="A2" s="4"><v>1.5</v></c>"#), "sheet={sheet}");
|
||||
// Text column B should NOT have right-align style s="4"
|
||||
assert!(!sheet.contains(r#"<c r="B2" s="4""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_right_align_disabled_applies_left_align_style_5() {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: Some("Disabled".to_string()),
|
||||
columns: vec!["amount".to_string(), "label".to_string()],
|
||||
column_types: vec!["decimal(10,2)".to_string(), "varchar(50)".to_string()],
|
||||
rows: vec![vec![json!(1.5), json!("row")]],
|
||||
numeric_column_right_align: false,
|
||||
})
|
||||
.expect("build workbook");
|
||||
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
||||
// Numeric column A should have left-align style (s="5"), not right-align (s="4"),
|
||||
// to override Excel's default right alignment for number cells.
|
||||
assert!(sheet.contains(r#"<c r="A2" s="5"><v>1.5</v></c>"#), "sheet={sheet}");
|
||||
assert!(!sheet.contains(r#"s="4""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_right_align_applies_across_database_numeric_types() {
|
||||
// Ensures the Rust classifier covers the same cross-database numeric
|
||||
// types as the frontend isNumericColumnType (ClickHouse wide integers,
|
||||
// Oracle/Dameng binary floats, SQL Server internal type names, etc.).
|
||||
let column_types = vec![
|
||||
"Int16".to_string(),
|
||||
"Int32".to_string(),
|
||||
"Int64".to_string(),
|
||||
"Int128".to_string(),
|
||||
"UInt256".to_string(),
|
||||
"Decimal128(18, 2)".to_string(),
|
||||
"Float16".to_string(),
|
||||
"BINARY_FLOAT".to_string(),
|
||||
"BINARY_DOUBLE".to_string(),
|
||||
"decimaln".to_string(),
|
||||
"numericn".to_string(),
|
||||
"intn".to_string(),
|
||||
"floatn".to_string(),
|
||||
"moneyn".to_string(),
|
||||
"smallmoneyn".to_string(),
|
||||
"varchar(50)".to_string(),
|
||||
];
|
||||
let row: Vec<Value> = column_types.iter().map(|_| json!(1)).collect::<Vec<_>>();
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: Some("CrossDb".to_string()),
|
||||
columns: column_types.iter().map(|t| t.to_lowercase()).collect(),
|
||||
column_types: column_types.clone(),
|
||||
rows: vec![row],
|
||||
numeric_column_right_align: true,
|
||||
})
|
||||
.expect("build workbook");
|
||||
let sheet = read_zip_entry(&workbook, "xl/worksheets/sheet1.xml");
|
||||
for (index, column_type) in column_types.iter().take(column_types.len() - 1).enumerate() {
|
||||
let col_letter = (b'A' + index as u8) as char;
|
||||
let cell = format!(r#"<c r="{col_letter}2" s="4"><v>1</v></c>"#);
|
||||
assert!(sheet.contains(&cell), "missing right-align style for {column_type} (cell={cell})");
|
||||
}
|
||||
// Text column (last) must not receive the numeric right-align style.
|
||||
let last_letter = (b'A' + column_types.len() as u8 - 1) as char;
|
||||
assert!(!sheet.contains(&format!(r#"<c r="{last_letter}2" s="4""#)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_type_classifier_matches_shared_backend_fixtures() {
|
||||
let fixtures: Value =
|
||||
serde_json::from_str(include_str!("../../../tests/fixtures/data-grid-numeric-column-types.json"))
|
||||
.expect("parse numeric column type fixtures");
|
||||
for fixture in fixtures["numeric"].as_array().expect("numeric fixtures") {
|
||||
let column_type = fixture["type"].as_str().expect("numeric fixture type").to_string();
|
||||
assert!(is_numeric_column_type(Some(&column_type)), "expected numeric backend type: {column_type}");
|
||||
}
|
||||
for fixture in fixtures["nonNumeric"].as_array().expect("non-numeric fixtures") {
|
||||
let column_type = fixture["type"].as_str().expect("non-numeric fixture type").to_string();
|
||||
assert!(!is_numeric_column_type(Some(&column_type)), "expected non-numeric backend type: {column_type}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user