fix(import): preserve Excel number formatting for text columns
This commit is contained in:
4 files changed
+701
-82
No files matched your search
Generated
+32
-1
@@ -1912,6 +1912,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"sqlparser",
|
"sqlparser",
|
||||||
|
"ssfmt",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tiberius",
|
"tiberius",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3276,6 +3277,17 @@ dependencies = [
|
|||||||
"foldhash 0.1.5",
|
"foldhash 0.1.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -4331,6 +4343,15 @@ dependencies = [
|
|||||||
"value-bag",
|
"value-bag",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.16.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -4722,7 +4743,7 @@ dependencies = [
|
|||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"keyed_priority_queue",
|
"keyed_priority_queue",
|
||||||
"lru",
|
"lru 0.18.1",
|
||||||
"mysql_common",
|
"mysql_common",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@@ -7666,6 +7687,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ssfmt"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8cafd85a137485737308ec6b94c19639833ca96bb83cbad2bc94a0e0eb763410"
|
||||||
|
dependencies = [
|
||||||
|
"lru 0.16.4",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ssh-cipher"
|
name = "ssh-cipher"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pageant = "0.2"
|
|||||||
portpicker = "0.1.1"
|
portpicker = "0.1.1"
|
||||||
csv = "1"
|
csv = "1"
|
||||||
calamine = { version = "0.30.1", features = ["dates"] }
|
calamine = { version = "0.30.1", features = ["dates"] }
|
||||||
|
ssfmt = { version = "0.1.2", default-features = false }
|
||||||
quick-xml = "0.37"
|
quick-xml = "0.37"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read as IoRead, Seek, SeekFrom};
|
use std::io::{BufRead, BufReader, Read as IoRead, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use calamine::{open_workbook_auto, Data, ExcelDateTime, Reader as CalamineReader};
|
use calamine::{open_workbook_auto, Data, ExcelDateTime, Reader as CalamineReader};
|
||||||
use chrono::{DateTime, NaiveDate, NaiveDateTime};
|
use chrono::{DateTime, NaiveDate, NaiveDateTime};
|
||||||
@@ -778,6 +779,12 @@ enum XlsxTemporalKind {
|
|||||||
Duration,
|
Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
struct XlsxCellStyle {
|
||||||
|
temporal_kind: Option<XlsxTemporalKind>,
|
||||||
|
number_format: Option<Arc<str>>,
|
||||||
|
}
|
||||||
|
|
||||||
fn format_chrono_duration_hms(duration: chrono::Duration, wrap_to_day: bool) -> String {
|
fn format_chrono_duration_hms(duration: chrono::Duration, wrap_to_day: bool) -> String {
|
||||||
let mut millis = duration.num_milliseconds();
|
let mut millis = duration.num_milliseconds();
|
||||||
let negative = millis < 0;
|
let negative = millis < 0;
|
||||||
@@ -852,6 +859,37 @@ fn xlsx_cell_value_with_temporal_kind(cell: &Data, temporal_kind: Option<XlsxTem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn xlsx_numeric_display_text(value: f64, style: Option<&XlsxCellStyle>) -> String {
|
||||||
|
style
|
||||||
|
.and_then(|style| style.number_format.as_deref())
|
||||||
|
.and_then(|format_code| {
|
||||||
|
let format = ssfmt::NumberFormat::parse(format_code).ok()?;
|
||||||
|
let mut options = ssfmt::FormatOptions::default();
|
||||||
|
let lcid = format.sections().iter().flat_map(|section| §ion.parts).find_map(|part| match part {
|
||||||
|
ssfmt::ast::FormatPart::Locale(locale) => locale.lcid,
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
// ssfmt 0.1 only provides en-US locale data; preserve the German separators explicitly.
|
||||||
|
if lcid == Some(0x0407) {
|
||||||
|
options.locale.decimal_separator = ',';
|
||||||
|
options.locale.thousands_separator = '.';
|
||||||
|
}
|
||||||
|
Some(format.format(value, &options))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xlsx_cell_text_value(cell: &Data, style: Option<&XlsxCellStyle>) -> Option<String> {
|
||||||
|
if style.and_then(|style| style.temporal_kind).is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match cell {
|
||||||
|
Data::Float(value) if value.is_finite() => Some(xlsx_numeric_display_text(*value, style)),
|
||||||
|
Data::Int(value) => Some(xlsx_numeric_display_text(*value as f64, style)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn xlsx_cell_value(cell: &Data) -> serde_json::Value {
|
pub fn xlsx_cell_value(cell: &Data) -> serde_json::Value {
|
||||||
xlsx_cell_value_with_temporal_kind(cell, None)
|
xlsx_cell_value_with_temporal_kind(cell, None)
|
||||||
}
|
}
|
||||||
@@ -883,7 +921,7 @@ fn xml_local_name_eq(name: &[u8], expected: &[u8]) -> bool {
|
|||||||
name.rsplit(|byte| *byte == b':').next().is_some_and(|local| local.eq_ignore_ascii_case(expected))
|
name.rsplit(|byte| *byte == b':').next().is_some_and(|local| local.eq_ignore_ascii_case(expected))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xml_attr_value(reader: &XmlReader<&[u8]>, element: &BytesStart<'_>, key: &[u8]) -> Option<String> {
|
fn xml_attr_value<R: BufRead>(reader: &XmlReader<R>, element: &BytesStart<'_>, key: &[u8]) -> Option<String> {
|
||||||
element.attributes().flatten().find_map(|attr| {
|
element.attributes().flatten().find_map(|attr| {
|
||||||
if xml_local_name_eq(attr.key.as_ref(), key) {
|
if xml_local_name_eq(attr.key.as_ref(), key) {
|
||||||
attr.decode_and_unescape_value(reader.decoder()).ok().map(|value| value.into_owned())
|
attr.decode_and_unescape_value(reader.decoder()).ok().map(|value| value.into_owned())
|
||||||
@@ -950,12 +988,12 @@ fn xlsx_temporal_kind_from_format_code(format_code: &str) -> Option<XlsxTemporal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_xlsx_style_temporal_kinds(styles_xml: &str) -> Vec<Option<XlsxTemporalKind>> {
|
fn parse_xlsx_styles(styles_xml: &str) -> Vec<XlsxCellStyle> {
|
||||||
let mut reader = XmlReader::from_str(styles_xml);
|
let mut reader = XmlReader::from_str(styles_xml);
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut custom_formats = HashMap::<u16, XlsxTemporalKind>::new();
|
let mut custom_formats = HashMap::<u16, String>::new();
|
||||||
let mut style_kinds = Vec::new();
|
let mut styles = Vec::new();
|
||||||
let mut in_cell_xfs = false;
|
let mut in_cell_xfs = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -966,9 +1004,7 @@ fn parse_xlsx_style_temporal_kinds(styles_xml: &str) -> Vec<Option<XlsxTemporalK
|
|||||||
let id = xml_attr_value(&reader, &element, b"numFmtId").and_then(|value| value.parse::<u16>().ok());
|
let id = xml_attr_value(&reader, &element, b"numFmtId").and_then(|value| value.parse::<u16>().ok());
|
||||||
let format_code = xml_attr_value(&reader, &element, b"formatCode");
|
let format_code = xml_attr_value(&reader, &element, b"formatCode");
|
||||||
if let (Some(id), Some(format_code)) = (id, format_code) {
|
if let (Some(id), Some(format_code)) = (id, format_code) {
|
||||||
if let Some(kind) = xlsx_temporal_kind_from_format_code(&format_code) {
|
custom_formats.insert(id, format_code);
|
||||||
custom_formats.insert(id, kind);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Event::Start(element)) if xml_local_name_eq(element.name().as_ref(), b"cellXfs") => {
|
Ok(Event::Start(element)) if xml_local_name_eq(element.name().as_ref(), b"cellXfs") => {
|
||||||
@@ -980,10 +1016,25 @@ fn parse_xlsx_style_temporal_kinds(styles_xml: &str) -> Vec<Option<XlsxTemporalK
|
|||||||
Ok(Event::Start(element)) | Ok(Event::Empty(element))
|
Ok(Event::Start(element)) | Ok(Event::Empty(element))
|
||||||
if in_cell_xfs && xml_local_name_eq(element.name().as_ref(), b"xf") =>
|
if in_cell_xfs && xml_local_name_eq(element.name().as_ref(), b"xf") =>
|
||||||
{
|
{
|
||||||
let kind = xml_attr_value(&reader, &element, b"numFmtId")
|
let num_fmt_id =
|
||||||
.and_then(|value| value.parse::<u16>().ok())
|
xml_attr_value(&reader, &element, b"numFmtId").and_then(|value| value.parse::<u16>().ok());
|
||||||
.and_then(|id| custom_formats.get(&id).copied().or_else(|| xlsx_builtin_temporal_kind(id)));
|
let custom_format_code = num_fmt_id.and_then(|id| custom_formats.get(&id).map(String::as_str));
|
||||||
style_kinds.push(kind);
|
let temporal_kind = num_fmt_id.and_then(|id| {
|
||||||
|
custom_formats
|
||||||
|
.get(&id)
|
||||||
|
.and_then(|code| xlsx_temporal_kind_from_format_code(code))
|
||||||
|
.or_else(|| xlsx_builtin_temporal_kind(id))
|
||||||
|
});
|
||||||
|
styles.push(XlsxCellStyle {
|
||||||
|
temporal_kind,
|
||||||
|
number_format: if temporal_kind.is_none() {
|
||||||
|
custom_format_code
|
||||||
|
.or_else(|| num_fmt_id.and_then(|id| ssfmt::format_code_from_id(id as u32)))
|
||||||
|
.map(Arc::<str>::from)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Ok(Event::Eof) | Err(_) => break,
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -991,7 +1042,7 @@ fn parse_xlsx_style_temporal_kinds(styles_xml: &str) -> Vec<Option<XlsxTemporalK
|
|||||||
buf.clear();
|
buf.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
style_kinds
|
styles
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xlsx_workbook_sheet_refs(workbook_xml: &str) -> Vec<(String, Option<String>)> {
|
fn xlsx_workbook_sheet_refs(workbook_xml: &str) -> Vec<(String, Option<String>)> {
|
||||||
@@ -1091,14 +1142,15 @@ fn xlsx_cell_ref_position(reference: &str) -> Option<(usize, usize)> {
|
|||||||
(saw_column && saw_row).then_some((row, column))
|
(saw_column && saw_row).then_some((row, column))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_xlsx_sheet_temporal_kinds(
|
fn parse_xlsx_sheet_cell_styles<R: BufRead>(
|
||||||
sheet_xml: &str,
|
source: R,
|
||||||
style_kinds: &[Option<XlsxTemporalKind>],
|
styles: &[XlsxCellStyle],
|
||||||
) -> HashMap<(usize, usize), XlsxTemporalKind> {
|
text_columns: &HashSet<usize>,
|
||||||
let mut reader = XmlReader::from_str(sheet_xml);
|
) -> Result<HashMap<(usize, usize), XlsxCellStyle>, String> {
|
||||||
|
let mut reader = XmlReader::from_reader(source);
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut kinds = HashMap::new();
|
let mut cell_styles = HashMap::new();
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
Ok(Event::Start(element)) | Ok(Event::Empty(element))
|
Ok(Event::Start(element)) | Ok(Event::Empty(element))
|
||||||
@@ -1110,22 +1162,25 @@ fn parse_xlsx_sheet_temporal_kinds(
|
|||||||
buf.clear();
|
buf.clear();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(kind) = style_kinds.get(style_id).copied().flatten() else {
|
let Some(style) = styles.get(style_id) else {
|
||||||
buf.clear();
|
buf.clear();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(position) =
|
if let Some(position) =
|
||||||
xml_attr_value(&reader, &element, b"r").and_then(|reference| xlsx_cell_ref_position(&reference))
|
xml_attr_value(&reader, &element, b"r").and_then(|reference| xlsx_cell_ref_position(&reference))
|
||||||
{
|
{
|
||||||
kinds.insert(position, kind);
|
if style.temporal_kind.is_some() || text_columns.contains(&position.1) {
|
||||||
|
cell_styles.insert(position, style.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Event::Eof) | Err(_) => break,
|
Ok(Event::Eof) => break,
|
||||||
|
Err(error) => return Err(error.to_string()),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
buf.clear();
|
buf.clear();
|
||||||
}
|
}
|
||||||
kinds
|
Ok(cell_styles)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_xlsx_zip_text(zip: &mut zip::ZipArchive<File>, path: &str) -> Result<String, String> {
|
fn read_xlsx_zip_text(zip: &mut zip::ZipArchive<File>, path: &str) -> Result<String, String> {
|
||||||
@@ -1135,12 +1190,16 @@ fn read_xlsx_zip_text(zip: &mut zip::ZipArchive<File>, path: &str) -> Result<Str
|
|||||||
Ok(content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn xlsx_temporal_cell_kinds(path: &str, sheet_name: &str) -> Result<HashMap<(usize, usize), XlsxTemporalKind>, String> {
|
fn xlsx_cell_styles(
|
||||||
|
path: &str,
|
||||||
|
sheet_name: &str,
|
||||||
|
text_columns: &HashSet<usize>,
|
||||||
|
) -> Result<HashMap<(usize, usize), XlsxCellStyle>, String> {
|
||||||
let file = File::open(path).map_err(|err| err.to_string())?;
|
let file = File::open(path).map_err(|err| err.to_string())?;
|
||||||
let mut zip = zip::ZipArchive::new(file).map_err(|err| err.to_string())?;
|
let mut zip = zip::ZipArchive::new(file).map_err(|err| err.to_string())?;
|
||||||
let styles_xml = read_xlsx_zip_text(&mut zip, "xl/styles.xml").unwrap_or_default();
|
let styles_xml = read_xlsx_zip_text(&mut zip, "xl/styles.xml").unwrap_or_default();
|
||||||
let style_kinds = parse_xlsx_style_temporal_kinds(&styles_xml);
|
let styles = parse_xlsx_styles(&styles_xml);
|
||||||
if style_kinds.is_empty() {
|
if styles.is_empty() {
|
||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,14 +1208,30 @@ fn xlsx_temporal_cell_kinds(path: &str, sheet_name: &str) -> Result<HashMap<(usi
|
|||||||
let Some(sheet_path) = xlsx_sheet_path_for_name(&workbook_xml, &rels_xml, sheet_name) else {
|
let Some(sheet_path) = xlsx_sheet_path_for_name(&workbook_xml, &rels_xml, sheet_name) else {
|
||||||
return Ok(HashMap::new());
|
return Ok(HashMap::new());
|
||||||
};
|
};
|
||||||
let sheet_xml = read_xlsx_zip_text(&mut zip, &sheet_path)?;
|
let sheet = zip.by_name(&sheet_path).map_err(|error| error.to_string())?;
|
||||||
Ok(parse_xlsx_sheet_temporal_kinds(&sheet_xml, &style_kinds))
|
parse_xlsx_sheet_cell_styles(BufReader::new(sheet), &styles, text_columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_legacy_xls_path(path: &str) -> bool {
|
||||||
|
Path::new(path)
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("xls"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_xlsx_file_with_options(
|
pub fn parse_xlsx_file_with_options(
|
||||||
path: &str,
|
path: &str,
|
||||||
options: &TableImportParseOptions,
|
options: &TableImportParseOptions,
|
||||||
preview_limit: usize,
|
preview_limit: usize,
|
||||||
|
) -> Result<ParsedImportFile, String> {
|
||||||
|
parse_xlsx_file_with_options_and_text_columns(path, options, preview_limit, &HashSet::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_xlsx_file_with_options_and_text_columns(
|
||||||
|
path: &str,
|
||||||
|
options: &TableImportParseOptions,
|
||||||
|
preview_limit: usize,
|
||||||
|
text_source_columns: &HashSet<String>,
|
||||||
) -> Result<ParsedImportFile, String> {
|
) -> Result<ParsedImportFile, String> {
|
||||||
let mut workbook = open_workbook_auto(path).map_err(|e| e.to_string())?;
|
let mut workbook = open_workbook_auto(path).map_err(|e| e.to_string())?;
|
||||||
let sheet_names = workbook.sheet_names().to_vec();
|
let sheet_names = workbook.sheet_names().to_vec();
|
||||||
@@ -1170,11 +1245,38 @@ pub fn parse_xlsx_file_with_options(
|
|||||||
} else {
|
} else {
|
||||||
sheet_names.first().cloned().ok_or_else(|| "Workbook has no sheets".to_string())?
|
sheet_names.first().cloned().ok_or_else(|| "Workbook has no sheets".to_string())?
|
||||||
};
|
};
|
||||||
let temporal_cell_kinds = xlsx_temporal_cell_kinds(path, &sheet_name).unwrap_or_default();
|
|
||||||
let range = workbook.worksheet_range(&sheet_name).map_err(|e| e.to_string())?;
|
let range = workbook.worksheet_range(&sheet_name).map_err(|e| e.to_string())?;
|
||||||
let (range_start_row, range_start_column) =
|
let (range_start_row, range_start_column) =
|
||||||
range.start().map(|(row, column)| (row as usize, column as usize)).unwrap_or_default();
|
range.start().map(|(row, column)| (row as usize, column as usize)).unwrap_or_default();
|
||||||
let row_range = effective_import_row_range(options)?;
|
let row_range = effective_import_row_range(options)?;
|
||||||
|
let mut style_selection_columns = Vec::new();
|
||||||
|
for (index, source_row) in range.rows().enumerate() {
|
||||||
|
let row_number = index + 1;
|
||||||
|
if row_range.title_row == Some(row_number) {
|
||||||
|
style_selection_columns = source_row
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, cell)| normalize_header(&xlsx_cell_label(cell), index))
|
||||||
|
.collect();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let row_is_within_range = match row_range.last_data_row {
|
||||||
|
Some(last) => row_number <= last,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if row_number >= row_range.data_start_row && row_is_within_range {
|
||||||
|
style_selection_columns = (0..source_row.len()).map(|index| format!("column_{}", index + 1)).collect();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text_worksheet_columns = style_selection_columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, column)| text_source_columns.contains(column).then_some(range_start_column + index + 1))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let legacy_xls = is_legacy_xls_path(path);
|
||||||
|
let cell_styles =
|
||||||
|
if legacy_xls { HashMap::new() } else { xlsx_cell_styles(path, &sheet_name, &text_worksheet_columns)? };
|
||||||
let mut columns = Vec::new();
|
let mut columns = Vec::new();
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut total_rows = 0;
|
let mut total_rows = 0;
|
||||||
@@ -1188,7 +1290,10 @@ pub fn parse_xlsx_file_with_options(
|
|||||||
// Calamine rows are relative to the used range, while XLSX style coordinates are worksheet-absolute.
|
// Calamine rows are relative to the used range, while XLSX style coordinates are worksheet-absolute.
|
||||||
let cell_position = (range_start_row + row_number, range_start_column + index + 1);
|
let cell_position = (range_start_row + row_number, range_start_column + index + 1);
|
||||||
normalize_header(
|
normalize_header(
|
||||||
&xlsx_cell_label_with_temporal_kind(cell, temporal_cell_kinds.get(&cell_position).copied()),
|
&xlsx_cell_label_with_temporal_kind(
|
||||||
|
cell,
|
||||||
|
cell_styles.get(&cell_position).and_then(|style| style.temporal_kind),
|
||||||
|
),
|
||||||
index,
|
index,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1209,16 +1314,27 @@ pub fn parse_xlsx_file_with_options(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut row = Vec::with_capacity(columns.len());
|
let mut row = Vec::with_capacity(columns.len());
|
||||||
for index in 0..columns.len() {
|
for (index, column) in columns.iter().enumerate() {
|
||||||
let cell_position = (range_start_row + row_number, range_start_column + index + 1);
|
let cell_position = (range_start_row + row_number, range_start_column + index + 1);
|
||||||
row.push(
|
let style = cell_styles.get(&cell_position);
|
||||||
source_row
|
let value = source_row
|
||||||
.get(index)
|
.get(index)
|
||||||
.map(|cell| {
|
.map(|cell| {
|
||||||
xlsx_cell_value_with_temporal_kind(cell, temporal_cell_kinds.get(&cell_position).copied())
|
if text_source_columns.contains(column) {
|
||||||
})
|
if legacy_xls && matches!(cell, Data::Float(_) | Data::Int(_)) {
|
||||||
.unwrap_or(serde_json::Value::Null),
|
return Err(format!(
|
||||||
);
|
"Legacy .xls files cannot preserve numeric display formatting for text target column '{column}'. Save the workbook as .xlsx or map this source column to a numeric target."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(text) = xlsx_cell_text_value(cell, style) {
|
||||||
|
return Ok(serde_json::Value::String(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(xlsx_cell_value_with_temporal_kind(cell, style.and_then(|style| style.temporal_kind)))
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
row.push(value);
|
||||||
}
|
}
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
@@ -1256,6 +1372,16 @@ pub async fn parse_import_file_with_options(
|
|||||||
source_format: Option<TableImportSourceFormat>,
|
source_format: Option<TableImportSourceFormat>,
|
||||||
options: &TableImportParseOptions,
|
options: &TableImportParseOptions,
|
||||||
preview_limit: usize,
|
preview_limit: usize,
|
||||||
|
) -> Result<ParsedImportFile, String> {
|
||||||
|
parse_import_file_with_options_and_text_columns(path, source_format, options, preview_limit, HashSet::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_import_file_with_options_and_text_columns(
|
||||||
|
path: &str,
|
||||||
|
source_format: Option<TableImportSourceFormat>,
|
||||||
|
options: &TableImportParseOptions,
|
||||||
|
preview_limit: usize,
|
||||||
|
text_source_columns: HashSet<String>,
|
||||||
) -> Result<ParsedImportFile, String> {
|
) -> Result<ParsedImportFile, String> {
|
||||||
let format = effective_source_format(path, source_format)?;
|
let format = effective_source_format(path, source_format)?;
|
||||||
ensure_non_streaming_file_size(path, format)?;
|
ensure_non_streaming_file_size(path, format)?;
|
||||||
@@ -1276,9 +1402,11 @@ pub async fn parse_import_file_with_options(
|
|||||||
TableImportSourceFormat::Excel => {
|
TableImportSourceFormat::Excel => {
|
||||||
let path = path.to_string();
|
let path = path.to_string();
|
||||||
let options = options.clone();
|
let options = options.clone();
|
||||||
tokio::task::spawn_blocking(move || parse_xlsx_file_with_options(&path, &options, preview_limit))
|
tokio::task::spawn_blocking(move || {
|
||||||
.await
|
parse_xlsx_file_with_options_and_text_columns(&path, &options, preview_limit, &text_source_columns)
|
||||||
.map_err(|e| e.to_string())?
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1393,7 +1521,7 @@ fn build_import_insert_batch_from_rows_with_format(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(target_index, (source_index, _))| {
|
.map(|(target_index, (source_index, _))| {
|
||||||
let value = row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null);
|
let value = row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null);
|
||||||
normalize_import_temporal_value(
|
normalize_import_value(
|
||||||
&value,
|
&value,
|
||||||
column_types.get(target_index).and_then(|data_type| data_type.as_deref()),
|
column_types.get(target_index).and_then(|data_type| data_type.as_deref()),
|
||||||
db_type,
|
db_type,
|
||||||
@@ -1426,6 +1554,76 @@ fn normalize_import_temporal_value(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_textual_import_target_type(data_type: &str) -> bool {
|
||||||
|
let mut lower = data_type.trim().trim_matches('"').to_ascii_lowercase();
|
||||||
|
loop {
|
||||||
|
let unwrapped = ["nullable", "lowcardinality"].iter().find_map(|wrapper| {
|
||||||
|
lower
|
||||||
|
.strip_prefix(&format!("{wrapper}("))
|
||||||
|
.and_then(|inner| inner.strip_suffix(')'))
|
||||||
|
.map(|inner| inner.trim().to_string())
|
||||||
|
});
|
||||||
|
match unwrapped {
|
||||||
|
Some(inner) => lower = inner,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lower == "long raw" || lower.starts_with("long raw(") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let base = lower.split(['(', ':', ' ']).next().unwrap_or("").trim();
|
||||||
|
matches!(
|
||||||
|
base,
|
||||||
|
"char"
|
||||||
|
| "character"
|
||||||
|
| "varchar"
|
||||||
|
| "varchar2"
|
||||||
|
| "nvarchar"
|
||||||
|
| "nvarchar2"
|
||||||
|
| "nchar"
|
||||||
|
| "string"
|
||||||
|
| "fixedstring"
|
||||||
|
| "sysname"
|
||||||
|
| "long"
|
||||||
|
| "text"
|
||||||
|
| "tinytext"
|
||||||
|
| "mediumtext"
|
||||||
|
| "longtext"
|
||||||
|
| "ntext"
|
||||||
|
| "clob"
|
||||||
|
| "nclob"
|
||||||
|
| "enum"
|
||||||
|
| "set"
|
||||||
|
) || lower.starts_with("character varying")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn textual_source_columns_for_import(
|
||||||
|
mappings: &[TableImportColumnMapping],
|
||||||
|
target_column_types: &[(String, String)],
|
||||||
|
) -> HashSet<String> {
|
||||||
|
mappings
|
||||||
|
.iter()
|
||||||
|
.filter(|mapping| {
|
||||||
|
target_column_types
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name.eq_ignore_ascii_case(&mapping.target_column))
|
||||||
|
.map(|(_, data_type)| data_type.as_str())
|
||||||
|
.or(mapping.target_data_type.as_deref())
|
||||||
|
.is_some_and(is_textual_import_target_type)
|
||||||
|
})
|
||||||
|
.map(|mapping| mapping.source_column.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_import_value(
|
||||||
|
value: &serde_json::Value,
|
||||||
|
data_type: Option<&str>,
|
||||||
|
db_type: &DatabaseType,
|
||||||
|
date_time_format: Option<&str>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
normalize_import_temporal_value(value, data_type, db_type, date_time_format)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_import_insert_batches(
|
pub fn build_import_insert_batches(
|
||||||
data: &ParsedImportFile,
|
data: &ParsedImportFile,
|
||||||
mappings: &[TableImportColumnMapping],
|
mappings: &[TableImportColumnMapping],
|
||||||
@@ -1458,17 +1656,6 @@ fn build_import_insert_batches_with_format(
|
|||||||
batch_size: usize,
|
batch_size: usize,
|
||||||
date_time_format: Option<&str>,
|
date_time_format: Option<&str>,
|
||||||
) -> Result<Vec<ImportSqlBatch>, String> {
|
) -> Result<Vec<ImportSqlBatch>, String> {
|
||||||
if *db_type == DatabaseType::CloudflareD1 {
|
|
||||||
return crate::db::cloudflare_d1::build_import_insert_batches(
|
|
||||||
&data.rows,
|
|
||||||
&data.columns,
|
|
||||||
mappings,
|
|
||||||
target_column_types,
|
|
||||||
table,
|
|
||||||
schema,
|
|
||||||
batch_size.clamp(1, 100),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mapped = mapping_indexes(data, mappings)?;
|
let mapped = mapping_indexes(data, mappings)?;
|
||||||
let columns = mapped.iter().map(|(_, target)| target.clone()).collect::<Vec<_>>();
|
let columns = mapped.iter().map(|(_, target)| target.clone()).collect::<Vec<_>>();
|
||||||
let column_types = columns
|
let column_types = columns
|
||||||
@@ -1480,6 +1667,17 @@ fn build_import_insert_batches_with_format(
|
|||||||
.map(|(_, data_type)| data_type.clone())
|
.map(|(_, data_type)| data_type.clone())
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
if *db_type == DatabaseType::CloudflareD1 {
|
||||||
|
return crate::db::cloudflare_d1::build_import_insert_batches(
|
||||||
|
&data.rows,
|
||||||
|
&data.columns,
|
||||||
|
mappings,
|
||||||
|
target_column_types,
|
||||||
|
table,
|
||||||
|
schema,
|
||||||
|
batch_size.clamp(1, 100),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Drivers without multi-row VALUES support still benefit from the agent
|
// Drivers without multi-row VALUES support still benefit from the agent
|
||||||
// batching the generated single-row statements during execution.
|
// batching the generated single-row statements during execution.
|
||||||
let batch_size = if supports_multi_row_insert_values(db_type) { batch_size.max(1) } else { 1 };
|
let batch_size = if supports_multi_row_insert_values(db_type) { batch_size.max(1) } else { 1 };
|
||||||
@@ -1494,7 +1692,7 @@ fn build_import_insert_batches_with_format(
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(target_index, (source_index, _))| {
|
.map(|(target_index, (source_index, _))| {
|
||||||
let value = row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null);
|
let value = row.get(*source_index).cloned().unwrap_or(serde_json::Value::Null);
|
||||||
normalize_import_temporal_value(
|
normalize_import_value(
|
||||||
&value,
|
&value,
|
||||||
column_types.get(target_index).and_then(|data_type| data_type.as_deref()),
|
column_types.get(target_index).and_then(|data_type| data_type.as_deref()),
|
||||||
db_type,
|
db_type,
|
||||||
@@ -2201,11 +2399,30 @@ where
|
|||||||
return Ok(TableImportSummary { import_id: request.import_id.clone(), rows_imported, total_rows });
|
return Ok(TableImportSummary { import_id: request.import_id.clone(), rows_imported, total_rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = match parse_import_file_with_options(
|
let mut target_column_types = get_columns_for_transfer(
|
||||||
|
state,
|
||||||
|
pool_key,
|
||||||
|
&request.connection_id,
|
||||||
|
&request.database,
|
||||||
|
&request.schema,
|
||||||
|
&request.table,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|column| (column.name, column.data_type))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if target_column_types.is_empty() {
|
||||||
|
target_column_types = created_column_types.clone().unwrap_or_default();
|
||||||
|
}
|
||||||
|
let text_source_columns = textual_source_columns_for_import(&request.mappings, &target_column_types);
|
||||||
|
|
||||||
|
let parsed = match parse_import_file_with_options_and_text_columns(
|
||||||
&request.file_path,
|
&request.file_path,
|
||||||
Some(source_format),
|
Some(source_format),
|
||||||
&request.parse_options,
|
&request.parse_options,
|
||||||
usize::MAX,
|
usize::MAX,
|
||||||
|
text_source_columns,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -2227,23 +2444,6 @@ where
|
|||||||
error: None,
|
error: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut target_column_types = get_columns_for_transfer(
|
|
||||||
state,
|
|
||||||
pool_key,
|
|
||||||
&request.connection_id,
|
|
||||||
&request.database,
|
|
||||||
&request.schema,
|
|
||||||
&request.table,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|column| (column.name, column.data_type))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if target_column_types.is_empty() {
|
|
||||||
target_column_types = created_column_types.clone().unwrap_or_default();
|
|
||||||
}
|
|
||||||
|
|
||||||
let batches = match build_import_insert_batches_with_format(
|
let batches = match build_import_insert_batches_with_format(
|
||||||
&parsed,
|
&parsed,
|
||||||
&request.mappings,
|
&request.mappings,
|
||||||
@@ -2317,12 +2517,13 @@ mod tests {
|
|||||||
zip.write_all(content.as_bytes()).unwrap();
|
zip.write_all(content.as_bytes()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_temporal_test_xlsx(date1904: bool, cells: &[(&str, usize, f64)]) -> Vec<u8> {
|
fn build_styled_test_xlsx<S: AsRef<str>>(date1904: bool, cells: &[(S, usize, f64)]) -> Vec<u8> {
|
||||||
let cursor = Cursor::new(Vec::new());
|
let cursor = Cursor::new(Vec::new());
|
||||||
let mut zip = zip::ZipWriter::new(cursor);
|
let mut zip = zip::ZipWriter::new(cursor);
|
||||||
let workbook_pr = if date1904 { r#"<workbookPr date1904="1"/>"# } else { "" };
|
let workbook_pr = if date1904 { r#"<workbookPr date1904="1"/>"# } else { "" };
|
||||||
let mut rows = std::collections::BTreeMap::<usize, String>::new();
|
let mut rows = std::collections::BTreeMap::<usize, String>::new();
|
||||||
for (reference, style_id, value) in cells {
|
for (reference, style_id, value) in cells {
|
||||||
|
let reference = reference.as_ref();
|
||||||
let (row, _) = xlsx_cell_ref_position(reference).expect("valid XLSX cell reference");
|
let (row, _) = xlsx_cell_ref_position(reference).expect("valid XLSX cell reference");
|
||||||
rows.entry(row).or_default().push_str(&format!(r#"<c r="{reference}" s="{style_id}"><v>{value}</v></c>"#));
|
rows.entry(row).or_default().push_str(&format!(r#"<c r="{reference}" s="{style_id}"><v>{value}</v></c>"#));
|
||||||
}
|
}
|
||||||
@@ -2372,27 +2573,43 @@ mod tests {
|
|||||||
write_xlsx_test_entry(
|
write_xlsx_test_entry(
|
||||||
&mut zip,
|
&mut zip,
|
||||||
"xl/styles.xml",
|
"xl/styles.xml",
|
||||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
r##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
<numFmts count="4">
|
<numFmts count="12">
|
||||||
<numFmt numFmtId="164" formatCode="yyyy-mm-dd"/>
|
<numFmt numFmtId="164" formatCode="yyyy-mm-dd"/>
|
||||||
<numFmt numFmtId="165" formatCode="yyyy-mm-dd hh:mm:ss"/>
|
<numFmt numFmtId="165" formatCode="yyyy-mm-dd hh:mm:ss"/>
|
||||||
<numFmt numFmtId="166" formatCode="hh:mm:ss"/>
|
<numFmt numFmtId="166" formatCode="hh:mm:ss"/>
|
||||||
<numFmt numFmtId="167" formatCode="[h]:mm:ss"/>
|
<numFmt numFmtId="167" formatCode="[h]:mm:ss"/>
|
||||||
|
<numFmt numFmtId="168" formatCode="0.0"/>
|
||||||
|
<numFmt numFmtId="169" formatCode="0.00"/>
|
||||||
|
<numFmt numFmtId="170" formatCode="00000"/>
|
||||||
|
<numFmt numFmtId="171" formatCode="#,##0.00"/>
|
||||||
|
<numFmt numFmtId="172" formatCode="0.00E+00"/>
|
||||||
|
<numFmt numFmtId="173" formatCode="0.0%"/>
|
||||||
|
<numFmt numFmtId="174" formatCode="[$€-407]#,##0.00"/>
|
||||||
|
<numFmt numFmtId="175" formatCode="[$-409]#,##0.00"/>
|
||||||
</numFmts>
|
</numFmts>
|
||||||
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>
|
||||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
|
<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>
|
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||||
<cellXfs count="5">
|
<cellXfs count="13">
|
||||||
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
|
||||||
<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="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="165" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
<xf numFmtId="166" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
<xf numFmtId="166" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
<xf numFmtId="167" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
<xf numFmtId="167" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="168" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="169" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="170" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="171" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="172" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="173" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="174" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
|
<xf numFmtId="175" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>
|
||||||
</cellXfs>
|
</cellXfs>
|
||||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
|
||||||
</styleSheet>"#,
|
</styleSheet>"##,
|
||||||
);
|
);
|
||||||
write_xlsx_test_entry(
|
write_xlsx_test_entry(
|
||||||
&mut zip,
|
&mut zip,
|
||||||
@@ -2408,6 +2625,145 @@ mod tests {
|
|||||||
zip.finish().unwrap().into_inner()
|
zip.finish().unwrap().into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retains_only_temporal_and_text_target_xlsx_styles() {
|
||||||
|
let styles = vec![
|
||||||
|
XlsxCellStyle { temporal_kind: None, number_format: Some(Arc::from("0.00")) },
|
||||||
|
XlsxCellStyle { temporal_kind: Some(XlsxTemporalKind::Date), number_format: None },
|
||||||
|
];
|
||||||
|
let sheet = r#"<worksheet><sheetData><row r="1">
|
||||||
|
<c r="A1" s="0"><v>10</v></c>
|
||||||
|
<c r="B1" s="0"><v>20</v></c>
|
||||||
|
<c r="C1" s="1"><v>45996</v></c>
|
||||||
|
</row></sheetData></worksheet>"#;
|
||||||
|
|
||||||
|
let retained =
|
||||||
|
parse_xlsx_sheet_cell_styles(Cursor::new(sheet.as_bytes()), &styles, &HashSet::from([2])).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(retained.len(), 2);
|
||||||
|
assert!(!retained.contains_key(&(1, 1)));
|
||||||
|
assert_eq!(retained.get(&(1, 2)).and_then(|style| style.number_format.as_deref()), Some("0.00"));
|
||||||
|
assert_eq!(retained.get(&(1, 3)).and_then(|style| style.temporal_kind), Some(XlsxTemporalKind::Date));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_xls_rejects_numeric_to_text_without_affecting_numeric_targets() {
|
||||||
|
let path = std::env::temp_dir().join(format!("dbx-table-import-formatted-{}.xls", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(&path, include_bytes!("../tests/fixtures/issue3683-formatted-numbers.xls")).unwrap();
|
||||||
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
|
||||||
|
let numeric =
|
||||||
|
parse_xlsx_file_with_options_and_text_columns(&path.to_string_lossy(), &options, 10, &HashSet::new())
|
||||||
|
.unwrap();
|
||||||
|
let values = numeric.rows[0].iter().map(|value| value.as_f64()).collect::<Vec<_>>();
|
||||||
|
assert_eq!(values, vec![Some(10.0), Some(42.0), Some(0.125), Some(1234.5), Some(99.5)]);
|
||||||
|
|
||||||
|
for column in 1..=4 {
|
||||||
|
let source_column = format!("column_{column}");
|
||||||
|
let error = parse_xlsx_file_with_options_and_text_columns(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
&options,
|
||||||
|
10,
|
||||||
|
&HashSet::from([source_column.clone()]),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.contains("Legacy .xls"), "{error}");
|
||||||
|
assert!(error.contains(&source_column), "{error}");
|
||||||
|
assert!(error.contains("Save the workbook as .xlsx"), "{error}");
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_process_rss_kib(pid: u32) -> Option<u64> {
|
||||||
|
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmRSS:")?.split_ascii_whitespace().next()?.parse::<u64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn xlsx_style_rss_helper() {
|
||||||
|
let Ok(sheet_path) = std::env::var("DBX_XLSX_STYLE_RSS_PATH") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ready_path = std::env::var("DBX_XLSX_STYLE_RSS_READY").unwrap();
|
||||||
|
let go_path = std::env::var("DBX_XLSX_STYLE_RSS_GO").unwrap();
|
||||||
|
std::fs::write(&ready_path, b"ready").unwrap();
|
||||||
|
while !Path::new(&go_path).exists() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let styles = [XlsxCellStyle { temporal_kind: None, number_format: Some(Arc::from("0.00")) }];
|
||||||
|
let sheet = BufReader::new(File::open(sheet_path).unwrap());
|
||||||
|
let retained = parse_xlsx_sheet_cell_styles(sheet, &styles, &HashSet::new()).unwrap();
|
||||||
|
assert!(retained.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn streaming_xlsx_style_scan_keeps_peak_rss_bounded() {
|
||||||
|
const ROWS: usize = 120_000;
|
||||||
|
const COLUMNS: usize = 8;
|
||||||
|
const MAX_RSS_GROWTH_KIB: u64 = 48 * 1024;
|
||||||
|
|
||||||
|
let suffix = uuid::Uuid::new_v4();
|
||||||
|
let sheet_path = std::env::temp_dir().join(format!("dbx-xlsx-style-rss-{suffix}.xml"));
|
||||||
|
let ready_path = std::env::temp_dir().join(format!("dbx-xlsx-style-rss-{suffix}.ready"));
|
||||||
|
let go_path = std::env::temp_dir().join(format!("dbx-xlsx-style-rss-{suffix}.go"));
|
||||||
|
let mut sheet = std::io::BufWriter::new(File::create(&sheet_path).unwrap());
|
||||||
|
write!(sheet, "<worksheet><sheetData>").unwrap();
|
||||||
|
for row in 1..=ROWS {
|
||||||
|
write!(sheet, "<row r=\"{row}\">").unwrap();
|
||||||
|
for column in 0..COLUMNS {
|
||||||
|
let column_name = (b'A' + column as u8) as char;
|
||||||
|
write!(sheet, "<c r=\"{column_name}{row}\" s=\"0\"><v>{row}</v></c>").unwrap();
|
||||||
|
}
|
||||||
|
write!(sheet, "</row>").unwrap();
|
||||||
|
}
|
||||||
|
write!(sheet, "</sheetData></worksheet>").unwrap();
|
||||||
|
sheet.flush().unwrap();
|
||||||
|
|
||||||
|
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||||
|
.args(["--exact", "table_import::tests::xlsx_style_rss_helper", "--nocapture"])
|
||||||
|
.env("DBX_XLSX_STYLE_RSS_PATH", &sheet_path)
|
||||||
|
.env("DBX_XLSX_STYLE_RSS_READY", &ready_path)
|
||||||
|
.env("DBX_XLSX_STYLE_RSS_GO", &go_path)
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
if ready_path.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(child.try_wait().unwrap().is_none(), "RSS helper exited before becoming ready");
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
assert!(ready_path.exists(), "RSS helper did not become ready");
|
||||||
|
let baseline_rss = linux_process_rss_kib(child.id()).expect("helper RSS before scan");
|
||||||
|
std::fs::write(&go_path, b"go").unwrap();
|
||||||
|
let mut peak_rss = baseline_rss;
|
||||||
|
let status = loop {
|
||||||
|
if let Some(rss) = linux_process_rss_kib(child.id()) {
|
||||||
|
peak_rss = peak_rss.max(rss);
|
||||||
|
}
|
||||||
|
if let Some(status) = child.try_wait().unwrap() {
|
||||||
|
break status;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&sheet_path);
|
||||||
|
let _ = std::fs::remove_file(&ready_path);
|
||||||
|
let _ = std::fs::remove_file(&go_path);
|
||||||
|
assert!(status.success());
|
||||||
|
assert!(
|
||||||
|
peak_rss.saturating_sub(baseline_rss) <= MAX_RSS_GROWTH_KIB,
|
||||||
|
"streaming style scan RSS grew by {} KiB (baseline {baseline_rss} KiB, peak {peak_rss} KiB)",
|
||||||
|
peak_rss.saturating_sub(baseline_rss)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_csv_headers_and_preview_rows() {
|
fn parses_csv_headers_and_preview_rows() {
|
||||||
let parsed = parse_csv_bytes(b"id,name,active\n1,Ada,true\n2,,false\n", 10).unwrap();
|
let parsed = parse_csv_bytes(b"id,name,active\n1,Ada,true\n2,,false\n", 10).unwrap();
|
||||||
@@ -2723,12 +3079,243 @@ mod tests {
|
|||||||
assert_eq!(infer_value_type(&time_value), Some(ImportInferredType::Decimal));
|
assert_eq!(infer_value_type(&time_value), Some(ImportInferredType::Decimal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_excel_numeric_and_text_cell_types_distinct() {
|
||||||
|
let integer_valued_float = xlsx_cell_value(&Data::Float(10_401_029_008.0));
|
||||||
|
assert_eq!(integer_valued_float, serde_json::json!(10_401_029_008.0));
|
||||||
|
assert_eq!(infer_value_type(&integer_valued_float), Some(ImportInferredType::Decimal));
|
||||||
|
assert_eq!(xlsx_cell_value(&Data::Float(10_401_029_008.5)), serde_json::json!(10_401_029_008.5));
|
||||||
|
assert_eq!(xlsx_cell_value(&Data::Float(9_007_199_254_740_992.0)), serde_json::json!(9_007_199_254_740_992.0));
|
||||||
|
for text in ["10.0", "00123", "1e3", "10401029008.0"] {
|
||||||
|
assert_eq!(xlsx_cell_value(&Data::String(text.to_string())), serde_json::json!(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_common_excel_numeric_display_formats() {
|
||||||
|
let display = |value, format_code: &str| {
|
||||||
|
xlsx_numeric_display_text(
|
||||||
|
value,
|
||||||
|
Some(&XlsxCellStyle { temporal_kind: None, number_format: Some(Arc::from(format_code)) }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(display(42.0, "00000"), "00042");
|
||||||
|
assert_eq!(display(1234.5, "#,##0.00"), "1,234.50");
|
||||||
|
assert_eq!(display(1234.0, "0.00E+00"), "1.23E+03");
|
||||||
|
assert_eq!(display(0.125, "0.0%"), "12.5%");
|
||||||
|
assert_eq!(display(1234.5, "[$€-407]#,##0.00"), "€1.234,50");
|
||||||
|
assert_eq!(display(1234.5, "[$-407]#,##0.00"), "1.234,50");
|
||||||
|
assert_eq!(display(1234.5, "[$-409]#,##0.00"), "1,234.50");
|
||||||
|
assert_eq!(display(12.5, "["), "12.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_only_excel_columns_mapped_to_text_targets() {
|
||||||
|
let path = std::env::temp_dir().join(format!("dbx-table-import-display-formats-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
build_styled_test_xlsx(
|
||||||
|
false,
|
||||||
|
&[
|
||||||
|
("A1", 7, 42.0),
|
||||||
|
("B1", 8, 1234.5),
|
||||||
|
("C1", 9, 1234.0),
|
||||||
|
("D1", 10, 0.125),
|
||||||
|
("E1", 11, 1234.5),
|
||||||
|
("F1", 12, 1234.5),
|
||||||
|
("G1", 6, 10.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
let text_source_columns = (1..=6).map(|index| format!("column_{index}")).collect::<HashSet<_>>();
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
parse_xlsx_file_with_options_and_text_columns(&path.to_string_lossy(), &options, 10, &text_source_columns)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed.rows[0],
|
||||||
|
vec![
|
||||||
|
serde_json::json!("00042"),
|
||||||
|
serde_json::json!("1,234.50"),
|
||||||
|
serde_json::json!("1.23E+03"),
|
||||||
|
serde_json::json!("12.5%"),
|
||||||
|
serde_json::json!("€1.234,50"),
|
||||||
|
serde_json::json!("1,234.50"),
|
||||||
|
serde_json::json!(10.0),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognizes_supported_textual_import_target_types() {
|
||||||
|
for data_type in [
|
||||||
|
"FixedString(32)",
|
||||||
|
"Nullable(FixedString(32))",
|
||||||
|
"LowCardinality(String)",
|
||||||
|
"sysname",
|
||||||
|
"LONG",
|
||||||
|
"LONG VARCHAR",
|
||||||
|
] {
|
||||||
|
assert!(is_textual_import_target_type(data_type), "{data_type}");
|
||||||
|
}
|
||||||
|
for data_type in ["LONG RAW", "BIGINT", "Nullable(Float64)"] {
|
||||||
|
assert!(!is_textual_import_target_type(data_type), "{data_type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selects_excel_display_conversion_only_for_textual_mappings() {
|
||||||
|
let mappings = vec![
|
||||||
|
TableImportColumnMapping {
|
||||||
|
source_column: "code_source".to_string(),
|
||||||
|
target_column: "code".to_string(),
|
||||||
|
target_data_type: None,
|
||||||
|
},
|
||||||
|
TableImportColumnMapping {
|
||||||
|
source_column: "amount_source".to_string(),
|
||||||
|
target_column: "amount".to_string(),
|
||||||
|
target_data_type: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = textual_source_columns_for_import(
|
||||||
|
&mappings,
|
||||||
|
&[("code".to_string(), "varchar(32)".to_string()), ("amount".to_string(), "double".to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(selected, HashSet::from(["code_source".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clickhouse_fixed_string_import_uses_excel_numeric_display_text() {
|
||||||
|
let path = std::env::temp_dir().join(format!("dbx-table-import-fixed-string-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(&path, build_styled_test_xlsx(false, &[("A1", 5, 10.0)])).unwrap();
|
||||||
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
let mappings = vec![TableImportColumnMapping {
|
||||||
|
source_column: "column_1".to_string(),
|
||||||
|
target_column: "code".to_string(),
|
||||||
|
target_data_type: None,
|
||||||
|
}];
|
||||||
|
let target_column_types = [("code".to_string(), "FixedString(16)".to_string())];
|
||||||
|
let text_source_columns = textual_source_columns_for_import(&mappings, &target_column_types);
|
||||||
|
|
||||||
|
let data =
|
||||||
|
parse_xlsx_file_with_options_and_text_columns(&path.to_string_lossy(), &options, 10, &text_source_columns)
|
||||||
|
.unwrap();
|
||||||
|
let batches = build_import_insert_batches(
|
||||||
|
&data,
|
||||||
|
&mappings,
|
||||||
|
&target_column_types,
|
||||||
|
"issue_3683_fixed_string",
|
||||||
|
"",
|
||||||
|
&DatabaseType::ClickHouse,
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(text_source_columns, HashSet::from(["column_1".to_string()]));
|
||||||
|
assert_eq!(data.rows, vec![vec![serde_json::json!("10.0")]]);
|
||||||
|
assert_eq!(batches[0].sql, "INSERT INTO `issue_3683_fixed_string` (`code`) VALUES\n('10.0')");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_varchar_import_uses_excel_numeric_display_text() {
|
||||||
|
let path = std::env::temp_dir().join(format!("dbx-table-import-number-format-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
build_styled_test_xlsx(false, &[("A1", 0, 10_401_029_008.0), ("A2", 5, 10.0), ("A3", 6, 10.0)]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
let numeric_data = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
|
||||||
|
let mut data = parse_xlsx_file_with_options_and_text_columns(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
&options,
|
||||||
|
10,
|
||||||
|
&HashSet::from(["column_1".to_string()]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
data.rows.extend([
|
||||||
|
vec![serde_json::json!("10.0")],
|
||||||
|
vec![serde_json::json!("00123")],
|
||||||
|
vec![serde_json::json!("1e3")],
|
||||||
|
vec![serde_json::json!("10401029008.0")],
|
||||||
|
]);
|
||||||
|
data.total_rows = data.rows.len();
|
||||||
|
let mappings = vec![TableImportColumnMapping {
|
||||||
|
source_column: "column_1".to_string(),
|
||||||
|
target_column: "code".to_string(),
|
||||||
|
target_data_type: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let batches = build_import_insert_batches(
|
||||||
|
&data,
|
||||||
|
&mappings,
|
||||||
|
&[("code".to_string(), "varchar(32)".to_string())],
|
||||||
|
"issue_3683",
|
||||||
|
"",
|
||||||
|
&DatabaseType::Mysql,
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
batches[0].sql,
|
||||||
|
"INSERT INTO `issue_3683` (`code`) VALUES\n('10401029008'),\n('10.0'),\n('10.00'),\n('10.0'),\n('00123'),\n('1e3'),\n('10401029008.0')"
|
||||||
|
);
|
||||||
|
assert!(data.rows[..3].iter().all(|row| row[0].as_str().is_some()));
|
||||||
|
assert!(numeric_data.rows.iter().all(|row| row[0].as_f64().is_some()));
|
||||||
|
|
||||||
|
let numeric_batches = build_import_insert_batches(
|
||||||
|
&numeric_data,
|
||||||
|
&mappings,
|
||||||
|
&[("code".to_string(), "double".to_string())],
|
||||||
|
"issue_3683_numeric",
|
||||||
|
"",
|
||||||
|
&DatabaseType::Mysql,
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(numeric_batches[0].sql.contains("(10401029008.0),\n(10.0),\n(10.0)"));
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excel_integer_sample_keeps_decimal_create_table_inference() {
|
||||||
|
let path = std::env::temp_dir().join(format!("dbx-table-import-inference-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
|
let cells = (1..=101)
|
||||||
|
.map(|row| (format!("A{row}"), 0, if row == 101 { 100.5 } else { row as f64 }))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
std::fs::write(&path, build_styled_test_xlsx(false, &cells)).unwrap();
|
||||||
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
let data =
|
||||||
|
parse_xlsx_file_with_options(&path.to_string_lossy(), &options, CREATE_TABLE_INFERENCE_ROWS).unwrap();
|
||||||
|
let mappings = vec![TableImportColumnMapping {
|
||||||
|
source_column: "column_1".to_string(),
|
||||||
|
target_column: "amount".to_string(),
|
||||||
|
target_data_type: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let plan = build_import_create_table_plan(&data, &mappings, "measurements", "", &DatabaseType::Mysql).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(data.total_rows, 101);
|
||||||
|
assert_eq!(data.rows.len(), CREATE_TABLE_INFERENCE_ROWS);
|
||||||
|
assert_eq!(plan.columns[0].data_type, "DOUBLE");
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_excel_temporal_styles_before_type_inference() {
|
fn parses_excel_temporal_styles_before_type_inference() {
|
||||||
let path = std::env::temp_dir().join(format!("dbx-table-import-temporal-{}.xlsx", uuid::Uuid::new_v4()));
|
let path = std::env::temp_dir().join(format!("dbx-table-import-temporal-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&path,
|
&path,
|
||||||
build_temporal_test_xlsx(false, &[("A1", 1, 45996.0), ("B1", 2, 45996.0), ("C1", 3, 0.5), ("D1", 4, 1.5)]),
|
build_styled_test_xlsx(false, &[("A1", 1, 45996.0), ("B1", 2, 45996.0), ("C1", 3, 0.5), ("D1", 4, 1.5)]),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
@@ -2755,7 +3342,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_excel_temporal_styles_with_1904_date_system() {
|
fn parses_excel_temporal_styles_with_1904_date_system() {
|
||||||
let path = std::env::temp_dir().join(format!("dbx-table-import-temporal-1904-{}.xlsx", uuid::Uuid::new_v4()));
|
let path = std::env::temp_dir().join(format!("dbx-table-import-temporal-1904-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
std::fs::write(&path, build_temporal_test_xlsx(true, &[("A1", 1, 1.0)])).unwrap();
|
std::fs::write(&path, build_styled_test_xlsx(true, &[("A1", 1, 1.0)])).unwrap();
|
||||||
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
|
||||||
let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
|
let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
|
||||||
@@ -2768,7 +3355,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn parses_excel_temporal_styles_from_non_a1_used_range() {
|
fn parses_excel_temporal_styles_from_non_a1_used_range() {
|
||||||
let path = std::env::temp_dir().join(format!("dbx-table-import-temporal-offset-{}.xlsx", uuid::Uuid::new_v4()));
|
let path = std::env::temp_dir().join(format!("dbx-table-import-temporal-offset-{}.xlsx", uuid::Uuid::new_v4()));
|
||||||
std::fs::write(&path, build_temporal_test_xlsx(false, &[("C3", 1, 45996.0), ("D3", 2, 45996.0)])).unwrap();
|
std::fs::write(&path, build_styled_test_xlsx(false, &[("C3", 1, 45996.0), ("D3", 2, 45996.0)])).unwrap();
|
||||||
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
let options = TableImportParseOptions { has_header: Some(false), ..TableImportParseOptions::default() };
|
||||||
|
|
||||||
let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
|
let parsed = parse_xlsx_file_with_options(&path.to_string_lossy(), &options, 10).unwrap();
|
||||||
|
|||||||
Binary file not shown.
Reference in new issue
Block a user